import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { STATUS_COUNTER_INACTIVE_LINGER_MS, createChunkFetchCounterLabel, createMinimumVisibleActivityCount, createPaddedCounterLabel, } from "./StatusBarDisplay.ts"; describe("createMinimumVisibleActivityCount", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-16T00:00:00Z")); }); afterEach(() => { vi.useRealTimers(); }); it("keeps a short activity visible for the configured minimum lifetime", () => { const source = reactiveSource(0); const display = createMinimumVisibleActivityCount(source, 150); const rendered = reactive(() => `active:${display.value}`); expect(rendered.value).toBe("active:0"); source.value = 1; expect(rendered.value).toBe("active:1"); vi.advanceTimersByTime(50); source.value = 0; expect(display.value).toBe(1); vi.advanceTimersByTime(99); expect(display.value).toBe(1); vi.advanceTimersByTime(1); expect(display.value).toBe(0); expect(rendered.value).toBe("active:0"); display.dispose(); }); it("updates overlapping activity and starts a new minimum lifetime after becoming idle", () => { const source = reactiveSource(0); const display = createMinimumVisibleActivityCount(source, 150); source.value = 1; vi.advanceTimersByTime(25); source.value = 2; expect(display.value).toBe(2); source.value = 0; vi.advanceTimersByTime(50); source.value = 1; expect(display.value).toBe(1); vi.advanceTimersByTime(75); source.value = 0; expect(display.value).toBe(0); source.value = 3; source.value = 0; expect(display.value).toBe(3); vi.advanceTimersByTime(150); expect(display.value).toBe(0); display.dispose(); }); it("cancels pending work and stops observing its source when disposed", () => { const source = reactiveSource(0); const display = createMinimumVisibleActivityCount(source, 150); source.value = 1; source.value = 0; display.dispose(); vi.advanceTimersByTime(150); source.value = 2; expect(display.value).toBe(1); }); }); describe("createPaddedCounterLabel", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("keeps the widest counter label until its inactive linger period ends", () => { const source = reactiveSource(0); const display = createPaddedCounterLabel(source, "📥"); expect(display.value).toBe(""); source.value = 9; expect(display.value).toBe(" 📥\u20079"); source.value = 123; expect(display.value).toBe(" 📥\u2007123"); source.value = 0; expect(display.value).toBe(" 📥\u2007\u2007\u20070"); vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1); expect(display.value).toBe(" 📥\u2007\u2007\u20070"); vi.advanceTimersByTime(1); expect(display.value).toBe(""); source.value = 7; expect(display.value).toBe(" 📥\u20077"); display.dispose(); }); it("exposes the numeric value while retaining zero through the existing linger", () => { const source = reactiveSource(0); const display = createPaddedCounterLabel(source, "📥"); expect(display.count.value).toBeUndefined(); source.value = 9; expect(display.count.value).toBe(9); source.value = 0; expect(display.count.value).toBe(0); vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1); expect(display.count.value).toBe(0); vi.advanceTimersByTime(1); expect(display.count.value).toBeUndefined(); display.dispose(); }); it("cancels the pending clear when counter activity resumes", () => { const source = reactiveSource(0); const display = createPaddedCounterLabel(source, "📄"); source.value = 1; source.value = 0; vi.advanceTimersByTime(1_000); source.value = 2; vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS); expect(display.value).toBe(" 📄\u20072"); display.dispose(); }); it("cancels its inactive timer and source subscription when disposed", () => { const source = reactiveSource(0); const display = createPaddedCounterLabel(source, "📄"); source.value = 4; source.value = 0; display.dispose(); vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS); source.value = 5; expect(display.value).toBe(" 📄\u20070"); }); }); describe("createChunkFetchCounterLabel", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("keeps initial and retry counts separate across an unchanged-total handoff", () => { const counts = reactiveSource({ initial: 2, retrying: 5 }); const display = createChunkFetchCounterLabel(counts); const withoutPadding = () => display.value.replace(/\u2007/g, ""); const transitionSnapshots: string[] = []; const observeTransitions = () => transitionSnapshots.push(withoutPadding()); expect(withoutPadding()).toBe(" 🛄2 🔁5"); display.onChanged(observeTransitions); counts.value = { initial: 0, retrying: 7 }; expect(withoutPadding()).toBe(" 🛄0 🔁7"); expect(transitionSnapshots).toEqual([" 🛄0 🔁7"]); display.offChanged(observeTransitions); vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1); expect(withoutPadding()).toBe(" 🛄0 🔁7"); vi.advanceTimersByTime(1); expect(withoutPadding()).toBe(" 🔁7"); counts.value = { initial: 0, retrying: 0 }; expect(withoutPadding()).toBe(" 🔁0"); display.dispose(); vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS); counts.value = { initial: 1, retrying: 0 }; expect(withoutPadding()).toBe(" 🔁0"); }); }); describe("status timeout ownership", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-24T00:00:00Z")); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it("counts the minimum lifetime from construction when activity is already visible", () => { const source = reactiveSource(2); const display = createMinimumVisibleActivityCount(source, 150); vi.advanceTimersByTime(40); source.value = 0; vi.advanceTimersByTime(109); expect(display.value).toBe(2); vi.advanceTimersByTime(1); expect(display.value).toBe(0); display.dispose(); }); it("hides immediately for a non-positive minimum lifetime and clamps negative activity", () => { const source = reactiveSource(-2); const display = createMinimumVisibleActivityCount(source, -10); expect(display.value).toBe(0); source.value = 1; source.value = -2; expect(display.value).toBe(0); expect(vi.getTimerCount()).toBe(0); display.dispose(); }); it("keeps a zero-duration counter clear asynchronous", () => { const source = reactiveSource(0); const display = createPaddedCounterLabel(source, "A", 0); source.value = 1; source.value = 0; expect(display.value).toBe(" A\u20070"); vi.advanceTimersByTime(0); expect(display.value).toBe(""); display.dispose(); }); it.each(["activity", "label"] as const)( "retains timer receivers, zero handles, and idempotent disposal for %s", (kind) => { const source = reactiveSource(0); const offChanged = vi.spyOn(source, "offChanged"); const setTimer = vi.spyOn(compatGlobal as Window, "setTimeout").mockReturnValue(0); const clearTimer = vi.spyOn(compatGlobal as Window, "clearTimeout").mockImplementation(() => {}); const display = kind === "activity" ? createMinimumVisibleActivityCount(source, 150) : createPaddedCounterLabel(source, "A", 150); source.value = 1; source.value = 0; expect(setTimer).toHaveBeenCalledTimes(1); expect(setTimer.mock.contexts[0]).toBe(compatGlobal); expect(setTimer.mock.calls[0][1]).toBe(150); source.value = 2; expect(clearTimer).toHaveBeenCalledExactlyOnceWith(0); source.value = 0; display.dispose(); display.dispose(); source.value = 3; expect(setTimer).toHaveBeenCalledTimes(2); expect(clearTimer).toHaveBeenCalledTimes(2); expect(clearTimer.mock.calls).toEqual([[0], [0]]); expect(clearTimer.mock.contexts).toEqual([compatGlobal, compatGlobal]); expect(offChanged).toHaveBeenCalledTimes(1); } ); it("keeps pending clears independent between display instances", () => { const firstSource = reactiveSource(0); const secondSource = reactiveSource(0); const first = createPaddedCounterLabel(firstSource, "A", 100); const second = createPaddedCounterLabel(secondSource, "B", 100); firstSource.value = 1; firstSource.value = 0; secondSource.value = 1; secondSource.value = 0; expect(vi.getTimerCount()).toBe(2); first.dispose(); expect(vi.getTimerCount()).toBe(1); vi.advanceTimersByTime(100); expect(first.value).toBe(" A\u20070"); expect(second.value).toBe(""); second.dispose(); expect(vi.getTimerCount()).toBe(0); }); it("disposes both child timers and its source subscription for chunk counts", () => { const source = reactiveSource({ initial: 2, retrying: 3 }); const offChanged = vi.spyOn(source, "offChanged"); const display = createChunkFetchCounterLabel(source); source.value = { initial: 0, retrying: 0 }; const lastLabel = display.value; expect(vi.getTimerCount()).toBe(2); display.dispose(); display.dispose(); expect(vi.getTimerCount()).toBe(0); vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS); source.value = { initial: 4, retrying: 5 }; expect(display.value).toBe(lastLabel); expect(offChanged).toHaveBeenCalledTimes(1); }); });