From 1147bdded963912c0e88d74e26aee28a678336a0 Mon Sep 17 00:00:00 2001 From: Timo Frank Date: Wed, 29 Jul 2026 08:43:42 +0200 Subject: [PATCH] =?UTF-8?q?Startseite=20und=20=C3=B6ffentliche=20Demo=20ak?= =?UTF-8?q?tualisieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/versorgungs-kompass.css | 108 ++++----------- frontend/app/versorgungs-kompass.html | 2 +- frontend/app/versorgungs-kompass.js | 112 ++++++---------- frontend/data/demo-api.js | 176 ++++++------------------- scripts/audit_public_assets.mjs | 1 + scripts/audit_target_assets.mjs | 1 + scripts/build_static_frontend.sh | 1 + scripts/test_demo_api.mjs | 69 +++++++--- scripts/test_deployment_separation.mjs | 2 + tests/contact-owner-visibility.spec.js | 33 ++--- tests/delivery-ui.spec.js | 40 +++++- tests/demo-contact-adjustments.spec.js | 120 +++++++++++++++++ tests/format-participations.spec.js | 22 ++-- tests/visual-smoke.spec.js | 5 +- 14 files changed, 360 insertions(+), 332 deletions(-) create mode 100644 tests/demo-contact-adjustments.spec.js diff --git a/frontend/app/versorgungs-kompass.css b/frontend/app/versorgungs-kompass.css index 88f7df1..857582b 100644 --- a/frontend/app/versorgungs-kompass.css +++ b/frontend/app/versorgungs-kompass.css @@ -5323,15 +5323,6 @@ } } - .home-hero__lead { - max-width: 680px; - margin: 30px auto 0; - color: #53617a; - font-size: clamp(0.96rem, 1.25vw, 1.08rem); - font-weight: 620; - line-height: 1.55; - } - .home-scroll-cue { min-height: 44px; display: inline-flex; @@ -5353,12 +5344,37 @@ .home-destinations-section { min-height: 100%; display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 22px; padding: 80px 56px; scroll-margin-top: 0; } + .home-destinations__intro { + width: min(1120px, 100%); + display: flex; + align-items: center; + gap: 9px; + margin: 0; + color: var(--text-muted); + font-size: 0.82rem; + font-weight: 650; + line-height: 1.45; + text-align: left; + } + + .home-destinations__intro::before { + width: 18px; + height: 2px; + flex: 0 0 18px; + border-radius: 999px; + background: var(--primary); + content: ""; + opacity: 0.58; + } + .home-destinations { width: min(1120px, 100%); display: grid; @@ -5500,6 +5516,7 @@ .home-destinations-section { align-items: flex-start; + gap: 17px; padding: 62px 18px; } @@ -12163,10 +12180,6 @@ flex-wrap: wrap; } - .format-profile__status-select { - width: 100%; - } - .format-participation-status { display: inline-flex; align-items: center; @@ -12199,7 +12212,7 @@ .format-profile__link-form { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(132px, 0.55fr); + grid-template-columns: minmax(0, 1fr); gap: 8px; align-items: end; border: 1px dashed var(--border-strong); @@ -12562,7 +12575,6 @@ align-items: stretch; } - .format-profile__status-select, .format-profile__status-form .action-button, .format-profile__link-form .action-button { width: 100%; @@ -32429,7 +32441,7 @@ .format-overview-hero { display: grid; - grid-template-columns: minmax(420px, 1fr) minmax(220px, 300px); + grid-template-columns: minmax(0, 1fr); gap: 18px; align-items: center; border: 0; @@ -32679,66 +32691,6 @@ height: 16px; } - .format-overview-visual { - min-width: 0; - display: grid; - align-items: center; - justify-items: end; - border: 0; - background: transparent; - padding: 0; - } - - .format-roundtable-illustration { - width: min(100%, 280px); - aspect-ratio: 16 / 10; - display: block; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: #ffffff; - } - - .format-roundtable-illustration__bubble { - fill: #ffffff; - stroke: #cad7ef; - stroke-width: 2.2; - } - - .format-roundtable-illustration__bubble-line, - .format-roundtable-illustration__person { - fill: none; - stroke-linecap: round; - stroke-linejoin: round; - } - - .format-roundtable-illustration__bubble-line { - stroke: var(--primary); - stroke-width: 3; - } - - .format-roundtable-illustration__person { - stroke-width: 3.2; - } - - .format-roundtable-illustration__person--blue { - stroke: var(--primary); - } - - .format-roundtable-illustration__person--green { - stroke: #178a55; - } - - .format-roundtable-illustration__person--amber { - stroke: #b7791f; - } - - .format-roundtable-illustration__connector { - fill: none; - stroke: #dbe5f8; - stroke-linecap: round; - stroke-width: 2.4; - } - .format-section { display: grid; gap: 12px; @@ -34498,10 +34450,6 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .format-overview-hero { - grid-template-columns: minmax(360px, 1fr) minmax(220px, 300px); - } - .format-diversity-board, .format-participant-filter-grid, .format-facts-grid { diff --git a/frontend/app/versorgungs-kompass.html b/frontend/app/versorgungs-kompass.html index 5496217..e21ccb3 100644 --- a/frontend/app/versorgungs-kompass.html +++ b/frontend/app/versorgungs-kompass.html @@ -552,7 +552,6 @@

Filter

-

Wähle den Kompass, in dem du weiterarbeiten möchtest.

@@ -25255,12 +25253,6 @@ -
@@ -25316,20 +25308,15 @@ if (!canEditCareObject(contact)) return; const data = new FormData(event.currentTarget); const formatId = String(data.get("formatId") || ""); - const invitationStatus = String(data.get("invitationStatus") || "Kandidat"); if (!formatId || !contact.id) return; const format = formats.find((item) => item.id === formatId); if (!canEditFormat(format)) { setProfileFormatFeedback("Das Format ist nicht mehr bearbeitbar. Lade die Ansicht neu und prüfe den Archivstatus.", "error"); return; } - if (["Eingeladen", "Zugesagt", "Teilgenommen"].includes(invitationStatus) && mitmachenConsentAvailability(contact).key !== "available") { - setProfileFormatFeedback("Der Kontakt kann als Kandidat verknüpft werden. Für eine Einladung fehlt eine wirksame #Mitmachen-Einwilligung.", "error"); - return; - } try { event.currentTarget.setAttribute("aria-busy", "true"); - const updated = await window.dataService.addFormatParticipant(formatId, contact.id, { invitationStatus }); + const updated = await window.dataService.addFormatParticipant(formatId, contact.id, { invitationStatus: "Kandidat" }); replaceFormat(updated); setStorageStatus("Kontakt zum Format hinzugefügt"); detailFormatLinkOpen = false; @@ -25340,35 +25327,6 @@ setProfileFormatFeedback(`Format konnte nicht verknüpft werden: ${error?.message || "Bitte prüfe Berechtigung und Verbindung."}`, "error"); } }); - root.querySelectorAll("[data-format-profile-status]").forEach((select) => { - select.addEventListener("change", async () => { - const format = formats.find((item) => item.id === select.dataset.formatProfileStatus); - if (!canEditFormat(format)) return; - const participant = (format?.participants || []).find((item) => (item.contactId || item.contact_id) === select.dataset.contactId); - if (["Eingeladen", "Zugesagt", "Teilgenommen"].includes(select.value) && mitmachenConsentAvailability(contact).key !== "available") { - select.value = participant?.invitationStatus || "Kandidat"; - setProfileFormatFeedback("Status nicht geändert: Vor einer Einladung muss eine wirksame #Mitmachen-Einwilligung dokumentiert sein.", "error"); - return; - } - select.disabled = true; - try { - const updated = await window.dataService.updateFormatParticipant( - select.dataset.formatProfileStatus, - select.dataset.contactId, - { invitationStatus: select.value }, - participant?.updatedAt || participant?.updated_at || "" - ); - replaceFormat(updated); - setStorageStatus("Beteiligungsstatus aktualisiert"); - rerender(); - } catch (error) { - console.error("Beteiligungsstatus konnte nicht gespeichert werden.", error); - select.disabled = false; - select.value = participant?.invitationStatus || "Kandidat"; - setProfileFormatFeedback(`Beteiligungsstatus nicht gespeichert: ${error?.message || "Bitte versuche es erneut."}`, "error"); - } - }); - }); root.querySelectorAll("[data-format-profile-action]").forEach((button) => { button.addEventListener("click", async () => { const action = button.dataset.formatProfileAction; @@ -25885,18 +25843,6 @@ ${canEditFormat(format) ? `` : ""}
-
- - - - - - - - - - -
`; } @@ -32917,7 +32863,7 @@ return `
${categoryButtons}
${ownerSelect}`; } - function renderHistoryTimeline(contactId, changes = []) { + function renderHistoryTimeline(contactId, changes = [], root = document) { const activeFilter = contactHistoryFilters.get(contactId) || "all"; const activeOwner = contactHistoryOwnerFilters.get(contactId) || "all"; const filteredActivities = changes.filter((change) => { @@ -32928,19 +32874,19 @@ return matchesCategory && matchesOwner; }); const groups = groupHistoryChanges(filteredActivities); - const timeline = document.getElementById("history-timeline"); - const filters = document.getElementById("history-filters"); + const timeline = root.querySelector("#history-timeline"); + const filters = root.querySelector("#history-filters"); if (filters) filters.innerHTML = renderHistoryFilters(contactId, changes); const bindHistoryControls = () => { filters?.querySelectorAll("[data-history-filter]").forEach((button) => { button.addEventListener("click", () => { contactHistoryFilters.set(contactId, button.dataset.historyFilter || "all"); - renderHistoryTimeline(contactId, changes); + renderHistoryTimeline(contactId, changes, root); }); }); filters?.querySelector("#history-owner-filter")?.addEventListener("change", (event) => { contactHistoryOwnerFilters.set(contactId, event.target.value || "all"); - renderHistoryTimeline(contactId, changes); + renderHistoryTimeline(contactId, changes, root); }); timeline?.querySelectorAll("[data-activity-details-toggle]").forEach((button) => { button.addEventListener("click", () => toggleActivityDetails(button)); @@ -32962,25 +32908,33 @@ bindHistoryControls(); } - async function loadContactHistory(contactId) { - const timeline = document.getElementById("history-timeline"); + async function loadContactHistory(contactId, root = document) { + const timeline = root.querySelector("#history-timeline"); if (!timeline) return; + const requestToken = {}; + contactHistoryRequests.set(root, requestToken); + const requestIsCurrent = () => contactHistoryRequests.get(root) === requestToken; if (!window.dataService?.getContactChanges) { - renderHistoryTimeline(contactId, []); + renderHistoryTimeline(contactId, [], root); return; } if (contactHistoryCache.has(contactId)) { - renderHistoryTimeline(contactId, contactHistoryCache.get(contactId)); + renderHistoryTimeline(contactId, contactHistoryCache.get(contactId), root); return; } timeline.innerHTML = `
Verlauf wird geladen...
`; try { const changes = await window.dataService.getContactChanges(contactId); contactHistoryCache.set(contactId, changes); - renderHistoryTimeline(contactId, changes); + if (!requestIsCurrent()) return; + renderHistoryTimeline(contactId, changes, root); } catch (error) { console.error("Änderungsverlauf konnte nicht geladen werden.", error); - timeline.innerHTML = `
Der Änderungsverlauf konnte nicht geladen werden. Bitte prüfe Anmeldung, Rolle und Verbindung.
`; + if (!requestIsCurrent()) return; + const currentTimeline = root.querySelector("#history-timeline"); + if (currentTimeline) { + currentTimeline.innerHTML = `
Der Änderungsverlauf konnte nicht geladen werden. Bitte prüfe Anmeldung, Rolle und Verbindung.
`; + } } } @@ -35351,6 +35305,24 @@ if (isMobileLayout()) setMobileSidebarExpanded(false); } + function openSidebarForHomeDestination() { + if (!isMobileLayout()) { + setSidebarCollapsed(false, { persist: false }); + return; + } + window.setTimeout(() => { + if (!isMobileLayout()) { + setSidebarCollapsed(false, { persist: false }); + return; + } + setMobileSidebarExpanded(true); + window.requestAnimationFrame(() => { + const activeItem = document.querySelector(".sidebar-nav .primary-tab.is-active"); + activeItem?.focus({ preventScroll: true }); + }); + }, 0); + } + function routeTokenForView(view) { const routeView = onboardingActive && view !== "onboarding" ? "onboarding" : view || activeView || "home"; if (routeView === "personProfile") return personProfileRoute(); @@ -36825,7 +36797,7 @@ } if (!expertScope) bindFavoriteContactButtons(targetPanel); refreshCustomSelects(targetPanel); - if (!expertScope) loadContactHistory(contact.id); + if (!expertScope) loadContactHistory(contact.id, targetPanel); window.requestAnimationFrame(() => { targetPanel.querySelector('.detail-tabs [role="tab"].is-active')?.scrollIntoView({ block: "nearest", inline: "nearest" }); }); @@ -38269,6 +38241,7 @@ }); routeLinks.forEach((link) => { const routeToken = link.dataset.routeLink || "home"; + const opensSidebarForOrientation = link.matches(".home-destination-link[data-home-module]"); if (APP_ROUTES?.urlForRouteToken) { link.setAttribute("href", APP_ROUTES.urlForRouteToken(routeToken, { search: window.location.search })); } @@ -38290,6 +38263,7 @@ setActiveView(targetView); updateRouteHash(routeToken); updateView(); + if (opensSidebarForOrientation) openSidebarForHomeDestination(); }); }); viewTabs.forEach((tab) => { diff --git a/frontend/data/demo-api.js b/frontend/data/demo-api.js index 0805844..241b3f5 100644 --- a/frontend/data/demo-api.js +++ b/frontend/data/demo-api.js @@ -13,6 +13,7 @@ if (CONFIG.dataMode !== "demo" || CONFIG.authMode !== "anonymous-demo") return; const OWNER_ONLY_CONTACT_CHANNELS = CONFIG.capabilities?.ownerOnlyContactChannels === true; + const ALL_DEMO_CONTACTS_INVITABLE = CONFIG.capabilities?.allDemoContactsInvitable === true; const NOW = "2026-07-19T12:00:00.000Z"; const DEMO_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]); const SENSITIVE_CONTACT_FIELDS = new Set(["email", "phone"]); @@ -27,24 +28,7 @@ ]); const FORMAT_PARTICIPANT_BATCH_LIMIT = 500; const FORMAT_IDEMPOTENCY_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; - const DEMO_NOTICE_DATA_VIEWS = new Set([ - "map", - "contacts", - "organizations", - "activities", - "analytics", - "quality", - "experts", - "patients", - "stakeholders", - "framework", - "hospitations", - "questionnaire", - "formats", - "team", - "personProfile", - "organizationProfile" - ]); + const DIRECTLY_INVITABLE_MITMACHEN_SOURCES = new Set(["online_form", "email", "written"]); const baseline = window.VERSORGUNGS_COMPASS_DEMO_DATA || {}; const originalFetch = window.fetch.bind(window); let idCounter = 0; @@ -70,6 +54,47 @@ || activeProfiles[0]?.id || "demo-profile-admin"; state.contacts ||= []; + if (ALL_DEMO_CONTACTS_INVITABLE) { + const profileIds = new Set(state.profiles.map((profile) => profile.id).filter(Boolean)); + state.contacts = state.contacts.map((contact) => { + const effectiveAt = String(contact.mitmachenConsentEffectiveAt || "").trim(); + const effectiveTime = new Date(effectiveAt).getTime(); + const source = String(contact.mitmachenConsentSource || "").trim(); + const recordedByCandidates = [ + contact.mitmachenConsentRecordedBy, + contact.ownerId, + ...(Array.isArray(contact.ownerIds) ? contact.ownerIds : []), + "demo-profile-admin" + ]; + const recordedBy = recordedByCandidates.find((candidate) => profileIds.has(candidate)) + || state.profiles[0]?.id + || "demo-profile-admin"; + const alreadyDirectlyInvitable = + contact.mitmachenConsentStatus === "granted" + && DIRECTLY_INVITABLE_MITMACHEN_SOURCES.has(source) + && Number.isFinite(effectiveTime) + && effectiveTime <= new Date(NOW).getTime() + && profileIds.has(contact.mitmachenConsentRecordedBy); + const wasArchived = ["archived", "Archiviert"].includes(contact.status); + return { + ...contact, + status: wasArchived ? "active" : contact.status, + note: wasArchived + ? "Synthetischer Demo-Kontakt für öffentliche Format-Einladungen." + : contact.note, + mitmachenConsentStatus: "granted", + mitmachenConsentEffectiveAt: Number.isFinite(effectiveTime) && effectiveTime <= new Date(NOW).getTime() + ? effectiveAt + : NOW, + mitmachenConsentSource: DIRECTLY_INVITABLE_MITMACHEN_SOURCES.has(source) ? source : "written", + mitmachenConsentTextVersion: contact.mitmachenConsentTextVersion || "mitmachen-kontakt-v2", + mitmachenConsentRecordedBy: recordedBy, + mitmachenConsentNote: alreadyDirectlyInvitable && contact.mitmachenConsentNote + ? contact.mitmachenConsentNote + : "Vollständig dokumentierte, rein synthetische #Mitmachen-Einwilligung für Einladungen zu Formaten." + }; + }); + } state.organizations ||= []; state.organizationPrimarySystems = state.organizations.flatMap((organization) => organization.primarySystems || []); state.expertGroups ||= []; @@ -1735,119 +1760,6 @@ return handleDemoApi(url, method, body, ifMatch); }; - function installDemoNotice() { - if (!document.body || document.getElementById("vk-public-demo-notice")) return; - const style = document.createElement("style"); - style.textContent = ` - #vk-public-demo-notice { - position: fixed; z-index: 70; - top: calc(14px + env(safe-area-inset-top, 0px)); right: calc(16px + env(safe-area-inset-right, 0px)); - min-height: 38px; box-sizing: border-box; - display: flex; gap: 10px; align-items: center; - padding: 4px 5px 4px 11px; border: 1px solid #d8e1ef; border-radius: 11px; - color: #64748b; background: rgba(255,255,255,.96); box-shadow: 0 7px 18px rgba(16,35,110,.08); - font: 620 11px/1.25 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - backdrop-filter: blur(12px); - } - #vk-public-demo-notice[hidden], #vk-public-demo-trigger[hidden] { display: none; } - #vk-public-demo-notice .vk-demo-copy { min-width: 0; display: flex; gap: 4px; align-items: center; } - #vk-public-demo-notice strong { color: #475569; font-size: 11px; font-weight: 760; } - #vk-public-demo-notice button { - min-width: 38px; min-height: 30px; padding: 0 9px; border: 0; border-radius: 8px; - color: #17275f; background: #eef3fb; font: inherit; font-weight: 760; cursor: pointer; - } - #vk-public-demo-notice button:hover, #vk-public-demo-notice button:focus-visible { - background: #e2eafb; outline: 3px solid #155fe4; outline-offset: 2px; - } - #vk-public-demo-trigger { - position: fixed; z-index: 70; - top: calc(12px + env(safe-area-inset-top, 0px)); right: calc(12px + env(safe-area-inset-right, 0px)); - width: 38px; height: 38px; display: inline-grid; place-items: center; - padding: 0; border: 1px solid #c9d8ef; border-radius: 11px; - color: #1555a5; background: #eaf2ff; box-shadow: 0 7px 18px rgba(16,35,110,.08); - backdrop-filter: blur(12px); cursor: pointer; - } - #vk-public-demo-trigger:hover, #vk-public-demo-trigger:focus-visible { - background: #dceaff; outline: 3px solid #155fe4; outline-offset: 2px; - } - #vk-public-demo-trigger .vk-demo-trigger-mark { - display: grid; place-items: center; width: 100%; height: 100%; - color: inherit; background: transparent; - } - #vk-public-demo-trigger svg { - width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; - stroke-linecap: round; stroke-linejoin: round; - } - @media (max-width: 620px) { - #vk-public-demo-notice { - top: calc(10px + env(safe-area-inset-top, 0px)); right: calc(10px + env(safe-area-inset-right, 0px)); - } - } - `; - document.head.appendChild(style); - const notice = document.createElement("aside"); - notice.id = "vk-public-demo-notice"; - notice.setAttribute("role", "note"); - notice.setAttribute("aria-label", "Hinweis zur öffentlichen Demo"); - notice.hidden = true; - notice.innerHTML = ` -
Hinweis: Öffentliche Demo
- - `; - const closeButton = notice.querySelector("[data-demo-notice-close]"); - const trigger = document.createElement("button"); - trigger.id = "vk-public-demo-trigger"; - trigger.type = "button"; - trigger.hidden = true; - trigger.setAttribute("aria-label", "Hinweis zur öffentlichen Demo anzeigen"); - trigger.setAttribute("aria-controls", notice.id); - trigger.setAttribute("aria-expanded", "false"); - trigger.innerHTML = ` - - `; - let noticeCollapsed = false; - const appShell = document.querySelector(".app-shell"); - const mobileViewport = window.matchMedia("(max-width: 760px)"); - const syncNoticeVisibility = () => { - const activeView = appShell?.dataset.activeView || ""; - const mobileNavigationOpen = - mobileViewport.matches && - appShell?.classList.contains("is-mobile-sidebar-expanded"); - const eligible = - DEMO_NOTICE_DATA_VIEWS.has(activeView) && - !mobileNavigationOpen; - notice.hidden = !eligible || noticeCollapsed; - trigger.hidden = !eligible || !noticeCollapsed; - trigger.setAttribute("aria-expanded", eligible && !noticeCollapsed ? "true" : "false"); - }; - closeButton.addEventListener("click", () => { - noticeCollapsed = true; - syncNoticeVisibility(); - if (!trigger.hidden) trigger.focus({ preventScroll: true }); - }); - trigger.addEventListener("click", () => { - noticeCollapsed = false; - syncNoticeVisibility(); - if (!notice.hidden) closeButton.focus({ preventScroll: true }); - }); - (document.querySelector(".app-main") || document.body).prepend(notice); - document.body.appendChild(trigger); - if (appShell) { - new MutationObserver(syncNoticeVisibility).observe(appShell, { - attributes: true, - attributeFilter: ["data-active-view", "class"] - }); - } - mobileViewport.addEventListener?.("change", syncNoticeVisibility); - syncNoticeVisibility(); - } - window.VERSORGUNGS_COMPASS_DEMO_RUNTIME = Object.freeze({ publicDemo: true, persistence: "memory-only", @@ -1866,6 +1778,4 @@ } }); - if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", installDemoNotice, { once: true }); - else installDemoNotice(); })(); diff --git a/scripts/audit_public_assets.mjs b/scripts/audit_public_assets.mjs index 0c2fab1..a309263 100644 --- a/scripts/audit_public_assets.mjs +++ b/scripts/audit_public_assets.mjs @@ -177,6 +177,7 @@ if (existsSync(runtimeConfigPath)) { assert(/requireApiGateway:\s*false/.test(runtimeConfig), `${artifactLabel}/data/runtime-config.js fordert unerwartet ein API-Gateway`); assert(/cleanUrls:\s*false/.test(runtimeConfig), `${artifactLabel}/data/runtime-config.js darf ohne Rewrite-Server keine Clean URLs aktivieren`); assert(/ownerOnlyContactChannels:\s*true/.test(runtimeConfig), `${artifactLabel}/data/runtime-config.js aktiviert den Owner-Schutz fuer Kontaktkanaele nicht`); + assert(/allDemoContactsInvitable:\s*true/.test(runtimeConfig), `${artifactLabel}/data/runtime-config.js gibt nicht alle Demo-Bestandskontakte fuer Einladungen frei`); assert(!/apiBaseUrl:\s*["']https?:/i.test(runtimeConfig), `${artifactLabel}/data/runtime-config.js konfiguriert einen externen API-Zugriff`); } diff --git a/scripts/audit_target_assets.mjs b/scripts/audit_target_assets.mjs index 12d57bb..b9e82d5 100644 --- a/scripts/audit_target_assets.mjs +++ b/scripts/audit_target_assets.mjs @@ -103,6 +103,7 @@ if (existsSync(configPath)) { assert(/apiCredentials:\s*"include"/.test(config), `${artifactLabel}/data/runtime-config.js sendet keine geschuetzte Sitzung`); assert(/authMode:\s*"(?:iap|oidc)"/.test(config), `${artifactLabel}/data/runtime-config.js verwendet keinen erlaubten signierten Auth-Modus`); assert(!/ownerOnlyContactChannels:\s*true/.test(config), `${artifactLabel}/data/runtime-config.js darf den Pages-spezifischen Owner-Schutz nicht aktivieren`); + assert(!/allDemoContactsInvitable:\s*true/.test(config), `${artifactLabel}/data/runtime-config.js darf keine synthetische Demo-Einladungsfreigabe aktivieren`); assert(!/supabaseUrl|supabaseAnonKey|registrationEndpoint/.test(config), `${artifactLabel}/data/runtime-config.js enthaelt direkte Supabase-Browserkonfiguration`); } diff --git a/scripts/build_static_frontend.sh b/scripts/build_static_frontend.sh index d3c8680..2fb90cc 100755 --- a/scripts/build_static_frontend.sh +++ b/scripts/build_static_frontend.sh @@ -211,6 +211,7 @@ window.VERSORGUNGS_COMPASS_CONFIG = { capabilities: { contactRole: true, contactConsent: true, + allDemoContactsInvitable: true, ownerOnlyContactChannels: true, organizationPrimarySystems: true, registrationIntake: true, diff --git a/scripts/test_demo_api.mjs b/scripts/test_demo_api.mjs index cf9cde7..7d6c949 100644 --- a/scripts/test_demo_api.mjs +++ b/scripts/test_demo_api.mjs @@ -24,17 +24,13 @@ assert.match(demoApiSource, /resetOnReload:\s*true/, "Die Demo-Runtime muss den assert.match(demoApiSource, /const\s+baseline\s*=\s*window\.VERSORGUNGS_COMPASS_DEMO_DATA/, "Die Demo-API muss ausschließlich den synthetischen Datensatz als Baseline verwenden."); assert.match(demoApiSource, /const\s+state\s*=\s*clone\(baseline\)|const\s+state\s*=\s*clone\s*\(\s*baseline\s*\)/, "Die Demo-API muss ihre Baseline tief kopieren."); assert.match(demoApiSource, /ownerOnlyContactChannels\s*===\s*true/, "Die Owner-Projektion muss explizit durch die Pages-Capability aktiviert werden."); +assert.match(demoApiSource, /allDemoContactsInvitable\s*===\s*true/, "Die vollständige Demo-Einladungsfreigabe muss explizit durch die Pages-Capability aktiviert werden."); assert.match(demoApiSource, /contactChannelAccess:\s*hasAccess\s*\?\s*["']owner["']\s*:\s*["']restricted["']/, "Der Demo-Adapter muss den Kontaktkanal-Zugriff explizit ausweisen."); -assert.match(demoApiSource, /Hinweis:<\/strong>\s*Öffentliche Demo<\/span>/, "Der Demo-Hinweis muss die öffentliche Demo knapp als Hinweis benennen."); -assert.doesNotMatch(demoApiSource, /Bitte keine echten Angaben eingeben/, "Der subtile Demo-Hinweis darf keinen zusätzlichen Warnsatz anzeigen."); -assert.match(demoApiSource, /data-demo-notice-close>OK<\/button>/, "Der Demo-Hinweis muss sich mit einer knappen Bestätigung schließen lassen."); -assert.match(demoApiSource, /id\s*=\s*["']vk-public-demo-trigger["']/, "Die geschlossene Demo-Leiste muss über einen schwebenden Trigger wieder erreichbar sein."); -assert.match(demoApiSource, /[\s\S]*? item.id === "demo-contact-76"); + Object.assign(contact, { + mitmachenConsentStatus: "not_requested", + mitmachenConsentEffectiveAt: "", + mitmachenConsentSource: "", + mitmachenConsentTextVersion: "", + mitmachenConsentRecordedBy: "", + mitmachenConsentNote: "" + }); +} + const inactiveRuntime = createRuntime({ dataMode: "api", authMode: "oidc" }); assert.equal(inactiveRuntime.window.fetch, inactiveRuntime.originalFetch, "Demo-API darf sich außerhalb des expliziten Demo-Profils nicht aktivieren."); assert.equal(inactiveRuntime.window.VersorgungsCompassDemoApi, undefined, "Target-Profil darf keine Demo-Runtime exportieren."); assert.equal(inactiveRuntime.window.VERSORGUNGS_COMPASS_DEMO_RUNTIME, undefined, "Target-Profil darf keine Demo-Metadaten exportieren."); -const editorRuntime = createRuntime({ demoProfile: "demo-profile-editor" }); +const editorRuntime = createRuntime({ + demoProfile: "demo-profile-editor", + allDemoContactsInvitable: false, + mutateDemoData: makeContact76EhcOnly +}); assert.equal( editorRuntime.window.VersorgungsCompassDemoApi.snapshot().currentProfileId, "demo-profile-editor", @@ -169,7 +183,9 @@ assert.equal(legacyContact.email, "kontakt-002@versorgung.example.invalid", "Ohn assert.equal(legacyContact.contactChannelAccess, undefined, "Ohne Pages-Capability darf kein neuer Access-State erzwungen werden."); const legacyRestrictedEhcRuntime = createRuntime({ demoProfile: "demo-profile-editor", - ownerOnlyContactChannels: false + ownerOnlyContactChannels: false, + allDemoContactsInvitable: false, + mutateDemoData: makeContact76EhcOnly }); const legacyRestrictedEhcContact = await ( await legacyRestrictedEhcRuntime.window.fetch("/api/contacts/demo-contact-76") @@ -181,7 +197,9 @@ assert.equal(legacyRestrictedEhcContact.email, ""); for (const ehcConsentStatus of ["withdrawn", "not_requested"]) { const historicalEhcRuntime = createRuntime({ demoProfile: "demo-profile-editor", + allDemoContactsInvitable: false, mutateDemoData(demoData) { + makeContact76EhcOnly(demoData); const contact = demoData.contacts.find((item) => item.id === "demo-contact-76"); contact.ehcConsentStatus = ehcConsentStatus; } @@ -199,7 +217,9 @@ for (const ehcConsentStatus of ["withdrawn", "not_requested"]) { } const verbalMitmachenEhcRuntime = createRuntime({ demoProfile: "demo-profile-editor", + allDemoContactsInvitable: false, mutateDemoData(demoData) { + makeContact76EhcOnly(demoData); const contact = demoData.contacts.find((item) => item.id === "demo-contact-76"); contact.mitmachenConsentStatus = "granted"; contact.mitmachenConsentSource = "verbal_confirmed"; @@ -270,7 +290,7 @@ assert.deepEqual( "Die Runtime-Metadaten müssen den öffentlichen, synthetischen memory-only Betrieb eindeutig beschreiben." ); assert.equal(runtime.storageAccesses.length, 0, "Initialisierung darf keinen persistenten Browser-Speicher berühren."); -assert.ok(runtime.documentListeners.has("DOMContentLoaded"), "Der sichtbare Demo-Hinweis muss für DOMContentLoaded registriert sein."); +assert.equal(runtime.documentListeners.has("DOMContentLoaded"), false, "Ohne schwebenden Demo-Hinweis ist kein DOMContentLoaded-Hook nötig."); const initialSnapshot = api.snapshot(); const immutableBaselineCount = window.VERSORGUNGS_COMPASS_DEMO_DATA.contacts.length; @@ -286,13 +306,29 @@ assert.equal(initialSnapshot.contacts.find((contact) => contact.id === "demo-con assert.equal(initialSnapshot.contacts.find((contact) => contact.id === "demo-contact-17")?.contactChannelAccess, "restricted"); assert.equal(initialSnapshot.contacts.find((contact) => contact.id === "demo-contact-17")?.email, "", "Ownerlose Kontakte müssen für alle Demo-Profile eingeschränkt bleiben."); const ownedEhcOnlyContact = initialSnapshot.contacts.find((contact) => contact.id === "demo-contact-76"); -assert.equal(ownedEhcOnlyContact?.profileAccess, "ehc_authorized", "Der EHC-Owner benötigt den expliziten Vollprofilzugriff."); +assert.equal(ownedEhcOnlyContact?.profileAccess, undefined, "Ein schriftlich für #Mitmachen freigegebener Kontakt ist nicht mehr EHC-only."); assert.equal(ownedEhcOnlyContact?.contactChannelAccess, "owner"); assert.notEqual(ownedEhcOnlyContact?.name, "Geschützter EHC-Kontakt"); assert.equal(ownedEhcOnlyContact?.ehcConsentStatus, "granted"); assert.equal(ownedEhcOnlyContact?.ehcConsentSource, "survalyzer_ehc"); -assert.equal(ownedEhcOnlyContact?.mitmachenConsentStatus, "not_requested"); +assert.equal(ownedEhcOnlyContact?.mitmachenConsentStatus, "granted"); +assert.equal(ownedEhcOnlyContact?.mitmachenConsentSource, "written"); assert.ok(ownedEhcOnlyContact?.ehcConsentTextVersion, "Der Owner muss den vollständigen synthetischen EHC-Nachweis sehen."); +const directlyInvitableContacts = initialSnapshot.contacts.filter((contact) => { + const effectiveTime = new Date(contact.mitmachenConsentEffectiveAt || "").getTime(); + return contact.mitmachenConsentStatus === "granted" + && ["online_form", "email", "written"].includes(contact.mitmachenConsentSource) + && Number.isFinite(effectiveTime) + && effectiveTime <= new Date(FORMAT_TEST_NOW).getTime() + && Boolean(contact.mitmachenConsentTextVersion) + && Boolean(contact.mitmachenConsentRecordedBy); +}); +assert.equal(directlyInvitableContacts.length, initialSnapshot.contacts.length, "Alle 130 Bestandskontakte der öffentlichen Demo müssen grün einladbar sein."); +assert.equal( + initialSnapshot.contacts.filter((contact) => ["archived", "Archiviert"].includes(contact.status)).length, + 0, + "Die öffentliche Demo darf keine archivierten und dadurch nicht einladbaren Bestandskontakte enthalten." +); assert.equal( window.VERSORGUNGS_COMPASS_DEMO_DATA.contacts.find((contact) => contact.id === "demo-contact-02")?.email, "kontakt-002@versorgung.example.invalid", @@ -841,6 +877,7 @@ assert.ok(!afterHospitationDelete.hospitationRoadmapAssessments.some((item) => ( assert.ok(!afterHospitationDelete.hospitationUnmetNeeds.some((item) => (item.hospitationId || item.hospitation_id) === hospitationId), "Unmet Needs müssen lokal kaskadieren."); const formatContractRuntime = createRuntime({ + allDemoContactsInvitable: false, mutateDemoData(demoData) { demoData.formats = []; demoData.contacts = [ diff --git a/scripts/test_deployment_separation.mjs b/scripts/test_deployment_separation.mjs index b43e893..efda5e9 100644 --- a/scripts/test_deployment_separation.mjs +++ b/scripts/test_deployment_separation.mjs @@ -159,6 +159,7 @@ try { assert.match(pagesConfig, /apiBaseUrl:\s*""/); assert.match(pagesConfig, /requireApiGateway:\s*false/); assert.match(pagesConfig, /ownerOnlyContactChannels:\s*true/); + assert.match(pagesConfig, /allDemoContactsInvitable:\s*true/); const pagesHtml = fs.readFileSync(path.join(pagesDir, "versorgungs-kompass.html"), "utf8"); const demoDataPosition = pagesHtml.indexOf("./data/demo-data.js"); @@ -260,6 +261,7 @@ try { assert.match(targetConfig, /apiCredentials:\s*"include"/); assert.match(targetConfig, /requireApiGateway:\s*true/); assert.doesNotMatch(targetConfig, /ownerOnlyContactChannels:\s*true/); + assert.doesNotMatch(targetConfig, /allDemoContactsInvitable:\s*true/); assert.ok(targetConfig.includes(`apiBaseUrl: "${apiBaseUrl}"`)); assert.doesNotMatch(targetConfig, /supabaseUrl|supabaseAnonKey|registrationEndpoint/); diff --git a/tests/contact-owner-visibility.spec.js b/tests/contact-owner-visibility.spec.js index b5b83cb..4d8a830 100644 --- a/tests/contact-owner-visibility.spec.js +++ b/tests/contact-owner-visibility.spec.js @@ -64,6 +64,7 @@ test.describe("GitHub-Pages-Demo: Owner-Sichtbarkeit der Kontaktkanäle", () => const runtimeConfig = await runtimeResponse.text(); expect(runtimeConfig).toContain('dataMode: "demo"'); expect(runtimeConfig).toMatch(/ownerOnlyContactChannels:\s*true/); + expect(runtimeConfig).toMatch(/allDemoContactsInvitable:\s*true/); }); test("Admin-Owner sieht E-Mail und Telefon samt Kontaktlinks", async ({ page }) => { @@ -79,16 +80,16 @@ test.describe("GitHub-Pages-Demo: Owner-Sichtbarkeit der Kontaktkanäle", () => }); }); - test("EHC-Owner sieht das vollständige zweckgebundene Profil", async ({ page }) => { + test("EHC-Kontakt ist zusätzlich vollständig für #Mitmachen freigegeben", async ({ page }) => { await page.goto(pagesUrl(PROFILE_ADMIN, "#person/contact/demo-contact-76?tab=consent")); const profile = page.locator("#person-profile-body"); await expect(profile).toBeVisible(); await expect(profile.getByRole("heading", { level: 3, name: EHC_ONLY_NAME })).toBeVisible(); - await expect(profile.locator(".ehc-profile-badge")).toContainText("EHC-only"); - await expect(profile.locator(".consent-scope-summary")).toContainText("Nur für die E-Health Community"); + await expect(profile.locator(".ehc-profile-badge")).toHaveCount(0); + await expect(profile.locator(".consent-scope-summary")).toContainText("#Mitmachen-Nachrichten erlaubt"); await expect(profile.locator('[data-consent-signal="ehc"]')).toContainText("Freigegeben"); - await expect(profile.locator('[data-consent-signal="mitmachen"]')).toContainText("Nicht angefragt"); + await expect(profile.locator('[data-consent-signal="mitmachen"]')).toContainText("Freigegeben"); await profile.getByRole("tab", { name: "Kontakt", exact: true }).click(); await expectContactLinks(profile.locator("#detail-contactways"), { @@ -97,22 +98,24 @@ test.describe("GitHub-Pages-Demo: Owner-Sichtbarkeit der Kontaktkanäle", () => }); }); - test("EHC-only-Profil bleibt für normale Nutzer nicht identifizierbar", async ({ page }) => { + test("Schriftliche #Mitmachen-Freigabe hebt den EHC-only-Profilschutz auf", async ({ page }) => { await page.goto(pagesUrl(PROFILE_EDITOR, "#person/contact/demo-contact-76?tab=consent")); const profile = page.locator("#person-profile-body"); await expect(profile).toBeVisible(); - await expect(profile.getByRole("heading", { level: 3, name: "Geschützter EHC-Kontakt" })).toBeVisible(); - await expect(profile.locator(".ehc-profile-badge")).toContainText("EHC geschützt"); - await expect(profile.locator(".consent-scope-summary")).toContainText("Nur für die E-Health Community"); - await expect(profile.locator('[data-consent-signal="ehc"]')).toContainText("Freigegeben · geschützt"); - await expect(profile.locator("[data-edit-consent-overview], [data-detail-edit-section], [data-detail-owner-edit]")).toHaveCount(0); + await expect(profile.getByRole("heading", { level: 3, name: EHC_ONLY_NAME })).toBeVisible(); + await expect(profile.locator(".ehc-profile-badge")).toHaveCount(0); + await expect(profile.locator(".consent-scope-summary")).toContainText("#Mitmachen-Nachrichten erlaubt"); + await expect(profile.locator('[data-consent-signal="ehc"]')).toContainText("Freigegeben"); + await expect(profile.locator('[data-consent-signal="mitmachen"]')).toContainText("Freigegeben"); + await expect(profile).toContainText(EHC_ONLY_ORGANIZATION); - const pageMarkup = await page.locator("html").innerHTML(); - for (const protectedValue of [EHC_ONLY_NAME, EHC_ONLY_EMAIL, EHC_ONLY_PHONE, EHC_ONLY_ORGANIZATION]) { - expect(pageMarkup).not.toContain(protectedValue); - } - await expect(page.locator('a[href^="mailto:"], a[href^="tel:"]')).toHaveCount(0); + await profile.getByRole("tab", { name: "Kontakt", exact: true }).click(); + const contactPanel = profile.locator("#detail-contactways"); + await expect(contactPanel.locator(".contact-channel-restricted")).toHaveCount(2); + await expect(contactPanel).not.toContainText(EHC_ONLY_EMAIL); + await expect(contactPanel).not.toContainText(EHC_ONLY_PHONE); + await expect(contactPanel.locator('a[href^="mailto:"], a[href^="tel:"]')).toHaveCount(0); }); test("Admin-Non-Owner sieht weder Werte noch Links des eingeschränkten Kontakts", async ({ page }) => { diff --git a/tests/delivery-ui.spec.js b/tests/delivery-ui.spec.js index 43c52d0..4993b5e 100644 --- a/tests/delivery-ui.spec.js +++ b/tests/delivery-ui.spec.js @@ -71,20 +71,21 @@ test("Desktop-Auslieferung: Pages startet direkt in der App-Startseite", async ( const heroSpacing = await page.locator(".home-hero").evaluate((hero) => { const brand = hero.querySelector(".home-hero__brand")?.getBoundingClientRect(); const title = hero.querySelector(".home-reveal-heading")?.getBoundingClientRect(); - const lead = hero.querySelector(".home-hero__lead")?.getBoundingClientRect(); const cue = hero.querySelector(".home-scroll-cue")?.getBoundingClientRect(); return { brandWidth: brand?.width || 0, brandToTitle: brand && title ? title.top - brand.bottom : 0, - titleToLead: title && lead ? lead.top - title.bottom : 0, - leadToCue: lead && cue ? cue.top - lead.bottom : 0 + titleToCue: title && cue ? cue.top - title.bottom : 0 }; }); const isMobileProject = testInfo.project.name.includes("mobile"); expect(heroSpacing.brandWidth).toBeGreaterThan(isMobileProject ? 250 : 340); expect(heroSpacing.brandToTitle).toBeGreaterThanOrEqual(isMobileProject ? 34 : 44); - expect(heroSpacing.titleToLead).toBeGreaterThanOrEqual(26); - expect(heroSpacing.leadToCue).toBeGreaterThanOrEqual(38); + expect(heroSpacing.titleToCue).toBeGreaterThanOrEqual(38); + await expect(page.locator(".home-hero .home-destinations__intro")).toHaveCount(0); + await expect(page.locator("#home-destinations > .home-destinations__intro")).toHaveText( + "Wähle den Kompass, in dem du weiterarbeiten möchtest." + ); }); test("Desktop-Auslieferung: reduzierte Bewegung zeigt die Startüberschrift ruhig", async ({ page }) => { @@ -98,6 +99,35 @@ test("Desktop-Auslieferung: reduzierte Bewegung zeigt die Startüberschrift ruhi await expect(heading.locator(".home-reveal-heading__char").first()).toHaveCSS("opacity", "1"); }); +test("Startkarte öffnet den passenden Navigationsbereich auf Desktop", async ({ page }, testInfo) => { + test.skip(testInfo.project.name.includes("mobile"), "Der Desktop-Zustand wird im Desktop-Projekt geprüft."); + await page.goto("/dist/pages/index.html"); + + const shell = page.locator(".app-shell"); + await expect(shell).toHaveClass(/is-sidebar-collapsed/); + await page.locator('.home-destination-link[data-home-module="planning"]').click(); + + await expect(page).toHaveURL(/#framework$/); + await expect(shell).not.toHaveClass(/is-sidebar-collapsed/); + await expect(page.locator('[data-sidebar-section="planning"]')).toHaveClass(/is-active-section/); + await expect(page.locator('[data-sidebar-section-toggle="planning"]')).toHaveAttribute("aria-expanded", "true"); + await expect(page.locator('[data-view-tab="framework"]')).toHaveAttribute("aria-current", "page"); +}); + +test("Startkarte öffnet und fokussiert die mobile Navigation", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/dist/pages/index.html"); + + const shell = page.locator(".app-shell"); + await page.locator('.home-destination-link[data-home-module="stakeholders"]').click(); + + await expect(page).toHaveURL(/#stakeholders\/kv$/); + await expect(shell).toHaveClass(/is-mobile-sidebar-expanded/); + await expect(page.locator('[data-sidebar-section="stakeholders"]')).toHaveClass(/is-active-section/); + await expect(page.locator('[data-sidebar-section-toggle="stakeholders"]')).toHaveAttribute("aria-expanded", "true"); + await expect(page.locator('[data-sidebar-section="stakeholders"] .primary-tab.is-active')).toBeFocused(); +}); + test("Mobile-Auslieferung: Startklar-Hinweis bleibt oberhalb der Fußzeile", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/dist/pages/index.html"); diff --git a/tests/demo-contact-adjustments.spec.js b/tests/demo-contact-adjustments.spec.js new file mode 100644 index 0000000..c670e84 --- /dev/null +++ b/tests/demo-contact-adjustments.spec.js @@ -0,0 +1,120 @@ +import { expect, test } from "@playwright/test"; + +const PAGES_APP = "/dist/pages/versorgungs-kompass.html"; + +test("Öffentliche Demo stellt alle Bestandskontakte vollständig grün und einladbar", async ({ page }) => { + await page.goto(`${PAGES_APP}#contacts`); + await expect(page.locator('[data-view-panel="contacts"]')).toBeVisible(); + + const invitationCheck = await page.evaluate(async () => { + const snapshot = window.VersorgungsCompassDemoApi?.snapshot(); + const contacts = snapshot?.contacts || []; + const invalidContacts = contacts.filter((contact) => { + const effectiveTime = new Date(contact.mitmachenConsentEffectiveAt || "").getTime(); + return contact.mitmachenConsentStatus !== "granted" + || !["online_form", "email", "written"].includes(contact.mitmachenConsentSource) + || !Number.isFinite(effectiveTime) + || effectiveTime > Date.now() + || !contact.mitmachenConsentTextVersion + || !contact.mitmachenConsentRecordedBy; + }).map((contact) => contact.id); + const archivedContacts = contacts + .filter((contact) => ["archived", "Archiviert"].includes(contact.status)) + .map((contact) => contact.id); + const formatId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + const createResponse = await window.fetch("/api/formats", { + method: "POST", + body: JSON.stringify({ + title: "Einladbarkeitsprüfung der öffentlichen Demo", + status: "Planung", + idempotencyKey: formatId + }) + }); + const invitationResponse = await window.fetch(`/api/formats/${formatId}/participants/batch`, { + method: "POST", + body: JSON.stringify({ + items: contacts.map((contact) => ({ + contactId: contact.id, + invitationStatus: "Eingeladen" + })) + }) + }); + return { + invalidContacts, + archivedContacts, + createStatus: createResponse.status, + invitationStatus: invitationResponse.status, + invitationPayload: await invitationResponse.json() + }; + }); + + expect(invitationCheck.invalidContacts).toEqual([]); + expect(invitationCheck.archivedContacts).toEqual([]); + expect(invitationCheck.createStatus).toBe(201); + expect(invitationCheck.invitationStatus).toBe(200); + expect(invitationCheck.invitationPayload.participants).toHaveLength(130); + await expect(page.locator("#vk-public-demo-notice, #vk-public-demo-trigger")).toHaveCount(0); +}); + +test("Kontaktverlauf lädt nach Rückkehr aus einem Vollprofil im sichtbaren Drawer", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-desktop", "Der Desktop-Drawer ist für diesen Regressionspfad maßgeblich."); + + await page.goto(`${PAGES_APP}#person/contact/demo-contact-130?tab=activity`); + const fullProfile = page.locator("#person-profile-body"); + await expect(fullProfile.locator('[data-detail-tab="activity"]')).toHaveAttribute("aria-selected", "true"); + await expect(fullProfile.locator("#history-timeline .history-loading")).toHaveCount(0); + + await fullProfile.locator("[data-person-profile-back]").click(); + await expect(page.locator('[data-view-panel="contacts"]')).toBeVisible(); + await page.locator('#contact-list [data-id="demo-contact-01"]').click(); + + const drawer = page.locator("#detail-drawer"); + await expect(drawer).toHaveAttribute("aria-hidden", "false"); + await drawer.locator('[data-detail-tab="activity"]').click(); + await expect(drawer.locator("#history-timeline .history-loading")).toHaveCount(0); + await expect(drawer.locator("#history-timeline .history-item, #history-timeline .history-empty").first()).toBeVisible(); +}); + +test("Kontaktverlauf verwirft verspätete Antworten eines zuvor geöffneten Kontakts", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-desktop", "Der wiederverwendete Desktop-Drawer ist für diesen Regressionspfad maßgeblich."); + + await page.goto(`${PAGES_APP}#contacts`); + await page.evaluate(() => { + const originalGetContactChanges = window.dataService.getContactChanges.bind(window.dataService); + window.dataService.getContactChanges = async (contactId, options) => { + const changes = await originalGetContactChanges(contactId, options); + await new Promise((resolve) => window.setTimeout(resolve, contactId === "demo-contact-01" ? 250 : 10)); + return changes; + }; + }); + + const drawer = page.locator("#detail-drawer"); + await page.locator('#contact-list [data-id="demo-contact-01"]').click(); + await drawer.locator('[data-detail-tab="activity"]').click(); + await expect(drawer.locator("#history-timeline .history-loading")).toBeVisible(); + await drawer.locator("#detail-close").click(); + + await page.locator('#contact-list [data-id="demo-contact-02"]').click(); + await drawer.locator('[data-detail-tab="activity"]').click(); + await expect(drawer.locator("#history-timeline")).toContainText("Verantwortung zugeordnet"); + await page.waitForTimeout(300); + await expect(drawer.locator("#history-timeline")).toContainText("Verantwortung zugeordnet"); + await expect(drawer.locator("#history-timeline")).not.toContainText("Kontaktdaten aktualisiert"); +}); + +test("Kontakt-Formate zeigen keinen Einladungsstatus und das Format-Akkordeon kein Personenbild", async ({ page }) => { + await page.goto(`${PAGES_APP}#person/contact/demo-contact-01?tab=formats`); + const profile = page.locator("#person-profile-body"); + const formatSection = profile.locator("[data-format-profile-section]"); + await expect(formatSection).toBeVisible(); + await expect(formatSection.locator("[data-format-profile-status], .format-participation-status")).toHaveCount(0); + + await page.evaluate(() => { window.location.hash = "#formats"; }); + const format = page.locator('[data-format-detail="demo-format-krankenhausentlassbrief"]'); + await expect(format).toBeVisible(); + if (!await format.evaluate((node) => node.classList.contains("is-open"))) { + await format.locator("[data-toggle-format-detail]").click(); + } + await expect(format.locator(".format-overview-hero")).toBeVisible(); + await expect(format.locator(".format-overview-visual, .format-roundtable-illustration")).toHaveCount(0); +}); diff --git a/tests/format-participations.spec.js b/tests/format-participations.spec.js index 7a73e19..4f37ca3 100644 --- a/tests/format-participations.spec.js +++ b/tests/format-participations.spec.js @@ -133,7 +133,7 @@ async function expectNoHorizontalOverflow(page, selector = "html") { expect(overflow).toBeLessThanOrEqual(1); } -test("Issue 28: Kontaktprofil zeigt Beteiligungsstatus kompakt; Pflege erfolgt im Format", async ({ page }, testInfo) => { +test("Kontaktprofil listet Formate ohne Einladungsstatus; Pflege erfolgt im Format", async ({ page }, testInfo) => { await gotoAuthenticated(page, "/frontend/app/versorgungs-kompass.html#person/contact/format-contact-ada", { role: "admin", backendFixtureScript: formatParticipationBackendFixtureScript() @@ -156,9 +156,8 @@ test("Issue 28: Kontaktprofil zeigt Beteiligungsstatus kompakt; Pflege erfolgt i const pastGroup = formatSection.locator("[data-format-profile-group='past']"); await expect(pastGroup).not.toHaveAttribute("open", ""); await expect(pastGroup.locator("[data-format-profile-item='format-past']")).toBeHidden(); - await expect(formatSection.locator("[data-format-profile-status]")).toHaveCount(2); - await expect(formatSection.locator("[data-format-profile-status='format-future']")).toHaveValue("Eingeladen"); - await expect(formatSection.locator("[data-format-profile-status='format-past']")).toHaveValue("Teilgenommen"); + await expect(formatSection.locator("[data-format-profile-status]")).toHaveCount(0); + await expect(formatSection.locator(".format-participation-status")).toHaveCount(0); await expect(formatSection.locator("[data-format-profile-status-form]")).toHaveCount(0); await expect(formatSection.locator("[data-format-profile-action='edit-status']")).toHaveCount(0); await expect(formatSection.locator("[data-format-profile-link-form]")).toHaveCount(0); @@ -169,11 +168,13 @@ test("Issue 28: Kontaktprofil zeigt Beteiligungsstatus kompakt; Pflege erfolgt i ); await expect(futureItem.locator(".format-profile__trailing .owner-badge__label")).toHaveText("Erika Editor"); await expect(futureItem.locator(".format-profile__trailing .format-profile__type-badge")).toHaveText("Workshop"); - await expect(futureItem.locator("[data-format-profile-status='format-future']")).toHaveValue("Eingeladen"); + await expect(futureItem).not.toContainText("Eingeladen"); await page.locator("#global-status").evaluate((status) => { status.hidden = true; }); await page.screenshot({ path: testInfo.outputPath("kontaktprofil-beteiligung-formate.png"), fullPage: false }); await formatSection.getByRole("button", { name: "Zu Format hinzufügen" }).click(); await expect(formatSection.locator("[data-format-profile-link-form] option[value='format-unlinked']")).toHaveCount(1); + await expect(formatSection.locator("[data-format-profile-link-form] [name='invitationStatus']")).toHaveCount(0); + await expect(formatSection.locator("[data-format-profile-link-form]")).not.toContainText("Beteiligungsstatus"); await expect(formatSection.locator("[data-format-profile-link-form]").getByRole("button", { name: "Hinzufügen", exact: true })).toBeVisible(); await page.screenshot({ path: testInfo.outputPath("kontaktprofil-beteiligung-format-verknuepfen.png"), fullPage: false }); await formatSection.locator("[data-format-profile-action='cancel-link']").click(); @@ -187,7 +188,7 @@ test("Issue 28: Kontaktprofil zeigt Beteiligungsstatus kompakt; Pflege erfolgt i ); await expect(pastItem.locator(".format-profile__trailing .owner-badge__label")).toHaveText("Erika Editor"); await expect(pastItem.locator(".format-profile__trailing .format-profile__type-badge")).toHaveText("Fachgespräch"); - await expect(pastItem.locator("[data-format-profile-status='format-past']")).toHaveValue("Teilgenommen"); + await expect(pastItem).not.toContainText("Teilgenommen"); await profile.locator("[data-format-profile-item='format-future'] [data-format-profile-action='open']").click(); await expect(page.locator(".app-shell")).toHaveAttribute("data-active-view", "formats"); @@ -204,7 +205,8 @@ test("Issue 28: Kontaktprofil zeigt Beteiligungsstatus kompakt; Pflege erfolgt i await page.locator("[data-format-detail='format-future'] [data-open-format-contact='format-contact-ada']").click(); await expect(page.locator(".app-shell")).toHaveAttribute("data-active-view", "personProfile"); await profile.locator('[data-detail-tab="formats"]').click(); - await expect(profile.locator("[data-format-profile-status='format-future']")).toHaveValue("Teilgenommen"); + await expect(profile.locator("[data-format-profile-status='format-future']")).toHaveCount(0); + await expect(profile.locator("[data-format-profile-item='format-future']")).not.toContainText("Teilgenommen"); await expect(profile.locator("[data-format-profile-item='format-future']")).not.toContainText("Rolle / Beitrag: Impulsgeberin"); await profile.locator("[data-format-profile-action='list']").click(); @@ -220,7 +222,7 @@ test("Issue 28: Kontaktprofil zeigt Beteiligungsstatus kompakt; Pflege erfolgt i } }); -test("Issue 28: Viewer sehen Beteiligungen, können sie aber nicht verändern", async ({ page }) => { +test("Viewer sehen verknüpfte Formate ohne Einladungsstatus", async ({ page }) => { await gotoAuthenticated(page, "/frontend/app/versorgungs-kompass.html#person/contact/format-contact-ada", { role: "viewer", backendFixtureScript: formatParticipationBackendFixtureScript("viewer") @@ -235,6 +237,6 @@ test("Issue 28: Viewer sehen Beteiligungen, können sie aber nicht verändern", await expect(formatSection.locator("[data-format-profile-action='edit-status']")).toHaveCount(0); await expect(formatSection.locator("[data-format-profile-action='link']")).toHaveCount(0); await expect(formatSection.locator("[data-format-profile-action='list']")).toHaveText("Alle Formate anzeigen"); - await expect(formatSection).toContainText("Eingeladen"); - await expect(formatSection).toContainText("Teilgenommen"); + await expect(formatSection).not.toContainText("Eingeladen"); + await expect(formatSection).not.toContainText("Teilgenommen"); }); diff --git a/tests/visual-smoke.spec.js b/tests/visual-smoke.spec.js index 1255643..c480572 100644 --- a/tests/visual-smoke.spec.js +++ b/tests/visual-smoke.spec.js @@ -6456,7 +6456,7 @@ test("Formate: Arbeitsbereich und Editor rendern", async ({ page }, testInfo) => await expect(createdFormat.locator(".format-type-icon")).toBeVisible(); await createdFormat.locator("[data-toggle-format-detail]").click(); await expect(page.locator(".format-overview-hero")).toBeVisible(); - await expect(page.locator(".format-roundtable-illustration")).toBeVisible(); + await expect(page.locator(".format-roundtable-illustration, .format-overview-visual")).toHaveCount(0); await expect(createdFormat).toBeVisible(); await expect(page.locator('[data-edit-format]')).toHaveCount(0); await expect(page.locator('[data-format-tab="notes"]')).toHaveText("Notizen"); @@ -6512,8 +6512,7 @@ test("Formate: Arbeitsbereich und Editor rendern", async ({ page }, testInfo) => hasText: "Roundtable Testversorgung" }); await expect(createdFormatProfileItem).toBeVisible(); - await expect(createdFormatProfileItem.locator("[data-format-profile-status]")).toBeVisible(); - await expect(createdFormatProfileItem.locator("[data-format-profile-status]")).toHaveValue("Kandidat"); + await expect(createdFormatProfileItem.locator("[data-format-profile-status], .format-participation-status")).toHaveCount(0); await page.locator("#person-profile-body [data-person-profile-back]").click(); await expect(page.locator(".app-shell")).toHaveAttribute("data-active-view", "formats"); await expect(page.locator(".format-diversity-board")).toHaveCount(0);