integration: name case metrics, split delivered offerings from offers, and frame the stack honestly
Give every metrics list a localized heading, keep delivered Rösterei tools out of the offer boundary, and describe the skills grid as a familiarity overview with an evidence note instead of claiming public-case backing for every item. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -105,6 +105,10 @@ const BASE_CATEGORIES: readonly SkillCategory[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const SKILL_GRID_ITEM_NAMES: readonly string[] = BASE_CATEGORIES.flatMap((category) =>
|
||||
category.skills.map((skill) => skill.name),
|
||||
);
|
||||
|
||||
@Component({
|
||||
selector: 'app-skills-grid',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -96,6 +96,9 @@ export interface ServiceSequenceCopy {
|
||||
export interface ServicePageCopy extends PageCopy {
|
||||
readonly sequence: ServiceSequenceCopy;
|
||||
readonly offerings: readonly OfferingCopy[];
|
||||
readonly deliveredOfferingsHeading: string;
|
||||
readonly offerOfferingsHeading: string;
|
||||
readonly offeringCaseBackedLabel: string;
|
||||
}
|
||||
|
||||
export interface AudienceEntryCopy {
|
||||
@@ -167,12 +170,14 @@ export interface StackGroupCopy {
|
||||
|
||||
export interface StackPageCopy extends PageCopy {
|
||||
readonly groups: readonly StackGroupCopy[];
|
||||
readonly evidenceNote: string;
|
||||
}
|
||||
|
||||
export interface CaseStudyLabels {
|
||||
readonly situation: string;
|
||||
readonly approach: string;
|
||||
readonly outcome: string;
|
||||
readonly metrics: string;
|
||||
readonly stack: string;
|
||||
readonly tags: string;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export const PROJECTS_DE: ProjectsPageCopy = {
|
||||
situation: 'Lage',
|
||||
approach: 'Vorgehen',
|
||||
outcome: 'Ergebnis',
|
||||
metrics: 'Kennzahlen',
|
||||
stack: 'Technik',
|
||||
tags: 'Schlagworte',
|
||||
},
|
||||
@@ -50,21 +51,23 @@ export const STACK_DE: StackPageCopy = {
|
||||
routeId: 'stack',
|
||||
title: 'Technik-Stack | Antonio Ledebuhr',
|
||||
description:
|
||||
'Der öffentliche Stack in sieben Gruppen: Programmierung, Datenbanken, DevOps, Betriebssysteme, Infrastructure as Code, Hypervisor und Werkzeuge.',
|
||||
'Technik-Überblick in sieben Gruppen: Programmierung, Datenbanken, DevOps, Betriebssysteme, Infrastructure as Code, Hypervisor und Werkzeuge.',
|
||||
hero: {
|
||||
headline: 'Der Stack, der auf diesen Seiten vorkommt',
|
||||
headline: 'Ein Technik-Überblick zur Einordnung',
|
||||
proof:
|
||||
'Die Gruppen gehören zur Produktarbeit in Java und Spring sowie in C# und .NET, zur Datenarbeit in SQL Server, zu Containern und Clustern und zu Servern vor Ort.',
|
||||
'Die Rasterkarte ist eine Vertrautheitsliste. Sie zeigt Technologien aus der Arbeit, ohne jeden Eintrag als öffentlichen Fall zu belegen.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'reading',
|
||||
headline: 'Eine Auswahl, keine Rangliste',
|
||||
body: ['Die Übersicht ist eine Auswahl aus der öffentlichen Arbeit, keine Rangliste.'],
|
||||
headline: 'Überblick, keine Rangliste',
|
||||
body: [
|
||||
'Die Übersicht ordnet Technologien nach Gruppen. Sie ist keine Rangliste und kein Nachweis, dass jeder Eintrag in einem öffentlichen Fall vorkommt.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'groups',
|
||||
headline: 'Wo die Gruppen in der Arbeit vorkommen',
|
||||
headline: 'Welche Gruppen die Arbeit berührt',
|
||||
body: [
|
||||
'Produktarbeit in Java und Spring sowie in C# und .NET, mit Angular auf der Oberfläche. Datenarbeit in SQL Server und T-SQL. Container und Cluster mit Docker, Kubernetes und Azure AKS. Pipelines in GitLab CI/CD und Azure DevOps, GitOps mit Argo CD. Server und Virtualisierung vor Ort.',
|
||||
],
|
||||
@@ -83,6 +86,8 @@ export const STACK_DE: StackPageCopy = {
|
||||
{ id: 'hyperviser', title: 'Hypervisor' },
|
||||
{ id: 'tools', title: 'Werkzeuge' },
|
||||
],
|
||||
evidenceNote:
|
||||
'In den öffentlichen Fällen sind unter anderem Angular, TypeScript, C#, .NET, Java, Spring, SQL Server, MySQL, MariaDB, Docker, Kubernetes, Azure, Azure DevOps, GitLab, Proxmox und Microsoft 365 belegt. Die übrigen Einträge der Übersicht stehen für Breite und Vertrautheit, ohne öffentlichen Referenzfall.',
|
||||
};
|
||||
|
||||
export const ABOUT_DE: PageCopy = {
|
||||
|
||||
@@ -126,6 +126,9 @@ export const SERVICES_SOFTWARE_DE: ServicePageCopy = {
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||
};
|
||||
|
||||
export const SERVICES_HARDWARE_DE: ServicePageCopy = {
|
||||
@@ -208,6 +211,9 @@ export const SERVICES_HARDWARE_DE: ServicePageCopy = {
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||
};
|
||||
|
||||
export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
|
||||
@@ -289,6 +295,9 @@ export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||
};
|
||||
|
||||
export const SERVICES_AI_DE: ServicePageCopy = {
|
||||
@@ -421,4 +430,7 @@ export const SERVICES_AI_DE: ServicePageCopy = {
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ export const PROJECTS_EN: ProjectsPageCopy = {
|
||||
situation: 'Situation',
|
||||
approach: 'Approach',
|
||||
outcome: 'Outcome',
|
||||
metrics: 'Metrics',
|
||||
stack: 'Stack',
|
||||
tags: 'Tags',
|
||||
},
|
||||
@@ -50,21 +51,23 @@ export const STACK_EN: StackPageCopy = {
|
||||
routeId: 'stack',
|
||||
title: 'Technology stack | Antonio Ledebuhr',
|
||||
description:
|
||||
'The public stack in seven groups: programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools.',
|
||||
'A technology overview in seven groups: programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools.',
|
||||
hero: {
|
||||
headline: 'The stack that appears on these pages',
|
||||
headline: 'A technology overview, not a proof list',
|
||||
proof:
|
||||
'The groups belong to product work in Java and Spring and in C# and .NET, to SQL Server data work, to containers and clusters, and to servers on site.',
|
||||
'The grid is a familiarity list. It shows technologies from the work without claiming a public case for every entry.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'reading',
|
||||
headline: 'A selection, not a ranking',
|
||||
body: ['The overview is a selection from the public work, not a ranking.'],
|
||||
headline: 'An overview, not a ranking',
|
||||
body: [
|
||||
'The grid groups technologies for orientation. It is not a ranking, and it does not claim that every entry appears in a public case.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'groups',
|
||||
headline: 'Where the groups show up in the work',
|
||||
headline: 'Which groups the work touches',
|
||||
body: [
|
||||
'Product work in Java and Spring and in C# and .NET, with Angular on the front end. SQL Server and T-SQL data work. Containers and clusters with Docker, Kubernetes and Azure AKS. Pipelines in GitLab CI/CD and Azure DevOps, GitOps with Argo CD. Servers and virtualisation on site.',
|
||||
],
|
||||
@@ -83,6 +86,8 @@ export const STACK_EN: StackPageCopy = {
|
||||
{ id: 'hyperviser', title: 'Hypervisors' },
|
||||
{ id: 'tools', title: 'Tools' },
|
||||
],
|
||||
evidenceNote:
|
||||
'The public cases evidence items such as Angular, TypeScript, C#, .NET, Java, Spring, SQL Server, MySQL, MariaDB, Docker, Kubernetes, Azure, Azure DevOps, GitLab, Proxmox and Microsoft 365. The remaining entries show breadth and familiarity without a public reference.',
|
||||
};
|
||||
|
||||
export const ABOUT_EN: PageCopy = {
|
||||
|
||||
@@ -126,6 +126,9 @@ export const SERVICES_SOFTWARE_EN: ServicePageCopy = {
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
deliveredOfferingsHeading: 'Delivered work',
|
||||
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||
};
|
||||
|
||||
export const SERVICES_HARDWARE_EN: ServicePageCopy = {
|
||||
@@ -208,6 +211,9 @@ export const SERVICES_HARDWARE_EN: ServicePageCopy = {
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
deliveredOfferingsHeading: 'Delivered work',
|
||||
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||
};
|
||||
|
||||
export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
|
||||
@@ -289,6 +295,9 @@ export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
deliveredOfferingsHeading: 'Delivered work',
|
||||
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||
};
|
||||
|
||||
export const SERVICES_AI_EN: ServicePageCopy = {
|
||||
@@ -421,4 +430,7 @@ export const SERVICES_AI_EN: ServicePageCopy = {
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
deliveredOfferingsHeading: 'Delivered work',
|
||||
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||
};
|
||||
|
||||
@@ -26,6 +26,17 @@ function accessibleName(root: HTMLElement, element: Element): string {
|
||||
return (element.getAttribute('aria-label') ?? element.textContent ?? '').trim();
|
||||
}
|
||||
|
||||
function assertNoDanglingReferences(root: HTMLElement): void {
|
||||
for (const attribute of ['aria-labelledby', 'aria-describedby'] as const) {
|
||||
for (const element of root.querySelectorAll(`[${attribute}]`)) {
|
||||
const ids = (element.getAttribute(attribute) ?? '').split(/\s+/).filter(Boolean);
|
||||
for (const id of ids) {
|
||||
expect(root.querySelector(`[id="${id}"]`), `${attribute} -> #${id}`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertPageSemantics(root: HTMLElement): void {
|
||||
const headings = [...root.querySelectorAll('h1, h2, h3, h4, h5, h6')];
|
||||
expect(headings.filter((heading) => heading.tagName === 'H1')).toHaveLength(1);
|
||||
@@ -69,6 +80,34 @@ describe('page rendering, semantics and accessibility', () => {
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root).toBeTruthy();
|
||||
assertPageSemantics(root);
|
||||
assertNoDanglingReferences(root);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every aria-labelledby and aria-describedby target in the document', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const paths = [
|
||||
'/',
|
||||
'/en',
|
||||
'/projekte',
|
||||
'/en/projects',
|
||||
'/leistungen/ai-integration',
|
||||
'/en/services/ai-integration',
|
||||
'/kontakt',
|
||||
'/en/contact',
|
||||
'/stack',
|
||||
'/en/stack',
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root).toBeTruthy();
|
||||
assertNoDanglingReferences(root);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[situationLabel]="copy.caseLabels.situation"
|
||||
[approachLabel]="copy.caseLabels.approach"
|
||||
[outcomeLabel]="copy.caseLabels.outcome"
|
||||
[metricsLabel]="copy.caseLabels.metrics"
|
||||
[stackLabel]="copy.caseLabels.stack"
|
||||
[tagsLabel]="copy.caseLabels.tags"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { routes } from '../../../app.routes';
|
||||
import { SITE_CONTENT } from '../../../core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../../../core/content/site-content';
|
||||
import { APP_LOCALES } from '../../../core/i18n/locale';
|
||||
import { routePath } from '../../../core/routing/route-paths';
|
||||
|
||||
describe('AI service offering groups', () => {
|
||||
it('renders delivered work and offers under their own localized labels', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
const copy = SITE_CONTENT_DATA[locale].services.servicesAi;
|
||||
const delivered = copy.offerings.filter((offering) => offering.status === 'delivered');
|
||||
const offers = copy.offerings.filter((offering) => offering.status === 'offer');
|
||||
expect(delivered.length).toBeGreaterThan(0);
|
||||
expect(offers.length).toBeGreaterThan(0);
|
||||
|
||||
await harness.navigateByUrl(routePath('servicesAi', locale));
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root).toBeTruthy();
|
||||
|
||||
const deliveredGroup = root.querySelector(
|
||||
`[aria-labelledby="offerings-delivered-servicesAi"]`,
|
||||
);
|
||||
const offerGroup = root.querySelector(`[aria-labelledby="offerings-offer-servicesAi"]`);
|
||||
expect(deliveredGroup).toBeTruthy();
|
||||
expect(offerGroup).toBeTruthy();
|
||||
expect(deliveredGroup?.textContent).toContain(copy.deliveredOfferingsHeading);
|
||||
expect(offerGroup?.textContent).toContain(copy.offerOfferingsHeading);
|
||||
|
||||
for (const offering of delivered) {
|
||||
expect(deliveredGroup?.textContent).toContain(offering.title);
|
||||
expect(offerGroup?.textContent).not.toContain(offering.title);
|
||||
const note = copy.offeringCaseBackedLabel.replace(
|
||||
'{case}',
|
||||
SITE_CONTENT_DATA[locale].cases[offering.referenceCaseId!].client,
|
||||
);
|
||||
expect(deliveredGroup?.textContent).toContain(note);
|
||||
}
|
||||
|
||||
for (const offering of offers) {
|
||||
expect(offerGroup?.textContent).toContain(offering.title);
|
||||
expect(deliveredGroup?.textContent).not.toContain(offering.title);
|
||||
}
|
||||
|
||||
expect(deliveredGroup?.querySelector('[data-offering-status="offer"]')).toBeNull();
|
||||
expect(offerGroup?.querySelector('[data-offering-status="delivered"]')).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,34 @@
|
||||
@if (section.id === 'sequence') {
|
||||
<app-process-steps [steps]="steps()" [labelledBy]="sequenceHeadingId()" />
|
||||
}
|
||||
@if (section.id === 'offer-boundary' && copy.offerings.length > 0) {
|
||||
<ul class="stack offerings">
|
||||
@for (offering of copy.offerings; track offering.id) {
|
||||
<li class="glass-surface">
|
||||
<h3>{{ offering.title }}</h3>
|
||||
<p>{{ offering.body }}</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (section.id === 'scope' && deliveredOfferings().length > 0) {
|
||||
<section class="stack offerings-group" [attr.aria-labelledby]="deliveredHeadingId()">
|
||||
<h3 [id]="deliveredHeadingId()">{{ copy.deliveredOfferingsHeading }}</h3>
|
||||
<ul class="stack offerings">
|
||||
@for (offering of deliveredOfferings(); track offering.id) {
|
||||
<li class="glass-surface" [attr.data-offering-status]="offering.status">
|
||||
<h4>{{ offering.title }}</h4>
|
||||
<p>{{ offering.body }}</p>
|
||||
@if (caseBackedNote(offering); as note) {
|
||||
<p class="case-backed">{{ note }}</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
@if (section.id === 'offer-boundary' && offerOfferings().length > 0) {
|
||||
<section class="stack offerings-group" [attr.aria-labelledby]="offerHeadingId()">
|
||||
<h3 [id]="offerHeadingId()">{{ copy.offerOfferingsHeading }}</h3>
|
||||
<ul class="stack offerings">
|
||||
@for (offering of offerOfferings(); track offering.id) {
|
||||
<li class="glass-surface" [attr.data-offering-status]="offering.status">
|
||||
<h4>{{ offering.title }}</h4>
|
||||
<p>{{ offering.body }}</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
<section class="stack" [attr.aria-labelledby]="'reference-' + copy.routeId">
|
||||
|
||||
@@ -12,12 +12,21 @@
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.offerings h3 {
|
||||
.offerings-group h3,
|
||||
.offerings h4 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.offerings h4 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.offerings p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.offerings .case-backed {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||
import {
|
||||
type OfferingCopy,
|
||||
type ProcessStepCopy,
|
||||
type ServicePageId,
|
||||
type ServiceSequenceCopy,
|
||||
@@ -42,4 +43,24 @@ export class ServicePageView {
|
||||
const section = this.page().sections.find((item) => item.id === 'sequence');
|
||||
return section ? `section-${section.id}` : null;
|
||||
});
|
||||
protected readonly deliveredOfferings = computed(() =>
|
||||
this.page().offerings.filter((offering) => offering.status === 'delivered'),
|
||||
);
|
||||
protected readonly offerOfferings = computed(() =>
|
||||
this.page().offerings.filter((offering) => offering.status === 'offer'),
|
||||
);
|
||||
protected readonly deliveredHeadingId = computed(() => `offerings-delivered-${this.routeId()}`);
|
||||
protected readonly offerHeadingId = computed(() => `offerings-offer-${this.routeId()}`);
|
||||
|
||||
protected caseBackedNote(offering: OfferingCopy): string | null {
|
||||
const caseId = offering.referenceCaseId;
|
||||
if (!caseId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.page().offeringCaseBackedLabel.replace(
|
||||
'{case}',
|
||||
this.content.caseStudy(caseId)().client,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
90
src/app/features/stack/stack-evidence.spec.ts
Normal file
90
src/app/features/stack/stack-evidence.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { routes } from '../../app.routes';
|
||||
import { SKILL_GRID_ITEM_NAMES } from '../../components/pages/skills/skills-grid/skills-grid';
|
||||
import { SITE_CONTENT } from '../../core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../../core/content/site-content';
|
||||
import { APP_LOCALES } from '../../core/i18n/locale';
|
||||
import { routePath } from '../../core/routing/route-paths';
|
||||
|
||||
const OVERCLAIMS = [
|
||||
'Der Stack, der auf diesen Seiten vorkommt',
|
||||
'The stack that appears on these pages',
|
||||
'Auswahl aus der öffentlichen Arbeit',
|
||||
'selection from the public work',
|
||||
];
|
||||
|
||||
function walkStrings(value: unknown, visit: (text: string) => void): void {
|
||||
if (typeof value === 'string') {
|
||||
visit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => walkStrings(item, visit));
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
Object.values(value).forEach((child) => walkStrings(child, visit));
|
||||
}
|
||||
}
|
||||
|
||||
function contentMentions(name: string): boolean {
|
||||
let found = false;
|
||||
walkStrings(SITE_CONTENT_DATA, (text) => {
|
||||
if (text.includes(name)) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('stack evidence framing', () => {
|
||||
it('backs every skills-grid name with public copy or the evidence note', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const note = SITE_CONTENT_DATA[locale].stack.evidenceNote;
|
||||
expect(note.trim().length).toBeGreaterThan(40);
|
||||
|
||||
for (const name of SKILL_GRID_ITEM_NAMES) {
|
||||
expect(
|
||||
contentMentions(name) || note.length > 0,
|
||||
`${locale} skill "${name}" must appear in copy or be covered by the evidence note`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
expect(SITE_CONTENT_DATA.de.stack.evidenceNote).not.toBe(
|
||||
SITE_CONTENT_DATA.en.stack.evidenceNote,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the evidence note and drops the old overclaiming sentences', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
const note = SITE_CONTENT_DATA[locale].stack.evidenceNote;
|
||||
await harness.navigateByUrl(routePath('stack', locale));
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
const rendered = root.querySelector('[role="note"]');
|
||||
|
||||
expect(rendered?.textContent).toContain(note);
|
||||
expect(root.textContent).toContain(note);
|
||||
|
||||
for (const claim of OVERCLAIMS) {
|
||||
expect(root.textContent).not.toContain(claim);
|
||||
expect(SITE_CONTENT_DATA[locale].stack.hero.headline).not.toContain(claim);
|
||||
expect(
|
||||
SITE_CONTENT_DATA[locale].stack.sections
|
||||
.map((section) => section.body?.join(' ') ?? '')
|
||||
.join(' '),
|
||||
).not.toContain(claim);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
<p class="evidence-note" role="note">{{ copy.evidenceNote }}</p>
|
||||
<app-skills-grid [categoryTitles]="categoryTitles()" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
</section>
|
||||
|
||||
@if (caseStudy().metrics.length > 0) {
|
||||
<app-metric-list [metrics]="caseStudy().metrics" [labelledBy]="metricsLabelId()" />
|
||||
<section class="stack" [attr.aria-labelledby]="metricsLabelId()">
|
||||
<h3 [id]="metricsLabelId()">{{ metricsLabel() }}</h3>
|
||||
<app-metric-list [metrics]="caseStudy().metrics" [labelledBy]="metricsLabelId()" />
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-stack'">
|
||||
|
||||
@@ -14,6 +14,7 @@ export class CaseStudy {
|
||||
readonly situationLabel = input.required<string>();
|
||||
readonly approachLabel = input.required<string>();
|
||||
readonly outcomeLabel = input.required<string>();
|
||||
readonly metricsLabel = input.required<string>();
|
||||
readonly stackLabel = input.required<string>();
|
||||
readonly tagsLabel = input.required<string>();
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page section p {
|
||||
.page section p,
|
||||
.page .evidence-note {
|
||||
margin: 0;
|
||||
max-width: 40rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
Reference in New Issue
Block a user