Add src/services/dataEvents.ts (ADR 0022): notifyDataChanged() and subscribeDataChanged(), without React, on the model of logService.subscribe. A throwing subscriber is logged and does not stop the others. Notify after every successful write of the tables the getting-started counters read, plus the import folder: setImportFolder, createSource, deleteSource, createImportedFile, updateFileStatus, both import deletions, insertBatch, updateTransactionCategory, applyKeywordWithReassignment (after COMMIT), the four budget writes and runRestore (after COMMIT, one point for the three restore modes). executeImport notifies once more when it ends, succeeded or not. A failed write notifies nothing; transactional writes notify once withTransaction has returned. applyTemplate notifies once for the whole template through a private writeBudgetEntry helper. Each service suite proves the notification after a success and its absence after a failure; executeImport is driven as a plain function with React's hooks stubbed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
2.6 KiB
TypeScript
89 lines
2.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
vi.mock("./logService", () => ({
|
|
logInfo: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
logError: vi.fn(),
|
|
}));
|
|
|
|
import { logError } from "./logService";
|
|
import { notifyDataChanged, subscribeDataChanged } from "./dataEvents";
|
|
|
|
// The channel is module state: every test unsubscribes what it subscribed.
|
|
const cleanups: Array<() => void> = [];
|
|
function subscribe(listener: () => void): () => void {
|
|
const unsubscribe = subscribeDataChanged(listener);
|
|
cleanups.push(unsubscribe);
|
|
return unsubscribe;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(logError).mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanups.splice(0).forEach((unsubscribe) => unsubscribe());
|
|
});
|
|
|
|
describe("dataEvents (#396)", () => {
|
|
it("a subscriber receives each notification", () => {
|
|
const listener = vi.fn();
|
|
subscribe(listener);
|
|
notifyDataChanged();
|
|
notifyDataChanged();
|
|
expect(listener).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("an unsubscribed listener no longer receives", () => {
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribe(listener);
|
|
notifyDataChanged();
|
|
unsubscribe();
|
|
notifyDataChanged();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("unsubscribing one listener leaves the others subscribed", () => {
|
|
const first = vi.fn();
|
|
const second = vi.fn();
|
|
const unsubscribeFirst = subscribe(first);
|
|
subscribe(second);
|
|
unsubscribeFirst();
|
|
notifyDataChanged();
|
|
expect(first).not.toHaveBeenCalled();
|
|
expect(second).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("a subscriber that throws does not stop the others, and notify never throws", () => {
|
|
const before = vi.fn();
|
|
const after = vi.fn();
|
|
subscribe(before);
|
|
subscribe(() => {
|
|
throw new Error("listener bug");
|
|
});
|
|
subscribe(after);
|
|
|
|
expect(() => notifyDataChanged()).not.toThrow();
|
|
expect(before).toHaveBeenCalledTimes(1);
|
|
expect(after).toHaveBeenCalledTimes(1);
|
|
expect(logError).toHaveBeenCalledTimes(1);
|
|
expect(vi.mocked(logError).mock.calls[0]).toEqual([
|
|
"Data events: a subscriber failed",
|
|
"listener bug",
|
|
]);
|
|
});
|
|
|
|
it("a listener that unsubscribes while being called does not skip the next one", () => {
|
|
const next = vi.fn();
|
|
const unsubscribeSelf: { fn: () => void } = { fn: () => undefined };
|
|
unsubscribeSelf.fn = subscribe(() => unsubscribeSelf.fn());
|
|
subscribe(next);
|
|
notifyDataChanged();
|
|
expect(next).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("notifies nobody without subscribers", () => {
|
|
expect(() => notifyDataChanged()).not.toThrow();
|
|
expect(logError).not.toHaveBeenCalled();
|
|
});
|
|
});
|