diff --git a/LOGISDATA/public/warehouse-interior.jpg b/LOGISDATA/public/warehouse-interior.jpg new file mode 100644 index 0000000..f34e5b2 Binary files /dev/null and b/LOGISDATA/public/warehouse-interior.jpg differ diff --git a/LOGISDATA/src/app/globals.css b/LOGISDATA/src/app/globals.css index 9534532..95478c6 100644 --- a/LOGISDATA/src/app/globals.css +++ b/LOGISDATA/src/app/globals.css @@ -325,11 +325,14 @@ button:focus-visible { * entering section no longer invalidates the other four full-viewport * subtrees stacked in the native scroll host. * - * Scroll snapping is intentionally omitted: the briefing follows the - * browser's continuous native scroll position, while nav and keyboard - * jumps choose exact section offsets explicitly. + * Each section is a native snap point. Wheel, trackpad and touch gestures + * advance through the five chapter positions; the browser settles on the + * nearest chapter when input stops. Reduced-motion users get proximity + * snapping rather than a forced long-distance settle. */ contain: layout paint style; + scroll-snap-align: start; + scroll-snap-stop: always; } .section-inner { diff --git a/LOGISDATA/src/components/Presentation.tsx b/LOGISDATA/src/components/Presentation.tsx index 2fb8548..ebf487c 100644 --- a/LOGISDATA/src/components/Presentation.tsx +++ b/LOGISDATA/src/components/Presentation.tsx @@ -22,7 +22,7 @@ import { presentationCopy } from "@/lib/data"; import { downloadExecutiveReportJson } from "@/lib/export"; import { t, text } from "@/lib/i18n"; import { shouldIgnorePresentationShortcut } from "@/lib/keyboard"; -import { DURATION, transition } from "@/lib/motion"; +import { DURATION, scrollBehaviorForMotion, transition } from "@/lib/motion"; import { sound } from "@/lib/sound"; import { SECTIONS, @@ -269,8 +269,8 @@ export function Presentation() { }; scrollMetricsRef.current = metrics; const targetTop = scrollTopForSection(index, metrics.scrollHeight, metrics.clientHeight); - element.scrollTo({ top: targetTop, behavior: "smooth" }); - }, []); + element.scrollTo({ top: targetTop, behavior: scrollBehaviorForMotion(reduced) }); + }, [reduced]); useEffect(() => { toggleLanguageRef.current = toggleLanguage; @@ -568,6 +568,7 @@ export function Presentation() { style={{ scrollbarWidth: "none", overscrollBehaviorY: "contain", + scrollSnapType: reduced ? "y proximity" : "y mandatory", }} > diff --git a/LOGISDATA/src/components/three/IndustrialScene.tsx b/LOGISDATA/src/components/three/IndustrialScene.tsx index c10877c..59b4642 100644 --- a/LOGISDATA/src/components/three/IndustrialScene.tsx +++ b/LOGISDATA/src/components/three/IndustrialScene.tsx @@ -1,8 +1,8 @@ "use client"; -import { useFrame, useThree } from "@react-three/fiber"; +import { useFrame, useLoader, useThree } from "@react-three/fiber"; import { useScroll } from "@react-three/drei"; -import { memo, useEffect, useMemo, useRef } from "react"; +import { memo, Suspense, useEffect, useMemo, useRef } from "react"; import * as THREE from "three"; import { SECTION_COUNT, pagePositionFromOffset } from "@/lib/sections"; import type { DeviceProfile } from "@/lib/device"; @@ -53,6 +53,36 @@ const DEPTH_STEP = 2.2; const clamp01 = (value: number) => THREE.MathUtils.clamp(value, 0, 1); const smoothStep = (value: number) => value * value * (3 - 2 * value); +/** Photo-real warehouse plate behind the scroll-synchronized 3D audit models. */ +function WarehouseBackdrop() { + const texture = useLoader(THREE.TextureLoader, "/warehouse-interior.jpg"); + + // Configure the cached GPU texture once; this is an owned renderer resource, + // not React state. The loader cache must retain the same texture reference. + /* eslint-disable react-hooks/immutability */ + useEffect(() => { + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + texture.needsUpdate = true; + }, [texture]); + /* eslint-enable react-hooks/immutability */ + + return ( + + + + + ); +} + function SceneRig({ language, theme, device }: IndustrialSceneProps) { const scroll = useScroll(); const invalidate = useThree((state) => state.invalidate); @@ -153,6 +183,11 @@ function SceneRig({ language, theme, device }: IndustrialSceneProps) { + {device.tier !== "low" && ( + + + + )} {device.effects && } diff --git a/LOGISDATA/src/components/three/WarehouseGrid.tsx b/LOGISDATA/src/components/three/WarehouseGrid.tsx index cb9d0f2..17f8097 100644 --- a/LOGISDATA/src/components/three/WarehouseGrid.tsx +++ b/LOGISDATA/src/components/three/WarehouseGrid.tsx @@ -11,6 +11,10 @@ import { easeApproach, getSectionApproach } from "@/lib/sceneFocus"; import { FocusFadeText } from "./FocusFadeText"; const SECTION_INDEX = 4; +const UPRIGHT_X = [-4.2, -2.1, 0, 2.1, 4.2] as const; +const RACK_DEPTHS = [-1.85, 0.85] as const; +const BEAM_X = [-3.15, -1.05, 1.05, 3.15] as const; +const BEAM_LEVELS = [-1.15, 0.35, 1.85] as const; interface WarehouseGridProps { language: Language; @@ -19,106 +23,266 @@ interface WarehouseGridProps { export function WarehouseGrid({ language, theme }: WarehouseGridProps) { const boxesRef = useRef(null); + const palletsRef = useRef(null); + const uprightsRef = useRef(null); + const beamsRef = useRef(null); const scanRef = useRef(null); + const forkliftRef = useRef(null); + const liftRef = useRef(null); const scroll = useScroll(); - const frameColor = theme === "dark" ? "#2e5265" : "#73989c"; + const frameColor = theme === "dark" ? "#3a5b6d" : "#66838b"; + const floorColor = theme === "dark" ? "#172b38" : "#c7d5d5"; + const mismatchColor = useMemo(() => new THREE.Color("#fb5b5b"), []); + const auditedColor = useMemo(() => new THREE.Color("#4de1c1"), []); const problemBins = useMemo( () => warehouseBins.filter((bin) => bin.status === "mismatch").slice(0, 3), [], ); - const boxGeometry = useMemo(() => new THREE.BoxGeometry(1, 1, 1), []); - const boxMaterial = useMemo( - () => - new THREE.MeshStandardMaterial({ - vertexColors: true, - transparent: true, - opacity: 0.92, - roughness: 0.48, - metalness: 0.2, - }), + + // Shared unit geometry keeps all repeated inventory and rack members + // instanced. The expanded interior adds detail without multiplying draw calls. + const unitBox = useMemo(() => new THREE.BoxGeometry(1, 1, 1), []); + const inventoryMaterial = useMemo( + () => new THREE.MeshStandardMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.94, + roughness: 0.48, + metalness: 0.2, + }), [], ); - - useEffect( - () => () => { - boxGeometry.dispose(); - boxMaterial.dispose(); - }, - [boxGeometry, boxMaterial], + const palletMaterial = useMemo( + () => new THREE.MeshStandardMaterial({ color: "#8b765a", roughness: 0.88 }), + [], + ); + const uprightMaterial = useMemo( + () => new THREE.MeshStandardMaterial({ color: frameColor, metalness: 0.64, roughness: 0.36 }), + [frameColor], ); + const beamMaterial = useMemo( + () => new THREE.MeshStandardMaterial({ color: "#d5a548", metalness: 0.48, roughness: 0.42 }), + [], + ); + + useEffect(() => () => { + unitBox.dispose(); + inventoryMaterial.dispose(); + palletMaterial.dispose(); + uprightMaterial.dispose(); + beamMaterial.dispose(); + }, [beamMaterial, inventoryMaterial, palletMaterial, unitBox, uprightMaterial]); useLayoutEffect(() => { - const mesh = boxesRef.current; - if (!mesh) return; const matrix = new THREE.Matrix4(); const rotation = new THREE.Quaternion(); - const scale = new THREE.Vector3(0.82, 0.62, 0.72); const position = new THREE.Vector3(); - const mismatchColor = new THREE.Color("#fb5b5b"); - const auditedColor = new THREE.Color("#4de1c1"); - warehouseBins.forEach((bin, index) => { - matrix.compose(position.set(bin.x, bin.y, bin.z), rotation, scale); - mesh.setMatrixAt(index, matrix); - mesh.setColorAt(index, bin.status === "mismatch" ? mismatchColor : auditedColor); - }); - mesh.instanceMatrix.needsUpdate = true; - if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; - }, []); + const scale = new THREE.Vector3(); + const cargo = boxesRef.current; + const pallets = palletsRef.current; + const uprights = uprightsRef.current; + const beams = beamsRef.current; + + if (cargo) { + warehouseBins.forEach((bin, index) => { + matrix.compose( + position.set(bin.x, bin.y, bin.z), + rotation, + scale.set(0.82, 0.62, 0.72), + ); + cargo.setMatrixAt(index, matrix); + cargo.setColorAt(index, bin.status === "mismatch" ? mismatchColor : auditedColor); + }); + cargo.instanceMatrix.needsUpdate = true; + if (cargo.instanceColor) cargo.instanceColor.needsUpdate = true; + } + + if (pallets) { + warehouseBins.forEach((bin, index) => { + matrix.compose( + position.set(bin.x, bin.y - 0.37, bin.z), + rotation, + scale.set(0.94, 0.12, 0.84), + ); + pallets.setMatrixAt(index, matrix); + }); + pallets.instanceMatrix.needsUpdate = true; + } + + if (uprights) { + let index = 0; + for (const z of RACK_DEPTHS) { + for (const x of UPRIGHT_X) { + matrix.compose( + position.set(x, -0.05, z), + rotation, + scale.set(0.1, 4.55, 0.1), + ); + uprights.setMatrixAt(index, matrix); + index += 1; + } + } + uprights.instanceMatrix.needsUpdate = true; + } + + if (beams) { + let index = 0; + for (const z of RACK_DEPTHS) { + for (const y of BEAM_LEVELS) { + for (const x of BEAM_X) { + matrix.compose( + position.set(x, y, z), + rotation, + scale.set(2.1, 0.09, 0.14), + ); + beams.setMatrixAt(index, matrix); + index += 1; + } + } + } + beams.instanceMatrix.needsUpdate = true; + } + }, [auditedColor, mismatchColor]); useFrame((_, delta) => { - if (!scanRef.current) return; + const progress = easeApproach(getSectionApproach(scroll.offset, SECTION_INDEX)); const step = Math.min(delta, 1 / 20); - const sectionProgress = getSectionApproach(scroll.offset, SECTION_INDEX); - const easedProgress = easeApproach(sectionProgress); - const targetY = THREE.MathUtils.lerp(-2.25, 2.25, easedProgress); - scanRef.current.position.y = THREE.MathUtils.damp(scanRef.current.position.y, targetY, 5.5, step); - const material = scanRef.current.material as THREE.MeshBasicMaterial; - material.opacity = THREE.MathUtils.lerp(0.08, 0.22, Math.sin(sectionProgress * Math.PI)); + + // The forklift and scan pass are driven only by chapter scroll progress. + // They settle at the selected point and do not run an idle animation. + if (forkliftRef.current) { + forkliftRef.current.position.x = THREE.MathUtils.lerp(-3.5, 2.45, progress); + } + if (liftRef.current) { + liftRef.current.position.y = 0.24 + progress * 0.78; + } + if (scanRef.current) { + const targetY = THREE.MathUtils.lerp(-2.25, 2.25, progress); + scanRef.current.position.y = THREE.MathUtils.damp(scanRef.current.position.y, targetY, 5.5, step); + const material = scanRef.current.material as THREE.MeshBasicMaterial; + material.opacity = THREE.MathUtils.lerp(0.06, 0.18, progress); + } }); return ( - - {/* Rack structural framework */} - - {[-4.2, -2.1, 0, 2.1, 4.2].map((x) => ( - - - + {/* Concrete slab and painted aisle/safety markings. */} + + + + + {[-2.06, -0.43, 1.16].map((z) => ( + + + + + ))} + + {/* Rear warehouse wall, loading bays, and overhead service lights. */} + + + + + {[-3.1, 0, 3.1].map((x) => ( + + + + - ))} - {[-2.25, -0.75, 0.75, 2.25].map((y) => ( - - - + + + + + {[-0.67, 0.67].map((side) => ( + + + + + ))} + + ))} + {[-1.55, -0.15, 1.25].map((z) => ( + + + + + + + + + + + ))} + + {/* Full double-sided rack frames, cross-beams and individually tracked pallets. */} + + + + + + {/* Scroll-driven electric reach truck: it travels the aisle and raises + its forks toward the audited rack level, then stops with the scroll. */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {[-0.1, 0.1].map((x) => ( + + + + + ))} + + + {[-0.24, 0.28].map((x) => ( + + + ))} - {[-1.4, 0.6].map((z) => ( - - - + {[-0.24, 0.28].map((x) => ( + + + ))} + + + + - {/* Sweeping optical scanning sheet */} + {/* Scanning sheet remains a single lightweight pass over the audited rack. */} - + - + {problemBins.map((bin) => ( diff --git a/LOGISDATA/src/components/ui/LiveTelemetryFeed.tsx b/LOGISDATA/src/components/ui/LiveTelemetryFeed.tsx index 18a78f7..69533ec 100644 --- a/LOGISDATA/src/components/ui/LiveTelemetryFeed.tsx +++ b/LOGISDATA/src/components/ui/LiveTelemetryFeed.tsx @@ -29,11 +29,18 @@ export function LiveTelemetryFeed({ open, onClose, language }: LiveTelemetryFeed const [filterSeverity, setFilterSeverity] = useState("all"); useEffect(() => { - // The drawer remains mounted so filter state survives closing, but a - // hidden simulation must not allocate timers or play notification audio. + // The drawer remains mounted so filter state survives closing. Do not + // allocate timers or play notification audio while it is closed, paused, + // or the browser tab is in the background. if (!open || isPaused) return; - const interval = setInterval(() => { + let interval: ReturnType | null = null; + const stop = () => { + if (interval === null) return; + clearInterval(interval); + interval = null; + }; + const tick = () => { const randomIdx = Math.floor(Math.random() * SAMPLE_TELEMETRY_EVENTS.length); const base = SAMPLE_TELEMETRY_EVENTS[randomIdx]; const now = new Date(); @@ -52,9 +59,23 @@ export function LiveTelemetryFeed({ open, onClose, language }: LiveTelemetryFeed } else if (newEvent.severity === "optimized") { sound.playSuccess(); } - }, 4500); - - return () => clearInterval(interval); + }; + const start = () => { + if (interval === null && document.visibilityState === "visible") { + interval = setInterval(tick, 4500); + } + }; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") start(); + else stop(); + }; + + start(); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; }, [open, isPaused]); if (!open) return null; diff --git a/LOGISDATA/src/lib/data.ts b/LOGISDATA/src/lib/data.ts index b9de95f..05eb9e2 100644 --- a/LOGISDATA/src/lib/data.ts +++ b/LOGISDATA/src/lib/data.ts @@ -134,7 +134,7 @@ export const presentationCopy: PresentationCopy = { }, mismatch: { en: "Mismatch", ar: "عدم تطابق" }, audited: { en: "Audited", ar: "مدقق" }, - scanLabel: { en: "REAL-TIME SCAN", ar: "مسح لحظي" }, + scanLabel: { en: "SIMULATED SCAN", ar: "مسح محاكى" }, callout: { en: "shelf variance detected", ar: "تم اكتشاف فرق على الرف" }, }, footer: { diff --git a/LOGISDATA/src/lib/device.ts b/LOGISDATA/src/lib/device.ts index 175d550..bb053ab 100644 --- a/LOGISDATA/src/lib/device.ts +++ b/LOGISDATA/src/lib/device.ts @@ -103,6 +103,24 @@ export function classifyDevice(input: { }; } +/** + * True when two profiles would drive identical rendering behaviour. + * Resize/orientation storms re-classify constantly; callers use this to + * keep the previous object (and its identity) when nothing observable + * changed, so `useSyncExternalStore` consumers do not re-render. + */ +export function deviceProfilesEqual(a: DeviceProfile, b: DeviceProfile): boolean { + return ( + a.tier === b.tier && + a.dpr[0] === b.dpr[0] && + a.dpr[1] === b.dpr[1] && + a.effects === b.effects && + a.particleBudget === b.particleBudget && + a.antialias === b.antialias && + a.reducedMotion === b.reducedMotion + ); +} + /** Browser-side convenience wrapper around {@link classifyDevice}. */ export function probeDevice(webglOverride?: boolean): DeviceProfile { if (typeof window === "undefined") return STATIC_PROFILE; diff --git a/LOGISDATA/src/lib/motion.ts b/LOGISDATA/src/lib/motion.ts index 81b4a5b..2c9a03e 100644 --- a/LOGISDATA/src/lib/motion.ts +++ b/LOGISDATA/src/lib/motion.ts @@ -39,6 +39,11 @@ export interface MotionOptions { delay?: number; } +/** Native scroll behaviour aligned with the user's motion preference. */ +export function scrollBehaviorForMotion(reduced: boolean): ScrollBehavior { + return reduced ? "auto" : "smooth"; +} + /** Builds a framer-motion transition that automatically honours reduced motion. */ export function transition( duration: keyof typeof DURATION | number = "base", diff --git a/LOGISDATA/src/lib/preferenceStore.ts b/LOGISDATA/src/lib/preferenceStore.ts index 9ca0f0a..aadf706 100644 --- a/LOGISDATA/src/lib/preferenceStore.ts +++ b/LOGISDATA/src/lib/preferenceStore.ts @@ -1,4 +1,4 @@ -import { probeDevice, STATIC_PROFILE, type DeviceProfile } from "./device"; +import { deviceProfilesEqual, probeDevice, STATIC_PROFILE, type DeviceProfile } from "./device"; import type { Language, ThemeMode } from "./types"; /** @@ -66,7 +66,10 @@ function initialize(): void { snapshot = { language: storedLanguage === "ar" || storedLanguage === "en" ? storedLanguage : "en", theme: storedTheme === "light" || storedTheme === "dark" ? storedTheme : readSystemTheme(), - device: probeDevice(), + // Capability is stable for the page lifetime: re-initialisation (for + // example a cross-tab storage sync) must not spend another WebGL probe + // context when the first result is already known. + device: probeDevice(webglAvailable ?? undefined), hydrated: true, }; webglAvailable = snapshot.device.tier !== "none"; @@ -99,7 +102,14 @@ export function subscribe(listener: () => void): () => void { // again. Reusing the cached capability avoids a WebGL context probe for // every intermediate resize event during browser-chrome/orientation // changes. - update({ device: probeDevice(webglAvailable ?? undefined) }); + const device = probeDevice(webglAvailable ?? undefined); + // A fresh profile object would otherwise replace the snapshot on every + // resize frame — re-rendering the whole provider tree and invalidating + // the memoized 3D scene even when the classification is unchanged. + // Keep the existing object (and its identity) until something actually + // observable changes. + if (deviceProfilesEqual(snapshot.device, device)) return; + update({ device }); }; const onEnvironmentChange = () => { if (resizeFrame !== null) return; diff --git a/LOGISDATA/src/lib/sound.ts b/LOGISDATA/src/lib/sound.ts index 31058a7..64dcc7f 100644 --- a/LOGISDATA/src/lib/sound.ts +++ b/LOGISDATA/src/lib/sound.ts @@ -8,15 +8,33 @@ * - Smooth exponential gain envelopes to guarantee zero clicking or audio clipping. */ +const SOUND_PREFERENCE_KEY = "logisdata_sound_enabled"; + +function readSoundPreference(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(SOUND_PREFERENCE_KEY) === "true"; + } catch { + // Storage can be unavailable in private browsing or hardened webviews. + return false; + } +} + +function persistSoundPreference(enabled: boolean): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(SOUND_PREFERENCE_KEY, String(enabled)); + } catch { + // Audio is an optional enhancement; persistence must never block toggling. + } +} + class SoundSynthesizer { private ctx: AudioContext | null = null; private enabled = false; constructor() { - if (typeof window !== "undefined") { - const stored = localStorage.getItem("logisdata_sound_enabled"); - this.enabled = stored === "true"; - } + this.enabled = readSoundPreference(); } public isEnabled(): boolean { @@ -25,9 +43,7 @@ class SoundSynthesizer { public toggle(): boolean { this.enabled = !this.enabled; - if (typeof window !== "undefined") { - localStorage.setItem("logisdata_sound_enabled", String(this.enabled)); - } + persistSoundPreference(this.enabled); if (this.enabled) { this.initContext(); this.playTone(880, 0.08, "sine", 0.05); @@ -37,9 +53,7 @@ class SoundSynthesizer { public setEnabled(val: boolean): void { this.enabled = val; - if (typeof window !== "undefined") { - localStorage.setItem("logisdata_sound_enabled", String(this.enabled)); - } + persistSoundPreference(this.enabled); } private initContext(): AudioContext | null { diff --git a/LOGISDATA/tests/e2e/navigation.spec.ts b/LOGISDATA/tests/e2e/navigation.spec.ts index f49c249..97c9ebe 100644 --- a/LOGISDATA/tests/e2e/navigation.spec.ts +++ b/LOGISDATA/tests/e2e/navigation.spec.ts @@ -46,6 +46,23 @@ test.describe("section navigation", () => { }); }); + test("wheel scrolling settles on chapter snap points and reverses to the prior step", async ({ page }) => { + await enterControlRoom(page); + const progressbar = page.getByRole("progressbar"); + const scrollTop = () => page.locator(".html-deck-host").evaluate((host) => host.parentElement?.scrollTop ?? -1); + const viewportHeight = await page.locator(".html-deck-host").evaluate( + (host) => host.parentElement?.clientHeight ?? 0, + ); + + await page.mouse.wheel(0, 700); + await expect(progressbar).toHaveAttribute("aria-valuenow", "2", { timeout: 15_000 }); + await expect.poll(async () => Math.abs((await scrollTop()) - viewportHeight)).toBeLessThan(4); + + await page.mouse.wheel(0, -700); + await expect(progressbar).toHaveAttribute("aria-valuenow", "1", { timeout: 15_000 }); + await expect.poll(scrollTop).toBeLessThan(4); + }); + test("the progress indicator tracks the active section", async ({ page }) => { await enterControlRoom(page); const progressbar = page.getByRole("progressbar"); diff --git a/LOGISDATA/tests/e2e/smoke.spec.ts b/LOGISDATA/tests/e2e/smoke.spec.ts index 24f76ed..58a5bd8 100644 --- a/LOGISDATA/tests/e2e/smoke.spec.ts +++ b/LOGISDATA/tests/e2e/smoke.spec.ts @@ -7,6 +7,13 @@ test.describe("control room entry", () => { await expect(page.getByRole("heading", { level: 1 })).toBeVisible({ timeout: 30_000 }); }); + test("the warehouse environment plate is served as a compact static image", async ({ request }) => { + const response = await request.get("/warehouse-interior.jpg"); + expect(response.ok()).toBe(true); + expect(response.headers()["content-type"]).toContain("image/jpeg"); + expect((await response.body()).byteLength).toBeLessThan(500_000); + }); + test("the 3D runtime stays a deferred chunk, not part of the initial document", async ({ request }) => { const html = await (await request.get("/")).text(); // The document is the static shell. If Three.js ever gets pulled into the diff --git a/LOGISDATA/tests/unit/device.test.ts b/LOGISDATA/tests/unit/device.test.ts index a731ad3..cc650a1 100644 --- a/LOGISDATA/tests/unit/device.test.ts +++ b/LOGISDATA/tests/unit/device.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { classifyDevice, detectWebGL, prefersReducedMotion, probeDevice, STATIC_PROFILE } from "@/lib/device"; +import { + classifyDevice, + detectWebGL, + deviceProfilesEqual, + prefersReducedMotion, + probeDevice, + STATIC_PROFILE, +} from "@/lib/device"; const base = { webgl: true, @@ -83,6 +90,29 @@ describe("detectWebGL", () => { }); }); +describe("deviceProfilesEqual", () => { + it("matches identical profiles and rejects any observable difference", () => { + const profile = classifyDevice(base); + expect(deviceProfilesEqual(profile, { ...profile })).toBe(true); + expect(deviceProfilesEqual(profile, { ...profile, tier: "medium" })).toBe(false); + expect(deviceProfilesEqual(profile, { ...profile, dpr: [1, 1] })).toBe(false); + expect(deviceProfilesEqual(profile, { ...profile, effects: !profile.effects })).toBe(false); + expect(deviceProfilesEqual(profile, { ...profile, particleBudget: 1 })).toBe(false); + expect(deviceProfilesEqual(profile, { ...profile, antialias: !profile.antialias })).toBe(false); + expect(deviceProfilesEqual(profile, { ...profile, reducedMotion: true })).toBe(false); + }); + + it("treats separately produced probes of the same environment as equal", () => { + expect(deviceProfilesEqual(classifyDevice(base), classifyDevice(base))).toBe(true); + expect( + deviceProfilesEqual( + classifyDevice(base), + classifyDevice({ ...base, viewportWidth: 420 }), + ), + ).toBe(false); + }); +}); + describe("prefersReducedMotion / probeDevice", () => { afterEach(() => vi.unstubAllGlobals()); diff --git a/LOGISDATA/tests/unit/motion.test.ts b/LOGISDATA/tests/unit/motion.test.ts new file mode 100644 index 0000000..5c0a064 --- /dev/null +++ b/LOGISDATA/tests/unit/motion.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { scrollBehaviorForMotion } from "@/lib/motion"; + +describe("scrollBehaviorForMotion", () => { + it("uses immediate navigation when reduced motion is requested", () => { + expect(scrollBehaviorForMotion(true)).toBe("auto"); + }); + + it("keeps smooth section transitions for users who allow motion", () => { + expect(scrollBehaviorForMotion(false)).toBe("smooth"); + }); +}); diff --git a/LOGISDATA/tests/unit/newComponents.test.tsx b/LOGISDATA/tests/unit/newComponents.test.tsx index faf3e1e..898bdf8 100644 --- a/LOGISDATA/tests/unit/newComponents.test.tsx +++ b/LOGISDATA/tests/unit/newComponents.test.tsx @@ -203,6 +203,30 @@ describe("LiveTelemetryFeed", () => { expect(timer).not.toHaveBeenCalled(); }); + it("suspends fixture updates in a background tab and resumes when visible", () => { + vi.useFakeTimers(); + let visibility: DocumentVisibilityState = "visible"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibility, + }); + render( {}} language="en" />); + const log = screen.getByRole("log"); + + act(() => vi.advanceTimersByTime(4_500)); + expect(log.children).toHaveLength(7); + + visibility = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + act(() => vi.advanceTimersByTime(9_000)); + expect(log.children).toHaveLength(7); + + visibility = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + act(() => vi.advanceTimersByTime(4_500)); + expect(log.children).toHaveLength(8); + }); + it.each([ [0, "playAlert"], [0.7, "playSuccess"], diff --git a/LOGISDATA/tests/unit/preferenceStore.test.ts b/LOGISDATA/tests/unit/preferenceStore.test.ts index 584d5e6..3b6061e 100644 --- a/LOGISDATA/tests/unit/preferenceStore.test.ts +++ b/LOGISDATA/tests/unit/preferenceStore.test.ts @@ -86,3 +86,63 @@ describe("preference store", () => { setItem.mockRestore(); }); }); + +describe("device refresh path", () => { + it("keeps snapshot identity on an unchanged resize and updates on a tier change", async () => { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + getExtension: () => ({ loseContext: vi.fn() }), + } as unknown as RenderingContext); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1920 }); + Object.defineProperty(window.navigator, "hardwareConcurrency", { configurable: true, value: 8 }); + Object.defineProperty(window.navigator, "deviceMemory", { configurable: true, value: 8 }); + vi.spyOn(window, "matchMedia").mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) as unknown as MediaQueryList); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + queueMicrotask(() => cb(0)); + return 1; + }); + + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + try { + const before = getSnapshot(); + expect(before.device.tier).toBe("high"); + + // A resize that leaves the classification unchanged must not notify + // subscribers or replace the snapshot object. + window.dispatchEvent(new Event("resize")); + await Promise.resolve(); + expect(listener).not.toHaveBeenCalled(); + expect(getSnapshot()).toBe(before); + + // A genuine tier change is propagated exactly once. + Object.defineProperty(window, "innerWidth", { configurable: true, value: 420 }); + window.dispatchEvent(new Event("resize")); + await Promise.resolve(); + expect(listener).toHaveBeenCalledOnce(); + expect(getSnapshot()).not.toBe(before); + expect(getSnapshot().device.tier).toBe("medium"); + } finally { + unsubscribe(); + } + }); + + it("does not spend another WebGL probe when cross-tab sync re-initialises", () => { + const getContext = vi.spyOn(HTMLCanvasElement.prototype, "getContext"); + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + try { + // First initialisation probed the capability (three getContext calls). + getContext.mockClear(); + + window.dispatchEvent(new StorageEvent("storage", { key: LANGUAGE_KEY, newValue: "ar" })); + expect(getContext).not.toHaveBeenCalled(); + expect(listener).toHaveBeenCalledOnce(); + } finally { + unsubscribe(); + } + }); +}); diff --git a/LOGISDATA/tests/unit/sound.test.ts b/LOGISDATA/tests/unit/sound.test.ts index e7b078b..0180b99 100644 --- a/LOGISDATA/tests/unit/sound.test.ts +++ b/LOGISDATA/tests/unit/sound.test.ts @@ -25,6 +25,25 @@ describe("sound synthesizer engine", () => { expect(sound.isEnabled()).toBe(false); }); + it("keeps audio controls usable when browser storage is denied", () => { + const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("storage denied"); + }); + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("storage denied"); + }); + try { + expect(() => sound.setEnabled(true)).not.toThrow(); + expect(sound.isEnabled()).toBe(true); + expect(() => sound.toggle()).not.toThrow(); + expect(sound.isEnabled()).toBe(false); + } finally { + getItem.mockRestore(); + setItem.mockRestore(); + sound.setEnabled(false); + } + }); + it("executes audio trigger methods without throwing exceptions", () => { sound.setEnabled(false); // Muted expect(() => sound.playClick()).not.toThrow();