feat(modelling-runs): pure domain core — filters, run record, status, bounds

TDD'd per ADR-0008: normaliseRunFilters (canonical postcodes, enum
vocabularies + Unknown, 40-postcode cap, absence = all), buildRunRecord /
parseRunConfig (app-authored task + config-subtask inputs, null-safe
round-trip), selectionSummary, deriveRunStatus (dispatched / in-progress
with subtask progress / complete / failed), isLargeRun (10k gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-06 16:51:55 +00:00
parent c7dfe95e15
commit 35eb681f8f
2 changed files with 332 additions and 0 deletions

View file

@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
buildRunRecord,
deriveRunStatus,
isLargeRun,
normaliseRunFilters,
parseRunConfig,
selectionSummary,
} 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 and config-subtask 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",
});
expect(record.configInputs).toEqual({
portfolio_id: 814,
scenario_ids: [12, 34],
filters: { postcodes: ["B93 8SU"], property_types: ["House"] },
previewed_property_count: 214,
triggered_by: "khalim@domna.homes",
});
});
});
describe("parseRunConfig", () => {
it("round-trips a stored run record back to the app's shape", () => {
const { configInputs } = buildRunRecord({
portfolioId: "814",
scenarioIds: ["12"],
filters: { builtForms: ["Detached", "Unknown"] },
previewedPropertyCount: 57,
triggeredBy: "priya@domna.homes",
});
expect(parseRunConfig(JSON.stringify(configInputs))).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);
});
});

View file

@ -0,0 +1,198 @@
/**
* Modelling-run domain model pure logic for the bulk-trigger-modelling
* journey. See CONTEXT.md (Modelling run, Run filter) and ADR-0008.
*/
import { normalisePostcode } from "@/lib/postcodeSearch/model";
import {
BuiltFormTypeValues,
PropertyTypeValues,
} from "@/app/db/schema/landlord_overrides";
/** An absent key means unconstrained — "all" is never an enumeration. */
export interface RunFilters {
postcodes?: string[];
propertyTypes?: string[];
builtForms?: string[];
}
export type NormaliseFiltersResult =
| { ok: true; filters: RunFilters }
| { ok: false; error: string };
/** UX guard, not a transport limit — the config is stored, not carried. */
export const MAX_FILTER_POSTCODES = 40;
export function normaliseRunFilters(input: {
postcodes?: string[];
propertyTypes?: string[];
builtForms?: string[];
}): NormaliseFiltersResult {
const filters: RunFilters = {};
if (input.postcodes?.length) {
const seen = new Set<string>();
for (const raw of input.postcodes) {
const pc = normalisePostcode(raw);
if (!pc) return { ok: false, error: `Invalid postcode '${raw}'` };
seen.add(pc);
}
filters.postcodes = [...seen].sort();
if (filters.postcodes.length > MAX_FILTER_POSTCODES) {
return {
ok: false,
error: `Filters are limited to ${MAX_FILTER_POSTCODES} postcodes per run — leave the filter empty to include all postcodes`,
};
}
}
// The filter vocabularies are the canonical enum lists (which already end
// in "Unknown" — the no-resolvable-value bucket, see CONTEXT.md Run filter).
for (const [key, allowed] of [
["propertyTypes", PropertyTypeValues],
["builtForms", BuiltFormTypeValues],
] as const) {
const values = input[key];
if (!values?.length) continue;
const bad = values.find((v) => !allowed.includes(v));
if (bad !== undefined) {
return { ok: false, error: `Invalid ${key === "propertyTypes" ? "property type" : "built form"} '${bad}'` };
}
filters[key] = [...new Set(values)];
}
return { ok: true, filters };
}
export interface RunConfigView {
scenarioIds: string[];
filters: RunFilters;
previewedPropertyCount: number;
triggeredBy: string;
}
/**
* Read a stored config-subtask `inputs` back into the app's shape. Null for
* anything that isn't a modelling-run config (malformed, legacy, absent)
* history reads must never throw on old rows.
*/
export function parseRunConfig(inputs: string | null): RunConfigView | null {
if (!inputs) return null;
try {
const raw = JSON.parse(inputs) as Partial<RunConfigInputs>;
if (!Array.isArray(raw.scenario_ids) || typeof raw.triggered_by !== "string") {
return null;
}
const filters: RunFilters = {};
if (raw.filters?.postcodes?.length) filters.postcodes = raw.filters.postcodes;
if (raw.filters?.property_types?.length) filters.propertyTypes = raw.filters.property_types;
if (raw.filters?.built_forms?.length) filters.builtForms = raw.filters.built_forms;
return {
scenarioIds: raw.scenario_ids.map(String),
filters,
previewedPropertyCount: raw.previewed_property_count ?? 0,
triggeredBy: raw.triggered_by,
};
} catch {
return null;
}
}
/** Above this, the UI asks for an explicit extra confirmation before running. */
export const LARGE_RUN_THRESHOLD = 10_000;
export function isLargeRun(matchedPropertyCount: number): boolean {
return matchedPropertyCount >= LARGE_RUN_THRESHOLD;
}
export type RunStatus =
| { state: "dispatched" }
| { state: "in_progress"; ready: number; total: number }
| { state: "complete" }
| { state: "failed" };
/**
* Display state of a run from its task + execution-subtask counts (the config
* subtask is excluded by the caller). Status strings follow the task system's
* loose conventions (case-insensitive; completion also signalled by
* job_completed).
*/
export function deriveRunStatus(input: {
taskStatus: string;
jobCompleted: Date | null;
total: number;
completed: number;
failed: number;
}): RunStatus {
const s = input.taskStatus.toLowerCase();
if (["failed", "failure", "error"].includes(s)) return { state: "failed" };
if (input.jobCompleted || ["completed", "complete"].includes(s)) {
return { state: "complete" };
}
if (input.total === 0) return { state: "dispatched" };
return { state: "in_progress", ready: input.completed, total: input.total };
}
/** Human summary of a Run filter, as shown in run history ("All properties"). */
export function selectionSummary(filters: RunFilters): string {
const bits: string[] = [];
if (filters.postcodes?.length) bits.push(`Postcodes: ${filters.postcodes.join(", ")}`);
if (filters.propertyTypes?.length) bits.push(`Type: ${filters.propertyTypes.join(", ")}`);
if (filters.builtForms?.length) bits.push(`Built form: ${filters.builtForms.join(", ")}`);
return bits.length ? bits.join(" · ") : "All properties";
}
export interface RunRequest {
portfolioId: string;
scenarioIds: string[];
filters: RunFilters;
previewedPropertyCount: number;
triggeredBy: string;
}
/** Backend-facing (snake_case) copy of a run's configuration. */
export interface RunConfigInputs {
portfolio_id: number;
scenario_ids: number[];
filters: {
postcodes?: string[];
property_types?: string[];
built_forms?: string[];
};
previewed_property_count: number;
triggered_by: string;
}
/**
* The rows a Modelling run persists (ADR-0008): an app-authored task (the
* BulkUpload trigger convention) plus the config-subtask inputs that make the
* run's request durable and history-readable.
*/
export function buildRunRecord(req: RunRequest): {
task: {
taskSource: string;
service: "modelling_run";
source: "portfolio_id";
sourceId: string;
status: "waiting";
};
configInputs: RunConfigInputs;
} {
const filters: RunConfigInputs["filters"] = {};
if (req.filters.postcodes) filters.postcodes = req.filters.postcodes;
if (req.filters.propertyTypes) filters.property_types = req.filters.propertyTypes;
if (req.filters.builtForms) filters.built_forms = req.filters.builtForms;
return {
task: {
taskSource: `Modelling run ${req.scenarioIds.length} scenario${req.scenarioIds.length === 1 ? "" : "s"}`,
service: "modelling_run",
source: "portfolio_id",
sourceId: req.portfolioId,
status: "waiting",
},
configInputs: {
portfolio_id: Number(req.portfolioId),
scenario_ids: req.scenarioIds.map(Number),
filters,
previewed_property_count: req.previewedPropertyCount,
triggered_by: req.triggeredBy,
},
};
}