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); }); });