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 => { await nextTick(); await nextTick(); }; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); /** Two animation frames: the return-to-view route re-sync. */ const frames = (): Promise => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); /** The lazy icon chunk has landed and its throttled write has run. */ const iconReady = async (): Promise => { 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('infrastructure'); const arrived = shallowRef(false); const dashcamOpen = shallowRef(false); const pending = shallowRef(options.pending ?? false); const labels: Record = { 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 = ``; 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(); }); });