import { describe, expect, it } from "vitest"; import { buildRunRecord, deriveRunStatus, estimateRunMinutes, historyRefetchInterval, isLargeRun, normaliseRunFilters, parseRunConfig, selectionSummary, type RunStatus, } from "./model"; describe("normaliseRunFilters", () => { it("canonicalises postcodes, dedupes, and drops empty selections (absent = all)", () => { const out = normaliseRunFilters({ postcodes: ["m20 4tf", "M20 4TF", "b93 8su"], propertyTypes: [], builtForms: undefined, }); expect(out).toEqual({ ok: true, filters: { postcodes: ["B93 8SU", "M20 4TF"] }, }); }); it("accepts canonical types/built forms plus Unknown, rejects anything else", () => { const ok = normaliseRunFilters({ propertyTypes: ["House", "Unknown"], builtForms: ["Semi-Detached"], }); expect(ok).toEqual({ ok: true, filters: { propertyTypes: ["House", "Unknown"], builtForms: ["Semi-Detached"] }, }); expect(normaliseRunFilters({ propertyTypes: ["Castle"] }).ok).toBe(false); expect(normaliseRunFilters({ builtForms: ["Terraced"] }).ok).toBe(false); }); it("caps the postcode selection at 40 — 'all postcodes' is absence, not enumeration", () => { const many = Array.from({ length: 41 }, (_, i) => `M${Math.floor(i / 10)}${i % 10} 4TF`); const out = normaliseRunFilters({ postcodes: many }); expect(out.ok).toBe(false); if (!out.ok) expect(out.error).toMatch(/40/); expect(normaliseRunFilters({ postcodes: many.slice(0, 40) }).ok).toBe(true); }); }); describe("buildRunRecord", () => { it("produces the app-authored task row carrying the run config as its inputs (ADR-0008)", () => { const record = buildRunRecord({ portfolioId: "814", scenarioIds: ["12", "34"], filters: { postcodes: ["B93 8SU"], propertyTypes: ["House"] }, previewedPropertyCount: 214, triggeredBy: "khalim@domna.homes", }); expect(record.task).toEqual({ taskSource: "Modelling run – 2 scenarios", service: "modelling_run", source: "portfolio_id", sourceId: "814", status: "waiting", inputs: JSON.stringify({ portfolio_id: 814, scenario_ids: [12, 34], filters: { postcodes: ["B93 8SU"], property_types: ["House"] }, previewed_property_count: 214, triggered_by: "khalim@domna.homes", }), }); // What the distributor receives — provenance fields stay on the task only expect(record.dispatchPayload).toEqual({ portfolio_id: 814, scenario_ids: [12, 34], filters: { postcodes: ["B93 8SU"], property_types: ["House"] }, }); }); }); describe("parseRunConfig", () => { it("round-trips a stored run record back to the app's shape", () => { const { task } = buildRunRecord({ portfolioId: "814", scenarioIds: ["12"], filters: { builtForms: ["Detached", "Unknown"] }, previewedPropertyCount: 57, triggeredBy: "priya@domna.homes", }); expect(parseRunConfig(task.inputs)).toEqual({ scenarioIds: ["12"], filters: { builtForms: ["Detached", "Unknown"] }, previewedPropertyCount: 57, triggeredBy: "priya@domna.homes", }); }); it("returns null for missing or malformed inputs — never throws on old rows", () => { expect(parseRunConfig(null)).toBeNull(); expect(parseRunConfig("not json")).toBeNull(); expect(parseRunConfig('{"unrelated":true}')).toBeNull(); }); }); describe("selectionSummary", () => { it("describes the filters in the run-history format; no filters = All properties", () => { expect(selectionSummary({})).toBe("All properties"); expect( selectionSummary({ postcodes: ["B93 8SU", "M20 4TF"], propertyTypes: ["House"], builtForms: ["Detached", "Unknown"], }), ).toBe("Postcodes: B93 8SU, M20 4TF · Type: House · Built form: Detached, Unknown"); }); }); describe("deriveRunStatus", () => { const base = { taskStatus: "waiting", jobCompleted: null, total: 0, completed: 0, failed: 0 }; it("maps the task lifecycle to the four display states", () => { // dispatched: accepted, no execution subtasks yet expect(deriveRunStatus(base)).toEqual({ state: "dispatched" }); // in progress once execution subtasks exist, with progress expect( deriveRunStatus({ ...base, taskStatus: "in progress", total: 104, completed: 61 }), ).toEqual({ state: "in_progress", ready: 61, total: 104 }); // complete when the task says so expect( deriveRunStatus({ ...base, taskStatus: "completed", jobCompleted: new Date(), total: 104, completed: 104 }), ).toEqual({ state: "complete" }); // failed statuses win regardless of counts expect( deriveRunStatus({ ...base, taskStatus: "Failed", total: 104, completed: 40, failed: 3 }), ).toEqual({ state: "failed" }); }); }); describe("isLargeRun", () => { it("gates runs of 10,000+ matched properties behind an extra confirm", () => { expect(isLargeRun(9999)).toBe(false); expect(isLargeRun(10000)).toBe(true); }); }); describe("estimateRunMinutes", () => { it("scales with plan count — roughly 300 plans per 5 minutes", () => { expect(estimateRunMinutes(3_000)).toBe(50); expect(estimateRunMinutes(12_000)).toBe(200); }); it("never promises less than 5 minutes, even for tiny or zero-plan runs", () => { expect(estimateRunMinutes(0)).toBe(5); expect(estimateRunMinutes(1)).toBe(5); expect(estimateRunMinutes(300)).toBe(5); }); }); describe("historyRefetchInterval", () => { const runs = (...states: RunStatus[]) => states.map((status) => ({ status })); it("polls fast while any run is still moving (dispatched or in progress)", () => { expect(historyRefetchInterval(runs({ state: "complete" }, { state: "dispatched" }))).toBe( 5_000, ); expect( historyRefetchInterval(runs({ state: "in_progress", ready: 1, total: 4 })), ).toBe(5_000); }); it("settles to a slow poll when every run is finished, or history is unknown", () => { expect(historyRefetchInterval(runs({ state: "complete" }, { state: "failed" }))).toBe(30_000); expect(historyRefetchInterval([])).toBe(30_000); expect(historyRefetchInterval(undefined)).toBe(30_000); }); });