mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-19 17:02:59 +00:00
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>
175 lines
6.2 KiB
TypeScript
175 lines
6.2 KiB
TypeScript
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);
|
||
});
|
||
});
|