assessment-model/src/lib/modellingRuns/model.test.ts
Khalim Conn-Kowlessar 8f7f5b3a88 fix(modelling): set-and-forget trigger state, visible back link, filter clearing
Post-review UX feedback on the run journey:

- The post-trigger card computed its ETA from the just-cleared scenario
  selection, so it always promised "~5 minutes" whatever the run size.
  ETA now comes from the captured run (estimateRunMinutes, TDD'd) and the
  done state is a compact status banner above a still-visible Recent runs
  table, which polls every 5s while a run is moving (historyRefetchInterval,
  TDD'd) — trigger, glance, leave.
- The only way back was a text-xs gray-400 breadcrumb (~2.5:1, sub-AA);
  replaced with an accessible "← Back to {portfolio}" link.
- "Remove all filters" button on Step 2.
- Scenario rows were a checkbox nested inside a button (state invisible to
  screen readers); now a label wrapping a real checkbox.
- Large-run confirm lists the scenario names and selection being committed,
  is labelled, closes on Escape, and starts focus on "Go back".
- Postcode filter no longer fails silently: loading and error-with-retry
  states.
- Run status "Dispatched" reads "Starting up"; scenarios page gains a
  "Run modelling" entry point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:37:16 +00:00

175 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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