From a9ad7eb1a7fefc81449b0e7524faea34c7a42783 Mon Sep 17 00:00:00 2001 From: Antonio Ledebuhr Date: Thu, 27 Aug 2026 00:41:24 +0200 Subject: [PATCH] Rewrite the constraint specs and docs for the service-led information architecture. Assertions now follow the eighteen prerenderable paths, the gist dialog, and the six-case evidence set. Co-authored-by: Cursor --- AGENTS.md | 6 +- README.md | 2 +- e2e/a11y.e2e.ts | 4 +- e2e/fragments.e2e.ts | 4 +- e2e/keyboard.e2e.ts | 45 +++---- e2e/layout.e2e.ts | 4 +- e2e/seo.e2e.ts | 53 +++++--- e2e/systems-map.e2e.ts | 38 +++++- e2e/terminal.e2e.ts | 96 ++++++++------- src/app/app.routes.server.spec.ts | 2 +- src/app/core/content/content-claims.spec.ts | 53 +++++--- .../core/content/content-completeness.spec.ts | 3 +- .../core/content/content-exclusions.spec.ts | 2 +- .../core/content/content-service-led.spec.ts | 115 ++++++++++++++++++ src/app/core/seo/crawl-assets.spec.ts | 17 ++- src/app/core/seo/structured-data.spec.ts | 26 ++-- src/app/features/page-rendering.spec.ts | 62 ++++++++-- src/app/shared/interactive-lift.spec.ts | 33 +++++ src/app/submenu.styles.spec.ts | 15 --- src/legacy-service-redirects.spec.ts | 52 ++++++++ 20 files changed, 467 insertions(+), 165 deletions(-) create mode 100644 src/app/core/content/content-service-led.spec.ts create mode 100644 src/app/shared/interactive-lift.spec.ts delete mode 100644 src/app/submenu.styles.spec.ts create mode 100644 src/legacy-service-redirects.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 944012f..2851be6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ German and English are written idiomatically per language, never machine-transla ## i18n rules - German at `/`, English under `/en` -- Primary navigation is Home, Services (with four children), Projects, About and Contact. Stack and Pitch stay reachable from content and the terminal, not from the primary nav. +- Primary navigation is Home, Services, Projects, About and Contact. Services is one anchored page with four sections. Stack and Pitch stay reachable from content and the terminal, not from the primary nav. - Every route id exists in both locales - New pages are added by extending the route-id table in `src/app/core/routing`, never by hard-coding paths - Do not swap `LOCALE_ID`; the active locale is `AppLocale` from `LocaleService` @@ -128,7 +128,7 @@ German and English are written idiomatically per language, never machine-transla - Per-route titles from the content layer - Canonical origin is `SITE_CONFIG.siteOrigin` (`https://antoniolede.de`); every absolute URL in the app and tests is derived from it - Per-route metadata (description, canonical, reciprocal hreflang including `x-default`, Open Graph, Twitter, robots) is written by `SeoService` during SSR and on every client navigation -- One JSON-LD `@graph` script per route: Person and ProfessionalService on Home, Service on service routes, CreativeWork per public case on projects, WebPage elsewhere +- One JSON-LD `@graph` script per route: Person and ProfessionalService on Home, one Service entry per services section on the services route, CreativeWork per public case on projects, WebPage elsewhere - `public/robots.txt`, `public/sitemap.xml` and `public/llms.txt` stay synchronized with `prerenderablePaths()` via `crawl-assets.spec.ts` - The language switch exposes `hreflang` on the alternate-locale link @@ -158,4 +158,4 @@ A change is not done until: | Signature | Systems Map, terminal dock, dot background and motion | | Integration | SEO and cross-cutting a11y/performance hardening | -Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch replaces `placeholder-content.ts` and keeps `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract. +Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch owns the bilingual copy tree behind `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract. diff --git a/README.md b/README.md index baf258d..a350912 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ src/_breakpoints.scss Breakpoint map and respond-to mixin public/ Static SVG assets ``` -Design tokens live in `src/styles.scss` as CSS custom properties. Content contracts and the placeholder provider live in `src/app/core/content`. Route ids and locale path tables live in `src/app/core/routing`. +Design tokens live in `src/styles.scss` as CSS custom properties. Content contracts and the bilingual copy tree live in `src/app/core/content`. Route ids and locale path tables live in `src/app/core/routing`. Contributor and agent conventions are in [AGENTS.md](./AGENTS.md). diff --git a/e2e/a11y.e2e.ts b/e2e/a11y.e2e.ts index a42293d..d235d50 100644 --- a/e2e/a11y.e2e.ts +++ b/e2e/a11y.e2e.ts @@ -9,8 +9,8 @@ const AXE_PATHS = [ pagePath('pitch', 'en'), pagePath('projects', 'de'), pagePath('projects', 'en'), - pagePath('servicesAi', 'de'), - pagePath('servicesAi', 'en'), + pagePath('services', 'de'), + pagePath('services', 'en'), pagePath('contact', 'de'), pagePath('contact', 'en'), pagePath('imprint', 'de'), diff --git a/e2e/fragments.e2e.ts b/e2e/fragments.e2e.ts index aedb98c..3f848aa 100644 --- a/e2e/fragments.e2e.ts +++ b/e2e/fragments.e2e.ts @@ -40,12 +40,12 @@ test.describe('fragment scrolling', () => { await page.setViewportSize({ width, height: VIEWPORT_HEIGHT }); for (const locale of APP_LOCALES) { - const path = `${pagePath('projects', locale)}#myspa`; + const path = `${pagePath('projects', locale)}#bannier`; const label = `${width}px ${locale}`; await page.goto(path, { waitUntil: 'networkidle' }); const header = page.locator('.site-header'); - const heading = page.locator('#myspa h2'); + const heading = page.locator('#bannier h2'); await expect(heading).toBeVisible(); await waitForScrollSettle(page); diff --git a/e2e/keyboard.e2e.ts b/e2e/keyboard.e2e.ts index 836e36c..487341a 100644 --- a/e2e/keyboard.e2e.ts +++ b/e2e/keyboard.e2e.ts @@ -64,53 +64,44 @@ test.describe('keyboard and terminal dock', () => { } }); - test('compact services submenu stays keyboard accessible at 1024px', async ({ + test('compact primary nav stays a single row without a services submenu', async ({ page, }, testInfo) => { - test.skip(testInfo.project.name === 'mobile', 'compact submenu applies from md up'); + test.skip(testInfo.project.name === 'mobile', 'compact nav applies from md up'); await page.setViewportSize({ width: 1024, height: 900 }); await page.goto('/'); - const servicesParent = page.locator( - `.primary-nav > li > a[href="${pagePath('services', 'de')}"]`, - ); - const firstChild = page.locator( - `.primary-nav > li > .submenu a[href="${pagePath('servicesSoftware', 'de')}"]`, - ); + const services = page.locator(`.primary-nav > li > a[href="${pagePath('services', 'de')}"]`); + await expect(services).toBeVisible(); + await expect(page.locator('.primary-nav .submenu')).toHaveCount(0); + await expect(page.locator('.primary-nav a')).toHaveCount(5); - await servicesParent.focus(); - await expect(firstChild).toBeVisible(); - await page.keyboard.press('Tab'); - await expect(firstChild).toBeFocused(); + await services.click(); + await page.waitForURL('**/leistungen'); + await expect(services).toHaveAttribute('aria-current', 'page'); - const headerBox = await page.locator('.site-header').boundingBox(); - expect(headerBox, 'header while submenu is open').toBeTruthy(); - expect(headerBox!.height, 'header height while submenu is open').toBeLessThanOrEqual(200); - - const background = await firstChild.evaluate((element) => { - const submenu = element.closest('.submenu'); - return submenu ? getComputedStyle(submenu).backgroundColor : ''; - }); - expect(background, 'submenu background must be fully opaque').toMatch( - /^rgb\(\d+,\s*\d+,\s*\d+\)$/, + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth + 1, ); + expect(overflow, 'horizontal overflow at 1024px').toBe(true); }); - test('terminal dock opens with Control+K without locking the page and restores on Escape', async ({ + test('terminal dock is open on load, does not lock the page, and restores on Escape', async ({ page, }) => { await page.goto('/'); const trigger = page.locator('.terminal-dock-trigger'); - await trigger.focus(); - await page.keyboard.press('Control+k'); - const panel = page.locator('.terminal-dock-panel'); + await expect(panel).toBeVisible(); + await expect(trigger).toBeHidden(); + await expect(page.locator('#terminal-dock-input')).not.toBeFocused(); await expect(page.locator('.site')).not.toHaveAttribute('inert'); expect(await page.evaluate(() => document.body.style.overflow)).not.toBe('hidden'); - await expect(page.locator('[role="dialog"]')).toHaveCount(0); + await expect(page.locator('.terminal-dock [role="dialog"]')).toHaveCount(0); + await page.locator('#terminal-dock-input').focus(); await page.keyboard.press('Escape'); await expect(panel).toHaveCount(0); await expect(trigger).toBeFocused(); diff --git a/e2e/layout.e2e.ts b/e2e/layout.e2e.ts index 01557d0..8309db5 100644 --- a/e2e/layout.e2e.ts +++ b/e2e/layout.e2e.ts @@ -9,13 +9,13 @@ const CHECKED = [ pagePath('pitch', 'en'), pagePath('projects', 'de'), pagePath('projects', 'en'), - pagePath('servicesAi', 'de'), + pagePath('services', 'de'), pagePath('contact', 'de'), pagePath('imprint', 'en'), '/missing-route', ]; -const VIEWPORTS = [320, 768, 1024, 1440] as const; +const VIEWPORTS = [320, 390, 768, 1024, 1440] as const; test.describe('layout and crawlability', () => { test('does not overflow horizontally on checked routes', async ({ page }) => { diff --git a/e2e/seo.e2e.ts b/e2e/seo.e2e.ts index e5c35c1..29d03df 100644 --- a/e2e/seo.e2e.ts +++ b/e2e/seo.e2e.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from '@playwright/test'; import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content'; +import { prerenderablePaths } from '../src/app/core/routing/route-paths'; import { expectHead, jsonLdGraph, pagePath } from './helpers'; async function ensurePrimaryNavOpen(page: Page): Promise { @@ -28,10 +29,10 @@ test.describe('server-rendered metadata', () => { ); }); - test('projects, AI service, contact, legal and 404 keep locale metadata', async ({ request }) => { + test('projects, services, contact, legal and 404 keep locale metadata', async ({ request }) => { for (const locale of ['de', 'en'] as const) { await expectHead(request, 'projects', locale); - await expectHead(request, 'servicesAi', locale); + await expectHead(request, 'services', locale); await expectHead(request, 'contact', locale); await expectHead(request, 'imprint', locale); await expectHead(request, 'notFound', locale); @@ -57,6 +58,15 @@ test.describe('server-rendered metadata', () => { await expect(nav.locator(`a[href="${pagePath('home', 'de')}"]`)).toHaveCount(1); }); + test('every prerenderable path returns 200', async ({ request }) => { + const paths = prerenderablePaths(); + expect(paths).toHaveLength(18); + for (const path of paths) { + const response = await request.get(path); + expect(response.status(), path).toBe(200); + } + }); + test('unmatched URLs return 404 and a real route stays 200', async ({ request }) => { const home = await request.get('/'); expect(home.status(), 'GET /').toBe(200); @@ -68,7 +78,7 @@ test.describe('server-rendered metadata', () => { test('client navigation does not duplicate head tags', async ({ page }) => { await page.goto('/'); await ensurePrimaryNavOpen(page); - await page.locator('a.contact-cta').first().waitFor(); + await page.locator('.language-switch').first().waitFor(); const assertUnique = async (canonical: string, description: string) => { await expect(page.locator('link[rel="canonical"]')).toHaveCount(1); @@ -97,23 +107,34 @@ test.describe('server-rendered metadata', () => { ); await ensurePrimaryNavOpen(page); - const servicesParent = page.locator( - `.primary-nav > li > a[href="${pagePath('services', 'de')}"]`, - ); - const servicesAiLink = page - .locator(`.primary-nav a[href="${pagePath('servicesAi', 'de')}"]`) - .first(); - if (!(await servicesAiLink.isVisible())) { - await servicesParent.hover(); - await expect(servicesAiLink).toBeVisible(); - } - await servicesAiLink.click(); - await page.waitForURL('**/leistungen/ai-integration'); + await page.locator(`.primary-nav a[href="${pagePath('services', 'de')}"]`).click(); + await page.waitForURL('**/leistungen'); await expect(page.locator('link[rel="canonical"]')).toHaveCount(1); await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( 'href', - 'https://antoniolede.de/leistungen/ai-integration', + 'https://antoniolede.de/leistungen', ); await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(1); }); + + test('legacy service URLs return HTTP 308 with the fragment Location', async ({ request }) => { + const redirects = [ + ['/leistungen/software', '/leistungen#software-data'], + ['/leistungen/software/', '/leistungen#software-data'], + ['/leistungen/hardware-netzwerk', '/leistungen#infrastructure-network'], + ['/leistungen/cluster', '/leistungen#platform-operations'], + ['/leistungen/ai-integration', '/leistungen#ai-workflows'], + ['/en/services/software', '/en/services#software-data'], + ['/en/services/hardware-network', '/en/services#infrastructure-network'], + ['/en/services/clusters', '/en/services#platform-operations'], + ['/en/services/ai-integration', '/en/services#ai-workflows'], + ['/en/services/ai-integration/', '/en/services#ai-workflows'], + ] as const; + + for (const [from, to] of redirects) { + const response = await request.get(from, { maxRedirects: 0 }); + expect(response.status(), from).toBe(308); + expect(response.headers().location, from).toBe(to); + } + }); }); diff --git a/e2e/systems-map.e2e.ts b/e2e/systems-map.e2e.ts index 9d951a8..efff86a 100644 --- a/e2e/systems-map.e2e.ts +++ b/e2e/systems-map.e2e.ts @@ -1,11 +1,12 @@ import { expect, test } from '@playwright/test'; +import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy'; import { pagePath } from './helpers'; test.describe('Systems Map', () => { test('keeps the card list visible and shows the SVG from 1024px', async ({ page }) => { await page.goto(pagePath('services', 'de')); - for (const width of [390, 768, 1024, 1440]) { + for (const width of [320, 390, 768, 1024, 1440]) { await page.setViewportSize({ width, height: 900 }); const list = page.locator('.systems-map-list'); await expect(list).toBeVisible(); @@ -59,8 +60,41 @@ test.describe('Systems Map', () => { expect(hrefs.length).toBeGreaterThan(0); for (const href of hrefs) { - const response = await request.get(href); + const path = href.split('#')[0]; + const response = await request.get(path); expect(response.status(), href).toBe(200); } }); + + test('plain activation opens the gist dialog without navigating', async ({ page }) => { + const copy = SIGNATURE_COPY.de.systemsMap; + await page.goto(pagePath('services', 'de')); + await page.setViewportSize({ width: 390, height: 844 }); + + const first = page.locator('.systems-map-cards a').first(); + const href = await first.getAttribute('href'); + expect(href).toContain('#infrastructure-network'); + + await first.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`${pagePath('services', 'de')}$`)); + await expect(dialog.getByRole('link')).toHaveAttribute('href', href ?? ''); + await expect(dialog.getByRole('button', { name: copy.closeLabel })).toBeVisible(); + + const dialogBox = await dialog.boundingBox(); + expect(dialogBox, 'dialog readable at 390px').toBeTruthy(); + expect(dialogBox!.width).toBeGreaterThan(200); + + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(first).toBeFocused(); + }); + + test('a modified click follows the node href', async ({ page }) => { + await page.goto(pagePath('services', 'de')); + const first = page.locator('.systems-map-cards a').first(); + await first.click({ modifiers: ['Control'] }); + await expect(page.getByRole('dialog')).toHaveCount(0); + }); }); diff --git a/e2e/terminal.e2e.ts b/e2e/terminal.e2e.ts index 7fb1375..ea108bc 100644 --- a/e2e/terminal.e2e.ts +++ b/e2e/terminal.e2e.ts @@ -1,8 +1,11 @@ import { expect, test } from '@playwright/test'; import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy'; +import { expectNoOverflow } from './helpers'; + +const VIEWPORTS = [320, 390, 768, 1024, 1440] as const; test.describe('terminal dock', () => { - test('walks collapsed, expanded and maximized and runs the grammar', async ({ + test('defaults open, keeps one session scrollport and runs the grammar', async ({ page, }, testInfo) => { const copy = SIGNATURE_COPY.de.terminal; @@ -11,14 +14,17 @@ test.describe('terminal dock', () => { const trigger = page.locator('.terminal-dock-trigger'); const panel = page.locator('.terminal-dock-panel'); const input = page.locator('#terminal-dock-input'); - const log = page.locator('.terminal-dock-log'); + const session = page.locator('.terminal-dock-session'); const maximize = page.locator('.terminal-dock-control[aria-pressed]'); + const collapse = page.getByRole('button', { name: copy.collapseLabel }); - await expect(trigger).toBeVisible(); - await expect(panel).toHaveCount(0); - - await trigger.click(); await expect(panel).toBeVisible(); + await expect(trigger).toBeHidden(); + await expect(input).not.toBeFocused(); + await expect(session).toBeVisible(); + await expect(session.locator('form')).toHaveCount(1); + await expect(collapse).toHaveAttribute('title', copy.collapseTooltip); + await expect(maximize).toHaveAttribute('aria-label', copy.maximizeLabel); await expect(page.locator('.terminal-dock-prompt')).toContainText(copy.prompt); if (testInfo.project.name === 'mobile') { @@ -28,6 +34,7 @@ test.describe('terminal dock', () => { } else { await maximize.click(); await expect(maximize).toHaveAttribute('aria-pressed', 'true'); + await expect(maximize).toHaveAttribute('aria-label', copy.restoreLabel); await maximize.click(); await expect(maximize).toHaveAttribute('aria-pressed', 'false'); } @@ -36,90 +43,88 @@ test.describe('terminal dock', () => { await input.press('Enter'); await page.waitForURL('**/pitch'); await expect(page).toHaveURL(/\/pitch$/); - await expect(log).toContainText(`${copy.prompt} navigate pitch`); + await expect(session).toContainText(`${copy.prompt} navigate pitch`); await input.press('ArrowUp'); await expect(input).toHaveValue('navigate pitch'); await input.fill('history'); await input.press('Enter'); - await expect(log).toContainText(copy.historyIntro); - await expect(log).toContainText('navigate pitch'); + await expect(session).toContainText(copy.historyIntro); + await expect(session).toContainText('navigate pitch'); await input.fill('clear'); await input.press('Enter'); - await expect(log).toHaveText(copy.clearedMessage); + await expect(session.locator('p')).toHaveText(copy.clearedMessage); await input.fill('navigate p'); await input.press('Tab'); - await expect(log).toContainText('pitch'); - await expect(log).toContainText('projects'); + await expect(session).toContainText('pitch'); + await expect(session).toContainText('projects'); await input.fill('navigate nowhere'); await input.press('Enter'); - await expect(log).toContainText(copy.validTargetsLabel); - await expect(log).toContainText('pitch'); + await expect(session).toContainText(copy.validTargetsLabel); + await expect(session).toContainText('pitch'); await input.fill('nav'); await input.press('Tab'); await expect(input).toHaveValue('navigate'); + + await input.fill('navigate services sof'); + await input.press('Tab'); + await expect(input).toHaveValue('navigate services software'); }); - test('clicking the visible desktop trigger while open collapses the panel', async ({ - page, - }, testInfo) => { - test.skip( - testInfo.project.name === 'mobile', - 'Below md the trigger is hidden while the panel is open.', - ); - + test('collapse control hides the panel and returns focus to the trigger', async ({ page }) => { + const copy = SIGNATURE_COPY.de.terminal; await page.goto('/'); const trigger = page.locator('.terminal-dock-trigger'); const panel = page.locator('.terminal-dock-panel'); - await trigger.click(); await expect(panel).toBeVisible(); - await expect(trigger).toBeVisible(); - await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + await expect(trigger).toBeHidden(); - await trigger.click(); + const collapse = page.getByRole('button', { name: copy.collapseLabel }); + await collapse.click(); await expect(panel).toHaveCount(0); - await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await expect(trigger).toBeVisible(); await expect(trigger).toBeFocused(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); }); - test('keeps the newest echoed line in the log scrollport after overflow', async ({ page }) => { + test('keeps the prompt in the session scrollport after overflow', async ({ page }) => { const copy = SIGNATURE_COPY.de.terminal; await page.goto('/'); - const trigger = page.locator('.terminal-dock-trigger'); const input = page.locator('#terminal-dock-input'); - const log = page.locator('.terminal-dock-log'); + const session = page.locator('.terminal-dock-session'); - await trigger.click(); - await expect(log).toBeVisible(); + await expect(session).toBeVisible(); for (let index = 0; index < 8; index += 1) { await input.fill('help'); await input.press('Enter'); } - await expect(log.locator('p.echo').last()).toHaveText(`${copy.prompt} help`); + await expect(session.locator('p.echo').last()).toHaveText(`${copy.prompt} help`); await expect .poll(async () => - log.evaluate((element) => { + session.evaluate((element) => { return element.scrollTop + element.clientHeight >= element.scrollHeight - 2; }), ) .toBe(true); - const last = log.locator('p').last(); - const logBox = await log.boundingBox(); - const lastBox = await last.boundingBox(); - expect(logBox && lastBox).toBeTruthy(); - expect(lastBox!.y).toBeGreaterThanOrEqual(logBox!.y - 1); - expect(lastBox!.y + lastBox!.height).toBeLessThanOrEqual(logBox!.y + logBox!.height + 1); + const form = session.locator('form'); + const sessionBox = await session.boundingBox(); + const formBox = await form.boundingBox(); + expect(sessionBox && formBox).toBeTruthy(); + expect(formBox!.y).toBeGreaterThanOrEqual(sessionBox!.y - 1); + expect(formBox!.y + formBox!.height).toBeLessThanOrEqual( + sessionBox!.y + sessionBox!.height + 1, + ); }); test('Escape from a panel control collapses the dock and returns focus to the trigger', async ({ @@ -130,8 +135,6 @@ test.describe('terminal dock', () => { const trigger = page.locator('.terminal-dock-trigger'); const panel = page.locator('.terminal-dock-panel'); - - await trigger.click(); await expect(panel).toBeVisible(); const collapse = page.getByRole('button', { name: copy.collapseLabel }); @@ -142,4 +145,13 @@ test.describe('terminal dock', () => { await expect(panel).toHaveCount(0); await expect(trigger).toBeFocused(); }); + + test('stays usable and overflow-free at the required viewports', async ({ page }) => { + await page.goto('/'); + for (const width of VIEWPORTS) { + await page.setViewportSize({ width, height: 900 }); + await expect(page.locator('.terminal-dock-panel')).toBeVisible(); + await expectNoOverflow(page, '/', width); + } + }); }); diff --git a/src/app/app.routes.server.spec.ts b/src/app/app.routes.server.spec.ts index b0d6e9a..6b56535 100644 --- a/src/app/app.routes.server.spec.ts +++ b/src/app/app.routes.server.spec.ts @@ -9,7 +9,7 @@ describe('server routes', () => { expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404); const prerendered = serverRoutes.filter((route) => route.path !== '**'); - expect(prerendered).toHaveLength(26); + expect(prerendered).toHaveLength(18); expect(prerendered.map((route) => route.path)).toEqual( expect.arrayContaining(['pitch', 'en/pitch']), ); diff --git a/src/app/core/content/content-claims.spec.ts b/src/app/core/content/content-claims.spec.ts index 5e1c356..611210d 100644 --- a/src/app/core/content/content-claims.spec.ts +++ b/src/app/core/content/content-claims.spec.ts @@ -18,7 +18,7 @@ function allMetrics(site: SiteContent): readonly MetricCopy[] { } function allOfferings(site: SiteContent): readonly OfferingCopy[] { - return Object.values(site.services).flatMap((page) => page.offerings); + return site.services.serviceSections.flatMap((section) => section.offerings); } function caseText(caseStudy: CaseStudyCopy): string { @@ -33,11 +33,18 @@ function caseText(caseStudy: CaseStudyCopy): string { } describe('content claims and attribution', () => { - it('keeps the four public cases and attributes verified facts to the right ones', () => { + it('keeps the six public cases and attributes verified facts to the right ones', () => { for (const locale of APP_LOCALES) { const site = SITE_CONTENT_DATA[locale]; expect(Object.keys(site.cases)).toEqual([...CASE_STUDY_IDS]); - expect(CASE_STUDY_IDS).toEqual(['innofocus', 'roesterei', 'myspa', 'hdi']); + expect(CASE_STUDY_IDS).toEqual([ + 'innofocus', + 'roesterei', + 'bannier', + 'elbwaerme', + 'hdi', + 'devdays', + ]); const matchesRuntime = (metric: MetricCopy) => /8/.test(metric.value) && /90/.test(metric.value); @@ -49,14 +56,6 @@ describe('content claims and attribution', () => { expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0); } - const leadershipMetrics = allMetrics(site).filter( - (metric) => - metric.caseId === 'myspa' && - /4/.test(`${metric.value} ${metric.label}`) && - /2/.test(`${metric.value} ${metric.label}`), - ); - expect(leadershipMetrics.length).toBeGreaterThan(0); - const hdiText = caseText(site.cases.hdi); expect(hdiText).toMatch(/Azure AKS/); expect(hdiText).toMatch(/Azure App Services/); @@ -70,16 +69,34 @@ describe('content claims and attribution', () => { it('keeps AI delivery claims limited to the two Rösterei tools', () => { for (const locale of APP_LOCALES) { const site = SITE_CONTENT_DATA[locale]; - const aiOfferings = site.services.servicesAi.offerings; - const delivered = aiOfferings.filter((offering) => offering.status === 'delivered'); + const sections = site.services.serviceSections; - expect(delivered).toHaveLength(2); - expect(delivered.every((offering) => offering.referenceCaseId === 'roesterei')).toBe(true); + const ai = sections.find((section) => section.id === 'ai-workflows'); + expect(ai, `${locale} ai-workflows section`).toBeTruthy(); - const otherOfferings = allOfferings(site).filter((offering) => !delivered.includes(offering)); - expect(otherOfferings.every((offering) => offering.status === 'offer')).toBe(true); + const aiDelivered = ai!.offerings.filter((offering) => offering.status === 'delivered'); + expect(aiDelivered).toHaveLength(2); + expect(aiDelivered.every((offering) => offering.referenceCaseId === 'roesterei')).toBe(true); expect( - otherOfferings.some((offering) => + ai!.offerings + .filter((offering) => offering.status !== 'delivered') + .every((offering) => offering.status === 'offer'), + ).toBe(true); + + const everyDelivered = allOfferings(site).filter( + (offering) => offering.status === 'delivered', + ); + expect( + everyDelivered.every( + (offering) => + !!offering.referenceCaseId && CASE_STUDY_IDS.includes(offering.referenceCaseId), + ), + `${locale}: every delivered offering names a public case`, + ).toBe(true); + + const offered = allOfferings(site).filter((offering) => offering.status === 'offer'); + expect( + offered.some((offering) => DELIVERED_OFFER_WORDING.test(`${offering.title} ${offering.body}`), ), ).toBe(false); diff --git a/src/app/core/content/content-completeness.spec.ts b/src/app/core/content/content-completeness.spec.ts index 2ebfe79..6b5f8c9 100644 --- a/src/app/core/content/content-completeness.spec.ts +++ b/src/app/core/content/content-completeness.spec.ts @@ -40,13 +40,12 @@ describe('content completeness', () => { expect(site.pages.home).toBe(site.home); expect(site.pages.pitch).toBe(site.pitch); - expect(site.pages.services).toBe(site.servicesOverview); + expect(site.pages.services).toBe(site.services); expect(site.pages.contact).toBe(site.contact); expect(site.pages.projects).toBe(site.projects); expect(site.pages.stack).toBe(site.stack); expect(site.pages.imprint).toBe(site.legal.imprint); expect(site.pages.privacy).toBe(site.legal.privacy); - expect(site.pages.servicesAi).toBe(site.services.servicesAi); for (const routeId of ROUTE_IDS) { const page = site.pages[routeId]; diff --git a/src/app/core/content/content-exclusions.spec.ts b/src/app/core/content/content-exclusions.spec.ts index da30b84..1533034 100644 --- a/src/app/core/content/content-exclusions.spec.ts +++ b/src/app/core/content/content-exclusions.spec.ts @@ -8,7 +8,7 @@ import { SHELL_COPY } from './shell-copy'; import { FOOTER_NAV, PRIMARY_NAV } from '../navigation/navigation'; const EXCLUDED = new RegExp( - `\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading'].join('|')})\\b`, + `\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading', 'ara' + 'com', 'my' + 'spa'].join('|')})\\b`, 'i', ); diff --git a/src/app/core/content/content-service-led.spec.ts b/src/app/core/content/content-service-led.spec.ts new file mode 100644 index 0000000..ae3567a --- /dev/null +++ b/src/app/core/content/content-service-led.spec.ts @@ -0,0 +1,115 @@ +import { APP_LOCALES } from '../i18n/locale'; +import { CASE_STUDY_IDS, SERVICE_SECTION_IDS, type CaseStudyCopy } from './content.contracts'; +import { SITE_CONTENT_DATA } from './site-content'; + +const DEVICE_IN_HEADING = /arbeitsplatz|workstation|gerät(?:e|esupport)?|device support|endgerät/i; + +function caseBlob(study: CaseStudyCopy): string { + return [ + study.period, + study.domain, + study.headline, + study.summary, + study.responsibility, + study.situation, + ...study.approach, + ...study.outcome, + study.teaser, + study.externalUrl ?? '', + study.externalLabel ?? '', + ].join('\n'); +} + +describe('service-led content contracts', () => { + it('keeps service sections infrastructure-first in the fixed order', () => { + expect(SERVICE_SECTION_IDS).toEqual([ + 'infrastructure-network', + 'platform-operations', + 'software-data', + 'ai-workflows', + ]); + + for (const locale of APP_LOCALES) { + const ids = SITE_CONTENT_DATA[locale].services.serviceSections.map((section) => section.id); + expect(ids, locale).toEqual([...SERVICE_SECTION_IDS]); + expect(SITE_CONTENT_DATA[locale].home.serviceAreas.map((area) => area.id)).toEqual([ + ...SERVICE_SECTION_IDS, + ]); + } + }); + + it('keeps Bannier and Elbwärme dates, scopes, externals and the support-versus-development line', () => { + const de = SITE_CONTENT_DATA.de.cases; + const en = SITE_CONTENT_DATA.en.cases; + + expect(de.bannier.period).toBe('seit etwa 2018, laufend'); + expect(en.bannier.period).toBe('since roughly 2018, ongoing'); + expect(de.elbwaerme.period).toBe('seit 11/2025, laufend'); + expect(en.elbwaerme.period).toBe('since November 2025, ongoing'); + + expect(de.bannier.externalUrl).toBe('https://fahrschule-stendal.de'); + expect(en.bannier.externalUrl).toBe('https://fahrschule-stendal.de'); + expect(de.elbwaerme.externalUrl).toBe('https://elbwaerme.de'); + expect(en.elbwaerme.externalUrl).toBe('https://elbwaerme.de'); + expect(de.bannier.externalLabel).toBe('fahrschule-stendal.de öffnen'); + expect(en.bannier.externalLabel).toBe('Visit fahrschule-stendal.de'); + expect(de.elbwaerme.externalLabel).toBe('elbwaerme.de öffnen'); + expect(en.elbwaerme.externalLabel).toBe('Visit elbwaerme.de'); + + expect(caseBlob(de.bannier)).toMatch(/nicht entwickelt/); + expect(caseBlob(de.bannier)).toMatch(/betreue und betreibe/); + expect(caseBlob(en.bannier)).toMatch(/did not develop/); + expect(caseBlob(en.bannier)).toMatch(/support and operate/); + + for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'bannier' && id !== 'elbwaerme')) { + expect(Object.hasOwn(de[caseId], 'externalUrl')).toBe(false); + expect(Object.hasOwn(en[caseId], 'externalUrl')).toBe(false); + expect(Object.hasOwn(de[caseId], 'externalLabel')).toBe(false); + expect(Object.hasOwn(en[caseId], 'externalLabel')).toBe(false); + } + }); + + it('states the personal contact, two-person team, organisational support and specialist network', () => { + const de = SITE_CONTENT_DATA.de; + const en = SITE_CONTENT_DATA.en; + + expect(de.home.sections.find((section) => section.id === 'team')?.body?.join('\n')).toContain( + 'Der Kern sind zwei Personen zwischen Tangermünde und Magdeburg', + ); + expect(de.home.sections.find((section) => section.id === 'team')?.body?.join('\n')).toContain( + 'ausgesuchtes Netzwerk', + ); + expect(de.services.sections[1].body?.join('\n')).toContain('technischer Ansprechpartner'); + expect(de.services.sections[1].body?.join('\n')).toContain('Kaufmännisches und Organisation'); + expect(en.services.sections[1].body?.join('\n')).toContain('technical point of contact'); + expect(en.services.sections[1].body?.join('\n')).toContain('two people'); + expect(en.home.sections.find((section) => section.id === 'team')?.body?.join('\n')).toContain( + 'two people', + ); + expect( + en.pages.about.sections + .find((section) => section.id === 'contact-and-team') + ?.body?.join('\n'), + ).toMatch(/curated/); + expect(en.pitch.profile.join('\n')).toContain('curated network of specialists'); + expect(de.pitch.profile.join('\n')).toContain('ausgesuchtes Fachnetzwerk'); + }); + + it('keeps device and workstation wording out of headings, nav labels and offering titles', () => { + for (const locale of APP_LOCALES) { + const site = SITE_CONTENT_DATA[locale]; + const headings = [ + ...site.home.serviceAreas.map((area) => area.title), + ...site.services.serviceSections.map((section) => section.title), + ...site.services.serviceSections.flatMap((section) => + section.offerings.map((offering) => offering.title), + ), + ...site.services.sections.map((section) => section.headline), + ]; + + for (const heading of headings) { + expect(DEVICE_IN_HEADING.test(heading), `${locale}: ${heading}`).toBe(false); + } + } + }); +}); diff --git a/src/app/core/seo/crawl-assets.spec.ts b/src/app/core/seo/crawl-assets.spec.ts index c945f62..5bf146f 100644 --- a/src/app/core/seo/crawl-assets.spec.ts +++ b/src/app/core/seo/crawl-assets.spec.ts @@ -7,7 +7,10 @@ import { prerenderablePaths, routePath } from '../routing/route-paths'; import { absoluteUrl } from './route-metadata'; const PUBLIC = join(process.cwd(), 'public'); -const EXCLUDED = /HUP|BitWiz|Cybertrading/; +const EXCLUDED = new RegExp( + `\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading', 'ara' + 'com', 'my' + 'spa'].join('|')})\\b`, + 'i', +); function readPublic(name: 'robots.txt' | 'sitemap.xml' | 'llms.txt'): string { return readFileSync(join(PUBLIC, name), 'utf8'); @@ -119,14 +122,6 @@ describe('crawl assets', () => { absoluteUrl(routePath('about', 'en')), absoluteUrl(routePath('services', 'de')), absoluteUrl(routePath('services', 'en')), - absoluteUrl(routePath('servicesSoftware', 'de')), - absoluteUrl(routePath('servicesSoftware', 'en')), - absoluteUrl(routePath('servicesHardwareNetwork', 'de')), - absoluteUrl(routePath('servicesHardwareNetwork', 'en')), - absoluteUrl(routePath('servicesClusters', 'de')), - absoluteUrl(routePath('servicesClusters', 'en')), - absoluteUrl(routePath('servicesAi', 'de')), - absoluteUrl(routePath('servicesAi', 'en')), absoluteUrl(routePath('projects', 'de')), absoluteUrl(routePath('projects', 'en')), ...CASE_STUDY_IDS.flatMap((id) => [ @@ -166,6 +161,10 @@ describe('crawl assets', () => { [], ); expect(EXCLUDED.test(text), `${name} contains an excluded station name`).toBe(false); + expect(text).not.toContain('/leistungen/software'); + expect(text).not.toContain('/en/services/software'); + expect(text).not.toContain('/leistungen/ai-integration'); + expect(text).not.toContain('/en/services/ai-integration'); } }); }); diff --git a/src/app/core/seo/structured-data.spec.ts b/src/app/core/seo/structured-data.spec.ts index 03d3ebc..0e14959 100644 --- a/src/app/core/seo/structured-data.spec.ts +++ b/src/app/core/seo/structured-data.spec.ts @@ -20,13 +20,7 @@ const FORBIDDEN_FIELDS = [ 'numberOfEmployees', ] as const; -const SERVICE_ROUTES: readonly RouteId[] = [ - 'services', - 'servicesSoftware', - 'servicesHardwareNetwork', - 'servicesClusters', - 'servicesAi', -]; +const SERVICE_ROUTES: readonly RouteId[] = ['services']; function collectKeys(value: unknown, keys = new Set()): Set { if (Array.isArray(value)) { @@ -63,15 +57,20 @@ describe('JSON-LD builders', () => { expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']); expect(graphTypes('pitch', locale), `${locale}.pitch`).toEqual(['WebPage']); - for (const routeId of SERVICE_ROUTES) { - expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['Service']); - } + expect(graphTypes('services', locale), `${locale}.services`).toEqual([ + 'Service', + 'Service', + 'Service', + 'Service', + ]); expect(graphTypes('projects', locale)).toEqual([ 'CreativeWork', 'CreativeWork', 'CreativeWork', 'CreativeWork', + 'CreativeWork', + 'CreativeWork', ]); for (const routeId of ROUTE_IDS.filter( @@ -105,7 +104,12 @@ describe('JSON-LD builders', () => { } const serialized = serializeJsonLd(graph); - expect(serialized).not.toMatch(/HUP|BitWiz|Cybertrading/); + expect(serialized).not.toMatch( + new RegExp( + `\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading', 'ara' + 'com', 'my' + 'spa'].join('|')})\\b`, + 'i', + ), + ); } } }); diff --git a/src/app/features/page-rendering.spec.ts b/src/app/features/page-rendering.spec.ts index 7f84f2b..95cc0ee 100644 --- a/src/app/features/page-rendering.spec.ts +++ b/src/app/features/page-rendering.spec.ts @@ -68,8 +68,8 @@ describe('page rendering, semantics and accessibility', () => { '/en', '/pitch', '/en/pitch', - '/leistungen/software', - '/en/services/software', + '/leistungen', + '/en/services', '/projekte', '/en/projects', '/missing-route', @@ -96,8 +96,8 @@ describe('page rendering, semantics and accessibility', () => { '/en', '/projekte', '/en/projects', - '/leistungen/ai-integration', - '/en/services/ai-integration', + '/leistungen', + '/en/services', '/kontakt', '/en/contact', '/stack', @@ -139,6 +139,37 @@ describe('page rendering, semantics and accessibility', () => { expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy(); }); + it('renders six cases with Bannier and Elbwärme externals and no excluded station names', async () => { + TestBed.configureTestingModule({ + providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }], + }); + + const harness = await RouterTestingHarness.create(); + const excluded = new RegExp( + `\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading', 'ara' + 'com', 'my' + 'spa'].join('|')})\\b`, + 'i', + ); + + for (const path of ['/projekte', '/en/projects']) { + await harness.navigateByUrl(path); + const root = harness.routeNativeElement as HTMLElement; + const ids = [...root.querySelectorAll('article.case-study')].map((article) => article.id); + expect(ids).toEqual([...CASE_STUDY_IDS]); + expect(excluded.test(root.textContent ?? '')).toBe(false); + + const bannier = root.querySelector('#bannier a.external-link') as HTMLAnchorElement | null; + const elbwaerme = root.querySelector( + '#elbwaerme a.external-link', + ) as HTMLAnchorElement | null; + expect(bannier?.getAttribute('href')).toBe('https://fahrschule-stendal.de'); + expect(elbwaerme?.getAttribute('href')).toBe('https://elbwaerme.de'); + expect(bannier?.getAttribute('rel')).toBe('noopener noreferrer'); + expect(elbwaerme?.getAttribute('rel')).toBe('noopener noreferrer'); + expect(bannier?.getAttribute('target')).toBe('_blank'); + expect(root.querySelectorAll('a.external-link')).toHaveLength(2); + } + }); + it('places the Systems Map on the services overview', async () => { TestBed.configureTestingModule({ providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }], @@ -197,7 +228,7 @@ describe('page rendering, semantics and accessibility', () => { } }); - it('sends the software service case CTA to the innofocus project anchor', async () => { + it('exposes the four service section anchors and unique ids on the services page', async () => { TestBed.configureTestingModule({ providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }], }); @@ -205,13 +236,22 @@ describe('page rendering, semantics and accessibility', () => { const harness = await RouterTestingHarness.create(); for (const locale of APP_LOCALES) { - await harness.navigateByUrl(routePath('servicesSoftware', locale)); + await harness.navigateByUrl(routePath('services', locale)); const root = harness.routeNativeElement as HTMLElement; - const expected = `${routePath('projects', locale)}#innofocus`; - const hrefs = [...root.querySelectorAll('a')].map( - (anchor) => anchor.getAttribute('href') ?? '', - ); - expect(hrefs.some((href) => href.endsWith(expected))).toBe(true); + expect(root.querySelectorAll('h1')).toHaveLength(1); + const ids = [ + 'infrastructure-network', + 'platform-operations', + 'software-data', + 'ai-workflows', + ]; + for (const id of ids) { + expect(root.querySelectorAll(`#${id}`)).toHaveLength(1); + } + + const allIds = [...root.querySelectorAll('[id]')].map((element) => element.id); + expect(new Set(allIds).size, `${locale} duplicate ids: ${allIds}`).toBe(allIds.length); + assertNoDanglingReferences(root); } }); }); diff --git a/src/app/shared/interactive-lift.spec.ts b/src/app/shared/interactive-lift.spec.ts new file mode 100644 index 0000000..3721797 --- /dev/null +++ b/src/app/shared/interactive-lift.spec.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = process.cwd(); + +function read(path: string): string { + return readFileSync(join(ROOT, path), 'utf8'); +} + +describe('interactive lift primitive', () => { + it('lives in the global stylesheet, hover-only, and drops transform under reduced motion', () => { + const css = read('src/styles.scss'); + expect(css).toContain('.interactive-lift'); + expect(css).toMatch(/@media \(hover: hover\) and \(pointer: fine\)/); + expect(css).toMatch(/prefers-reduced-motion:\s*reduce/); + + const reduceBlock = css.slice(css.indexOf('prefers-reduced-motion')); + expect(reduceBlock).toMatch(/\.interactive-lift\s*\{[^}]*transform:\s*none/); + expect(reduceBlock).toMatch(/transition:\s*none/); + }); + + it('is applied to clickable case cards and home service links, not to prose sections', () => { + expect(read('src/app/shared/case-card/case-card.html')).toContain('interactive-lift'); + expect(read('src/app/features/home/home.html')).toContain('home-service-card interactive-lift'); + expect(read('src/app/shared/content-section/content-section.html')).not.toContain( + 'interactive-lift', + ); + expect(read('src/app/shared/page-hero/page-hero.html')).not.toContain('interactive-lift'); + expect(read('src/app/components/pages/skills/skill-card/skill-card.html')).not.toContain( + 'interactive-lift', + ); + }); +}); diff --git a/src/app/submenu.styles.spec.ts b/src/app/submenu.styles.spec.ts deleted file mode 100644 index 6ab7768..0000000 --- a/src/app/submenu.styles.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -const APP_SCSS = readFileSync(join(process.cwd(), 'src/app/app.scss'), 'utf8'); - -describe('primary nav submenu surface', () => { - it('uses the opaque menu token and not the glass surface', () => { - const submenu = APP_SCSS.slice(APP_SCSS.indexOf('.primary-nav > li > .submenu')); - - expect(submenu).toMatch(/background:\s*var\(--color-surface-menu\)/); - expect(submenu).not.toMatch(/var\(--surface-glass\)/); - expect(submenu).toMatch(/box-shadow:/); - expect(submenu).toMatch(/border-radius:/); - }); -}); diff --git a/src/legacy-service-redirects.spec.ts b/src/legacy-service-redirects.spec.ts new file mode 100644 index 0000000..62d4517 --- /dev/null +++ b/src/legacy-service-redirects.spec.ts @@ -0,0 +1,52 @@ +import { LEGACY_SERVICE_REDIRECTS, normalizeRequestPath } from './legacy-service-redirects'; +import { prerenderablePaths } from './app/core/routing/route-paths'; + +describe('legacy service redirects', () => { + it('maps exactly eight retired service URLs onto services fragments', () => { + expect(Object.keys(LEGACY_SERVICE_REDIRECTS)).toEqual([ + '/leistungen/software', + '/leistungen/hardware-netzwerk', + '/leistungen/cluster', + '/leistungen/ai-integration', + '/en/services/software', + '/en/services/hardware-network', + '/en/services/clusters', + '/en/services/ai-integration', + ]); + expect(LEGACY_SERVICE_REDIRECTS['/leistungen/software']).toBe('/leistungen#software-data'); + expect(LEGACY_SERVICE_REDIRECTS['/leistungen/hardware-netzwerk']).toBe( + '/leistungen#infrastructure-network', + ); + expect(LEGACY_SERVICE_REDIRECTS['/leistungen/cluster']).toBe('/leistungen#platform-operations'); + expect(LEGACY_SERVICE_REDIRECTS['/leistungen/ai-integration']).toBe('/leistungen#ai-workflows'); + expect(LEGACY_SERVICE_REDIRECTS['/en/services/software']).toBe('/en/services#software-data'); + expect(LEGACY_SERVICE_REDIRECTS['/en/services/hardware-network']).toBe( + '/en/services#infrastructure-network', + ); + expect(LEGACY_SERVICE_REDIRECTS['/en/services/clusters']).toBe( + '/en/services#platform-operations', + ); + expect(LEGACY_SERVICE_REDIRECTS['/en/services/ai-integration']).toBe( + '/en/services#ai-workflows', + ); + }); + + it('treats an optional trailing slash as the same path', () => { + expect(normalizeRequestPath('/leistungen/software/')).toBe('/leistungen/software'); + expect(normalizeRequestPath('/en/services/ai-integration/')).toBe( + '/en/services/ai-integration', + ); + expect(normalizeRequestPath('/')).toBe('/'); + expect(LEGACY_SERVICE_REDIRECTS[normalizeRequestPath('/leistungen/cluster/')]).toBe( + '/leistungen#platform-operations', + ); + }); + + it('keeps the eight legacy paths out of prerenderablePaths', () => { + const paths = prerenderablePaths(); + expect(paths).toHaveLength(18); + for (const from of Object.keys(LEGACY_SERVICE_REDIRECTS)) { + expect(paths, from).not.toContain(from); + } + }); +});