feat: finish portfolio title, navigation, and destination effects
Add the approved name animation, rounded docking navigation, localized keyboard focus cue, unified builds card, and destination copy hierarchy.
Introduce lightweight one-shot meteors and a sun ring that repeats every three seconds at Contact. Open the email action in a separate browsing context. Preserve reduced-motion behavior and keep private handoff and visual-check artifacts out of version control.
Validation: 649 tests passed; all 20 failures in world-models and SceneCanvas reproduce on baseline 103427f. Typecheck, artifact verification, and transfer budgets passed. Client/SSR preview build passed before this commit. Existing baseline test failures remain unchanged.
This commit is contained in:
76
tests/browser/arrival-finale.spec.ts
Normal file
76
tests/browser/arrival-finale.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from '../helpers/serve-dist';
|
||||
|
||||
test('arrival draws six meteor triangles, uses warmed shaders, then stops drawing the meteors', async ({ page, distUrl }, testInfo) => {
|
||||
test.setTimeout(45_000);
|
||||
if (testInfo.project.name === 'desktop-chromium') await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.addInitScript((quality: string) => {
|
||||
localStorage.setItem('portfolio.motion.maximum-quality', quality);
|
||||
const probe = { draws: 0, maxVertices: 0, programs: 0, frames: 0, time: -1, ring: 0 };
|
||||
(window as any).__arrivalProbe = probe;
|
||||
const proto = WebGL2RenderingContext.prototype;
|
||||
const meteorPrograms = new WeakSet<WebGLProgram>();
|
||||
const locations = new WeakMap<WebGLUniformLocation, { program: WebGLProgram; name: string }>();
|
||||
let activeProgram: WebGLProgram | null = null;
|
||||
const meteorShaders = new WeakSet<WebGLShader>();
|
||||
const source = proto.shaderSource;
|
||||
proto.shaderSource = function (shader, text) {
|
||||
if (text.includes('attribute vec2 aFlight;')) meteorShaders.add(shader);
|
||||
return source.call(this, shader, text);
|
||||
};
|
||||
const attach = proto.attachShader;
|
||||
proto.attachShader = function (program, shader) {
|
||||
if (meteorShaders.has(shader) && !meteorPrograms.has(program)) {
|
||||
meteorPrograms.add(program);
|
||||
probe.programs++;
|
||||
}
|
||||
return attach.call(this, program, shader);
|
||||
};
|
||||
const getUniform = proto.getUniformLocation;
|
||||
proto.getUniformLocation = function (program, name) {
|
||||
const location = getUniform.call(this, program, name);
|
||||
if (location) locations.set(location, { program, name });
|
||||
return location;
|
||||
};
|
||||
const uniform = proto.uniform1f;
|
||||
proto.uniform1f = function (location, value) {
|
||||
const entry = location && locations.get(location);
|
||||
if (entry?.name === 'uTime' && meteorPrograms.has(entry.program)) probe.time = value;
|
||||
if (entry?.name === 'uRingOpacity') probe.ring = value;
|
||||
return uniform.call(this, location, value);
|
||||
};
|
||||
const useProgram = proto.useProgram;
|
||||
proto.useProgram = function (program) { activeProgram = program; return useProgram.call(this, program); };
|
||||
const draw = proto.drawArrays;
|
||||
proto.drawArrays = function (mode, first, count) {
|
||||
if (activeProgram && meteorPrograms.has(activeProgram)) {
|
||||
probe.draws++;
|
||||
probe.maxVertices = Math.max(probe.maxVertices, count);
|
||||
}
|
||||
return draw.call(this, mode, first, count);
|
||||
};
|
||||
const clear = proto.clear;
|
||||
proto.clear = function (mask) { probe.frames++; return clear.call(this, mask); };
|
||||
}, process.env.ARRIVAL_TEST_QUALITY === 'high' ? 'high' : 'low');
|
||||
await page.goto(distUrl);
|
||||
await expect(page.locator('html')).toHaveAttribute('data-expressway', 'live');
|
||||
const read = () => page.evaluate(() => ({ ...(window as any).__arrivalProbe }));
|
||||
const baseline = await read();
|
||||
expect(baseline.draws).toBe(1); // Transparent startup warm-up only.
|
||||
expect(baseline.programs).toBe(1);
|
||||
await page.locator('.hero-journey__action--primary').click();
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-arrived', 'true');
|
||||
await expect.poll(async () => (await read()).draws, { timeout: 12_000 }).toBeGreaterThan(baseline.draws);
|
||||
await expect.poll(async () => (await read()).ring).toBeGreaterThan(0);
|
||||
await expect.poll(async () => (await read()).time).toBeGreaterThan(1.2);
|
||||
await page.screenshot({ path: testInfo.outputPath('arrival-active.png') });
|
||||
await expect.poll(async () => (await read()).time, { timeout: 15_000 }).toBeGreaterThan(3.8);
|
||||
const ending = await read();
|
||||
await expect.poll(async () => (await read()).frames).toBeGreaterThan(ending.frames + 30);
|
||||
const settled = await read();
|
||||
expect(settled.maxVertices).toBe(18);
|
||||
expect(settled.programs).toBe(baseline.programs);
|
||||
await expect.poll(async () => (await read()).ring).toBeGreaterThan(0);
|
||||
await expect.poll(async () => (await read()).frames).toBeGreaterThan(settled.frames + 30);
|
||||
expect((await read()).draws).toBe(settled.draws);
|
||||
await page.screenshot({ path: testInfo.outputPath('arrival-settled.png') });
|
||||
});
|
||||
@@ -1,6 +1,40 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '../helpers/serve-dist';
|
||||
|
||||
test('builds heading shares the first project surface and contact keeps a clear copy hierarchy', async ({ page, distUrl }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(distUrl);
|
||||
const joined = await page.locator('#selected-builds-heading').evaluate(heading => {
|
||||
const surface = heading.closest('.project-stage__lead');
|
||||
return Boolean(surface?.querySelector('.project-case-study') &&
|
||||
getComputedStyle(surface).backgroundColor !== 'rgba(0, 0, 0, 0)');
|
||||
});
|
||||
expect(joined).toBe(true);
|
||||
const hierarchy = await page.locator('#contact').evaluate(section => {
|
||||
const lead = section.querySelector('.contact-destination__copy');
|
||||
const availability = section.querySelector('.contact-destination__availability');
|
||||
return Boolean(lead && availability &&
|
||||
parseFloat(getComputedStyle(lead).fontSize) > parseFloat(getComputedStyle(availability).fontSize));
|
||||
});
|
||||
expect(hierarchy).toBe(true);
|
||||
});
|
||||
|
||||
test('the live reading mask measures the inset builds heading', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl);
|
||||
await expect(page.locator('html')).toHaveAttribute('data-expressway', 'live');
|
||||
const geometry = await page.locator('#selected-builds').evaluate(section => ({
|
||||
offset: parseFloat((section as HTMLElement).style.getPropertyValue('--chapter-heading-offset')) || 0,
|
||||
actual: section.querySelector('h2')!.getBoundingClientRect().top - section.getBoundingClientRect().top,
|
||||
}));
|
||||
expect(geometry.offset).toBeGreaterThan(20);
|
||||
expect(geometry.offset).toBeCloseTo(geometry.actual, 1);
|
||||
const disclosure = page.locator('.site-navigation summary');
|
||||
if (await disclosure.isVisible()) await disclosure.click();
|
||||
await page.locator('.site-navigation a[href="#selected-builds"]').click();
|
||||
await expect.poll(() => page.locator('#selected-builds-heading').evaluate(heading =>
|
||||
Number(getComputedStyle(heading).opacity))).toBe(1);
|
||||
});
|
||||
|
||||
test('start navigation, contact identity, and toolkit icons use the approved content', async ({ page, distUrl }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const response = await page.goto(distUrl);
|
||||
@@ -11,6 +45,10 @@ test('start navigation, contact identity, and toolkit icons use the approved con
|
||||
await page.locator('.hero-journey__action--primary').click();
|
||||
await expect(page).toHaveURL(/#contact$/);
|
||||
await expect(page.locator('#contact')).toBeInViewport();
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-arrived', 'true');
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true');
|
||||
await expect(page.locator('.journey-canvas')).toHaveCount(0);
|
||||
expect(await page.locator('.journey-poster').evaluate(poster => getComputedStyle(poster).filter)).not.toBe('none');
|
||||
await expect(page.locator('.contact-avatar')).toBeVisible();
|
||||
await expect(page.locator('.contact-social-link')).toHaveText(['LinkedIn', 'GitHub', 'GitLab']);
|
||||
const missing = await page.locator('[data-testid="toolkit-technology"]').evaluateAll(items => items.filter(item => !item.querySelector('svg')).map(item => item.textContent));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, expectInsetFocusIndicator, test } from "../helpers/serve-dist";
|
||||
import { expect, expectContentFocusCue, test } from "../helpers/serve-dist";
|
||||
|
||||
const navHrefs = [
|
||||
"#start",
|
||||
@@ -130,7 +130,7 @@ test("activating the skip link focuses the visible unobscured portfolio content"
|
||||
"tabindex",
|
||||
"-1",
|
||||
);
|
||||
await expectInsetFocusIndicator(page, "#portfolio-content");
|
||||
await expectContentFocusCue(page);
|
||||
});
|
||||
|
||||
test("explicit section activation scrolls to and focuses its heading", async ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PortfolioContent } from "@/content/types";
|
||||
import { expect, expectInsetFocusIndicator, test } from "../helpers/serve-dist";
|
||||
import { expect, expectContentFocusCue, test } from "../helpers/serve-dist";
|
||||
|
||||
test.use({ javaScriptEnabled: false });
|
||||
|
||||
@@ -401,7 +401,7 @@ test("keeps every raw SSR reading-path segment visible without JavaScript", asyn
|
||||
.locator("main > section")
|
||||
.evaluateAll((sections) => sections.map((section) => section.id)),
|
||||
).toEqual(sectionIds);
|
||||
expect(await page.locator("main > section > h2").allTextContents()).toEqual(
|
||||
expect(await page.locator("main > section h2").allTextContents()).toEqual(
|
||||
sectionHeadings,
|
||||
);
|
||||
|
||||
@@ -518,7 +518,7 @@ test("activating the skip link focuses visible portfolio content without JavaScr
|
||||
"tabindex",
|
||||
"-1",
|
||||
);
|
||||
await expectInsetFocusIndicator(page, "#portfolio-content");
|
||||
await expectContentFocusCue(page);
|
||||
});
|
||||
|
||||
test("retains native section navigation when scripts are disabled", async ({
|
||||
|
||||
143
tests/browser/title-navigation.spec.ts
Normal file
143
tests/browser/title-navigation.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { expect, test } from '../helpers/serve-dist';
|
||||
|
||||
test('desktop navigation docks without stretching labels or shifting the document', async ({ page, distUrl }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium');
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(distUrl, { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const nav = page.locator('.site-navigation');
|
||||
await expect(nav).toHaveAttribute('data-enhanced', 'true');
|
||||
const surfaceScale = () => nav.evaluate(el => new DOMMatrixReadOnly(getComputedStyle(el, '::before').transform).a);
|
||||
expect(await surfaceScale()).toBeLessThan(1);
|
||||
const label = nav.locator('a').first();
|
||||
const initialFont = await label.evaluate(el => getComputedStyle(el).fontSize);
|
||||
// Measure the content beside this fixed header, not unrelated lazy media
|
||||
// farther down the page that can settle independently of navigation.
|
||||
const heroGeometry = () => page.locator('#start').evaluate(el => ({
|
||||
top: el.getBoundingClientRect().top + scrollY,
|
||||
height: el.getBoundingClientRect().height,
|
||||
}));
|
||||
const initialHero = await heroGeometry();
|
||||
await page.evaluate(() => scrollTo(0, 100));
|
||||
await expect.poll(async () => (await nav.boundingBox())!.y).toBeLessThan(1);
|
||||
await expect.poll(async () => (await nav.boundingBox())!.width).toBe(1920);
|
||||
await expect.poll(surfaceScale).toBe(1);
|
||||
expect(await label.evaluate(el => getComputedStyle(el).fontSize)).toBe(initialFont);
|
||||
expect(await heroGeometry()).toEqual(initialHero);
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
await expect.poll(async () => (await nav.boundingBox())!.y).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('selected navigation has rounded inset highlights', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl);
|
||||
const disclosure = page.locator('.site-navigation summary');
|
||||
if (await disclosure.isVisible()) await disclosure.click();
|
||||
const selected = page.locator('.site-navigation a[aria-current="page"]');
|
||||
expect(await selected.evaluate(el => parseFloat(getComputedStyle(el).borderTopLeftRadius))).toBeGreaterThanOrEqual(5);
|
||||
await selected.focus();
|
||||
await page.keyboard.press('Shift');
|
||||
expect(await selected.evaluate(el => getComputedStyle(el).outlineStyle)).not.toBe('none');
|
||||
});
|
||||
|
||||
test('skip link uses a localized visible cue and Tab continues into content', async ({ page, distUrl }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(distUrl);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator('.skip-link')).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
const main = page.locator('#portfolio-content');
|
||||
await expect(main).toBeFocused();
|
||||
expect(await main.evaluate(el => getComputedStyle(el).boxShadow)).toBe('none');
|
||||
expect(await main.evaluate(el => getComputedStyle(el).outlineStyle)).toBe('none');
|
||||
await expect(page.locator('.content-focus-cue')).toBeVisible();
|
||||
const cue = await page.locator('.content-focus-cue').boundingBox();
|
||||
expect(cue!.width).toBeLessThan(240);
|
||||
expect(cue!.height).toBeLessThan(60);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator('.hero-journey__action--primary')).toBeFocused();
|
||||
expect(await page.locator('.hero-journey__action--primary').evaluate(el => getComputedStyle(el).outlineStyle)).not.toBe('none');
|
||||
await expect(page.locator('.content-focus-cue')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Shift cannot draw a page-wide focus frame in the live reading stage', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl);
|
||||
await page.locator('main').evaluate(el => (el as HTMLElement).focus({ preventScroll: true }));
|
||||
await page.keyboard.press('Shift');
|
||||
expect(await page.locator('main').evaluate(el => ({ outline: getComputedStyle(el).outlineStyle, shadow: getComputedStyle(el).boxShadow }))).toEqual({ outline: 'none', shadow: 'none' });
|
||||
await page.locator('.hero-journey__action--primary').focus();
|
||||
await page.keyboard.press('Shift');
|
||||
expect(await page.locator('.hero-journey__action--primary').evaluate(el => getComputedStyle(el).outlineStyle)).not.toBe('none');
|
||||
});
|
||||
|
||||
test('name decoration pauses offscreen and leaves one readable heading', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Luca Pîslaru');
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const title = await page.locator('.hero-name').evaluate(el => ({
|
||||
height: el.getBoundingClientRect().height,
|
||||
lineHeight: parseFloat(getComputedStyle(el).lineHeight),
|
||||
}));
|
||||
expect(title.height).toBeLessThanOrEqual(title.lineHeight * 2.1);
|
||||
const particles = page.locator('.hero-name-particles');
|
||||
await expect(particles).toHaveAttribute('aria-hidden', 'true');
|
||||
await expect(particles).toHaveAttribute('data-active', 'true');
|
||||
const canvas = particles.locator('canvas');
|
||||
await expect(canvas).toHaveCount(1);
|
||||
const nameBox = await particles.boundingBox();
|
||||
const effectBox = await canvas.boundingBox();
|
||||
expect(effectBox!.x).toBeCloseTo(nameBox!.x - 12, 0);
|
||||
expect(effectBox!.width).toBeCloseTo(nameBox!.width + 24, 0);
|
||||
const roleBox = await page.locator('.hero-journey__role').boundingBox();
|
||||
// The complete drawing area (including every wave crest) clears the role.
|
||||
expect(roleBox!.y - (effectBox!.y + effectBox!.height)).toBeGreaterThanOrEqual(4);
|
||||
await expect.poll(() => canvas.evaluate(el => {
|
||||
const context = (el as HTMLCanvasElement).getContext('2d')!;
|
||||
const { width, height } = el as HTMLCanvasElement;
|
||||
// Sparks occupy the name area, while the waves sit below it.
|
||||
return [
|
||||
context.getImageData(0, 0, width, Math.floor(height * 0.75)).data.some(value => value > 0),
|
||||
context.getImageData(0, Math.floor(height * 0.92), width, Math.max(1, Math.floor(height * 0.08))).data.some(value => value > 0),
|
||||
];
|
||||
})).toEqual([true, true]);
|
||||
expect(await particles.evaluate(el => getComputedStyle(el, '::after').animationName)).toBe('name-sheen');
|
||||
expect(await particles.evaluate(el => getComputedStyle(el, '::after').animationPlayState)).toBe('running');
|
||||
const firstFrame = await canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL());
|
||||
await expect.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL())).not.toBe(firstFrame);
|
||||
await page.locator('#contact').scrollIntoViewIfNeeded();
|
||||
await expect(particles).toHaveAttribute('data-active', 'false');
|
||||
expect(await particles.evaluate(el => getComputedStyle(el, '::after').animationPlayState)).toBe('paused');
|
||||
const stoppedFrame = await canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL());
|
||||
await page.waitForTimeout(150);
|
||||
expect(await canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL())).toBe(stoppedFrame);
|
||||
});
|
||||
|
||||
test('wave animation clears the role text across desktop, short screens, and narrow phones', async ({ page, distUrl }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium');
|
||||
await page.goto(distUrl);
|
||||
for (const [width, height] of [[1920, 1080], [1024, 768], [820, 600], [768, 520], [393, 851], [320, 640]]) {
|
||||
await page.setViewportSize({ width: width!, height: height! });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await expect.poll(async () => {
|
||||
const effect = await page.locator('.hero-name-particles canvas').boundingBox();
|
||||
const role = await page.locator('.hero-journey__role').boundingBox();
|
||||
return role!.y - (effect!.y + effect!.height);
|
||||
}, { message: `Wave clearance at ${width} × ${height}` }).toBeGreaterThanOrEqual(4);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('reduced motion and no-JavaScript retain the name and native skip link', async ({ browser, page, distUrl }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(distUrl);
|
||||
await expect(page.locator('.hero-name-particles')).toHaveAttribute('data-active', 'false');
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
const native = await context.newPage();
|
||||
await native.goto(distUrl);
|
||||
await expect(native.getByRole('heading', { level: 1 })).toHaveText('Luca Pîslaru');
|
||||
await native.keyboard.press('Tab');
|
||||
await native.keyboard.press('Enter');
|
||||
await expect(native.locator('main')).toBeFocused();
|
||||
await expect(native.locator('.content-focus-cue')).toBeVisible();
|
||||
await context.close();
|
||||
});
|
||||
@@ -120,6 +120,19 @@ export async function expectInsetFocusIndicator(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectContentFocusCue(page: Page): Promise<void> {
|
||||
await expect(page.locator('#portfolio-content')).toBeFocused();
|
||||
const cue = page.locator('.content-focus-cue');
|
||||
await expect(cue).toBeVisible();
|
||||
expect(await page.locator('#portfolio-content').evaluate(element => ({
|
||||
outline: getComputedStyle(element).outlineStyle,
|
||||
shadow: getComputedStyle(element).boxShadow,
|
||||
}))).toEqual({ outline: 'none', shadow: 'none' });
|
||||
const box = await cue.boundingBox();
|
||||
expect(box!.width).toBeLessThan(240);
|
||||
expect(box!.height).toBeLessThan(60);
|
||||
}
|
||||
|
||||
export async function revealNavigationLinks(page: Page): Promise<void> {
|
||||
const summary = page.locator(".site-navigation summary");
|
||||
if (!(await summary.isVisible())) return;
|
||||
|
||||
@@ -417,7 +417,7 @@ describe('artifact verification', () => {
|
||||
{
|
||||
name: 'identity rendered only inside a template',
|
||||
mutate: (html: string) => html.replace(
|
||||
/(<h1[^>]*>Luca Pîslaru<\/h1><p class="hero-journey__role">System Administrator & Full-Stack Developer<\/p>)/u,
|
||||
/(<h1\b[^>]*>[\s\S]*?<\/h1><p class="hero-journey__role">System Administrator & Full-Stack Developer<\/p>)/u,
|
||||
'<template>$1</template>',
|
||||
),
|
||||
},
|
||||
@@ -456,7 +456,9 @@ describe('artifact verification', () => {
|
||||
},
|
||||
])('rejects $name as non-visible semantic evidence', async ({ mutate }) => {
|
||||
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
||||
await writeFile(join(directory, 'index.html'), mutate(html, inputs));
|
||||
const mutated = mutate(html, inputs);
|
||||
expect(mutated).not.toBe(html);
|
||||
await writeFile(join(directory, 'index.html'), mutated);
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/visible semantic|visible h1|visible navigation|visible section|connected/i);
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('semantic portfolio document', () => {
|
||||
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||
]);
|
||||
expect([...document.querySelectorAll('main > section')].every((section) => section.hasAttribute('aria-labelledby'))).toBe(true);
|
||||
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
|
||||
expect([...document.querySelectorAll('main > section h2')].map((heading) => heading.textContent)).toEqual([
|
||||
'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Additional work', 'Toolkit', 'Contact',
|
||||
]);
|
||||
expect([...document.querySelectorAll('main > section')].map((section) => section.getAttribute('data-layout'))).toEqual([
|
||||
@@ -132,7 +132,7 @@ describe('semantic portfolio document', () => {
|
||||
|
||||
const document = await renderedDocumentFor(reorderedInputs);
|
||||
|
||||
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
|
||||
expect([...document.querySelectorAll('main > section h2')].map((heading) => heading.textContent)).toEqual([
|
||||
'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Additional work', 'Toolkit', 'Contact',
|
||||
]);
|
||||
expect([...document.querySelectorAll('nav a')].map((link) => [link.getAttribute('href'), link.textContent])).toEqual([
|
||||
|
||||
@@ -17,6 +17,8 @@ describe('contact disclosure', () => {
|
||||
await wrapper.get('.contact-email__action').trigger('focusin');
|
||||
expect(wrapper.get('a.contact-link').attributes('href')).toBe('mailto:luca.pislaru@gmail.com');
|
||||
expect(wrapper.get('a.contact-link').text()).toBe('Email Me');
|
||||
expect(wrapper.get('a.contact-link').attributes('target')).toBe('_blank');
|
||||
expect(wrapper.get('a.contact-link').attributes('rel')).toBe('noopener noreferrer');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
||||
126
tests/unit/sun-arrival.test.ts
Normal file
126
tests/unit/sun-arrival.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { createSun } from '@/scene/world/sun';
|
||||
import { createMutableChapterPalette } from '@/scene/world/palette';
|
||||
|
||||
interface Halo {
|
||||
scale: { x: number };
|
||||
material: { opacity: number; color: { getHex(): number } };
|
||||
}
|
||||
|
||||
describe('destination sun', () => {
|
||||
it('keeps a distinct destination glow after the one-shot finale, without allocating on updates', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sun = createSun(resources);
|
||||
const palette = createMutableChapterPalette();
|
||||
const halo = sun.group.getObjectByName('horizon-halo') as Halo;
|
||||
sun.update(palette, 1, 10, 0, false);
|
||||
const initial = { width: halo.scale.x, opacity: halo.material.opacity, color: halo.material.color.getHex() };
|
||||
const allocation = resources.snapshot();
|
||||
sun.update(palette, 1, 10, 0, true);
|
||||
sun.update(palette, 1, 11, 0, true);
|
||||
expect(halo.scale.x).toBeGreaterThan(initial.width);
|
||||
expect(halo.material.opacity).toBeGreaterThan(initial.opacity);
|
||||
expect(halo.material.color.getHex()).not.toBe(initial.color);
|
||||
sun.update(palette, 1, 15, 0, true);
|
||||
const settled = { width: halo.scale.x, opacity: halo.material.opacity, color: halo.material.color.getHex() };
|
||||
expect(settled.color).not.toBe(initial.color);
|
||||
expect(settled.opacity).toBeGreaterThan(initial.opacity);
|
||||
for (const time of [20, 100]) {
|
||||
sun.update(palette, 1, time, 0, true);
|
||||
expect(halo.scale.x).toBe(settled.width);
|
||||
expect(halo.material.opacity).toBe(settled.opacity);
|
||||
expect(halo.material.color.getHex()).toBe(settled.color);
|
||||
}
|
||||
sun.update(palette, 0.8, 101, 0, false);
|
||||
sun.update(palette, 1, 102, 0, true);
|
||||
sun.update(palette, 1, 103, 0, true);
|
||||
expect(halo.scale.x).toBeGreaterThan(initial.width);
|
||||
expect(resources.snapshot()).toEqual(allocation);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('draws three batched meteor trails only during arrival and re-arms after leaving', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sun = createSun(resources);
|
||||
const palette = createMutableChapterPalette();
|
||||
const meteors = sun.group.getObjectByName('arrival-meteors') as {
|
||||
visible: boolean;
|
||||
geometry: { getAttribute(name: string): { count: number; array: Float32Array } };
|
||||
material: { uniforms: { uTime: { value: number } } };
|
||||
} | undefined;
|
||||
expect(meteors).toBeDefined();
|
||||
sun.update(palette, 1, 0, 0, false);
|
||||
expect(meteors!.visible).toBe(true);
|
||||
expect(meteors!.material.uniforms.uTime.value).toBe(-1);
|
||||
sun.update(palette, 1, 0.1, 0, false);
|
||||
expect(meteors!.visible).toBe(false);
|
||||
const allocation = resources.snapshot();
|
||||
const positions = meteors!.geometry.getAttribute('position');
|
||||
expect(positions.count).toBeLessThanOrEqual(18);
|
||||
sun.update(palette, 1, 1, 0, true);
|
||||
sun.update(palette, 1, 2, 0, true);
|
||||
expect(meteors!.visible).toBe(true);
|
||||
expect(meteors!.material.uniforms.uTime.value).toBe(1);
|
||||
sun.update(palette, 1, 5.1, 0, true);
|
||||
expect(meteors!.visible).toBe(false);
|
||||
sun.update(palette, 1, 50, 0, true);
|
||||
expect(meteors!.visible).toBe(false);
|
||||
sun.update(palette, 0.8, 51, 0, false);
|
||||
sun.update(palette, 1, 52, 0, true);
|
||||
sun.update(palette, 1, 53, 0, true);
|
||||
expect(meteors!.visible).toBe(true);
|
||||
sun.update(palette, 0.8, 53.1, 0, false);
|
||||
expect(meteors!.visible).toBe(false);
|
||||
expect(meteors!.geometry.getAttribute('position').array).toBe(positions.array);
|
||||
expect(resources.snapshot()).toEqual(allocation);
|
||||
resources.dispose();
|
||||
expect(resources.snapshot().owned.geometry).toBe(0);
|
||||
expect(resources.snapshot().owned.material).toBe(0);
|
||||
});
|
||||
|
||||
it('reuses the sun rim for an expanding ring that repeats every three seconds only at the destination', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sun = createSun(resources);
|
||||
const palette = createMutableChapterPalette();
|
||||
const limb = sun.group.getObjectByName('sun-limb-glow') as {
|
||||
material: { uniforms: Record<string, { value: number }> };
|
||||
};
|
||||
const uniforms = limb.material.uniforms;
|
||||
expect(uniforms.uRingRadius).toBeDefined();
|
||||
sun.update(palette, 1, 0, 0, true);
|
||||
sun.update(palette, 1, 1, 0, true);
|
||||
const firstRadius = uniforms.uRingRadius!.value;
|
||||
const firstOpacity = uniforms.uRingOpacity!.value;
|
||||
expect(uniforms.uRingOpacity!.value).toBeGreaterThan(0);
|
||||
sun.update(palette, 1, 2, 0, true);
|
||||
expect(uniforms.uRingRadius!.value).toBeGreaterThan(firstRadius);
|
||||
sun.update(palette, 1, 3.5, 0, true);
|
||||
expect(uniforms.uRingOpacity!.value).toBe(0);
|
||||
for (const time of [4, 7, 40]) {
|
||||
sun.update(palette, 1, time, 0, true);
|
||||
expect(uniforms.uRingRadius!.value).toBeCloseTo(firstRadius);
|
||||
expect(uniforms.uRingOpacity!.value).toBeCloseTo(firstOpacity);
|
||||
}
|
||||
sun.update(palette, 0.8, 40.1, 0, false);
|
||||
expect(uniforms.uRingOpacity!.value).toBe(0);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('does not pulse from route progress alone and cancels when leaving Contact', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sun = createSun(resources);
|
||||
const palette = createMutableChapterPalette();
|
||||
const halo = sun.group.getObjectByName('horizon-halo') as Halo;
|
||||
sun.update(palette, 1, 0);
|
||||
const width = halo.scale.x;
|
||||
sun.update(palette, 1, 1);
|
||||
expect(halo.scale.x).toBe(width);
|
||||
sun.update(palette, 1, 2, 0, true);
|
||||
sun.update(palette, 1, 3, 0, true);
|
||||
expect(halo.scale.x).toBeGreaterThan(width);
|
||||
sun.update(palette, 1, 3.1, 0, false);
|
||||
expect(halo.scale.x).toBe(width);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user