// useOnboardingChecklist — reducer, loader and data-channel follower (#351, #396). // // No jsdom / renderHook harness in this project (see useSnapshotEditor.test.ts): // the exported reducer, loader and follower are driven directly. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // The hook module imports the onboarding service (→ db → tauri plugins) and // useEntitlement (→ LicenseContext → licenseService → tauri invoke). Stub both // so importing the module never touches a real connection. vi.mock("../services/db", () => { const getDb = vi.fn(); return { getDb, withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), }; }); vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: vi.fn() })); import { onboardingChecklistReducer, initialOnboardingChecklistState, createOnboardingLoader, deriveOnboardingView, followDataChanges, DATA_CHANGE_DELAY_MS, type OnboardingChecklistAction, type OnboardingChecklistState, type OnboardingLoaderDeps, } from "./useOnboardingChecklist"; import { notifyDataChanged } from "../services/dataEvents"; import { computeChecklist } from "../utils/onboardingChecklist"; import type { OnboardingSnapshot } from "../services/onboardingService"; import type { OnboardingCounters } from "../shared/types"; const snapshot: OnboardingSnapshot = { counters: { sources: 1, imports: 1, manualCategorized: 0, budgetEntries: 0, transactions: 12 }, importFolderSet: true, hidden: false, skipped: [], }; const reduce = ( actions: OnboardingChecklistAction[], from: OnboardingChecklistState = initialOnboardingChecklistState, ): OnboardingChecklistState => actions.reduce(onboardingChecklistReducer, from); /** A first successful load (seq 1). */ const ready = (snap: OnboardingSnapshot = snapshot): OnboardingChecklistState => reduce([ { type: "LOAD_START", seq: 1 }, { type: "LOAD_DONE", seq: 1, snapshot: snap }, ]); describe("onboardingChecklistReducer — load lifecycle (#351, #396)", () => { it("starts loading with no counters, nothing hidden or skipped, and no card", () => { expect(initialOnboardingChecklistState).toEqual({ status: "loading", counters: null, importFolderSet: false, hidden: false, skipped: [], loadSeq: 0, hiddenSetAt: 0, skippedSetAt: 0, cardPage: null, }); }); it("LOAD_DONE stores the snapshot and sets ready", () => { const s = ready({ ...snapshot, skipped: ["budget"] }); expect(s.status).toBe("ready"); expect(s.counters).toEqual(snapshot.counters); expect(s.importFolderSet).toBe(true); expect(s.hidden).toBe(false); expect(s.skipped).toEqual(["budget"]); }); it("LOAD_DONE applies the persisted hidden flag", () => { expect(ready({ ...snapshot, hidden: true }).hidden).toBe(true); }); it("a stale LOAD_DONE (an older load answering after a newer one started) is ignored", () => { const s = reduce([ { type: "LOAD_START", seq: 1 }, { type: "LOAD_START", seq: 2 }, { type: "LOAD_DONE", seq: 1, snapshot: { ...snapshot, hidden: true } }, ]); expect(s.status).toBe("loading"); expect(s.counters).toBeNull(); expect(s.hidden).toBe(false); const answered = reduce([{ type: "LOAD_DONE", seq: 2, snapshot }], s); expect(answered.status).toBe("ready"); expect(answered.counters).toEqual(snapshot.counters); }); it("a stale LOAD_DONE arriving after the newer one does not overwrite it", () => { const newer = { ...snapshot, counters: { ...snapshot.counters, imports: 4 } }; const s = reduce([ { type: "LOAD_START", seq: 1 }, { type: "LOAD_START", seq: 2 }, { type: "LOAD_DONE", seq: 2, snapshot: newer }, { type: "LOAD_DONE", seq: 1, snapshot }, ]); expect(s.counters?.imports).toBe(4); }); it("a refresh keeps ready and the counters while it runs", () => { const s = reduce([{ type: "LOAD_START", seq: 2 }], ready()); expect(s.status).toBe("ready"); expect(s.counters).toEqual(snapshot.counters); expect(s.importFolderSet).toBe(true); expect(s.loadSeq).toBe(2); }); it("a failed re-read after a first success keeps ready and the last state", () => { const hiddenReady = ready({ ...snapshot, hidden: true, skipped: ["source"] }); const s = reduce( [ { type: "LOAD_START", seq: 2 }, { type: "LOAD_ERROR", seq: 2 }, ], hiddenReady, ); expect(s.status).toBe("ready"); expect(s.counters).toEqual(snapshot.counters); expect(s.hidden).toBe(true); expect(s.skipped).toEqual(["source"]); }); it("a first load that fails sets status=error and keeps hidden=false", () => { const s = reduce([ { type: "LOAD_START", seq: 1 }, { type: "LOAD_ERROR", seq: 1 }, ]); expect(s.status).toBe("error"); expect(s.hidden).toBe(false); expect(s.counters).toBeNull(); }); it("a stale LOAD_ERROR is ignored", () => { const s = reduce([ { type: "LOAD_START", seq: 1 }, { type: "LOAD_START", seq: 2 }, { type: "LOAD_ERROR", seq: 1 }, ]); expect(s.status).toBe("loading"); }); it("a retry after a failed first load goes back to loading, then ready", () => { const errored = reduce([ { type: "LOAD_START", seq: 1 }, { type: "LOAD_ERROR", seq: 1 }, ]); const loading = reduce([{ type: "LOAD_START", seq: 2 }], errored); expect(loading.status).toBe("loading"); const done = reduce([{ type: "LOAD_DONE", seq: 2, snapshot }], loading); expect(done.status).toBe("ready"); expect(done.counters).toEqual(snapshot.counters); }); it("never mutates the previous state", () => { const before = ready(); const frozen = Object.freeze({ ...before }); expect(() => onboardingChecklistReducer(frozen, { type: "HIDE" })).not.toThrow(); expect(() => onboardingChecklistReducer(frozen, { type: "SKIP", key: "budget" })).not.toThrow(); expect(frozen.hidden).toBe(false); expect(frozen.skipped).toEqual([]); }); }); describe("onboardingChecklistReducer — local actions win over an older snapshot (#384, #396)", () => { it("HIDE during a load wins over that load's snapshot", () => { const s = reduce( [ { type: "LOAD_START", seq: 2 }, { type: "HIDE" }, { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: false } }, ], ready(), ); expect(s.hidden).toBe(true); // The rest of the snapshot still lands. expect(s.status).toBe("ready"); }); it("SHOW during a load wins over that load's snapshot", () => { const s = reduce( [ { type: "LOAD_START", seq: 2 }, { type: "SHOW" }, { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: true } }, ], ready({ ...snapshot, hidden: true }), ); expect(s.hidden).toBe(false); }); it("SKIP during a load wins over that load's snapshot", () => { const s = reduce( [ { type: "LOAD_START", seq: 2 }, { type: "SKIP", key: "budget" }, { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: [] } }, ], ready(), ); expect(s.skipped).toEqual(["budget"]); }); it("RESUME during a load wins over that load's snapshot", () => { const s = reduce( [ { type: "LOAD_START", seq: 2 }, { type: "RESUME", key: "budget" }, { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: ["budget"] } }, ], ready({ ...snapshot, skipped: ["budget"] }), ); expect(s.skipped).toEqual([]); }); it("HIDE during the very first load wins too", () => { const s = reduce([ { type: "LOAD_START", seq: 1 }, { type: "HIDE" }, { type: "LOAD_DONE", seq: 1, snapshot: { ...snapshot, hidden: false } }, ]); expect(s.status).toBe("ready"); expect(s.hidden).toBe(true); }); it("a load started after the action brings the stored value back", () => { // The user hid the path, then showed it again from Settings → Users: // a later load reads the preference, which is the truth again. const s = reduce( [ { type: "HIDE" }, { type: "LOAD_START", seq: 2 }, { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: false } }, ], ready(), ); expect(s.hidden).toBe(false); }); it("HIDE does not shield the skipped steps, nor SKIP the hidden flag", () => { const afterHide = reduce( [ { type: "LOAD_START", seq: 2 }, { type: "HIDE" }, { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, skipped: ["source"] } }, ], ready(), ); expect(afterHide.skipped).toEqual(["source"]); const afterSkip = reduce( [ { type: "LOAD_START", seq: 2 }, { type: "SKIP", key: "budget" }, { type: "LOAD_DONE", seq: 2, snapshot: { ...snapshot, hidden: true } }, ], ready(), ); expect(afterSkip.hidden).toBe(true); }); it("HIDE and SHOW do not change the load state", () => { const hidden = reduce([{ type: "HIDE" }], ready()); expect(hidden.hidden).toBe(true); expect(hidden.status).toBe("ready"); expect(hidden.counters).toEqual(snapshot.counters); const shown = reduce([{ type: "SHOW" }], hidden); expect(shown.hidden).toBe(false); expect(shown.status).toBe("ready"); }); it("HIDE while the first load runs keeps status loading", () => { const s = reduce([{ type: "LOAD_START", seq: 1 }, { type: "HIDE" }]); expect(s.status).toBe("loading"); expect(s.hidden).toBe(true); }); }); describe("onboardingChecklistReducer — skipped steps and the card latch (#396)", () => { it("SKIP keeps checklist order and never duplicates a step", () => { const s = reduce( [ { type: "SKIP", key: "budget" }, { type: "SKIP", key: "source" }, { type: "SKIP", key: "budget" }, ], ready(), ); expect(s.skipped).toEqual(["source", "budget"]); }); it("RESUME removes only its step", () => { const s = reduce([{ type: "RESUME", key: "source" }], ready({ ...snapshot, skipped: ["source", "budget"] })); expect(s.skipped).toEqual(["budget"]); }); it("SKIP releases the card latch; RESUME, HIDE and a load do not touch it", () => { const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready()); expect(latched.cardPage).toBe("/import"); expect(reduce([{ type: "SKIP", key: "importFolder" }], latched).cardPage).toBeNull(); expect(reduce([{ type: "RESUME", key: "importFolder" }], latched).cardPage).toBe("/import"); expect( reduce( [ { type: "LOAD_START", seq: 2 }, { type: "LOAD_DONE", seq: 2, snapshot }, ], latched, ).cardPage, ).toBe("/import"); }); it("SET_CARD_PAGE with the same page returns the same state object", () => { const latched = reduce([{ type: "SET_CARD_PAGE", cardPage: "/import" }], ready()); expect(onboardingChecklistReducer(latched, { type: "SET_CARD_PAGE", cardPage: "/import" })).toBe( latched, ); }); }); // --------------------------------------------------------------------------- // Loader // --------------------------------------------------------------------------- /** A promise whose resolution the test controls. */ function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function loaderDeps(overrides: Partial = {}) { const actions: OnboardingChecklistAction[] = []; const deps: OnboardingLoaderDeps = { initializeGuidedPath: vi.fn(async () => ({ hidden: false })), getOnboardingSnapshot: vi.fn(async () => snapshot), dispatch: (action) => { actions.push(action); }, logError: vi.fn(), ...overrides, }; return { deps, actions }; } describe("createOnboardingLoader (#396)", () => { it("numbers its loads and dispatches LOAD_START then LOAD_DONE with the same number", async () => { const { deps, actions } = loaderDeps(); const load = createOnboardingLoader(deps); await load(); await load(); expect(actions).toEqual([ { type: "LOAD_START", seq: 1 }, { type: "LOAD_DONE", seq: 1, snapshot }, { type: "LOAD_START", seq: 2 }, { type: "LOAD_DONE", seq: 2, snapshot }, ]); }); it("the first load reads the snapshot only once rule D9 is applied", async () => { const init = deferred<{ hidden: boolean }>(); const { deps } = loaderDeps({ initializeGuidedPath: vi.fn(() => init.promise) }); const load = createOnboardingLoader(deps); const pending = load(); await Promise.resolve(); await Promise.resolve(); expect(deps.getOnboardingSnapshot).not.toHaveBeenCalled(); init.resolve({ hidden: true }); await pending; expect(deps.getOnboardingSnapshot).toHaveBeenCalledTimes(1); }); it("two concurrent first loads (StrictMode double mount) run D9 once and share it", async () => { const init = deferred<{ hidden: boolean }>(); const { deps, actions } = loaderDeps({ initializeGuidedPath: vi.fn(() => init.promise) }); const load = createOnboardingLoader(deps); const first = load(); const second = load(); init.resolve({ hidden: false }); await Promise.all([first, second]); expect(deps.initializeGuidedPath).toHaveBeenCalledTimes(1); expect(deps.getOnboardingSnapshot).toHaveBeenCalledTimes(2); // Both answers are dispatched; the reducer keeps the one of load 2. const final = actions.reduce(onboardingChecklistReducer, initialOnboardingChecklistState); expect(final.status).toBe("ready"); expect(final.loadSeq).toBe(2); }); it("later loads never run D9 again", async () => { const { deps } = loaderDeps(); const load = createOnboardingLoader(deps); await load(); await load(); await load(); expect(deps.initializeGuidedPath).toHaveBeenCalledTimes(1); }); it("a failed D9 is logged and the load still reads the snapshot", async () => { const { deps, actions } = loaderDeps({ initializeGuidedPath: vi.fn(async () => { throw new Error("database is locked"); }), }); await createOnboardingLoader(deps)(); expect(deps.logError).toHaveBeenCalledTimes(1); expect(vi.mocked(deps.logError).mock.calls[0][1]).toBe("database is locked"); expect(actions[actions.length - 1]).toEqual({ type: "LOAD_DONE", seq: 1, snapshot }); }); it("a failed snapshot dispatches LOAD_ERROR with its number", async () => { const { deps, actions } = loaderDeps({ getOnboardingSnapshot: vi.fn(async () => { throw new Error("boom"); }), }); await createOnboardingLoader(deps)(); expect(actions).toEqual([ { type: "LOAD_START", seq: 1 }, { type: "LOAD_ERROR", seq: 1 }, ]); }); it("logs a failure only when a failure streak starts", async () => { const results: Array<"ok" | "fail"> = ["fail", "fail", "fail", "ok", "fail"]; const { deps } = loaderDeps({ getOnboardingSnapshot: vi.fn(async () => { if (results.shift() === "fail") throw new Error("database is locked"); return snapshot; }), }); const load = createOnboardingLoader(deps); for (let i = 0; i < 5; i++) await load(); // Streak 1 (three failures) then streak 2 (one): two lines, not four. expect(deps.logError).toHaveBeenCalledTimes(2); expect(vi.mocked(deps.logError).mock.calls[0]).toEqual([ "Onboarding: failed to load the getting-started path", "database is locked", ]); }); it("the failure of a stale load is not logged", async () => { const first = deferred(); let calls = 0; const { deps } = loaderDeps({ getOnboardingSnapshot: vi.fn(() => (++calls === 1 ? first.promise : Promise.resolve(snapshot))), }); const load = createOnboardingLoader(deps); const stale = load(); await load(); first.reject(new Error("late failure")); await stale; expect(deps.logError).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // Provider view: the path end to end, without rendering // --------------------------------------------------------------------------- describe("deriveOnboardingView — the path end to end (#396)", () => { // Every card page of the milestone, so the latch is exercised before the // card issues add their pages to CARD_PAGES. const cardPages = new Set(["/import", "/transactions", "/budget"]); const empty: OnboardingCounters = { sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0, }; /** One render of the provider: derive, then store a latch that changed, as the hook does. */ function render( state: OnboardingChecklistState, pathname: string, { budgetAllowed = true, budgetReady = true } = {}, ) { const checklist = state.counters ? computeChecklist(state.counters, { importFolderSet: state.importFolderSet, budgetAllowed, skipped: state.skipped, }) : null; const view = deriveOnboardingView({ state, checklist, pathname, budgetReady, cardPages }); const settled = view.cardPage === state.cardPage ? state : onboardingChecklistReducer(state, { type: "SET_CARD_PAGE", cardPage: view.cardPage }); // The latch settles in one pass (React re-renders once after the update). const again = deriveOnboardingView({ state: settled, checklist, pathname, budgetReady, cardPages }); expect(again.cardPage).toBe(settled.cardPage); return { state: settled, view, checklist }; } /** A load numbered after every previous one, answered with `snap`. */ function load(state: OnboardingChecklistState, snap: OnboardingSnapshot): OnboardingChecklistState { const seq = state.loadSeq + 1; return reduce( [ { type: "LOAD_START", seq }, { type: "LOAD_DONE", seq, snapshot: snap }, ], state, ); } const fresh: OnboardingSnapshot = { counters: empty, importFolderSet: false, hidden: false, skipped: [] }; it("shows nothing before the first load", () => { const { view } = render(initialOnboardingChecklistState, "/import"); expect(view.mode).toBe("none"); expect(view.nextStep).toBeNull(); expect(view.cardPage).toBeNull(); }); it("walks a new profile from the folder to the budget, the card staying when its step gets done", () => { let s = load(initialOnboardingChecklistState, fresh); // Empty dashboard: the welcome card holds the list (D11). let r = render(s, "/"); expect(r.view.mode).toBe("none"); expect(r.view.nextStep?.key).toBe("importFolder"); // On /import: the card of the folder step. s = load(r.state, fresh); r = render(s, "/import"); expect(r.view.mode).toBe("card"); expect(r.state.cardPage).toBe("/import"); // Folder chosen, page open: next step on the same page, same card. s = load(r.state, { ...fresh, importFolderSet: true }); r = render(s, "/import"); expect(r.view.nextStep?.key).toBe("source"); expect(r.view.mode).toBe("card"); // Import done, page open: the next step lives on /transactions, the card // stays on /import to say "Done." and offer it. const imported = { ...empty, sources: 1, imports: 1, transactions: 20 }; s = load(r.state, { ...fresh, importFolderSet: true, counters: imported }); r = render(s, "/import"); expect(r.view.nextStep?.key).toBe("manualCategory"); expect(r.view.mode).toBe("card"); expect(r.state.cardPage).toBe("/import"); // Following "Next step": the latch moves to /transactions. s = load(r.state, { ...fresh, importFolderSet: true, counters: imported }); r = render(s, "/transactions"); expect(r.view.mode).toBe("card"); expect(r.state.cardPage).toBe("/transactions"); // A category changed by hand, page open: the card stays. const categorized = { ...imported, manualCategorized: 1 }; s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized }); r = render(s, "/transactions"); expect(r.view.nextStep?.key).toBe("budget"); expect(r.view.mode).toBe("card"); // Elsewhere: the banner. s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized }); r = render(s, "/reports"); expect(r.view.mode).toBe("banner"); expect(r.state.cardPage).toBeNull(); // The budget page, then a first amount: the card stays, in its end mode. s = load(r.state, { ...fresh, importFolderSet: true, counters: categorized }); r = render(s, "/budget"); expect(r.view.mode).toBe("card"); s = load(r.state, { ...fresh, importFolderSet: true, counters: { ...categorized, budgetEntries: 1 }, }); r = render(s, "/budget"); expect(r.view.nextStep).toBeNull(); expect(r.checklist?.complete).toBe(true); expect(r.view.mode).toBe("card"); // Anywhere else: the end message (D4). r = render(r.state, "/"); expect(r.view.mode).toBe("complete"); }); it('"Skip" towards a step of the same page keeps the card', () => { let r = render(load(initialOnboardingChecklistState, fresh), "/import"); expect(r.state.cardPage).toBe("/import"); const skipped = reduce([{ type: "SKIP", key: "importFolder" }], r.state); expect(skipped.cardPage).toBeNull(); r = render(skipped, "/import"); expect(r.view.nextStep?.key).toBe("source"); expect(r.view.mode).toBe("card"); expect(r.state.cardPage).toBe("/import"); }); it('"Skip" towards a step of another page releases the card for the banner', () => { const snap = { ...fresh, importFolderSet: true, counters: { ...empty, sources: 1, transactions: 20 }, }; let r = render(load(initialOnboardingChecklistState, snap), "/import"); expect(r.view.nextStep?.key).toBe("import"); expect(r.view.mode).toBe("card"); r = render(reduce([{ type: "SKIP", key: "import" }], r.state), "/import"); expect(r.view.nextStep?.key).toBe("manualCategory"); expect(r.view.mode).toBe("banner"); expect(r.state.cardPage).toBeNull(); }); it('"Skip" of the last step on its page ends the path with the end message', () => { const snap = { ...fresh, importFolderSet: true, counters: { ...empty, sources: 1, imports: 1, manualCategorized: 1, transactions: 20 }, }; let r = render(load(initialOnboardingChecklistState, snap), "/budget"); expect(r.view.mode).toBe("card"); r = render(reduce([{ type: "SKIP", key: "budget" }], r.state), "/budget"); expect(r.view.mode).toBe("complete"); }); it("hiding releases the card and shows nothing; showing again brings it back", () => { let r = render(load(initialOnboardingChecklistState, fresh), "/import"); expect(r.view.mode).toBe("card"); r = render(reduce([{ type: "HIDE" }], r.state), "/import"); expect(r.view.mode).toBe("none"); expect(r.state.cardPage).toBeNull(); r = render(reduce([{ type: "SHOW" }], r.state), "/import"); expect(r.view.mode).toBe("card"); }); it("a profile hidden by rule D9 shows nothing on any page", () => { const s = load(initialOnboardingChecklistState, { ...fresh, hidden: true, counters: { ...empty, transactions: 40 }, }); for (const page of ["/", "/import", "/transactions", "/budget", "/reports"]) { expect(render(s, page).view.mode).toBe("none"); } }); it("Free edition: five steps, never the budget card, and the end at 5/5", () => { const snap = { ...fresh, importFolderSet: true, counters: { ...empty, sources: 1, imports: 1, manualCategorized: 1, transactions: 20 }, }; const s = load(initialOnboardingChecklistState, snap); const r = render(s, "/budget", { budgetAllowed: false }); expect(r.checklist?.total).toBe(5); expect(r.view.nextStep).toBeNull(); expect(r.state.cardPage).toBeNull(); expect(r.view.mode).toBe("complete"); }); it("a failed re-read keeps the card and the counters on screen", () => { let r = render(load(initialOnboardingChecklistState, fresh), "/import"); const s = reduce( [ { type: "LOAD_START", seq: r.state.loadSeq + 1 }, { type: "LOAD_ERROR", seq: r.state.loadSeq + 1 }, ], r.state, ); r = render(s, "/import"); expect(r.view.mode).toBe("card"); expect(r.checklist?.total).toBe(6); }); }); // --------------------------------------------------------------------------- // Data-channel follower // --------------------------------------------------------------------------- describe("followDataChanges (#396, ADR 0022)", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("waits 150 ms after an event before re-reading", () => { const run = vi.fn(); const stop = followDataChanges(run); notifyDataChanged(); vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS - 1); expect(run).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(run).toHaveBeenCalledTimes(1); stop(); }); it("groups a burst of events into one re-read, 150 ms after the last one", () => { const run = vi.fn(); const stop = followDataChanges(run); notifyDataChanged(); vi.advanceTimersByTime(100); notifyDataChanged(); vi.advanceTimersByTime(100); notifyDataChanged(); vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS - 1); expect(run).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(run).toHaveBeenCalledTimes(1); stop(); }); it("the cleanup drops a pending re-read and unsubscribes", () => { const run = vi.fn(); const stop = followDataChanges(run); notifyDataChanged(); stop(); vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS * 2); notifyDataChanged(); vi.advanceTimersByTime(DATA_CHANGE_DELAY_MS * 2); expect(run).not.toHaveBeenCalled(); }); });