mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 14:58:18 +00:00
Reuse scoped content and progress operations
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user