90 lines
4.7 KiB
TypeScript
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);
|
||
|
|
});
|
||
|
|
});
|