fix: enforce first-frame timeout and nav labels
This commit is contained in:
@@ -186,3 +186,28 @@ test("explicit keyboard navigation focuses a section heading without a fixed-sur
|
||||
expect(geometry.insideSafeBounds).toBe(true);
|
||||
expect(geometry.overlapArea).toBe(0);
|
||||
});
|
||||
|
||||
test("responsive navigation exposes the exact visible label as each link's accessible name", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("link", { name: "Experience", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Additional work", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Career", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "More work", exact: true })).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
const career = page.getByRole("link", { name: "Career", exact: true });
|
||||
const moreWork = page.getByRole("link", { name: "More work", exact: true });
|
||||
await expect(career.locator(".site-navigation__compact-label")).toHaveText("Career");
|
||||
await expect(moreWork.locator(".site-navigation__compact-label")).toHaveText("More work");
|
||||
await expect(page.getByRole("link", { name: "Experience", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Additional work", exact: true })).toHaveCount(0);
|
||||
|
||||
await career.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#experience-heading")).toBeFocused();
|
||||
});
|
||||
|
||||
@@ -94,6 +94,11 @@ test.describe('scene failure matrix', () => {
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true');
|
||||
await expect(page.locator('.journey-canvas')).toHaveCount(0);
|
||||
await assertSemanticFaultOracle(page);
|
||||
const fault = await readFaultSnapshot(page);
|
||||
expect(fault.events).toContain('first-frame-suppressed');
|
||||
expect(fault.events).not.toContain('first-frame-deadline');
|
||||
expect(fault.staticFallbackCount).toBe(1);
|
||||
expect((await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
|
||||
await assertNoEscapedErrors(page, errors.pageErrors);
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,19 @@ describe('semantic portfolio document', () => {
|
||||
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
|
||||
});
|
||||
|
||||
it('renders separate full and compact navigation labels without overriding their accessible names', async () => {
|
||||
const document = await renderedDocument();
|
||||
const experience = document.querySelector('nav a[href="#experience"]')!;
|
||||
const additional = document.querySelector('nav a[href="#additional-work"]')!;
|
||||
|
||||
expect(experience.hasAttribute('aria-label')).toBe(false);
|
||||
expect(experience.querySelector('.site-navigation__full-label')?.textContent).toBe('Experience');
|
||||
expect(experience.querySelector('.site-navigation__compact-label')?.textContent).toBe('Career');
|
||||
expect(additional.hasAttribute('aria-label')).toBe(false);
|
||||
expect(additional.querySelector('.site-navigation__full-label')?.textContent).toBe('Additional work');
|
||||
expect(additional.querySelector('.site-navigation__compact-label')?.textContent).toBe('More work');
|
||||
});
|
||||
|
||||
it('binds navigation and section labels to their canonical IDs when content navigation is reordered', async () => {
|
||||
const content = fixture('portfolio-content.valid.json') as {
|
||||
navigation: Array<{ id: SectionId; label: string }>;
|
||||
@@ -78,7 +91,10 @@ describe('semantic portfolio document', () => {
|
||||
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
|
||||
'START', 'PROFILE', 'EXPERIENCE', 'INFRASTRUCTURE', 'SELECTED BUILDS', 'ADDITIONAL WORK', 'TOOLKIT', 'CONTACT',
|
||||
]);
|
||||
expect([...document.querySelectorAll('nav a')].map((link) => [link.getAttribute('href'), link.textContent])).toEqual([
|
||||
expect([...document.querySelectorAll('nav a')].map((link) => [
|
||||
link.getAttribute('href'),
|
||||
link.querySelector('.site-navigation__full-label')?.textContent,
|
||||
])).toEqual([
|
||||
['#start', 'Start'], ['#profile', 'Profile'], ['#experience', 'Experience'], ['#infrastructure', 'Infrastructure'],
|
||||
['#selected-builds', 'Selected builds'], ['#additional-work', 'Additional work'], ['#toolkit', 'Toolkit'], ['#contact', 'Contact'],
|
||||
]);
|
||||
|
||||
@@ -106,6 +106,82 @@ describe('scene controller lifecycle', () => {
|
||||
expect(renderedFrames).toEqual([{ start: 2_000, end: 2_300 }]);
|
||||
});
|
||||
|
||||
it('enters static five seconds after a live-scene request that never completes its first frame', async () => {
|
||||
const harness = createHarness({ deferredBuild: true });
|
||||
const controller = createSceneController(harness.options);
|
||||
const start = controller.start();
|
||||
|
||||
harness.scheduler.flushTimers(4_999);
|
||||
expect(controller.snapshot().state.kind).toBe('loading');
|
||||
|
||||
harness.scheduler.flushTimers(5_000);
|
||||
expect(controller.snapshot()).toMatchObject({
|
||||
state: { kind: 'static', reason: 'first-frame-timeout' },
|
||||
currentGeneration: null,
|
||||
rafCount: 0,
|
||||
});
|
||||
expect(harness.buildContexts[0]?.signal.aborted).toBe(true);
|
||||
|
||||
harness.resolveBuild(0);
|
||||
await start;
|
||||
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('cancels the first-frame watchdog only after a complete update and render', async () => {
|
||||
const harness = createHarness();
|
||||
const controller = createSceneController(harness.options);
|
||||
await controller.start();
|
||||
|
||||
harness.scheduler.flushFrame(16);
|
||||
harness.scheduler.flushTimers(5_001);
|
||||
|
||||
expect(controller.snapshot()).toMatchObject({
|
||||
state: { kind: 'running', generation: 1 },
|
||||
currentGeneration: 1,
|
||||
rafCount: 1,
|
||||
});
|
||||
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the watchdog armed when a frame explicitly reports incomplete', async () => {
|
||||
const harness = createHarness();
|
||||
const onFrameRendered = vi.fn();
|
||||
const controller = createSceneController({ ...harness.options, onFrameRendered });
|
||||
await controller.start();
|
||||
harness.generations[0]?.render.mockReturnValue(false);
|
||||
|
||||
harness.scheduler.flushFrame(16);
|
||||
harness.scheduler.flushTimers(5_000);
|
||||
|
||||
expect(controller.snapshot()).toMatchObject({
|
||||
state: { kind: 'static', reason: 'first-frame-timeout' },
|
||||
currentGeneration: null,
|
||||
rafCount: 0,
|
||||
});
|
||||
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
||||
expect(onFrameRendered).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces a torn-down generation watchdog with one deadline for its restart', async () => {
|
||||
const harness = createHarness({ deferredBuild: true });
|
||||
const controller = createSceneController(harness.options);
|
||||
const first = controller.start();
|
||||
harness.clock.now = 1_000;
|
||||
controller.pause();
|
||||
const second = controller.resume();
|
||||
|
||||
harness.scheduler.flushTimers(5_000);
|
||||
expect(controller.snapshot()).toMatchObject({ state: { kind: 'loading', generation: 2 } });
|
||||
|
||||
harness.scheduler.flushTimers(6_000);
|
||||
expect(controller.snapshot().state).toEqual({ kind: 'static', reason: 'first-frame-timeout' });
|
||||
harness.resolveBuild(0);
|
||||
harness.resolveBuild(1);
|
||||
await Promise.all([first, second]);
|
||||
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
||||
expect(harness.generations[1]?.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('retains the same complete generation and frame across a visitor pause', async () => {
|
||||
const harness = createHarness();
|
||||
const controller = createSceneController(harness.options);
|
||||
@@ -440,7 +516,7 @@ function createHarness(options: { deferredBuild?: boolean } = {}) {
|
||||
class FakeScheduler implements SceneScheduler {
|
||||
private nextId = 1;
|
||||
private frames = new Map<number, FrameRequestCallback>();
|
||||
private timers = new Map<number, () => void>();
|
||||
private timers = new Map<number, { callback: () => void; dueAt: number }>();
|
||||
|
||||
constructor(private readonly clock: { now: number }) {}
|
||||
|
||||
@@ -451,9 +527,9 @@ class FakeScheduler implements SceneScheduler {
|
||||
return id;
|
||||
};
|
||||
cancelAnimationFrame = (id: number): void => { this.frames.delete(id); };
|
||||
setTimeout = (callback: () => void): number => {
|
||||
setTimeout = (callback: () => void, delay: number): number => {
|
||||
const id = this.nextId++;
|
||||
this.timers.set(id, callback);
|
||||
this.timers.set(id, { callback, dueAt: this.clock.now + delay });
|
||||
return id;
|
||||
};
|
||||
clearTimeout = (id: number): void => { this.timers.delete(id); };
|
||||
@@ -464,6 +540,17 @@ class FakeScheduler implements SceneScheduler {
|
||||
this.frames.clear();
|
||||
for (const [, callback] of entries) callback(now);
|
||||
}
|
||||
|
||||
flushTimers(now: number): void {
|
||||
this.clock.now = now;
|
||||
const due = [...this.timers.entries()]
|
||||
.filter(([, timer]) => timer.dueAt <= now)
|
||||
.sort((left, right) => left[1].dueAt - right[1].dueAt);
|
||||
for (const [id, timer] of due) {
|
||||
if (!this.timers.delete(id)) continue;
|
||||
timer.callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pageEvent(type: string, persisted: boolean): Event {
|
||||
|
||||
Reference in New Issue
Block a user