mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
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:
parent
c7dfe95e15
commit
35eb681f8f
2 changed files with 332 additions and 0 deletions
134
src/lib/modellingRuns/model.test.ts
Normal file
134
src/lib/modellingRuns/model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
198
src/lib/modellingRuns/model.ts
Normal file
198
src/lib/modellingRuns/model.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue