Reuse scoped content and progress operations

This commit is contained in:
vorotamoroz committed 2026-10-29 14:56:56 +00:00
1 parent 28bc6e87a1
commit cfdc698775
16 files changed
+1596 -132

No files matched your search

@@ -0,0 +1,142 @@
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { createDeferredAction, type TimeoutScheduler } from "octagonal-wheels/concurrency/deferredAction";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
STATUS_COUNTER_INACTIVE_LINGER_MS,
createChunkFetchCounterLabel,
createMinimumVisibleActivityCount,
createPaddedCounterLabel,
} from "./StatusBarDisplay.ts";
vi.mock("octagonal-wheels/concurrency/deferredAction", { spy: true });
function createdControllers() {
return vi.mocked(createDeferredAction).mock.results.map((result) => {
if (result.type !== "return") throw new Error("Expected a real timeout controller");
return result.value;
});
}
function createScheduler() {
const entries: { callback: () => void; delayMs: number; cancel: ReturnType<typeof vi.fn> }[] = [];
const schedule = vi.fn<TimeoutScheduler>((callback, delayMs) => {
const cancel = vi.fn();
entries.push({ callback, delayMs, cancel });
return cancel;
});
return { schedule, entries };
}
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-24T00:00:00Z"));
});
afterEach(() => {
vi.clearAllTimers();
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("status timeout composition", () => {
it("uses the real shared controller for minimum visibility, resumption, and disposal", () => {
const scheduler = createScheduler();
const source = reactiveSource(0);
const display = createMinimumVisibleActivityCount(source, 150, scheduler.schedule);
expect(createDeferredAction).toHaveBeenCalledExactlyOnceWith(expect.any(Function), scheduler.schedule);
const controller = createdControllers()[0];
const schedule = vi.spyOn(controller, "schedule");
const cancel = vi.spyOn(controller, "cancel");
const dispose = vi.spyOn(controller, "dispose");
source.value = 1;
vi.advanceTimersByTime(50);
source.value = 0;
expect(schedule).toHaveBeenCalledExactlyOnceWith(100);
expect(scheduler.entries[0].delayMs).toBe(100);
source.value = 2;
expect(cancel).toHaveBeenCalledTimes(3);
expect(scheduler.entries[0].cancel).toHaveBeenCalledTimes(1);
scheduler.entries[0].callback();
expect(display.value).toBe(2);
source.value = 0;
scheduler.entries[1].callback();
expect(display.value).toBe(0);
source.value = 3;
source.value = 0;
display.dispose();
display.dispose();
scheduler.entries[2].callback();
expect(display.value).toBe(3);
expect(scheduler.entries[2].cancel).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
});
it("uses the shared controller for the counter linger and cancels through it when activity resumes", () => {
const scheduler = createScheduler();
const source = reactiveSource(0);
const display = createPaddedCounterLabel(source, "A", 25, scheduler.schedule);
expect(createDeferredAction).toHaveBeenCalledExactlyOnceWith(expect.any(Function), scheduler.schedule);
const controller = createdControllers()[0];
const schedule = vi.spyOn(controller, "schedule");
const cancel = vi.spyOn(controller, "cancel");
const dispose = vi.spyOn(controller, "dispose");
source.value = 1;
source.value = 0;
expect(schedule).toHaveBeenCalledExactlyOnceWith(25);
source.value = 2;
expect(cancel).toHaveBeenCalledTimes(3);
expect(scheduler.entries[0].cancel).toHaveBeenCalledTimes(1);
scheduler.entries[0].callback();
expect(display.value).toBe(" A\u20072");
expect(display.count.value).toBe(2);
source.value = 0;
scheduler.entries[1].callback();
expect(display.value).toBe("");
expect(display.count.value).toBeUndefined();
source.value = 3;
source.value = 0;
display.dispose();
scheduler.entries[2].callback();
expect(display.value).toBe(" A\u20070");
expect(display.count.value).toBe(0);
expect(scheduler.entries[2].cancel).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
});
it("passes the scheduler to independent chunk-count controllers and disposes both children", () => {
const scheduler = createScheduler();
const source = reactiveSource({ initial: 2, retrying: 3 });
const display = createChunkFetchCounterLabel(source, scheduler.schedule);
expect(display.initialCount.value).toBe(2);
expect(display.retryingCount.value).toBe(3);
expect(createDeferredAction).toHaveBeenCalledTimes(2);
expect(vi.mocked(createDeferredAction).mock.calls.map((args) => args[1])).toEqual([
scheduler.schedule,
scheduler.schedule,
]);
const controllers = createdControllers();
const disposers = controllers.map((controller) => vi.spyOn(controller, "dispose"));
source.value = { initial: 0, retrying: 0 };
expect(scheduler.entries.map((entry) => entry.delayMs)).toEqual([
STATUS_COUNTER_INACTIVE_LINGER_MS,
STATUS_COUNTER_INACTIVE_LINGER_MS,
]);
scheduler.entries[0].callback();
expect(display.value.replace(/\u2007/g, "")).toBe(" 🔁0");
expect(display.initialCount.value).toBeUndefined();
expect(display.retryingCount.value).toBe(0);
display.dispose();
display.dispose();
expect(scheduler.entries[0].cancel).not.toHaveBeenCalled();
expect(scheduler.entries[1].cancel).toHaveBeenCalledTimes(1);
for (const dispose of disposers) expect(dispose).toHaveBeenCalledTimes(1);
scheduler.entries[1].callback();
expect(display.value.replace(/\u2007/g, "")).toBe(" 🔁0");
expect(display.initialCount.value).toBeUndefined();
expect(display.retryingCount.value).toBe(0);
});
});
+51 -30
View File
@@ -1,7 +1,13 @@
import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
import { createDeferredAction, type TimeoutScheduler } from "octagonal-wheels/concurrency/deferredAction";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
const scheduleStatusTimeout: TimeoutScheduler = (callback, delayMs) => {
const handle = compatGlobal.setTimeout(callback, delayMs);
return () => compatGlobal.clearTimeout(handle);
};
const STATUS_COUNTER_PADDING = "\u2007".repeat(10);
export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000;
@@ -37,32 +43,32 @@ function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => v
/**
* Mirrors an activity count while keeping each visible period on screen for a
* minimum total lifetime. The delay applies only when the source becomes zero.
*
* @param source - The activity count to observe.
* @param minimumVisibleMs - The minimum visible lifetime, clamped to zero.
* @param scheduleTimeout - The scheduler for this display. Defaults to the
* existing compatible host, preserving its timer method receivers.
*/
export function createMinimumVisibleActivityCount(
source: ReactiveValue<number>,
minimumVisibleMs: number
minimumVisibleMs: number,
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
): DisposableReactiveValue<number> {
const minimumLifetime = Math.max(0, minimumVisibleMs);
const displayed = reactiveSource(Math.max(0, source.value));
let visibleSince = displayed.value > 0 ? Date.now() : undefined;
let hideTimer: number | undefined;
let disposed = false;
const cancelHide = () => {
if (hideTimer === undefined) return;
compatGlobal.clearTimeout(hideTimer);
hideTimer = undefined;
};
const hideIfIdle = () => {
hideTimer = undefined;
if (disposed || Math.max(0, source.value) > 0) return;
displayed.value = 0;
visibleSince = undefined;
};
const hideTimeout = createDeferredAction(hideIfIdle, scheduleTimeout);
const update = () => {
if (disposed) return;
const nextCount = Math.max(0, source.value);
cancelHide();
hideTimeout.cancel();
if (nextCount > 0) {
if (displayed.value === 0) {
visibleSince = Date.now();
@@ -80,7 +86,7 @@ export function createMinimumVisibleActivityCount(
if (remaining === 0) {
hideIfIdle();
} else {
hideTimer = compatGlobal.setTimeout(hideIfIdle, remaining);
hideTimeout.schedule(remaining);
}
};
@@ -88,7 +94,7 @@ export function createMinimumVisibleActivityCount(
return asDisposableReactiveValue(displayed, () => {
if (disposed) return;
disposed = true;
cancelHide();
hideTimeout.dispose();
source.offChanged(update);
});
}
@@ -96,24 +102,31 @@ export function createMinimumVisibleActivityCount(
/**
* Formats a counter with a stable width and briefly retains its zero value so
* that the completion of queued work remains visible.
*
* @param source - The counter to observe.
* @param mark - The label prefix before the padded count.
* @param inactiveLingerMs - The delay before clearing a zero count, clamped to zero.
* @param scheduleTimeout - The scheduler for this display. Defaults to the
* existing compatible host, preserving its timer method receivers.
*/
export function createPaddedCounterLabel(
source: ReactiveValue<number>,
mark: string,
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS,
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
): PaddedCounterLabel {
const linger = Math.max(0, inactiveLingerMs);
const formatted = reactiveSource("");
const displayedCount = reactiveSource<number | undefined>(undefined);
let maximumLength = 1;
let clearTimer: number | undefined;
let disposed = false;
const cancelClear = () => {
if (clearTimer === undefined) return;
compatGlobal.clearTimeout(clearTimer);
clearTimer = undefined;
};
const clearLabelTimeout = createDeferredAction(() => {
if (disposed) return;
formatted.value = "";
displayedCount.value = undefined;
maximumLength = 1;
}, scheduleTimeout);
const format = (count: number) => {
const requiredLength = `${Math.abs(count)}`.length + 1;
maximumLength = Math.max(maximumLength, requiredLength);
@@ -121,25 +134,19 @@ export function createPaddedCounterLabel(
};
const update = () => {
if (disposed) return;
cancelClear();
clearLabelTimeout.cancel();
const count = source.value;
formatted.value = format(count);
displayedCount.value = count;
if (count !== 0) return;
clearTimer = compatGlobal.setTimeout(() => {
clearTimer = undefined;
if (disposed) return;
formatted.value = "";
displayedCount.value = undefined;
maximumLength = 1;
}, linger);
clearLabelTimeout.schedule(linger);
};
source.onChanged(update);
const display = asDisposableReactiveValue(formatted, () => {
if (disposed) return;
disposed = true;
cancelClear();
clearLabelTimeout.dispose();
source.offChanged(update);
});
return {
@@ -162,14 +169,28 @@ export function createPaddedCounterLabel(
/**
* Displays the disjoint initial and retry chunk-fetch counts with the same
* padding and inactive linger behaviour as the other status counters.
*
* @param source - The initial and retrying chunk-fetch counts.
* @param scheduleTimeout - The scheduler passed to each independent child display.
*/
export function createChunkFetchCounterLabel(
source: ReactiveValue<{ initial: number; retrying: number }>
source: ReactiveValue<{ initial: number; retrying: number }>,
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
): ChunkFetchCounterLabel {
const initialCount = reactiveSource(0);
const retryingCount = reactiveSource(0);
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
const retryingLabel = createPaddedCounterLabel(retryingCount, "🔁");
const initialLabel = createPaddedCounterLabel(
initialCount,
"🛄",
STATUS_COUNTER_INACTIVE_LINGER_MS,
scheduleTimeout
);
const retryingLabel = createPaddedCounterLabel(
retryingCount,
"🔁",
STATUS_COUNTER_INACTIVE_LINGER_MS,
scheduleTimeout
);
const formatted = reactiveSource(`${initialLabel.value}${retryingLabel.value}`);
let updatingCounts = false;
let disposed = false;
@@ -1,3 +1,4 @@
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";
@@ -194,3 +195,121 @@ describe("createChunkFetchCounterLabel", () => {
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);
});
});