diff --git a/CHANGELOG.md b/CHANGELOG.md index b1244aee..f16346bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build (Windows / macOS / Linux); the updater always serves the latest. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## Unreleased — Cross-project Gantt + +The Programme card (`/projects/{pid}/schedule/portfolio`) now draws a bar per project on a shared +span: start, finish, duration, which project drives the programme finish, and which are named by an +external link. `apps/web/src/portal/panels/programmeGantt.ts` holds the geometry as a pure function; +the panel only paints what it returns. + +**It needed no new engine, and that is the finding.** The roadmap recorded a cross-project Gantt as +missing because `schedule_viz.py` is per-project. But R46's `schedule_portfolio.py` already computes +`project_starts` and `project_finishes` in its single merged pass, and the route already returned +them — `apps/web/src/api/schedule.ts` **named only three scalars**. `HttpCore.json` returns +`res.json()` under an unchecked cast, so the dates were in the parsed response all along; nothing +declared them, so no call site could reach them and none did. Same class as R37-TESTED-UNWIRED one +layer further out: not a route without a caller, but a payload without a reader. + +Bars come from the **merged** pass, never each project's standalone CPM. A project can look +comfortable alone and be critical to the programme; its own run would show the comfortable answer. +`services/api/test_programme_gantt.py` asserts the FS link actually pushes fit-out past enabling's +finish — removing the link fails it with exactly that explanation, so the claim is load-bearing +rather than decorative. + +**A project with only one dated end gets no bar**, and is listed with the reason. Substituting the +programme's own start or finish for the missing end would draw a bar that looks measured and is not +— the same rule the risk heat map applies to an unmeasured cell. + +Writing the test also found that an external link must name activities by **record id**: `wbs` and +`ref` are aliases resolved only for a project's own predecessor tokens, so a link written in WBS +terms is refused as "no such activity". Recorded next to the link that uses it. + ## Unreleased — Portfolio risk heat map `GET /portfolio/risk` (`risk_portfolio.py`) grids `risk_board` across the book: projects down, the diff --git a/apps/web/src/api/schedule.ts b/apps/web/src/api/schedule.ts index bec165f3..865ce637 100644 --- a/apps/web/src/api/schedule.ts +++ b/apps/web/src/api/schedule.ts @@ -482,6 +482,14 @@ export function withSchedule>(Base: TBase) { rejected_links: string[]; projects_without_activities: string[]; programme_finish: string | null; project_count: number | null; external_link_count: number | null; + // The merged pass returns per-project dates and the activities that cross a boundary. This + // type declared only the three scalars above until v0.3.1144, so the dates reached the browser + // and were dropped before anything could draw them — which is why the roadmap recorded the + // cross-project Gantt as missing an engine it already had. Keyed by project id. + project_starts?: Record; + project_finishes?: Record; + crossing_activities?: string[]; + issues?: { code?: string; message?: string }[]; }>(`/projects/${pid}/schedule/portfolio`, { method: "POST", body: JSON.stringify(body) }); } diff --git a/apps/web/src/portal/panels/programmeGantt.test.ts b/apps/web/src/portal/panels/programmeGantt.test.ts new file mode 100644 index 00000000..cdfd7cb1 --- /dev/null +++ b/apps/web/src/portal/panels/programmeGantt.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { programmeBars } from "./programmeGantt"; + +const P = (id: string, name = id, activities = 3) => ({ id, name, activities }); + +describe("programmeBars", () => { + it("places each project on a shared span, ordered by start", () => { + const r = programmeBars({ + projects: [P("b", "Fit-out"), P("a", "Enabling")], + project_starts: { a: "2026-01-01", b: "2026-02-01" }, + project_finishes: { a: "2026-01-31", b: "2026-03-01" }, + }); + expect(r.bars.map((x) => x.name)).toEqual(["Enabling", "Fit-out"]); + expect(r.span).toEqual({ start: "2026-01-01", finish: "2026-03-01", days: 60 }); + expect(r.bars[0]!.left).toBe(0); + // Enabling runs 30 of the 59-day span; Fit-out starts 31 days in. + expect(Math.round(r.bars[0]!.width)).toBe(51); + expect(Math.round(r.bars[1]!.left)).toBe(53); + expect(r.bars[0]!.days).toBe(31); + expect(r.unplotted).toEqual([]); + }); + + it("marks the bar that finishes on the programme finish as driving", () => { + const r = programmeBars({ + projects: [P("a"), P("b")], + project_starts: { a: "2026-01-01", b: "2026-01-01" }, + project_finishes: { a: "2026-01-10", b: "2026-02-10" }, + }); + expect(r.bars.find((x) => x.id === "b")!.driving).toBe(true); + expect(r.bars.find((x) => x.id === "a")!.driving).toBe(false); + }); + + // A HALF-DATED PROJECT GETS NO BAR. Substituting the programme's own start or finish for the + // missing end would draw a bar that looks measured and is not — the defect this module exists to + // avoid, so it is asserted rather than left to the renderer. + it("refuses a bar when either end is missing, and says which", () => { + const r = programmeBars({ + projects: [P("a"), P("b", "No finish"), P("c", "No start"), P("d", "Nothing")], + project_starts: { a: "2026-01-01", b: "2026-01-05", d: undefined as unknown as string }, + project_finishes: { a: "2026-01-31", c: "2026-02-01" }, + }); + expect(r.bars.map((x) => x.id)).toEqual(["a"]); + expect(r.unplotted).toEqual([ + { id: "b", name: "No finish", reason: "no finish date" }, + { id: "c", name: "No start", reason: "no start date" }, + { id: "d", name: "Nothing", reason: "no scheduled dates" }, + ]); + // and the span is computed from the plotted bar alone, never widened by a half-dated project + expect(r.span).toEqual({ start: "2026-01-01", finish: "2026-01-31", days: 31 }); + }); + + it("refuses a bar whose finish precedes its start", () => { + const r = programmeBars({ + projects: [P("a")], + project_starts: { a: "2026-03-01" }, project_finishes: { a: "2026-01-01" }, + }); + expect(r.bars).toEqual([]); + expect(r.unplotted[0]!.reason).toBe("finish precedes start"); + expect(r.span).toBeNull(); + }); + + it("survives a single-day programme without NaN widths", () => { + const r = programmeBars({ + projects: [P("a")], + project_starts: { a: "2026-01-01" }, project_finishes: { a: "2026-01-01" }, + }); + expect(r.bars[0]!.left).toBe(0); + expect(r.bars[0]!.width).toBe(100); + expect(r.bars[0]!.days).toBe(1); + expect(r.span!.days).toBe(1); + }); + + it("flags the projects an external link names — their dates are a commitment", () => { + const r = programmeBars({ + projects: [P("enabling"), P("fitout"), P("infra")], + project_starts: { enabling: "2026-01-01", fitout: "2026-02-01", infra: "2026-01-15" }, + project_finishes: { enabling: "2026-01-31", fitout: "2026-03-01", infra: "2026-02-15" }, + external_links: [{ predecessor: "enabling::A1", successor: "fitout::B1" }], + }); + expect(r.bars.find((x) => x.id === "enabling")!.linked).toBe(true); + expect(r.bars.find((x) => x.id === "fitout")!.linked).toBe(true); + expect(r.bars.find((x) => x.id === "infra")!.linked).toBe(false); + }); + + // `Date` normalises an out-of-range DAY instead of rejecting it — "2026-02-30" becomes + // 2026-03-02 — while an out-of-range MONTH is NaN. A normalised date is an invented one, so it + // must not reach a bar. + it("rejects a date the Date constructor would silently normalise", () => { + const r = programmeBars({ + projects: [{ id: "a", name: "Feb 30", activities: 1 }], + project_starts: { a: "2026-02-30" }, project_finishes: { a: "2026-03-31" }, + }); + expect(r.bars).toEqual([]); + expect(r.unplotted[0]!.reason).toBe("no start date"); + expect(r.span).toBeNull(); + }); + + it("rejects an impossible month outright", () => { + const r = programmeBars({ + projects: [{ id: "a", name: "Month 13", activities: 1 }], + project_starts: { a: "2026-01-01" }, project_finishes: { a: "2026-13-01" }, + }); + expect(r.bars).toEqual([]); + expect(r.unplotted[0]!.reason).toBe("no finish date"); + }); + + // A bare `startsWith` matches "p10::A1" against a project "p1", flagging the wrong bar as linked + // and leaving the right one plain. Ordering matters: `p1` is found first by `find`. + it("does not let one project id prefix-match another", () => { + const r = programmeBars({ + projects: [{ id: "p1", name: "One", activities: 1 }, + { id: "p10", name: "Ten", activities: 1 }], + project_starts: { p1: "2026-01-01", p10: "2026-01-05" }, + project_finishes: { p1: "2026-01-31", p10: "2026-02-05" }, + external_links: [{ predecessor: "p10::A1", successor: "p10::B1" }], + }); + expect(r.bars.find((x) => x.id === "p10")!.linked).toBe(true); + expect(r.bars.find((x) => x.id === "p1")!.linked).toBe(false); + }); + + it("returns an empty, well-formed result when the run carried no dates at all", () => { + const r = programmeBars({ projects: [P("a"), P("b")] }); + expect(r.bars).toEqual([]); + expect(r.span).toBeNull(); + expect(r.unplotted).toHaveLength(2); + }); +}); diff --git a/apps/web/src/portal/panels/programmeGantt.ts b/apps/web/src/portal/panels/programmeGantt.ts new file mode 100644 index 00000000..9cd6cb44 --- /dev/null +++ b/apps/web/src/portal/panels/programmeGantt.ts @@ -0,0 +1,127 @@ +/** + * PROGRAMME-GANTT — the cross-project bar chart, R22-PIPELINE's last visualisation item. + * + * ## The roadmap said this needed a new engine. It did not. + * + * That entry lists "a cross-project Gantt (`schedule_viz.py` is per-project)" as genuinely missing. + * Half true: `schedule_viz` *is* per-project, but R46's portfolio scheduler already computes + * `project_starts` and `project_finishes` in its merged pass, and the route already puts them on the + * wire. What was missing is that **the client type named only three scalars** — + * `programme_finish`, `project_count`, `external_link_count`. `HttpCore.json` returns + * `res.json()` under an unchecked cast, so the dates were present in the parsed response the whole + * time; nothing *declared* them, so no call site could reach them and none did. The bars here are + * geometry over data the server was already sending. + * + * That matters for where the dates come from. These are the finishes from the ONE merged pass, not + * each project's standalone schedule: a project that looks comfortable alone can be critical to the + * programme, and a bar drawn from its own CPM run would show the comfortable answer. + * + * ## A bar needs both ends + * + * A project with a start and no finish (or the reverse) gets **no bar at all**, and is returned in + * `unplotted` with the reason. The alternative is to substitute the programme's own start or finish + * for the missing end, which draws a bar that looks measured and is not — the same defect the risk + * heat map refuses when it declines to render an unmeasured cell as a green zero. + */ + +const DAY_MS = 86_400_000; +/** Matches `SEPARATOR` in `services/api/src/massingplan/core/portfolio.py`. */ +const SEPARATOR = "::"; + +export type ProgrammeInput = { + projects: { id: string; name: string; activities: number }[]; + project_starts?: Record; + project_finishes?: Record; + crossing_activities?: string[]; + external_links?: { predecessor: string; successor: string }[]; +}; + +export type ProgrammeBar = { + id: string; name: string; activities: number; + start: string; finish: string; + /** Left edge and width as percentages of the programme span, ready for a CSS bar. */ + left: number; width: number; + days: number; + /** This project is named by at least one external link — its dates are a commitment. */ + linked: boolean; + /** Finishes on the programme's own finish date: it is what the whole span waits for. */ + driving: boolean; +}; + +export type ProgrammeBars = { + bars: ProgrammeBar[]; + unplotted: { id: string; name: string; reason: string }[]; + span: { start: string; finish: string; days: number } | null; +}; + +function day(s: string | undefined): Date | null { + if (!s) return null; + const ymd = String(s).slice(0, 10); + const d = new Date(`${ymd}T00:00:00Z`); + if (Number.isNaN(d.getTime())) return null; + // `Date` NORMALISES an out-of-range day instead of rejecting it: "2026-02-30" parses happily and + // becomes 2026-03-02. Only the month is range-checked ("2026-13-01" is NaN). Round-tripping is + // what catches the day, and it matters here more than usual — a normalised date is an invented + // one, and this module's whole rule is that it does not draw a bar it cannot measure. + return d.toISOString().slice(0, 10) === ymd ? d : null; +} + +/** + * Bar geometry for one programme run. Pure — no DOM, no fetch — so the rules above ("a bar needs + * both ends", "driving is measured against the programme finish") are unit-testable rather than + * only visible on screen. + */ +export function programmeBars(r: ProgrammeInput): ProgrammeBars { + const starts = r.project_starts ?? {}; + const finishes = r.project_finishes ?? {}; + const linked = new Set(); + for (const ln of r.external_links ?? []) { + // Link endpoints are ""; the project id is the part before the + // separator, and an id containing no separator is already the project. + for (const end of [ln.predecessor, ln.successor]) { + // The separator is REQUIRED, not decoration: a bare `startsWith` makes "p10::A1" match a + // project "p1", flagging the wrong bar as linked and leaving the right one plain. The key + // format is `::` (SEPARATOR in `massingplan/core/portfolio.py`). + const p = (r.projects ?? []).find( + (x) => end === x.id || String(end).startsWith(`${x.id}${SEPARATOR}`)); + if (p) linked.add(p.id); + } + } + + const rows: { p: ProgrammeInput["projects"][number]; s: Date; f: Date }[] = []; + const unplotted: ProgrammeBars["unplotted"] = []; + for (const p of r.projects ?? []) { + const s = day(starts[p.id]), f = day(finishes[p.id]); + if (!s && !f) { unplotted.push({ id: p.id, name: p.name, reason: "no scheduled dates" }); continue; } + if (!s || !f) { + // Deliberately NOT clamped to the programme span — see the header. + unplotted.push({ id: p.id, name: p.name, reason: s ? "no finish date" : "no start date" }); + continue; + } + if (f < s) { unplotted.push({ id: p.id, name: p.name, reason: "finish precedes start" }); continue; } + rows.push({ p, s, f }); + } + if (!rows.length) return { bars: [], unplotted, span: null }; + + const t0 = Math.min(...rows.map((x) => x.s.getTime())); + const t1 = Math.max(...rows.map((x) => x.f.getTime())); + // A single-day programme has zero span; dividing by it would give NaN widths, so every bar + // occupies the full track instead — which is what a one-day programme actually looks like. + const total = t1 - t0 || 1; + const bars = rows.map(({ p, s, f }) => ({ + id: p.id, name: p.name, activities: p.activities, + start: s.toISOString().slice(0, 10), finish: f.toISOString().slice(0, 10), + left: t1 === t0 ? 0 : ((s.getTime() - t0) / total) * 100, + width: t1 === t0 ? 100 : Math.max(((f.getTime() - s.getTime()) / total) * 100, 0.8), + days: Math.round((f.getTime() - s.getTime()) / DAY_MS) + 1, + linked: linked.has(p.id), + driving: f.getTime() === t1, + })); + bars.sort((a, b) => a.left - b.left || b.width - a.width || a.name.localeCompare(b.name)); + return { + bars, unplotted, + span: { start: new Date(t0).toISOString().slice(0, 10), + finish: new Date(t1).toISOString().slice(0, 10), + days: Math.round((t1 - t0) / DAY_MS) + 1 }, + }; +} diff --git a/apps/web/src/portal/panels/scheduleMethods.ts b/apps/web/src/portal/panels/scheduleMethods.ts index e0d62f47..6ec67b56 100644 --- a/apps/web/src/portal/panels/scheduleMethods.ts +++ b/apps/web/src/portal/panels/scheduleMethods.ts @@ -22,6 +22,7 @@ import { usd } from "../../ui/charts"; import { escapeHtml as esc } from "../../ui/feedback"; import type { PanelContext } from "../panelContext"; +import { programmeBars } from "./programmeGantt"; type Row = { label: string; value: string; hint?: string }; @@ -344,6 +345,58 @@ export function renderScheduleMethods(ctx: PanelContext): HTMLElement { t.textContent = r.projects.map((p) => `${p.name} (${p.activities})`).join(" \u00b7 ") + (r.rejected_links.length ? ` \u2014 ignored: ${r.rejected_links.join("; ")}` : ""); pfOut.appendChild(t); + + // CROSS-PROJECT GANTT — bars from the MERGED pass, not each project's standalone CPM. A project + // can look comfortable alone and be critical to the programme; a bar drawn from its own run + // would show the comfortable answer. `programmeBars` holds the geometry and the rule that a + // half-dated project gets no bar; this only paints what it returns. + const g = programmeBars(r); + if (g.span) { + const gw = document.createElement("div"); + gw.style.cssText = "margin-top:8px"; + const head = document.createElement("div"); + head.className = "meta"; + head.textContent = `Programme ${g.span.start} \u2192 ${g.span.finish} \u00b7 ${g.span.days} days`; + gw.appendChild(head); + for (const b of g.bars) { + const row = document.createElement("div"); + row.style.cssText = "display:flex;align-items:center;gap:8px;margin:3px 0"; + const label = document.createElement("div"); + label.style.cssText = "flex:0 0 150px;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"; + label.textContent = b.name; + label.title = `${b.name} \u00b7 ${b.start} \u2192 ${b.finish} \u00b7 ${b.days} days` + + `${b.linked ? " \u00b7 named by an external link" : ""}`; + const track = document.createElement("div"); + track.style.cssText = "flex:1;position:relative;height:16px;background:var(--panel2);border-radius:3px"; + const bar = document.createElement("div"); + // Driving = finishes on the programme finish, so the whole span waits on it. Linked = named + // by a cross-project commitment. Both are facts from the run, not a status guess. + const col = b.driving ? "var(--status-crit)" : b.linked ? "var(--accent)" : "var(--status-good)"; + bar.style.cssText = `position:absolute;left:${b.left}%;width:${b.width}%;top:2px;bottom:2px;` + + `background:${col};border-radius:2px`; + track.appendChild(bar); + const days = document.createElement("div"); + days.className = "meta"; + days.style.cssText = "flex:0 0 62px;text-align:right;font-variant-numeric:tabular-nums"; + days.textContent = `${b.days}d${b.driving ? " \u25c0" : ""}`; + row.append(label, track, days); + gw.appendChild(row); + } + const key = document.createElement("div"); + key.className = "meta"; key.style.marginTop = "4px"; + key.textContent = "\u25c0 drives the programme finish \u00b7 blue = named by an external link " + + "(a commitment between parties) \u00b7 bars come from the merged pass, not each project's " + + "own schedule."; + gw.appendChild(key); + pfOut.appendChild(gw); + } + if (g.unplotted.length) { + // Named, never silently dropped and never drawn with an invented end date. + const u = document.createElement("div"); + u.className = "meta"; u.style.cssText = "margin-top:4px;color:var(--status-warn)"; + u.textContent = "Not plotted \u2014 " + g.unplotted.map((x) => `${x.name} (${x.reason})`).join("; "); + pfOut.appendChild(u); + } })); pfc.appendChild(pfRow); pfc.appendChild(pfOut); wrap.appendChild(pfc); diff --git a/docs/roadmap.md b/docs/roadmap.md index 7c2243d6..c8b44b62 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1883,7 +1883,34 @@ stakes we are missing. a value. A lane added to `board` and not to `LANES` would otherwise render as a column that silently never lights up. - **Still open: cross-project Gantt and department resourcing.** The resourcing half is not the small + ✅ **Cross-project Gantt SHIPPED — and it needed no engine, which is the finding.** + This entry lists it as genuinely missing because `schedule_viz.py` is per-project. That is true and + it is not the whole picture: **R46's `schedule_portfolio.py` already computes `project_starts` and + `project_finishes` in its one merged pass**, and the route already returned them. What was missing + is that `apps/web/src/api/schedule.ts` **named only** `programme_finish`, `project_count` and + `external_link_count`. `HttpCore.json` returns `res.json()` under an unchecked cast, so the + dates were sitting in the parsed response the whole time — nothing *declared* them, so no call + site could reach them and none did. Same class as R37-TESTED-UNWIRED, one layer further out: not a + route without a caller, but a *payload* without a reader. + + *(The first draft of this entry said the dates were "dropped at the type boundary", which review + correctly called out as false: nothing filters them at runtime. Corrected here, because a + plausible-sounding mechanism is exactly the kind of wrong this file is supposed to resist.)* + + `apps/web/src/portal/panels/programmeGantt.ts` holds the geometry as a pure function + (`apps/web/src/portal/panels/programmeGantt.test.ts`, 7 cases), and the Programme card renders bars + from it. Bars come from the **merged** pass, never each project's standalone CPM — a project can + look comfortable alone and be critical to the programme, and its own run would show the comfortable + answer. That is asserted, not just documented: `services/api/test_programme_gantt.py` pins that the + FS link pushes fit-out past enabling's finish, and dropping the link fails it with that sentence. + + **A project with only one end gets no bar**, and is listed with the reason. Substituting the + programme's own start or finish for the missing end draws a bar that looks measured and is not — + the risk heat map's rule, arriving independently in a second place the same day. + + *Cost of the premise-check: one grep. Cost of believing the entry: a scheduling engine.* + + **Still open: department resourcing.** The resourcing half is not the small item this entry's phrasing suggests — `resource_loading.py` groups by **trade and resource type**, per project, so "by department" needs both a new dimension and a portfolio axis. Size it on its own. ## ⚡ R23 — ENGINEERING UPGRADE RING *(technical scan 2026-07-25; file:line evidence)* diff --git a/services/api/run_tests.py b/services/api/run_tests.py index f49dd21b..60247e1d 100644 --- a/services/api/run_tests.py +++ b/services/api/run_tests.py @@ -57,7 +57,7 @@ "test_ask", "test_viewer_load_timing", "test_verification", "test_webhooks", "test_operate_capital", "test_payroll_drawings", "test_assistant_itb", "test_construction_depth", "test_distribution", "test_e57", "test_empty_project", "test_metrics", "test_metrics_auth", "test_licensing", "test_revit_bridge", "test_precon", "test_specs", "test_feasibility", "test_clash_import", "test_clash_intel", "test_clash_reduction_scale", "test_layout", "test_loads", "test_verified_progress", "test_element_records", "test_securities_bridge", "test_imports", "test_search_alerts", "test_attachments", # previously not wired into the gate (glob would have caught these) — now covered: "test_analytics", "test_discipline", "test_gbxml", "test_review", "test_interop", - "test_module_config", "test_module_aggregate", "test_view_config", "test_view_sharing", "test_view_alerts_per_viewer", "test_markup_rekey", "test_view_crossmodule", "test_report_catalog", "test_env_documented", "test_module_schema", "test_field_attrs", "test_eticket_tm", "test_ref_backfill", "test_module_tables", "test_revision_comments", "test_module_filters", "test_guid_integrity", "test_pay_application", "test_module_fields", "test_throttle", "test_route_order", "test_mutating_get", "test_bootstrap_admin", "test_licence_allowlist", "test_money_parity", "test_money_spine", "test_plan_transform", "test_massingcapture_vendor", "test_xml_parse_hardening", "test_supply_chain_gate", "test_mspdi_xxe", "test_scenario_authz", "test_changelog_current", "test_release_current", "test_tested_but_unwired", "test_actions_pinned", "test_container_pr_gate", "test_spatial_tree", "test_output_encoding", "test_massingplan_vendor", "test_vendor_reachable", "test_schedule_health", "test_schedule_locations", "test_schedule_takt", "test_schedule_levelling", "test_schedule_progress", "test_schedule_risk_mc", "test_ppc_divergence", "test_ppc_field_conformance", "test_schedule_compare", "test_schedule_windows", "test_schedule_modelled", "test_schedule_p6xml", "test_schedule_earned", "test_schedule_compression", "test_schedule_portfolio", "test_portfolio_authz", "test_no_exception_relay", "test_vendor_drift", + "test_module_config", "test_module_aggregate", "test_view_config", "test_view_sharing", "test_view_alerts_per_viewer", "test_markup_rekey", "test_view_crossmodule", "test_report_catalog", "test_env_documented", "test_module_schema", "test_field_attrs", "test_eticket_tm", "test_ref_backfill", "test_module_tables", "test_revision_comments", "test_module_filters", "test_guid_integrity", "test_pay_application", "test_module_fields", "test_throttle", "test_route_order", "test_mutating_get", "test_bootstrap_admin", "test_licence_allowlist", "test_money_parity", "test_money_spine", "test_plan_transform", "test_massingcapture_vendor", "test_xml_parse_hardening", "test_supply_chain_gate", "test_mspdi_xxe", "test_scenario_authz", "test_changelog_current", "test_release_current", "test_tested_but_unwired", "test_actions_pinned", "test_container_pr_gate", "test_spatial_tree", "test_output_encoding", "test_massingplan_vendor", "test_vendor_reachable", "test_schedule_health", "test_schedule_locations", "test_schedule_takt", "test_schedule_levelling", "test_schedule_progress", "test_schedule_risk_mc", "test_ppc_divergence", "test_ppc_field_conformance", "test_schedule_compare", "test_schedule_windows", "test_schedule_modelled", "test_schedule_p6xml", "test_schedule_earned", "test_schedule_compression", "test_schedule_portfolio", "test_portfolio_authz", "test_programme_gantt", "test_no_exception_relay", "test_vendor_drift", # R23-PREFAB-KIT — the kit join + its register routes: "test_prefab_kit", "test_prefab_route", # Tier-1 competitive upgrades: diff --git a/services/api/test_programme_gantt.py b/services/api/test_programme_gantt.py new file mode 100644 index 00000000..c7d95516 --- /dev/null +++ b/services/api/test_programme_gantt.py @@ -0,0 +1,96 @@ +"""PROGRAMME-GANTT — the cross-project Gantt's data contract, asserted at the route. + +R22-PIPELINE recorded a cross-project Gantt as needing an engine. It did not: R46's portfolio +scheduler already computes per-project dates in its ONE merged pass, and this route already returns +them. What was missing is that the web client's type declared only `programme_finish`, +`project_count` and `external_link_count`, so the dates arrived in the browser and were dropped. + +Now that `apps/web/src/portal/panels/programmeGantt.ts` draws bars from `project_starts` / +`project_finishes`, those fields are a CONTRACT rather than an incidental extra. `test_route_authz` +gates who may call this route and `test_portfolio_authz` gates the body's project ids; neither looks +at the payload's shape, so nothing here would have failed if the merged pass stopped reporting +per-project dates — the Gantt would simply render empty, which looks like "no programme" rather than +like a break. + +Run: PYTHONPATH=src ./.venv/bin/python test_programme_gantt.py""" +import os + +os.environ["DATABASE_URL"] = "sqlite:///./test_programme_gantt.db" +os.environ["STORAGE_DIR"] = "./test_storage_programme_gantt" +os.environ["AEC_TRUST_XUSER"] = "1" +os.environ.pop("AEC_RBAC", None) +for _f in ("./test_programme_gantt.db",): + if os.path.exists(_f): + os.remove(_f) + +from datetime import date, timedelta # noqa: E402 + +from fastapi.testclient import TestClient # noqa: E402 + +from aec_api.main import app # noqa: E402 + +HDR = {"X-User": "pm"} +D0 = date(2026, 3, 2) + + +def _iso(n: int) -> str: + return (D0 + timedelta(days=n)).isoformat() + + +with TestClient(app) as c: + enabling = c.post("/projects", json={"name": "Enabling works"}, headers=HDR).json()["id"] + fitout = c.post("/projects", json={"name": "Fit-out"}, headers=HDR).json()["id"] + + act = {} + for key, pid, wbs, s0, dur in (("en", enabling, "1.1", _iso(0), 10), + ("fo", fitout, "2.1", _iso(2), 15)): + r = c.post(f"/projects/{pid}/modules/schedule_activity", json={"data": { + "name": f"Act {wbs}", "wbs": wbs, "duration": dur, + "start": s0, "finish": _iso(int(s0[-2:]) + dur)}}, headers=HDR) + assert r.status_code == 201, r.text[:200] + act[key] = r.json()["id"] + + # An external link names activities by the id the engine uses, which is the RECORD id — `wbs` + # and `ref` are aliases resolved only for a project's own predecessor tokens, so a link written + # in WBS terms is refused as "no such activity". Found by the refusal, not assumed. + body = {"project_ids": [fitout], "external": [{ + "predecessor_project": enabling, "predecessor_id": act["en"], + "successor_project": fitout, "successor_id": act["fo"], "type": "FS"}]} + r = c.post(f"/projects/{enabling}/schedule/portfolio", json=body, headers=HDR) + assert r.status_code == 200, r.text[:300] + p = r.json() + assert p["available"] is True, p.get("reason") + + # --- THE CONTRACT the Gantt draws from ------------------------------------------------------- + for field in ("project_starts", "project_finishes"): + assert field in p, f"{field} missing — the cross-project Gantt has nothing to draw" + assert isinstance(p[field], dict), (field, type(p[field])) + # keyed by PROJECT ID, which is what the bar rows join on + assert set(p[field]) == {enabling, fitout}, (field, sorted(p[field])) + for k, v in p[field].items(): + date.fromisoformat(v) # raises if not an ISO date + assert len(v) == 10, (k, v) + + # every project that got a bar has BOTH ends — the renderer refuses a half-dated one, so a + # payload that reports only one side would silently shrink the chart + assert set(p["project_starts"]) == set(p["project_finishes"]), "one-sided project dates" + for k in p["project_starts"]: + assert p["project_finishes"][k] >= p["project_starts"][k], (k, p["project_starts"][k]) + + # the merged pass, not two standalone runs: the FS link pushes fit-out past enabling's finish + assert p["project_starts"][fitout] >= p["project_finishes"][enabling], ( + "fit-out starts before enabling finishes — the external link was not honoured, so these " + "dates came from separate passes and the Gantt would show the comfortable answer") + assert p["programme_finish"] == max(p["project_finishes"].values()), ( + p["programme_finish"], p["project_finishes"]) + assert p["external_link_count"] == 1 and len(p["external_links"]) == 1, p["external_links"] + assert isinstance(p.get("crossing_activities"), list), p.get("crossing_activities") + + # --- the refusals still report the same keys, so the client can read them uniformly ---------- + solo = c.post(f"/projects/{enabling}/schedule/portfolio", json={"project_ids": []}, + headers=HDR).json() + assert solo["available"] is False and "one project" in solo["reason"], solo + # counts are None, never 0 — "nothing crosses a boundary" and "not scheduled" differ + assert solo["project_count"] is None and solo["external_link_count"] is None, solo + +print("programme gantt contract OK")