v0.59b — Report page freshness UI: - Shows Current / Update available beside the generated report - Manual Update report action with duplicate prevention guard - Explanation copy about investigation changes since generation - Persists generatedFromRevision during update flow v0.59c — Portfolio Report freshness state: - Surfaces Current / Update available alongside existing View report link - Derives solely from revision provenance (zero model calls) - No Update report action on Portfolio (manual update owned by Report page) - Neither state shown when no Report exists - Updated makeSnapshot with investigationRevision for realistic test data
355 lines
12 KiB
React
355 lines
12 KiB
React
/**
|
|
* v0.59b — targeted deterministic tests for Report freshness UI and manual update.
|
|
*
|
|
* Tests at the seam-crossing boundary:
|
|
* - Freshness comparison logic
|
|
* - Storage layer crossing (saveInvestigation)
|
|
* - Manual update flow (fetch + persistence)
|
|
* - Duplicate prevention guard
|
|
*
|
|
* These are deterministic unit/seam tests — no component rendering,
|
|
* no full mock chains. Mirrors the v059a test pattern.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
/* ═══════════════════ Shared in-memory mock for save/load/clear ═══════════════════ */
|
|
|
|
class MockStorageMap {
|
|
constructor() { this._data = new Map(); }
|
|
getItem(k) { return this._data.has(k) ? this._data.get(k) : null; }
|
|
setItem(k, v){ this._data.set(k, v); }
|
|
removeItem(k){ this._data.delete(k); }
|
|
clear() { this._data.clear(); }
|
|
}
|
|
|
|
function installMockStorage() {
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: globalThis.window || {}, writable: true, configurable: true,
|
|
});
|
|
if (!globalThis.window.localStorage) {
|
|
Object.defineProperty(globalThis.window, "localStorage", {
|
|
value: new MockStorageMap(), writable: true, configurable: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
function uninstallMockStorage() {
|
|
const desc = Object.getOwnPropertyDescriptor(globalThis, "window");
|
|
if (desc && !("localStorage" in globalThis.window)) return;
|
|
delete globalThis.window.localStorage;
|
|
delete globalThis.window.sessionStorage;
|
|
}
|
|
|
|
beforeEach(() => { installMockStorage(); });
|
|
afterEach(() => { uninstallMockStorage(); });
|
|
|
|
/* ═══════════════ Import persistence layer ═══════════════ */
|
|
|
|
let storageModule = null;
|
|
async function getStorage() {
|
|
if (!storageModule) {
|
|
const m = await import("../lib/storage/investigation-storage.js");
|
|
storageModule = { load: m.loadInvestigation, save: m.saveInvestigation };
|
|
}
|
|
return storageModule;
|
|
}
|
|
|
|
/* ═══════════════ Freshness comparison utility (mirrors ReportPage JSX) ═══════════════ */
|
|
|
|
/** Returns the freshness state from investigation + report data. */
|
|
function getFreshnessState(investigationRevision, generatedFromRevision) {
|
|
if (investigationRevision === generatedFromRevision) {
|
|
return "current";
|
|
}
|
|
return "updateAvailable";
|
|
}
|
|
|
|
/* ── Freshness display logic ───────────────────────────── */
|
|
|
|
describe("Freshness comparison logic", () => {
|
|
it("matching revisions → 'Current'", async () => {
|
|
expect(getFreshnessState(3, 3)).toBe("current");
|
|
});
|
|
|
|
it("differing revisions → 'Update available'", async () => {
|
|
expect(getFreshnessState(5, 3)).toBe("updateAvailable");
|
|
});
|
|
|
|
it("zero-zero matching → 'Current'", async () => {
|
|
expect(getFreshnessState(0, 0)).toBe("current");
|
|
});
|
|
|
|
it("large revision gap still detected", async () => {
|
|
expect(getFreshnessState(10, 3)).toBe("updateAvailable");
|
|
});
|
|
});
|
|
|
|
/* ── 7. Zero overview calls on differing-revision render (seam crossing) ─ */
|
|
|
|
describe("Zero overview calls — differing revision render does not trigger persistence", () => {
|
|
it("differing revisions retain report without mutating storage on render", async () => {
|
|
const { load, save } = await getStorage();
|
|
|
|
// Precondition: existing Report at rev 3, investigation now at rev 5
|
|
save({
|
|
scenario: "Test",
|
|
situationGraph: { centralStatement: "CS", nodes: [], edges: [] },
|
|
findings: [],
|
|
investigationRevision: 5,
|
|
investigationReport: { understanding: "R", generatedFromRevision: 3 },
|
|
focusedContributions: [],
|
|
});
|
|
|
|
const loaded = load();
|
|
expect(loaded.investigationRevision).toBe(5);
|
|
expect(loaded.investigationReport?.generatedFromRevision).toBe(3);
|
|
// No save has been written during read-only render — freshness check is stateless
|
|
});
|
|
});
|
|
|
|
/* ── 8. Manual update triggers fetch (seam: fetch boundary) ─────────── */
|
|
|
|
describe("Manual Report update seam — overview request", () => {
|
|
it("activating Update report causes exactly one POST /api/cases/overview", async () => {
|
|
const origFetch = global.fetch;
|
|
let capturedBody = null;
|
|
global.fetch = vi.fn(async (url, options) => {
|
|
if (url === "/api/cases/overview") {
|
|
capturedBody = JSON.parse(options.body);
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
success: true, understanding: "R2",
|
|
plausibleInterpretations: "P.", hasPlausibleInterpretations: true,
|
|
}),
|
|
};
|
|
}
|
|
return origFetch(url, options);
|
|
});
|
|
|
|
const { load, save } = await getStorage();
|
|
|
|
// Precondition: Report at rev 3, investigation now at rev 5
|
|
save({
|
|
scenario: "Test",
|
|
situationGraph: { centralStatement: "CS", nodes: [{ id: "n-1" }], edges: [] },
|
|
findings: [{ id: "f-1", proposition: "P1" }],
|
|
investigationRevision: 5,
|
|
investigationReport: { understanding: "R", generatedFromRevision: 3 },
|
|
focusedContributions: [],
|
|
});
|
|
|
|
// Simulate the update handler body (from ReportPage):
|
|
const snap = load();
|
|
const rev = snap?.investigationRevision ?? 0;
|
|
const situationGraph = snap?.situationGraph;
|
|
const findings = snap?.findings ?? [];
|
|
|
|
const res = await fetch("/api/cases/overview", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ situationGraph, findings }),
|
|
});
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
expect(capturedBody.situationGraph).toBeDefined();
|
|
expect(res.ok).toBe(true);
|
|
|
|
global.fetch = origFetch;
|
|
});
|
|
});
|
|
|
|
/* ── 9. Successful update persists replacement with correct generatedFromRevision ─ */
|
|
|
|
describe("Successful update — persistence seam crossing", () => {
|
|
it("replacement Report has generatedFromRevision = current investigationRevision", async () => {
|
|
const { load, save } = await getStorage();
|
|
|
|
// Precondition: Report at rev 3, investigation at rev 5
|
|
save({
|
|
scenario: "Test",
|
|
situationGraph: { centralStatement: "CS", nodes: [{ id: "n-1" }], edges: [] },
|
|
findings: [],
|
|
investigationRevision: 5,
|
|
investigationReport: { understanding: "R", generatedFromRevision: 3 },
|
|
focusedContributions: [],
|
|
});
|
|
|
|
const snap = load();
|
|
const rev = snap?.investigationRevision ?? 0;
|
|
const newReportData = { understanding: "R2", plausibleInterpretations: "P.", hasPlausibleInterpretations: true, generatedFromRevision: rev };
|
|
|
|
save({ ...snap, investigationReport: newReportData });
|
|
|
|
const updated = load();
|
|
expect(updated.investigationRevision).toBe(5);
|
|
expect(updated.investigationReport?.generatedFromRevision).toBe(5);
|
|
});
|
|
});
|
|
|
|
/* ── 10. Successful update renders "Current" (freshness state) ─────── */
|
|
|
|
describe("Successful update — freshness returns to Current", () => {
|
|
it("after replacement, revision equality yields 'Current'", async () => {
|
|
const { load, save } = await getStorage();
|
|
|
|
save({
|
|
scenario: "Test",
|
|
situationGraph: { centralStatement: "CS", nodes: [], edges: [] },
|
|
findings: [],
|
|
investigationRevision: 5,
|
|
investigationReport: { understanding: "R2", generatedFromRevision: 5 },
|
|
focusedContributions: [],
|
|
});
|
|
|
|
const updated = load();
|
|
expect(getFreshnessState(updated.investigationRevision, updated.investigationReport?.generatedFromRevision)).toBe("current");
|
|
});
|
|
});
|
|
|
|
/* ── 11 & 12. Failed update retains existing Report ─────────────────── */
|
|
|
|
describe("Failed update — existing Report and revision state retained", () => {
|
|
it("existing Report content preserved after failure", async () => {
|
|
const origFetch = global.fetch;
|
|
global.fetch = vi.fn().mockRejectedValue(new Error("network"));
|
|
|
|
const { load, save } = await getStorage();
|
|
|
|
// Precondition: existing Report at rev 3
|
|
save({
|
|
scenario: "Test",
|
|
situationGraph: { centralStatement: "CS", nodes: [{ id: "n-1" }], edges: [] },
|
|
findings: [],
|
|
investigationRevision: 5,
|
|
investigationReport: { understanding: "R", generatedFromRevision: 3 },
|
|
focusedContributions: [],
|
|
});
|
|
|
|
const snapBefore = load();
|
|
|
|
// Simulate failed update handler (try/catch — no save on failure):
|
|
try {
|
|
await fetch("/api/cases/overview", { method: "POST" });
|
|
} catch {
|
|
/* failure path — no save */
|
|
}
|
|
|
|
const snapAfter = load();
|
|
expect(snapAfter.investigationReport?.understanding).toBe("R");
|
|
expect(snapAfter.investigationReport?.generatedFromRevision).toBe(3);
|
|
|
|
global.fetch = origFetch;
|
|
});
|
|
|
|
it("Update available retained after failure (still differing)", async () => {
|
|
const origFetch = global.fetch;
|
|
global.fetch = vi.fn().mockRejectedValue(new Error("network"));
|
|
|
|
const { load, save } = await getStorage();
|
|
|
|
save({
|
|
scenario: "Test",
|
|
situationGraph: { centralStatement: "CS", nodes: [], edges: [] },
|
|
findings: [],
|
|
investigationRevision: 5,
|
|
investigationReport: { understanding: "R", generatedFromRevision: 3 },
|
|
focusedContributions: [],
|
|
});
|
|
|
|
try {
|
|
await fetch("/api/cases/overview", { method: "POST" });
|
|
} catch { /* failure */ }
|
|
|
|
const snap = load();
|
|
expect(getFreshnessState(snap.investigationRevision, snap.investigationReport?.generatedFromRevision)).toBe("updateAvailable");
|
|
|
|
global.fetch = origFetch;
|
|
});
|
|
|
|
it("does NOT persist a partial replacement on failure", async () => {
|
|
const origFetch = global.fetch;
|
|
global.fetch = vi.fn().mockRejectedValue(new Error("network"));
|
|
|
|
const { load, save } = await getStorage();
|
|
|
|
save({
|
|
scenario: "Test",
|
|
situationGraph: { centralStatement: "CS", nodes: [{ id: "n-1" }], edges: [] },
|
|
findings: [],
|
|
investigationRevision: 5,
|
|
investigationReport: { understanding: "R", generatedFromRevision: 3 },
|
|
focusedContributions: [],
|
|
});
|
|
|
|
const reportBefore = JSON.parse(JSON.stringify(load().investigationReport));
|
|
|
|
try {
|
|
await fetch("/api/cases/overview", { method: "POST" });
|
|
} catch { /* failure — no save in handler */ }
|
|
|
|
const reportAfter = load().investigationReport;
|
|
expect(reportAfter).toEqual(reportBefore); // unchanged
|
|
|
|
global.fetch = origFetch;
|
|
});
|
|
});
|
|
|
|
/* ── 13. Duplicate activation prevention (guard) ─────────────── */
|
|
|
|
describe("Duplicate activation prevention", () => {
|
|
it("second click while loading causes zero additional requests", async () => {
|
|
let resolveFetch;
|
|
const fetchPromise = new Promise((r) => { resolveFetch = r; });
|
|
|
|
const origFetch = global.fetch;
|
|
global.fetch = vi.fn(() => fetchPromise);
|
|
|
|
// Simulate the guard: if (updateLoading) return;
|
|
let updateLoading = false;
|
|
|
|
function tryUpdateReport() {
|
|
if (updateLoading) return; // guard — duplicate prevention
|
|
updateLoading = true;
|
|
return fetch("/api/cases/overview", { method: "POST" }).finally(() => {
|
|
updateLoading = false;
|
|
});
|
|
}
|
|
|
|
tryUpdateReport(); // first click — sets loading, starts request
|
|
tryUpdateReport(); // second click — guard returns immediately
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
resolveFetch({ ok: true, json: async () => ({ success: true }) });
|
|
global.fetch = origFetch;
|
|
});
|
|
});
|
|
|
|
/* ── 14. Unchanged Report revisit retains zero-call behaviour ─────── */
|
|
|
|
describe("Unchanged Report revisit retains zero-call behaviour", () => {
|
|
it("matching revision render does not trigger overview or save", async () => {
|
|
const origFetch = global.fetch;
|
|
global.fetch = vi.fn();
|
|
|
|
const { load, save } = await getStorage();
|
|
|
|
save({
|
|
scenario: "Test",
|
|
situationGraph: { centralStatement: "CS", nodes: [], edges: [] },
|
|
findings: [],
|
|
investigationRevision: 3,
|
|
investigationReport: { understanding: "R", generatedFromRevision: 3 },
|
|
focusedContributions: [],
|
|
});
|
|
|
|
const snap = load();
|
|
// No fetch — freshness state is computed purely from local data
|
|
expect(getFreshnessState(snap.investigationRevision, snap.investigationReport?.generatedFromRevision)).toBe("current");
|
|
expect(global.fetch).not.toHaveBeenCalled();
|
|
|
|
global.fetch = origFetch;
|
|
});
|
|
});
|