andre482_O-tie/tests/model.test.ts
Andre482 a784ad5851 Fix inline-edit cancel, lenient file/view validation, and filename hardening
- Inline edit: add one-shot guard so Enter commits once (no duplicate
  undo/render) and Escape reliably cancels instead of saving via blur.
- validateBowtie: normalize threats/consequences missing barrier arrays or
  labels instead of throwing and flagging recoverable files as malformed.
- deserializeBowtie: sanitize and clamp view zoom/pan to avoid an invisible
  canvas and Infinity divide during export.
- Add shared sanitizeBaseName: strip control/leading/trailing dot chars,
  reject bare dot names, and escape Windows reserved names; reuse for new
  file and PNG export names.
- Add model tests for the above.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 19:46:47 +03:00

211 lines
6.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
bowtieStructureSignature,
cloneBowtie,
createBarrier,
createBowtie,
createConsequence,
createThreat,
deserializeBowtie,
sanitizeBaseName,
serializeBowtie,
type Bowtie,
} from "../src/model";
function sampleBowtie(): Bowtie {
const bt = createBowtie("Sample");
bt.events[0].label = "Loss of containment";
bt.events[0].hazard = "High pressure";
const threat = createThreat("Corrosion");
threat.preventionBarriers = [createBarrier("Inspection")];
bt.threats = [threat];
bt.consequences = [createConsequence("Fire")];
return bt;
}
describe("createBowtie", () => {
it("creates a valid single-event bowtie", () => {
const bt = createBowtie("New");
expect(bt.events).toHaveLength(1);
expect(bt.threats).toEqual([]);
expect(bt.consequences).toEqual([]);
expect(bt.id).toBeTruthy();
});
});
describe("serialize/deserialize", () => {
it("round-trips structure", () => {
const bt = sampleBowtie();
const restored = deserializeBowtie(serializeBowtie(bt));
expect(restored.name).toBe(bt.name);
expect(restored.events[0].label).toBe("Loss of containment");
expect(restored.events[0].hazard).toBe("High pressure");
expect(restored.threats[0].preventionBarriers[0].label).toBe("Inspection");
expect(restored.consequences[0].label).toBe("Fire");
});
it("adds a default view when missing", () => {
const bt = sampleBowtie();
delete bt.view;
const restored = deserializeBowtie(serializeBowtie(bt));
expect(restored.view).toEqual({ zoom: 1, panX: 0, panY: 0 });
});
});
describe("legacy migration", () => {
it("moves top-level hazard/topEvent into events", () => {
const legacy = JSON.stringify({
id: "x",
name: "Legacy",
hazard: "Ethylene",
topEvent: "Loss of containment",
threats: [],
consequences: [],
});
const bt = deserializeBowtie(legacy);
expect(bt.events).toHaveLength(1);
expect(bt.events[0].hazard).toBe("Ethylene");
expect(bt.events[0].label).toBe("Loss of containment");
expect((bt as Bowtie & { topEvent?: string }).topEvent).toBeUndefined();
expect((bt as Bowtie & { hazard?: string }).hazard).toBeUndefined();
});
it("converts barrier escalationFactors into degradationChains", () => {
const legacy = JSON.stringify({
id: "x",
name: "Legacy",
hazard: "H",
topEvent: "T",
threats: [
{
id: "t1",
label: "Threat",
preventionBarriers: [
{
id: "b1",
label: "Barrier",
escalationFactors: [
{
id: "ef1",
label: "Deferred inspection",
escalationBarriers: [{ id: "eb1", label: "Audit" }],
},
],
},
],
},
],
consequences: [],
});
const bt = deserializeBowtie(legacy);
const barrier = bt.threats[0].preventionBarriers[0];
expect(barrier.degradationChains).toHaveLength(1);
const chain = barrier.degradationChains[0];
const labels = chain.safeguards.map((s) => s.label);
expect(labels).toContain("Deferred inspection");
expect(labels).toContain("Audit");
expect((barrier as { escalationFactors?: unknown }).escalationFactors).toBeUndefined();
});
});
describe("validation", () => {
it("throws when id or name is missing", () => {
expect(() => deserializeBowtie(JSON.stringify({ name: "x", events: [{}], threats: [], consequences: [] }))).toThrow();
});
it("throws when threats/consequences are not arrays", () => {
expect(() =>
deserializeBowtie(JSON.stringify({ id: "a", name: "b", events: [{ hazard: "" }], threats: {}, consequences: [] }))
).toThrow();
});
it("repairs an empty events array into a single event", () => {
const bt = deserializeBowtie(
JSON.stringify({ id: "a", name: "b", events: [], threats: [], consequences: [] })
);
expect(bt.events).toHaveLength(1);
expect(typeof bt.events[0].hazard).toBe("string");
});
it("normalizes threats/consequences missing barrier arrays instead of throwing", () => {
const bt = deserializeBowtie(
JSON.stringify({
id: "a",
name: "b",
events: [{ id: "e1", label: "T", hazard: "H" }],
threats: [{ id: "t1", label: "Threat" }],
consequences: [{ id: "c1", label: "Consequence" }],
})
);
expect(bt.threats[0].preventionBarriers).toEqual([]);
expect(bt.consequences[0].mitigationBarriers).toEqual([]);
});
});
describe("view sanitization", () => {
it("clamps an out-of-range zoom on load", () => {
const bt = deserializeBowtie(
JSON.stringify({
id: "a",
name: "b",
events: [{ id: "e1", label: "T", hazard: "H" }],
threats: [],
consequences: [],
view: { zoom: 0, panX: 10, panY: -5 },
})
);
expect(bt.view).toEqual({ zoom: 0.2, panX: 10, panY: -5 });
});
it("replaces non-finite view values with defaults", () => {
const bt = deserializeBowtie(
JSON.stringify({
id: "a",
name: "b",
events: [{ id: "e1", label: "T", hazard: "H" }],
threats: [],
consequences: [],
view: { zoom: "nope", panX: null, panY: 0 },
})
);
expect(bt.view).toEqual({ zoom: 1, panX: 0, panY: 0 });
});
});
describe("sanitizeBaseName", () => {
it("strips separators and illegal characters", () => {
expect(sanitizeBaseName("a/b:c*?")).toBe("a-b-c--");
});
it("falls back for bare dot names", () => {
expect(sanitizeBaseName("..")).toBe("bowtie");
expect(sanitizeBaseName(" ")).toBe("bowtie");
});
it("strips trailing dots and spaces", () => {
expect(sanitizeBaseName("My bowtie. ")).toBe("My bowtie");
});
it("escapes Windows reserved names", () => {
expect(sanitizeBaseName("CON")).toBe("_CON");
expect(sanitizeBaseName("lpt1")).toBe("_lpt1");
});
});
describe("bowtieStructureSignature", () => {
it("ignores view and timestamps", () => {
const a = sampleBowtie();
const b = cloneBowtie(a);
b.view = { zoom: 2, panX: 100, panY: 50 };
b.updatedAt = "2099-01-01T00:00:00.000Z";
b.createdAt = "2099-01-01T00:00:00.000Z";
expect(bowtieStructureSignature(a)).toBe(bowtieStructureSignature(b));
});
it("changes when content changes", () => {
const a = sampleBowtie();
const b = cloneBowtie(a);
b.threats[0].label = "Different";
expect(bowtieStructureSignature(a)).not.toBe(bowtieStructureSignature(b));
});
});