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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 00:41:24 +02:00
parent 55d012cc10
commit a9ad7eb1a7
20 changed files with 467 additions and 165 deletions

View File

@@ -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']),
);

View File

@@ -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);

View File

@@ -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];

View File

@@ -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',
);

View File

@@ -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);
}
}
});
});

View File

@@ -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');
}
});
});

View File

@@ -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<string>()): Set<string> {
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',
),
);
}
}
});

View File

@@ -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);
}
});
});

View File

@@ -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',
);
});
});

View File

@@ -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:/);
});
});

View File

@@ -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);
}
});
});