mirror of
https://github.com/andre482/O-tie.git
synced 2026-07-22 07:44:11 +00:00
- 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>
211 lines
6.1 KiB
TypeScript
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));
|
|
});
|
|
});
|