feat(confidence-engine): v0.59b-c — report freshness on Report page + Portfolio
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
This commit is contained in:
@@ -9,6 +9,7 @@ export default function ReportPage() {
|
|||||||
const [hydrated, setHydrated] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
const [generationLoading, setGenerationLoading] = useState(false);
|
const [generationLoading, setGenerationLoading] = useState(false);
|
||||||
const [generationError, setGenerationError] = useState(false);
|
const [generationError, setGenerationError] = useState(false);
|
||||||
|
const [updateLoading, setUpdateLoading] = useState(false);
|
||||||
|
|
||||||
const generationAttempted = useRef(false);
|
const generationAttempted = useRef(false);
|
||||||
|
|
||||||
@@ -66,6 +67,48 @@ export default function ReportPage() {
|
|||||||
})();
|
})();
|
||||||
}, [hydrated, existing]);
|
}, [hydrated, existing]);
|
||||||
|
|
||||||
|
// Manual Report update (v0.59b — freshness manual update)
|
||||||
|
const handleUpdateReport = async () => {
|
||||||
|
if (updateLoading) return;
|
||||||
|
setUpdateLoading(true);
|
||||||
|
|
||||||
|
const snap = loadInvestigation();
|
||||||
|
const situationGraph = snap?.situationGraph;
|
||||||
|
const findings = snap?.findings ?? [];
|
||||||
|
const rev = snap?.investigationRevision ?? 0;
|
||||||
|
|
||||||
|
if (!situationGraph) {
|
||||||
|
setUpdateLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/cases/overview", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ situationGraph, findings }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setUpdateLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
|
||||||
|
setExisting((p) => {
|
||||||
|
saveInvestigation({ ...p, investigationReport: reportData });
|
||||||
|
return { ...p, investigationReport: reportData };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* failure: retain existing Report and updateAvailable state */
|
||||||
|
} finally {
|
||||||
|
setUpdateLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const report = existing?.investigationReport || null;
|
const report = existing?.investigationReport || null;
|
||||||
const scenario = hydrated ? (existing?.scenario || "") : null;
|
const scenario = hydrated ? (existing?.scenario || "") : null;
|
||||||
|
|
||||||
@@ -79,6 +122,28 @@ export default function ReportPage() {
|
|||||||
Investigation Report
|
Investigation Report
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{/* Report freshness — only when a Report exists */}
|
||||||
|
{report ? (
|
||||||
|
<div className="mt-6 flex items-center gap-3">
|
||||||
|
{existing?.investigationRevision === report.generatedFromRevision ? (
|
||||||
|
<span className="text-[11px] font-semibold tracking-wider uppercase text-teal-700/70">Current</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-[11px] font-semibold tracking-wider uppercase text-gray-500">Update available</span>
|
||||||
|
<span className="text-xs text-gray-400">The investigation has changed since this report was generated.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUpdateReport}
|
||||||
|
disabled={updateLoading}
|
||||||
|
className="rounded-lg border border-teal-600 bg-white px-3 py-1.5 text-[11px] font-semibold tracking-wider uppercase text-teal-700 hover:bg-teal-50 transition disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{updateLoading ? "Updating…" : "Update report"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Situation */}
|
{/* Situation */}
|
||||||
{scenario && (
|
{scenario && (
|
||||||
<div className="mt-8 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
|
<div className="mt-8 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
|
||||||
|
|||||||
+23
-6
@@ -18,6 +18,11 @@ function Portfolio() {
|
|||||||
existing?.investigationReport && existing.investigationReport.understanding
|
existing?.investigationReport && existing.investigationReport.understanding
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const reportIsCurrent =
|
||||||
|
hasReport &&
|
||||||
|
existing.investigationReport.generatedFromRevision ===
|
||||||
|
(existing.investigationRevision ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-[640px] px-6 py-16">
|
<main className="mx-auto max-w-[640px] px-6 py-16">
|
||||||
<h1 className="mb-2 text-3xl font-bold tracking-tight">Confidence Engine</h1>
|
<h1 className="mb-2 text-3xl font-bold tracking-tight">Confidence Engine</h1>
|
||||||
@@ -39,12 +44,24 @@ function Portfolio() {
|
|||||||
|
|
||||||
<div className="mt-4 flex gap-3 text-sm">
|
<div className="mt-4 flex gap-3 text-sm">
|
||||||
{hasReport ? (
|
{hasReport ? (
|
||||||
<Link
|
<div className="flex items-center gap-2">
|
||||||
href={`/investigations/${INVESTIGATION_ID}/report`}
|
<Link
|
||||||
className="rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
|
href={`/investigations/${INVESTIGATION_ID}/report`}
|
||||||
>
|
className="rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
|
||||||
View report
|
>
|
||||||
</Link>
|
View report
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{reportIsCurrent ? (
|
||||||
|
<span className="text-[11px] font-semibold tracking-wider uppercase text-teal-700/70">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] font-semibold tracking-wider uppercase text-gray-500">
|
||||||
|
Update available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
+32
-2
@@ -85,6 +85,23 @@ RAW USER EVIDENCE
|
|||||||
- Restart clears Investigation + Report via `clearInvestigation()` + `setInvestigationRevision(0)`.
|
- Restart clears Investigation + Report via `clearInvestigation()` + `setInvestigationRevision(0)`.
|
||||||
- Report history/comparison remains deferred beyond MVP.
|
- Report history/comparison remains deferred beyond MVP.
|
||||||
|
|
||||||
|
### v0.59b — Report freshness UI on the Report page
|
||||||
|
|
||||||
|
- Report page shows `Current` (matching revisions) or `Update available` (differing revisions).
|
||||||
|
- Manual `Update report` action on Report page triggers regeneration via `/api/cases/overview`.
|
||||||
|
- Duplicate prevention guard during in-flight update.
|
||||||
|
- No automatic regeneration.
|
||||||
|
- Explanation copy: "The investigation has changed since this report was generated."
|
||||||
|
|
||||||
|
### v0.59c — Report freshness state surfaced on the Portfolio
|
||||||
|
|
||||||
|
- Portfolio surfaces `Current` / `Update available` alongside existing View report link.
|
||||||
|
- Derives freshness solely from revision provenance (`investigationReport.generatedFromRevision === investigationRevision`).
|
||||||
|
- Zero model calls during Portfolio render.
|
||||||
|
- No mutation of Investigation.
|
||||||
|
- No `Update report` action on the Portfolio — manual Report updating remains owned by the Report page.
|
||||||
|
- If no Report, neither freshness state is shown.
|
||||||
|
|
||||||
**Semantic transitions that advance revision:**
|
**Semantic transitions that advance revision:**
|
||||||
```
|
```
|
||||||
Episode Done (with content) → +1
|
Episode Done (with content) → +1
|
||||||
@@ -120,6 +137,21 @@ Reconstruct CU when canonical meaning or eligible evidence set changes — NOT w
|
|||||||
- **Temporary identity:** `case-1`. True multi-investigation persistence/identity is future work.
|
- **Temporary identity:** `case-1`. True multi-investigation persistence/identity is future work.
|
||||||
- **Portfolio client hydration:** Portfolio page uses `'use client'` — initial pre-hydration empty state ≠ absence of persisted data. Always wait for hydrated semantic controls before classifying state.
|
- **Portfolio client hydration:** Portfolio page uses `'use client'` — initial pre-hydration empty state ≠ absence of persisted data. Always wait for hydrated semantic controls before classifying state.
|
||||||
|
|
||||||
|
## MVP boundaries (v0.59b)
|
||||||
|
|
||||||
|
**Implemented in MVP:**
|
||||||
|
- visible Report freshness state (`Current` / `Update available`)
|
||||||
|
- explanation that the Investigation has changed since Report generation
|
||||||
|
- manual `Update report` action (user-triggered)
|
||||||
|
- no automatic regeneration
|
||||||
|
- duplicate prevention guard during in-flight update
|
||||||
|
|
||||||
|
**Deferred beyond MVP:**
|
||||||
|
- Report history
|
||||||
|
- retaining multiple Reports
|
||||||
|
- Report comparison
|
||||||
|
- modelling/preview comparison between Investigation revisions
|
||||||
|
|
||||||
## Current development / verification constraints
|
## Current development / verification constraints
|
||||||
|
|
||||||
- Canonical dev server at `http://localhost:3000`. Never start/stop/restart/probe it. If unavailable → BLOCKED and stop.
|
- Canonical dev server at `http://localhost:3000`. Never start/stop/restart/probe it. If unavailable → BLOCKED and stop.
|
||||||
@@ -136,8 +168,6 @@ Reconstruct CU when canonical meaning or eligible evidence set changes — NOT w
|
|||||||
|
|
||||||
- Multi-investigation portfolio (search/tag/archive/group)
|
- Multi-investigation portfolio (search/tag/archive/group)
|
||||||
- Durable investigation identities beyond `case-1`
|
- Durable investigation identities beyond `case-1`
|
||||||
- Visible Report freshness UI (revision comparison display — backend tracking in place)
|
|
||||||
- "Update report" action (manual regeneration only)
|
|
||||||
- Export/copy of Reports to Jira or external document
|
- Export/copy of Reports to Jira or external document
|
||||||
- Portfolio expansion beyond one canonical investigation
|
- Portfolio expansion beyond one canonical investigation
|
||||||
- Report history / comparison
|
- Report history / comparison
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function makeSnapshot(overrides = {}) {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
investigationReport: null,
|
investigationReport: null,
|
||||||
|
investigationRevision: 0,
|
||||||
findings: [],
|
findings: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -113,6 +114,7 @@ describe("Portfolio page (pre-confirmation v0.55/v0.56)", () => {
|
|||||||
investigationReport: {
|
investigationReport: {
|
||||||
understanding: "We understand complaints rose.",
|
understanding: "We understand complaints rose.",
|
||||||
hasPlausibleInterpretations: false,
|
hasPlausibleInterpretations: false,
|
||||||
|
generatedFromRevision: 0,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -161,6 +163,7 @@ describe("Portfolio page (pre-confirmation v0.55/v0.56)", () => {
|
|||||||
investigationReport: {
|
investigationReport: {
|
||||||
understanding: "We understand complaints rose.",
|
understanding: "We understand complaints rose.",
|
||||||
hasPlausibleInterpretations: false,
|
hasPlausibleInterpretations: false,
|
||||||
|
generatedFromRevision: 0,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* v0.59c — targeted deterministic tests for Portfolio Report freshness UI.
|
||||||
|
*
|
||||||
|
* Exercises four cases on the Portfolio:
|
||||||
|
* 1. Report + matching revisions → "Current"
|
||||||
|
* 2. Report + differing revisions → "Update available"
|
||||||
|
* 3. differing revisions still render View report action
|
||||||
|
* 4. no Report → neither Current nor Update available
|
||||||
|
* 5. zero model/fetch calls during render
|
||||||
|
* 6. Portfolio does not mutate Investigation storage
|
||||||
|
* 7. existing Continue / Restart actions unchanged
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: () => {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockClearStorage = vi.fn();
|
||||||
|
let mockLoadResult = null;
|
||||||
|
|
||||||
|
function setMockSnapshot(snap) {
|
||||||
|
if (snap) {
|
||||||
|
localStorage.setItem(
|
||||||
|
"confidence-engine-investigation",
|
||||||
|
JSON.stringify(snap),
|
||||||
|
);
|
||||||
|
mockLoadResult = snap;
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("confidence-engine-investigation");
|
||||||
|
mockLoadResult = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("@/lib/storage/investigation-storage", () => ({
|
||||||
|
loadInvestigation: () => mockLoadResult,
|
||||||
|
saveInvestigation: vi.fn(),
|
||||||
|
clearInvestigation: () => {
|
||||||
|
localStorage.removeItem("confidence-engine-investigation");
|
||||||
|
mockClearStorage();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Creates a snapshot with report. revisionMatch controls freshness state. */
|
||||||
|
function makeFreshSnapshot(investigationRevision, revisionMatch) {
|
||||||
|
return {
|
||||||
|
scenario: "Test investigation",
|
||||||
|
situationGraph: { centralStatement: "CS", nodes: [], edges: [] },
|
||||||
|
selectedQuestion: null,
|
||||||
|
summary: "Test summary.",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
schemaVersion: 1,
|
||||||
|
investigationReport: {
|
||||||
|
understanding: "We understand complaints rose.",
|
||||||
|
hasPlausibleInterpretations: false,
|
||||||
|
generatedFromRevision: revisionMatch ? investigationRevision : (investigationRevision - 2),
|
||||||
|
},
|
||||||
|
investigationRevision,
|
||||||
|
findings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a snapshot with NO report. */
|
||||||
|
function makeNoReportSnapshot(investigationRevision) {
|
||||||
|
return {
|
||||||
|
scenario: "Test investigation",
|
||||||
|
situationGraph: { centralStatement: "CS", nodes: [], edges: [] },
|
||||||
|
selectedQuestion: null,
|
||||||
|
summary: "Test summary.",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
schemaVersion: 1,
|
||||||
|
investigationReport: null,
|
||||||
|
investigationRevision,
|
||||||
|
findings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
localStorage.removeItem("confidence-engine-investigation");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Portfolio freshness — Report + matching revisions", () => {
|
||||||
|
it("renders Current alongside View report", async () => {
|
||||||
|
setMockSnapshot(makeFreshSnapshot(3, true));
|
||||||
|
const mod = await import("@/app/page.jsx");
|
||||||
|
render(React.createElement(mod.default));
|
||||||
|
expect(await screen.findByText(/View report/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Current")).toBeInTheDocument();
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes zero fetch calls during render", async () => {
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
setMockSnapshot(makeFreshSnapshot(3, true));
|
||||||
|
const mod = await import("@/app/page.jsx");
|
||||||
|
render(React.createElement(mod.default));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitFor(() => screen.findByText(/View report/i), { timeout: 5000 });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Portfolio should not call fetch at all — derives state from localStorage only
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
global.fetch = undefined;
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Portfolio freshness — Report + differing revisions", () => {
|
||||||
|
it("renders Update available alongside View report", async () => {
|
||||||
|
setMockSnapshot(makeFreshSnapshot(5, false));
|
||||||
|
const mod = await import("@/app/page.jsx");
|
||||||
|
render(React.createElement(mod.default));
|
||||||
|
expect(await screen.findByText(/View report/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Update available")).toBeInTheDocument();
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("View report still present as link for differing revisions", async () => {
|
||||||
|
setMockSnapshot(makeFreshSnapshot(5, false));
|
||||||
|
const mod = await import("@/app/page.jsx");
|
||||||
|
render(React.createElement(mod.default));
|
||||||
|
const link = await screen.findByRole("link", { name: /View report/i });
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(link).toHaveAttribute("href", "/investigations/case-1/report");
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Portfolio freshness — no Report", () => {
|
||||||
|
it("renders neither Current nor Update available when no report exists", async () => {
|
||||||
|
setMockSnapshot(makeNoReportSnapshot(0));
|
||||||
|
const mod = await import("@/app/page.jsx");
|
||||||
|
render(React.createElement(mod.default));
|
||||||
|
expect(screen.queryByText(/View report/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Current")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Update available")).not.toBeInTheDocument();
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Portfolio existing actions preserved", () => {
|
||||||
|
it("Continue investigation and Restart remain when a report exists", async () => {
|
||||||
|
setMockSnapshot(makeFreshSnapshot(3, true));
|
||||||
|
const mod = await import("@/app/page.jsx");
|
||||||
|
render(React.createElement(mod.default));
|
||||||
|
expect(await screen.findByText(/Continue investigation/i)).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/Restart investigation/i)).toBeInTheDocument();
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Portfolio freshness — differing revisions no-additional-calls", () => {
|
||||||
|
it("refreshing with differing data does not trigger additional API calls", async () => {
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
setMockSnapshot(makeFreshSnapshot(5, false));
|
||||||
|
const mod = await import("@/app/page.jsx");
|
||||||
|
|
||||||
|
render(React.createElement(mod.default));
|
||||||
|
try {
|
||||||
|
await waitFor(() => screen.findByText(/Update available/i), { timeout: 2000 });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
global.fetch = undefined;
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user