Files
Triple-C/app/src/viewer/viewerState.test.ts
T
shadowdaoandClaude Opus 5.5 247ec764ea feat(viewer): reload/conflict reducer and editability rules
- viewerState.ts: pure reducer for the clean/dirty, same/changed/gone,
  container-down and overwrite-on-save states (spec §5), plus the
  pollEffect/canSave helpers EditorPane will drive off.
- editability.ts: classifies a fetched file as text/image/binary and
  decides whether it is editable, deferring to Rust's readonly_reason
  when it refuses.

Per preflight P1, languages.ts/.test.ts move to Task 10 (needs the
CodeMirror packages Task 6 installs; out of scope for this task's
worktree). Per P3, the "reloaded" action now carries `truncated` and
`polledHash` so a poll-driven reload of a truncated (prefix-hash-only)
file adopts the polled full-file hash instead of re-triggering a
reload on every subsequent poll -- with a reducer test covering it.
Per P13, tightened the poll_failed/canSave test to start from a dirty
doc so it actually exercises containerDown rather than passing only
because the doc was clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 21:07:28 -07:00

90 lines
4.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { canSave, initialViewerState, pollEffect, reduceViewer, type ViewerDocState } from "./viewerState";
const H1 = "1".repeat(64);
const H2 = "2".repeat(64);
const H3 = "3".repeat(64);
const loaded = (truncated = false): ViewerDocState =>
reduceViewer(initialViewerState, { type: "loaded", hash: H1, truncated });
const poll = (s: ViewerDocState, hash: string | null, exists = true) =>
reduceViewer(s, { type: "polled", poll: { exists, hash, size: exists ? 1 : null } });
describe("reduceViewer", () => {
it("seeds both hashes from an untruncated load", () => {
expect(loaded()).toMatchObject({ doc: "clean", disk: "same", baseHash: H1, diskHash: H1 });
});
it("leaves diskHash unknown after a truncated load, so the first poll seeds it silently", () => {
const s = loaded(true);
expect(s.diskHash).toBeNull();
const after = poll(s, H2);
expect(after).toMatchObject({ disk: "same", diskHash: H2 });
expect(pollEffect(s, after)).toBe("none");
});
it("an unchanged poll is a no-op", () => {
const s = loaded();
expect(pollEffect(s, poll(s, H1))).toBe("none");
});
it("a changed poll on a clean doc reloads", () => {
const s = loaded();
const after = poll(s, H2);
expect(after).toMatchObject({ disk: "changed", diskHash: H2, doc: "clean" });
expect(pollEffect(s, after)).toBe("reload");
const reloaded = reduceViewer(after, { type: "reloaded", hash: H2, truncated: false, polledHash: H2 });
expect(reloaded).toMatchObject({ disk: "same", baseHash: H2, diskHash: H2, justReloaded: true });
});
it("a truncated reload adopts the polled hash, not the prefix hash; the next identical poll is a no-op", () => {
// A truncated load never gets a comparable full-file hash of its own, so a
// poll-driven reload of a large file must seed diskHash from the poll's
// hash (spec Decision 2) -- otherwise every poll re-triggers a reload.
const seeded = poll(loaded(true), H2);
const changed = poll(seeded, H3);
expect(pollEffect(seeded, changed)).toBe("reload");
const reloaded = reduceViewer(changed, { type: "reloaded", hash: H1, truncated: true, polledHash: changed.diskHash });
expect(reloaded).toMatchObject({ disk: "same", diskHash: H3, baseHash: H1, justReloaded: true });
expect(pollEffect(reloaded, poll(reloaded, H3))).toBe("none");
});
it("a changed poll on a dirty doc shows the banner and never reloads", () => {
const s = reduceViewer(loaded(), { type: "edited" });
const after = poll(s, H2);
expect(after).toMatchObject({ doc: "dirty", disk: "changed" });
expect(pollEffect(s, after)).toBe("banner");
expect(pollEffect(after, poll(after, H2))).toBe("none");
});
it("overwrite-on-save adopts the disk hash as the base", () => {
const s = poll(reduceViewer(loaded(), { type: "edited" }), H2);
const o = reduceViewer(s, { type: "overwrite_on_save" });
expect(o).toMatchObject({ baseHash: H2, disk: "same", overwrite: true, doc: "dirty" });
expect(canSave(o, true)).toBe(true);
});
it("a save clears dirty and aligns hashes; a conflict marks disk changed", () => {
const s = reduceViewer(loaded(), { type: "edited" });
expect(reduceViewer(s, { type: "saved", hash: H2 })).toMatchObject({ doc: "clean", disk: "same", baseHash: H2, diskHash: H2, overwrite: false });
expect(reduceViewer(s, { type: "save_conflict" })).toMatchObject({ doc: "dirty", disk: "changed" });
expect(reduceViewer(s, { type: "save_gone" })).toMatchObject({ disk: "gone" });
});
it("a gone file disables saving but keeps the buffer state", () => {
const s = reduceViewer(loaded(), { type: "edited" });
const gone = poll(s, null, false);
expect(gone).toMatchObject({ disk: "gone", doc: "dirty" });
expect(canSave(gone, true)).toBe(false);
expect(pollEffect(s, gone)).toBe("banner");
});
it("a failed poll flags the container down and a good one clears it", () => {
// Regression: start from a dirty doc, not a clean one -- otherwise
// canSave(down, true) is false purely because doc !== "dirty", and the
// assertion never actually exercises containerDown.
const dirty = reduceViewer(loaded(), { type: "edited" });
const down = reduceViewer(dirty, { type: "poll_failed" });
expect(down.containerDown).toBe(true);
expect(canSave(down, true)).toBe(false);
expect(poll(down, H1).containerDown).toBe(false);
});
it("canSave needs dirty + editable + disk in sync", () => {
expect(canSave(loaded(), true)).toBe(false);
const dirty = reduceViewer(loaded(), { type: "edited" });
expect(canSave(dirty, true)).toBe(true);
expect(canSave(dirty, false)).toBe(false);
expect(canSave(poll(dirty, H2), true)).toBe(false);
});
});