215 lines
8.2 KiB
TypeScript
215 lines
8.2 KiB
TypeScript
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();
|
|
});
|
|
});
|