feat: living tab title and favicon follow the drive
This commit is contained in:
@@ -72,6 +72,7 @@ describe('release budgets', () => {
|
||||
expect(classifyArtifact('assets/high-tier-b.js', 'optional bloom')).toBe('high-tier-extras');
|
||||
expect(classifyArtifact('assets/DashcamFrame-c.js', 'postcard')).toBe('deferred-non-video');
|
||||
expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).toBe('deferred-non-video');
|
||||
expect(classifyArtifact('assets/tab-icon-c.js', 'renderTabIcon')).toBe('deferred-non-video');
|
||||
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
|
||||
});
|
||||
|
||||
|
||||
214
tests/unit/living-tab.test.ts
Normal file
214
tests/unit/living-tab.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, shallowRef } from 'vue';
|
||||
import { LIVING_TAB_MIN_GAP_MS, useLivingTab } from '@/composables/useLivingTab';
|
||||
import { renderTabIcon, tabIconHref, TAB_ICON } from '@/journey/tab-icon';
|
||||
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
||||
import type { SectionId } from '@/content/types';
|
||||
|
||||
const ORIGINAL_TITLE = 'Luca Pîslaru — System Administrator & Full-Stack Developer';
|
||||
const ORIGINAL_HREF = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E";
|
||||
|
||||
let visibility: DocumentVisibilityState = 'visible';
|
||||
const setVisibility = (next: DocumentVisibilityState): void => {
|
||||
visibility = next;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
const iconHref = (): string | null => document.querySelector("link[rel~='icon']")?.getAttribute('href') ?? null;
|
||||
const flush = async (): Promise<void> => {
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
};
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
/** Two animation frames: the return-to-view route re-sync. */
|
||||
const frames = (): Promise<void> => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
|
||||
/** The lazy icon chunk has landed and its throttled write has run. */
|
||||
const iconReady = async (): Promise<void> => {
|
||||
await import('@/journey/tab-icon');
|
||||
await flush();
|
||||
await sleep(LIVING_TAB_MIN_GAP_MS + 20);
|
||||
await flush();
|
||||
};
|
||||
|
||||
function setup(options: { animated?: boolean; pending?: boolean } = {}) {
|
||||
const route = shallowRef(0.47);
|
||||
const section = shallowRef<SectionId>('infrastructure');
|
||||
const arrived = shallowRef(false);
|
||||
const dashcamOpen = shallowRef(false);
|
||||
const pending = shallowRef(options.pending ?? false);
|
||||
const labels: Record<string, string> = { infrastructure: 'Infrastructure', contact: 'Contact', toolkit: 'Toolkit' };
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
useLivingTab({
|
||||
routeProgress: () => route.value,
|
||||
activeSectionId: () => section.value,
|
||||
label: () => labels[section.value] ?? section.value,
|
||||
arrived: () => arrived.value,
|
||||
dashcamOpen,
|
||||
animated: () => options.animated ?? true,
|
||||
scenePending: () => pending.value,
|
||||
});
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
const wrapper = mount(Host);
|
||||
return { wrapper, route, section, arrived, dashcamOpen, pending };
|
||||
}
|
||||
|
||||
describe('useLivingTab', () => {
|
||||
beforeEach(() => {
|
||||
visibility = 'visible';
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility });
|
||||
document.head.innerHTML = `<link rel="icon" type="image/svg+xml" href="${ORIGINAL_HREF}">`;
|
||||
document.title = ORIGINAL_TITLE;
|
||||
delete document.documentElement.dataset.dawn;
|
||||
delete document.documentElement.dataset.ignition;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.head.innerHTML = '';
|
||||
});
|
||||
|
||||
it('writes the route title and the lit icon after mount', async () => {
|
||||
const { wrapper } = setup();
|
||||
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
|
||||
await iconReady();
|
||||
expect(iconHref()).toBe(tabIconHref(renderTabIcon({
|
||||
route: 0.45, accent: SIGN_ACCENTS.infrastructure, parked: false, dawn: false,
|
||||
})));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('parks on visibilitychange and relights on return in one step', async () => {
|
||||
const { wrapper } = setup();
|
||||
await iconReady();
|
||||
const lit = iconHref();
|
||||
setVisibility('hidden');
|
||||
await flush();
|
||||
expect(document.title).toBe('PARKED · 45% · INFRASTRUCTURE');
|
||||
expect(decodeURIComponent(iconHref() ?? '')).toContain(TAB_ICON.parking);
|
||||
setVisibility('visible');
|
||||
await flush();
|
||||
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
|
||||
expect(iconHref()).toBe(lit);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps the last seen route while parked (the drive resets to 0 when hidden)', async () => {
|
||||
const { wrapper, route } = setup();
|
||||
await iconReady();
|
||||
const lit = iconHref();
|
||||
// useScrollDrive resets its snapshot on hidden, before this composable hears the event.
|
||||
route.value = 0;
|
||||
setVisibility('hidden');
|
||||
await flush();
|
||||
expect(document.title).toBe('PARKED · 45% · INFRASTRUCTURE');
|
||||
setVisibility('visible');
|
||||
await flush();
|
||||
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
|
||||
expect(iconHref()).toBe(lit);
|
||||
// The drive's first frame after return reads the real scroll position.
|
||||
route.value = 0.47;
|
||||
await flush();
|
||||
await sleep(LIVING_TAB_MIN_GAP_MS + 20);
|
||||
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('restores the original title and icon on unmount', async () => {
|
||||
const { wrapper } = setup();
|
||||
await iconReady();
|
||||
expect(document.title).not.toBe(ORIGINAL_TITLE);
|
||||
expect(iconHref()).not.toBe(ORIGINAL_HREF);
|
||||
wrapper.unmount();
|
||||
expect(document.title).toBe(ORIGINAL_TITLE);
|
||||
expect(iconHref()).toBe(ORIGINAL_HREF);
|
||||
setVisibility('hidden');
|
||||
await flush();
|
||||
expect(document.title).toBe(ORIGINAL_TITLE);
|
||||
});
|
||||
|
||||
it('leaves the title to the dashcam while open and resumes on close', async () => {
|
||||
const { wrapper, dashcamOpen, route } = setup();
|
||||
dashcamOpen.value = true;
|
||||
await flush();
|
||||
document.title = 'DASHCAM · 45% · INFRASTRUCTURE · 03:51';
|
||||
route.value = 0.8;
|
||||
setVisibility('hidden');
|
||||
await flush();
|
||||
await sleep(LIVING_TAB_MIN_GAP_MS + 20);
|
||||
expect(document.title).toBe('DASHCAM · 45% · INFRASTRUCTURE · 03:51');
|
||||
setVisibility('visible');
|
||||
// The frame restores the title it found when it opened, then the tab resumes.
|
||||
document.title = 'ROUTE 45% · INFRASTRUCTURE';
|
||||
dashcamOpen.value = false;
|
||||
await frames();
|
||||
await flush();
|
||||
expect(document.title).toBe('ROUTE 80% · INFRASTRUCTURE');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('steps at most once per gap while the route moves, and reads arrival', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] });
|
||||
const { wrapper, route, section, arrived } = setup();
|
||||
route.value = 0.481; // same 5% step as 0.47: nothing to do
|
||||
await flush();
|
||||
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
|
||||
route.value = 0.6;
|
||||
await flush();
|
||||
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
|
||||
vi.advanceTimersByTime(LIVING_TAB_MIN_GAP_MS);
|
||||
expect(document.title).toBe('ROUTE 60% · INFRASTRUCTURE');
|
||||
section.value = 'contact';
|
||||
route.value = 1;
|
||||
arrived.value = true;
|
||||
await flush();
|
||||
vi.advanceTimersByTime(LIVING_TAB_MIN_GAP_MS);
|
||||
expect(document.title).toBe('ARRIVED · CONTACT');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('turns the icon gold on peach at day', async () => {
|
||||
const { wrapper } = setup();
|
||||
await iconReady();
|
||||
document.documentElement.dataset.dawn = 'day';
|
||||
await iconReady();
|
||||
expect(decodeURIComponent(iconHref() ?? '')).toContain(TAB_ICON.dawnPlate);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps the metadata title through the ignition', async () => {
|
||||
document.documentElement.dataset.ignition = 'cold';
|
||||
const { wrapper } = setup();
|
||||
expect(document.title).toBe(ORIGINAL_TITLE);
|
||||
delete document.documentElement.dataset.ignition;
|
||||
await sleep(0);
|
||||
await flush();
|
||||
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('holds the metadata title while the scene comes up', async () => {
|
||||
const { wrapper, pending } = setup({ pending: true });
|
||||
expect(document.title).toBe(ORIGINAL_TITLE);
|
||||
pending.value = false;
|
||||
await flush();
|
||||
await sleep(120);
|
||||
expect(document.title).toBe('ROUTE 45% · INFRASTRUCTURE');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps a fixed lit icon without full motion (no visibility swap)', async () => {
|
||||
const { wrapper, route } = setup({ animated: false });
|
||||
const fixed = tabIconHref(renderTabIcon({ route: 1, accent: TAB_ICON.glyph, parked: false, dawn: false }));
|
||||
await iconReady();
|
||||
expect(iconHref()).toBe(fixed);
|
||||
route.value = 0.1;
|
||||
await flush();
|
||||
setVisibility('hidden');
|
||||
await flush();
|
||||
expect(iconHref()).toBe(fixed);
|
||||
expect(document.title).toBe('PARKED · 10% · INFRASTRUCTURE');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
115
tests/unit/tab-feed.test.ts
Normal file
115
tests/unit/tab-feed.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatTabTitle, quantiseRoute } from '@/journey/tab-feed';
|
||||
import { renderTabIcon, tabIconHref, TAB_ICON } from '@/journey/tab-icon';
|
||||
import { validateProductionInputs } from '@/content/schemas';
|
||||
import { render } from '@/entry-server';
|
||||
|
||||
const base = { routeProgress: 0.47, label: 'Infrastructure', arrived: false, parked: false, dashcam: false };
|
||||
|
||||
describe('formatTabTitle', () => {
|
||||
it('reads the route in 5% steps with an uppercase chapter', () => {
|
||||
expect(formatTabTitle(base)).toBe('ROUTE 45% · INFRASTRUCTURE');
|
||||
expect(formatTabTitle({ ...base, routeProgress: 0.48 })).toBe('ROUTE 50% · INFRASTRUCTURE');
|
||||
expect(formatTabTitle({ ...base, routeProgress: 0, label: 'Start' })).toBe('ROUTE 0% · START');
|
||||
expect(formatTabTitle({ ...base, label: ' Selected builds ' })).toBe('ROUTE 45% · SELECTED BUILDS');
|
||||
});
|
||||
|
||||
it('clamps and survives non-finite progress', () => {
|
||||
expect(formatTabTitle({ ...base, routeProgress: 1.4 })).toBe('ROUTE 100% · INFRASTRUCTURE');
|
||||
expect(formatTabTitle({ ...base, routeProgress: -1 })).toBe('ROUTE 0% · INFRASTRUCTURE');
|
||||
expect(formatTabTitle({ ...base, routeProgress: Number.NaN })).toBe('ROUTE 0% · INFRASTRUCTURE');
|
||||
});
|
||||
|
||||
it('parks, arrives, and yields to the dashcam', () => {
|
||||
expect(formatTabTitle({ ...base, parked: true })).toBe('PARKED · 45% · INFRASTRUCTURE');
|
||||
expect(formatTabTitle({ ...base, arrived: true, label: 'Contact' })).toBe('ARRIVED · CONTACT');
|
||||
expect(formatTabTitle({ ...base, arrived: true, parked: true, label: 'Contact' })).toBe('PARKED · 100% · CONTACT');
|
||||
expect(formatTabTitle({ ...base, dashcam: true })).toBeNull();
|
||||
expect(formatTabTitle({ ...base, dashcam: true, parked: true, arrived: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('quantises to stable steps', () => {
|
||||
expect(quantiseRoute(0.024)).toBe(0);
|
||||
expect(quantiseRoute(0.026)).toBe(0.05);
|
||||
expect(new Set([0.5, 0.51, 0.52, 0.49].map((r) => formatTabTitle({ ...base, routeProgress: r }))).size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
const beamLengths = (svg: string): number[] =>
|
||||
[...svg.matchAll(/<path d='M16 \d+L([\d.]+) /gu)].map((m) => Number(m[1]) - 16);
|
||||
const opacities = (svg: string): number[] => [...svg.matchAll(/fill-opacity='([\d.]+)'/gu)].map((m) => Number(m[1]));
|
||||
|
||||
describe('renderTabIcon', () => {
|
||||
const icon = { route: 0, accent: '#8be9ed', parked: false, dawn: false };
|
||||
|
||||
it('keeps the dark plate and the L glyph', () => {
|
||||
const svg = renderTabIcon(icon);
|
||||
expect(svg.startsWith("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>")).toBe(true);
|
||||
expect(svg).toContain(`fill='${TAB_ICON.plate}'`);
|
||||
expect(svg).toContain(`<path d='M11 7v18h11' fill='none' stroke='${TAB_ICON.glyph}' stroke-width='4'/>`);
|
||||
expect(svg).not.toMatch(/["\n]/u);
|
||||
});
|
||||
|
||||
it('lengthens and brightens two beams with the route, in the accent', () => {
|
||||
const at = (route: number) => renderTabIcon({ ...icon, route });
|
||||
const [l0, l50, l100] = [at(0), at(0.5), at(1)].map(beamLengths);
|
||||
expect(l0).toEqual([4, 4]);
|
||||
expect(l50).toEqual([9, 9]);
|
||||
expect(l100).toEqual([14, 14]);
|
||||
expect(opacities(at(0))).toEqual([0.55, 0.55]);
|
||||
expect(opacities(at(0.5))).toEqual([0.75, 0.75]);
|
||||
expect(opacities(at(1))).toEqual([0.95, 0.95]);
|
||||
expect(at(0.5).match(/fill='#8be9ed'/gu)).toHaveLength(2);
|
||||
expect(at(0.51)).toBe(at(0.5));
|
||||
});
|
||||
|
||||
it('drops to amber parking lights when parked', () => {
|
||||
const svg = renderTabIcon({ ...icon, route: 0.8, parked: true });
|
||||
expect(beamLengths(svg)).toEqual([]);
|
||||
expect(svg.match(new RegExp(`fill='${TAB_ICON.parking}'`, 'gu'))).toHaveLength(2);
|
||||
expect(svg).not.toContain('#8be9ed');
|
||||
});
|
||||
|
||||
it('turns gold on peach at day', () => {
|
||||
const svg = renderTabIcon({ ...icon, route: 1, dawn: true });
|
||||
expect(svg).toContain(`<rect width='32' height='32' rx='6' fill='${TAB_ICON.dawnPlate}'/>`);
|
||||
expect(svg).toContain(`stroke='${TAB_ICON.dawnGlyph}'`);
|
||||
expect(svg).not.toContain(TAB_ICON.plate);
|
||||
expect(svg).not.toContain('#8be9ed');
|
||||
});
|
||||
|
||||
it('ignores an accent that is not a hex colour', () => {
|
||||
expect(renderTabIcon({ ...icon, accent: "red'/><script>" })).not.toContain('script');
|
||||
});
|
||||
|
||||
it('encodes the href as a data:image/svg+xml URI', () => {
|
||||
const svg = renderTabIcon({ ...icon, route: 0.5 });
|
||||
const href = tabIconHref(svg);
|
||||
expect(href.startsWith('data:image/svg+xml,%3Csvg')).toBe(true);
|
||||
expect(href).not.toMatch(/[<>#" ]/u);
|
||||
expect(decodeURIComponent(href.slice('data:image/svg+xml,'.length))).toBe(svg);
|
||||
});
|
||||
});
|
||||
|
||||
describe('server HTML', () => {
|
||||
const fixture = (name: string): unknown =>
|
||||
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
|
||||
|
||||
it('carries no living tab state', async () => {
|
||||
const html = await render('/', validateProductionInputs(
|
||||
fixture('portfolio-content.valid.json'),
|
||||
fixture('asset-manifest.valid.json'),
|
||||
fixture('site-config.preview.json'),
|
||||
new URL('file:///prepared-assets/'),
|
||||
));
|
||||
expect(html).not.toMatch(/ROUTE \d+%|PARKED ·|ARRIVED ·|data:image\/svg\+xml/u);
|
||||
});
|
||||
|
||||
it('leaves index.html title and icon alone', () => {
|
||||
const index = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
|
||||
expect(index).toContain('<title>Portfolio</title>');
|
||||
expect(index).toContain("%3Cpath d='M11 7v18h11' fill='none' stroke='%23ffd693' stroke-width='4'/%3E");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user