mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 03:31:40 -10:00
merge(0.3.1): slice 13.5 mission-progress-artifacts-heatmap — new MissionProgressHeatmap component on Mission Progress tab (slice × acceptance-item grid; DESIGN status-token color mapping)
--no-ff merge from slice-13-5-mission-progress-heatmap (6dbfb99; single commit; parent main 8d9d86f; safety tag temp/slice-13-5-pre-ff preserves prior tip 162babb pre-forward-fix). NEW packages/ui/src/components/project/MissionProgressHeatmap.tsx (237 lines): slice × acceptance-item grid renders BEFORE PROGRESS.md markdown + ScopeProgressRollup. Cells = filled (done) / outline (not done); done cells take slice status tone via heatmapTone + doneCellClass single-source-of-truth resolver. heatmapTone overrides "active" → info/sky so canonical SliceStatus values render distinct cell colors. Color palette (cellToneClass) cousins the existing toneClass in ProjectMetaPrimitives: emerald (active/done) / sky (progress) / amber (warning) / rose (blocked) / outline-variant (not done). No new color system; reuses canonical DESIGN status tokens via stateTone with denser fill for checkbox-feel. Slice × acceptance-item (not slice × phase) — driver chose this structure because acceptance items are durable per-slice PROGRESS.md content while phases require workflow_spec binding not every slice has. Variable density handled via flex-wrap on cells container. Empty-state graceful degradation: per-slice no-items "No acceptance items declared yet"; mission no-slices EmptyState "NO MISSION SLICES"; loading state "LOADING PROGRESS". Blocked-slice-with-done-cells edge case: tinted danger (rose) not false-success emerald — partial-but-blocked reads correctly. Tests: mission-progress-heatmap.test.tsx 10/10 (8 original + 2 forward-fix regressions for DOM order + legend↔cell color parity); project-workspace-overview.test.tsx 6/6 sanity; 4 pre-existing UI failures unchanged baseline. SC-29 EXCEPTION declared: NONE. guard verdict: CLEAR after forward-fix (redo3-guard-3 narrow re-review). design-reviewer verdict: SHIP — V1-bar professional-grade met. Slice × acceptance-item is V1-bar correct answer; color discipline uses canonical DESIGN tokens via stateTone+cellToneClass parallel palette; gestalt-shape differentiation from Artifacts tab (dense color-grid vs sparse 4-cell metric-card) accomplished.
This commit is contained in:
commit
2b5b86b105
3 files changed
+740
No files matched your search
@@ -0,0 +1,275 @@
|
||||
// V0.3.1 slice 13.5 mission-progress-artifacts-heatmap.
|
||||
//
|
||||
// Mission scope Progress tab differentiation: render a slice ×
|
||||
// acceptance-cell heat-map ABOVE the existing PROGRESS.md markdown
|
||||
// + per-slice rollup. Pre-slice 13.5, Mission Progress + Artifacts
|
||||
// tabs both used the same 4-cell metric-grid primitive shape and
|
||||
// looked too similar at-a-glance. The heat-map gives Progress its
|
||||
// own visual gestalt without touching Artifacts.
|
||||
//
|
||||
// Cell semantics: one row per slice; one cell per acceptance-item
|
||||
// in that slice's PROGRESS.md checklist. Filled (success token) =
|
||||
// done; outline (outline-variant) = not done. Leading column is
|
||||
// the slice display name + state pill; trailing column is the
|
||||
// (done/total) tally + percentage.
|
||||
//
|
||||
// Why slice × acceptance instead of slice × phase: acceptance items
|
||||
// are the durable progress unit the operator already authors per
|
||||
// slice in PROGRESS.md. Phases are workflow_spec-bound and only
|
||||
// some slices have them. Acceptance items work for every slice.
|
||||
//
|
||||
// All colors derive from existing DESIGN.md status tokens via the
|
||||
// `stateTone` + `cellToneClass` mappings; no new color system.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { EmptyState } from "../ui/empty-state.js";
|
||||
import { ProjectPill } from "./ProjectMetaPrimitives.js";
|
||||
import {
|
||||
type ProjectMetaTone,
|
||||
stateTone,
|
||||
} from "./ProjectMetaPrimitives.js";
|
||||
import type { SliceDetail } from "../../hooks/useSlices.js";
|
||||
import type { SliceListEntry } from "../../hooks/useSlices.js";
|
||||
|
||||
/** Maps a ProjectMetaTone to a single SOLID-fill cell class. Pairs
|
||||
* with the existing toneClass map in ProjectMetaPrimitives.tsx (which
|
||||
* is private to that module). Heat-map cells need a denser fill than
|
||||
* the muted-bg pill chrome — these classes lift the existing palette
|
||||
* to a "checked checkbox" feel without inventing new tokens. */
|
||||
const cellToneClass: Record<ProjectMetaTone, string> = {
|
||||
neutral: "border-stone-400 bg-stone-300",
|
||||
info: "border-sky-400 bg-sky-200",
|
||||
success: "border-emerald-500 bg-emerald-400",
|
||||
warning: "border-amber-400 bg-amber-300",
|
||||
danger: "border-rose-400 bg-rose-300",
|
||||
};
|
||||
|
||||
/** Heat-map-local tone resolution. Wraps the shared `stateTone` but
|
||||
* overrides for `SliceStatus` values whose canonical UI tone differs
|
||||
* from what `stateTone` infers from generic state strings.
|
||||
*
|
||||
* Specifically: `stateTone("active")` returns `"neutral"` (no keyword
|
||||
* match), which would make active slices' done cells fall through to
|
||||
* the `success` tone. That collapses the at-a-glance distinction
|
||||
* between an in-flight active slice's done items vs a shipped done
|
||||
* slice's done items. Override `"active"` → `"info"` so the heat-map
|
||||
* shows the canonical SliceStatus differentiation. Other status
|
||||
* strings flow through `stateTone` unchanged. */
|
||||
function heatmapTone(status: string | undefined): ProjectMetaTone {
|
||||
if (status === "active") return "info";
|
||||
return stateTone(status);
|
||||
}
|
||||
|
||||
/** Resolution rule used by both the heat-map cells AND the legend so
|
||||
* the legend swatch class is always EXACTLY the same string the cell
|
||||
* would render. Pure function of tone; no side effects. */
|
||||
function doneCellClass(tone: ProjectMetaTone): string {
|
||||
return cellToneClass[tone === "neutral" ? "success" : tone];
|
||||
}
|
||||
|
||||
interface HeatmapRow {
|
||||
/** Slice id (used for the drill-in link target). */
|
||||
name: string;
|
||||
/** Display name shown in the leading column. */
|
||||
displayName: string;
|
||||
/** Slice state ("active" | "done" | "blocked" | "draft"); drives the
|
||||
* state pill in the leading column + the implied tone of done cells
|
||||
* for blocked/danger slices (so a blocked slice's "done" cells read
|
||||
* warning-tinted rather than success-tinted). */
|
||||
status: string;
|
||||
/** Acceptance items in PROGRESS.md order. May be empty when the slice
|
||||
* hasn't authored a PROGRESS.md yet. */
|
||||
items: { text: string; done: boolean }[];
|
||||
/** Pre-computed totals shown in the trailing column. */
|
||||
doneItems: number;
|
||||
totalItems: number;
|
||||
/** Acceptance percentage (0-100), pre-computed by the daemon route. */
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export function MissionProgressHeatmap({
|
||||
rows,
|
||||
detailsByName,
|
||||
isLoading,
|
||||
}: {
|
||||
rows: SliceListEntry[];
|
||||
detailsByName: Map<string, SliceDetail>;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const heatmapRows: HeatmapRow[] = rows.map((row) => {
|
||||
const detail = detailsByName.get(row.name);
|
||||
const acceptance = detail?.acceptance;
|
||||
return {
|
||||
name: row.name,
|
||||
displayName: row.displayName,
|
||||
status: row.status,
|
||||
items: acceptance?.items ?? [],
|
||||
doneItems: acceptance?.doneItems ?? 0,
|
||||
totalItems: acceptance?.totalItems ?? 0,
|
||||
percentage: acceptance?.percentage ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
if (isLoading && heatmapRows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
label="LOADING PROGRESS"
|
||||
description="Reading mission slice acceptance."
|
||||
variant="card"
|
||||
testId="mission-progress-heatmap-loading"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (heatmapRows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
label="NO MISSION SLICES"
|
||||
description="Mission has no scoped slices to render a heat-map."
|
||||
variant="card"
|
||||
testId="mission-progress-heatmap-empty"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="mission-progress-heatmap"
|
||||
className="border border-outline-variant bg-white/35 p-4 backdrop-blur-sm"
|
||||
>
|
||||
<header className="mb-3 flex items-center justify-between gap-3 border-b border-outline-variant pb-2">
|
||||
<h3 className="font-mono text-[11px] uppercase tracking-[0.16em] text-stone-900">
|
||||
Acceptance heat-map
|
||||
</h3>
|
||||
<span className="font-mono text-[10px] text-stone-600">
|
||||
{heatmapRows.length} slice{heatmapRows.length === 1 ? "" : "s"} ·
|
||||
{" "}
|
||||
one cell per acceptance item
|
||||
</span>
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
{heatmapRows.map((row) => (
|
||||
<HeatmapSliceRow key={row.name} row={row} />
|
||||
))}
|
||||
</div>
|
||||
<HeatmapLegend />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatmapSliceRow({ row }: { row: HeatmapRow }) {
|
||||
const tone = heatmapTone(row.status);
|
||||
return (
|
||||
<article
|
||||
data-testid={`mission-progress-heatmap-row-${row.name}`}
|
||||
data-status={row.status}
|
||||
data-tone={tone}
|
||||
className="grid grid-cols-[minmax(8rem,16rem)_1fr_auto] items-center gap-3"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Link
|
||||
to="/project/slice/$sliceId"
|
||||
params={{ sliceId: row.name }}
|
||||
className="block truncate font-mono text-[11px] uppercase tracking-[0.12em] text-stone-900 hover:underline"
|
||||
title={row.displayName}
|
||||
aria-label={`${row.displayName} (${row.doneItems}/${row.totalItems} acceptance items)`}
|
||||
>
|
||||
{row.displayName}
|
||||
</Link>
|
||||
<ProjectPill token={{ label: row.status, tone }} compact />
|
||||
</div>
|
||||
<Cells row={row} />
|
||||
<div className="font-mono text-[10px] text-stone-700 tabular-nums whitespace-nowrap">
|
||||
{row.doneItems}/{row.totalItems || 0}
|
||||
{row.totalItems > 0 ? ` (${row.percentage}%)` : ""}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Cells({ row }: { row: HeatmapRow }) {
|
||||
if (row.items.length === 0) {
|
||||
return (
|
||||
<div
|
||||
data-testid={`mission-progress-heatmap-cells-${row.name}`}
|
||||
data-cell-state="empty"
|
||||
className="font-mono text-[10px] italic text-stone-500"
|
||||
>
|
||||
No acceptance items declared yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const tone = heatmapTone(row.status);
|
||||
// Done cells take the slice's status tone (info for active, success
|
||||
// for done, danger for blocked, etc.) so the heat-map reads both
|
||||
// per-cell + per-row at-a-glance. Not-done cells stay outline-only
|
||||
// so the eye finds incomplete work fast.
|
||||
const doneClass = doneCellClass(tone);
|
||||
return (
|
||||
<div
|
||||
data-testid={`mission-progress-heatmap-cells-${row.name}`}
|
||||
className="flex flex-wrap gap-[3px]"
|
||||
>
|
||||
{row.items.map((item, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
data-testid={`mission-progress-heatmap-cell-${row.name}-${idx}`}
|
||||
data-done={item.done ? "true" : "false"}
|
||||
aria-label={`${item.text} (${item.done ? "done" : "not done"})`}
|
||||
title={item.text}
|
||||
className={
|
||||
item.done
|
||||
? `h-4 w-4 border ${doneClass}`
|
||||
: "h-4 w-4 border border-outline-variant bg-white/35"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatmapLegend() {
|
||||
// Legend swatches MUST use the same class string the cells would
|
||||
// render for the given tone. doneCellClass() is the single source
|
||||
// of truth; the legend just feeds it the same tone the heat-map
|
||||
// resolves for each status keyword.
|
||||
return (
|
||||
<footer
|
||||
data-testid="mission-progress-heatmap-legend"
|
||||
className="mt-3 flex flex-wrap items-center gap-3 border-t border-outline-variant pt-2 font-mono text-[10px] text-stone-600"
|
||||
>
|
||||
<LegendCell label="done (active)" tone="info" testId="legend-active" />
|
||||
<LegendCell label="done (complete)" tone="success" testId="legend-complete" />
|
||||
<LegendCell label="done (warning)" tone="warning" testId="legend-warning" />
|
||||
<LegendCell label="done (blocked)" tone="danger" testId="legend-blocked" />
|
||||
<LegendCell label="not done" notDone testId="legend-not-done" />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendCell({
|
||||
label,
|
||||
tone,
|
||||
notDone,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
tone?: ProjectMetaTone;
|
||||
notDone?: boolean;
|
||||
testId: string;
|
||||
}): ReactNode {
|
||||
const swatchClass = notDone
|
||||
? "border-outline-variant bg-white/35"
|
||||
: doneCellClass(tone!);
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid={`mission-progress-heatmap-${testId}`}
|
||||
className={`inline-block h-3 w-3 border ${swatchClass}`}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import { DecisionsTab } from "../slices/tabs/DecisionsTab.js";
|
||||
import { TestsVerificationTab } from "../slices/tabs/TestsVerificationTab.js";
|
||||
import { TopologyTab } from "../slices/tabs/TopologyTab.js";
|
||||
import { HostMultiRigGraph } from "../topology/HostMultiRigGraph.js";
|
||||
import { MissionProgressHeatmap } from "./MissionProgressHeatmap.js";
|
||||
import { QueueItemTrigger } from "../drawer-triggers/QueueItemTrigger.js";
|
||||
import {
|
||||
DateChip,
|
||||
@@ -808,6 +809,11 @@ export function MissionScopePage() {
|
||||
) : null}
|
||||
{active === "progress" ? (
|
||||
<div data-testid="mission-progress-panel" className="space-y-6">
|
||||
<MissionProgressHeatmap
|
||||
rows={rollup.rows}
|
||||
detailsByName={rollup.details.itemsByName}
|
||||
isLoading={rollup.list.isLoading || rollup.details.isFetching}
|
||||
/>
|
||||
{missionProgress.content && (
|
||||
<section data-testid="mission-progress-readme" className="border border-outline-variant bg-white/20 p-4">
|
||||
<MarkdownViewer content={missionProgress.content} hideFrontmatter hideRawToggle />
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
// V0.3.1 slice 13.5 mission-progress-artifacts-heatmap.
|
||||
//
|
||||
// MissionProgressHeatmap is a per-slice acceptance-cell heat-map
|
||||
// rendered ABOVE the existing Progress tab content. Tests cover:
|
||||
//
|
||||
// T1 — component renders a grid shape with N slices × M acceptance
|
||||
// cells per row (HG-1)
|
||||
// T2 — cell colors map to slice state via stateTone (done cells
|
||||
// carry the slice's status tone; not-done cells outline-only)
|
||||
// (HG-2)
|
||||
// T3 — legend renders the canonical state -> color mapping (HG-2)
|
||||
// T4 — MissionScopePage Progress tab composes markdown + heat-map +
|
||||
// per-slice rollup; Artifacts tab unchanged (no heat-map mount)
|
||||
// (HG-3 + HG-4)
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
} from "@tanstack/react-router";
|
||||
import { MissionProgressHeatmap } from "../src/components/project/MissionProgressHeatmap.js";
|
||||
import { MissionScopePage } from "../src/components/project/ScopePages.js";
|
||||
import type { SliceDetail, SliceListEntry } from "../src/hooks/useSlices.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function makeRow(name: string, status: SliceListEntry["status"]): SliceListEntry {
|
||||
return {
|
||||
name,
|
||||
displayName: name,
|
||||
railItem: "RELEASE-PROOF",
|
||||
status,
|
||||
rawStatus: status,
|
||||
qitemCount: 0,
|
||||
hasProofPacket: false,
|
||||
lastActivityAt: "2026-05-11T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetail(
|
||||
name: string,
|
||||
items: { text: string; done: boolean }[],
|
||||
): SliceDetail {
|
||||
const doneItems = items.filter((it) => it.done).length;
|
||||
const totalItems = items.length;
|
||||
const percentage = totalItems === 0 ? 0 : Math.round((doneItems / totalItems) * 100);
|
||||
return {
|
||||
name,
|
||||
missionId: "RELEASE-PROOF",
|
||||
slicePath: `/workspace/${name}`,
|
||||
displayName: name,
|
||||
railItem: "RELEASE-PROOF",
|
||||
status: "active",
|
||||
rawStatus: "active",
|
||||
qitemIds: [],
|
||||
commitRefs: [],
|
||||
lastActivityAt: "2026-05-11T00:00:00.000Z",
|
||||
workflowBinding: null,
|
||||
story: { events: [], phaseDefinitions: null },
|
||||
acceptance: {
|
||||
totalItems,
|
||||
doneItems,
|
||||
percentage,
|
||||
items: items.map((it) => ({
|
||||
text: it.text,
|
||||
done: it.done,
|
||||
source: { file: "PROGRESS.md", line: 1 },
|
||||
})),
|
||||
closureCallout: null,
|
||||
currentStep: null,
|
||||
},
|
||||
decisions: { rows: [] },
|
||||
docs: { tree: [] },
|
||||
tests: {
|
||||
proofPackets: [],
|
||||
aggregate: { passCount: 0, failCount: 0 },
|
||||
},
|
||||
topology: { affectedRigs: [], totalSeats: 0, specGraph: null },
|
||||
};
|
||||
}
|
||||
|
||||
function withRouter(ui: React.ReactNode) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> });
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
component: () => <>{ui}</>,
|
||||
});
|
||||
const sliceRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/project/slice/$sliceId",
|
||||
component: () => null,
|
||||
});
|
||||
const fallback = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "$",
|
||||
component: () => null,
|
||||
});
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([indexRoute, sliceRoute, fallback]),
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MissionProgressHeatmap (slice 13.5)", () => {
|
||||
it("HG-1: renders N rows × M acceptance cells in a grid shape", async () => {
|
||||
const rows = [makeRow("alpha", "active"), makeRow("beta", "done")];
|
||||
const detailsByName = new Map<string, SliceDetail>([
|
||||
[
|
||||
"alpha",
|
||||
makeDetail("alpha", [
|
||||
{ text: "a1", done: true },
|
||||
{ text: "a2", done: true },
|
||||
{ text: "a3", done: false },
|
||||
]),
|
||||
],
|
||||
[
|
||||
"beta",
|
||||
makeDetail("beta", [
|
||||
{ text: "b1", done: true },
|
||||
{ text: "b2", done: true },
|
||||
]),
|
||||
],
|
||||
]);
|
||||
const { findByTestId, queryAllByTestId } = withRouter(
|
||||
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
|
||||
);
|
||||
expect(await findByTestId("mission-progress-heatmap")).toBeTruthy();
|
||||
expect(await findByTestId("mission-progress-heatmap-row-alpha")).toBeTruthy();
|
||||
expect(await findByTestId("mission-progress-heatmap-row-beta")).toBeTruthy();
|
||||
|
||||
// Each row's cells container holds the per-acceptance-item span.
|
||||
const alphaCells = queryAllByTestId(/^mission-progress-heatmap-cell-alpha-\d+$/);
|
||||
expect(alphaCells.length).toBe(3);
|
||||
const betaCells = queryAllByTestId(/^mission-progress-heatmap-cell-beta-\d+$/);
|
||||
expect(betaCells.length).toBe(2);
|
||||
});
|
||||
|
||||
it("HG-2: done cells take the slice's status tone; not-done cells render outline-only", async () => {
|
||||
const rows = [makeRow("alpha", "active")];
|
||||
const detailsByName = new Map<string, SliceDetail>([
|
||||
[
|
||||
"alpha",
|
||||
makeDetail("alpha", [
|
||||
{ text: "a1", done: true },
|
||||
{ text: "a2", done: false },
|
||||
]),
|
||||
],
|
||||
]);
|
||||
const { findByTestId } = withRouter(
|
||||
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
|
||||
);
|
||||
const row = await findByTestId("mission-progress-heatmap-row-alpha");
|
||||
expect(row.getAttribute("data-status")).toBe("active");
|
||||
// The heat-map's local heatmapTone() overrides stateTone for the
|
||||
// canonical SliceStatus value "active" -> "info", so done cells on
|
||||
// an active slice are visually distinct from done cells on a
|
||||
// shipped done slice. This matches the legend.
|
||||
expect(row.getAttribute("data-tone")).toBe("info");
|
||||
|
||||
const doneCell = await findByTestId("mission-progress-heatmap-cell-alpha-0");
|
||||
const notDoneCell = await findByTestId("mission-progress-heatmap-cell-alpha-1");
|
||||
expect(doneCell.getAttribute("data-done")).toBe("true");
|
||||
expect(notDoneCell.getAttribute("data-done")).toBe("false");
|
||||
// Active slice done cells render in the info tone (sky); not-done
|
||||
// cells stay outline-only.
|
||||
expect(doneCell.className).toMatch(/bg-sky-200/);
|
||||
expect(notDoneCell.className).toMatch(/border-outline-variant/);
|
||||
expect(notDoneCell.className).not.toMatch(/bg-sky-200/);
|
||||
});
|
||||
|
||||
it("HG-2 (blocked tone): blocked-status slice rows mark done cells with the danger tone", async () => {
|
||||
const rows = [makeRow("blockedSlice", "blocked")];
|
||||
const detailsByName = new Map<string, SliceDetail>([
|
||||
[
|
||||
"blockedSlice",
|
||||
makeDetail("blockedSlice", [
|
||||
{ text: "x1", done: true },
|
||||
{ text: "x2", done: false },
|
||||
]),
|
||||
],
|
||||
]);
|
||||
const { findByTestId } = withRouter(
|
||||
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
|
||||
);
|
||||
const row = await findByTestId("mission-progress-heatmap-row-blockedSlice");
|
||||
expect(row.getAttribute("data-tone")).toBe("danger");
|
||||
const doneCell = await findByTestId("mission-progress-heatmap-cell-blockedSlice-0");
|
||||
expect(doneCell.className).toMatch(/bg-rose-300/);
|
||||
});
|
||||
|
||||
it("HG-2 (legend): legend renders all five state -> color samples", async () => {
|
||||
const rows = [makeRow("alpha", "active")];
|
||||
const detailsByName = new Map<string, SliceDetail>([
|
||||
["alpha", makeDetail("alpha", [{ text: "a1", done: true }])],
|
||||
]);
|
||||
const { findByTestId } = withRouter(
|
||||
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
|
||||
);
|
||||
const legend = await findByTestId("mission-progress-heatmap-legend");
|
||||
expect(legend.textContent).toContain("done (active)");
|
||||
expect(legend.textContent).toContain("done (complete)");
|
||||
expect(legend.textContent).toContain("done (warning)");
|
||||
expect(legend.textContent).toContain("done (blocked)");
|
||||
expect(legend.textContent).toContain("not done");
|
||||
});
|
||||
|
||||
// Forward-fix-1 coverage gap closed: legend swatch className must
|
||||
// match the className of an actual heat-map cell rendered for a
|
||||
// slice of that status. The legend says "done (active)" labels the
|
||||
// info-toned swatch; an active-status slice's done cell must use the
|
||||
// same className. Previously, legend hard-coded cellToneClass.info
|
||||
// but active-status rows fell through neutral->success (emerald) for
|
||||
// their done cells, leaving the legend out of sync with reality.
|
||||
it("HG-2 (legend ↔ cell parity): legend swatch className equals the actual cell className for each canonical SliceStatus", async () => {
|
||||
const rows = [
|
||||
makeRow("activeSlice", "active"),
|
||||
makeRow("doneSlice", "done"),
|
||||
makeRow("blockedSlice", "blocked"),
|
||||
];
|
||||
const detailsByName = new Map<string, SliceDetail>([
|
||||
["activeSlice", makeDetail("activeSlice", [{ text: "a1", done: true }])],
|
||||
["doneSlice", makeDetail("doneSlice", [{ text: "b1", done: true }])],
|
||||
["blockedSlice", makeDetail("blockedSlice", [{ text: "c1", done: true }])],
|
||||
]);
|
||||
const { findByTestId } = withRouter(
|
||||
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
|
||||
);
|
||||
|
||||
// active -> legend-active swatch should carry the SAME color
|
||||
// classes (bg + border) the actual active-row done cell renders.
|
||||
const activeCell = await findByTestId("mission-progress-heatmap-cell-activeSlice-0");
|
||||
const activeLegend = await findByTestId("mission-progress-heatmap-legend-active");
|
||||
expect(activeLegend.className).toMatch(/bg-sky-200/);
|
||||
expect(activeCell.className).toMatch(/bg-sky-200/);
|
||||
|
||||
const doneCell = await findByTestId("mission-progress-heatmap-cell-doneSlice-0");
|
||||
const completeLegend = await findByTestId("mission-progress-heatmap-legend-complete");
|
||||
expect(completeLegend.className).toMatch(/bg-emerald-400/);
|
||||
expect(doneCell.className).toMatch(/bg-emerald-400/);
|
||||
|
||||
const blockedCell = await findByTestId("mission-progress-heatmap-cell-blockedSlice-0");
|
||||
const blockedLegend = await findByTestId("mission-progress-heatmap-legend-blocked");
|
||||
expect(blockedLegend.className).toMatch(/bg-rose-300/);
|
||||
expect(blockedCell.className).toMatch(/bg-rose-300/);
|
||||
|
||||
// warning tone has no canonical SliceStatus mapping but the legend
|
||||
// still publishes the swatch shape. Asserting the legend swatch
|
||||
// for warning gives operators a forward-compatible reference for
|
||||
// any future status string that resolves to the warning tone.
|
||||
const warningLegend = await findByTestId("mission-progress-heatmap-legend-warning");
|
||||
expect(warningLegend.className).toMatch(/bg-amber-300/);
|
||||
});
|
||||
|
||||
it("renders an empty-state when the mission has no scoped slices", async () => {
|
||||
const { findByTestId } = withRouter(
|
||||
<MissionProgressHeatmap rows={[]} detailsByName={new Map()} />,
|
||||
);
|
||||
expect(await findByTestId("mission-progress-heatmap-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the tally column with done/total + percentage when acceptance items exist", async () => {
|
||||
const rows = [makeRow("alpha", "active")];
|
||||
const detailsByName = new Map<string, SliceDetail>([
|
||||
[
|
||||
"alpha",
|
||||
makeDetail("alpha", [
|
||||
{ text: "a1", done: true },
|
||||
{ text: "a2", done: true },
|
||||
{ text: "a3", done: false },
|
||||
]),
|
||||
],
|
||||
]);
|
||||
const { findByTestId } = withRouter(
|
||||
<MissionProgressHeatmap rows={rows} detailsByName={detailsByName} />,
|
||||
);
|
||||
const row = await findByTestId("mission-progress-heatmap-row-alpha");
|
||||
expect(row.textContent).toContain("2/3");
|
||||
expect(row.textContent).toContain("67%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MissionScopePage Progress tab composes heat-map (slice 13.5)", () => {
|
||||
function installMissionFetchMock() {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/api/config")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
settings: { "workspace.root": { value: "/Users/admin/.openrig/workspace" } },
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/missions/RELEASE-PROOF") && !url.includes("/api/missions/RELEASE-PROOF/")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
missionId: "RELEASE-PROOF",
|
||||
missionPath: "/workspace/missions/release-proof",
|
||||
slices: [
|
||||
{
|
||||
name: "alpha",
|
||||
displayName: "alpha",
|
||||
railItem: "RELEASE-PROOF",
|
||||
status: "active",
|
||||
rawStatus: "active",
|
||||
qitemCount: 0,
|
||||
hasProofPacket: false,
|
||||
lastActivityAt: "2026-05-11T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
topology: { specGraph: null },
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/slices/alpha")) {
|
||||
return new Response(JSON.stringify(makeDetail("alpha", [{ text: "a1", done: true }])), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/slices")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
slices: [
|
||||
{
|
||||
name: "alpha",
|
||||
displayName: "alpha",
|
||||
railItem: "RELEASE-PROOF",
|
||||
status: "active",
|
||||
rawStatus: "active",
|
||||
qitemCount: 0,
|
||||
hasProofPacket: false,
|
||||
lastActivityAt: "2026-05-11T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
filter: "all",
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/scope-markdown") || url.includes("PROGRESS.md") || url.includes("README.md")) {
|
||||
return new Response(JSON.stringify({ content: "# Mission progress goes here\n" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
return new Response("[]", { status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
function renderMissionScope(): ReturnType<typeof render> {
|
||||
installMissionFetchMock();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> });
|
||||
const missionRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/project/mission/$missionId",
|
||||
component: () => <MissionScopePage />,
|
||||
});
|
||||
const sliceRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/project/slice/$sliceId",
|
||||
component: () => null,
|
||||
});
|
||||
const fallback = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "$",
|
||||
component: () => null,
|
||||
});
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([missionRoute, sliceRoute, fallback]),
|
||||
history: createMemoryHistory({ initialEntries: ["/project/mission/RELEASE-PROOF"] }),
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("HG-3: Mission Progress tab includes heat-map ALONGSIDE existing rollup", async () => {
|
||||
const { findByTestId } = renderMissionScope();
|
||||
// Sanity: mission overview mounts.
|
||||
expect(await findByTestId("mission-overview-panel")).toBeTruthy();
|
||||
|
||||
// Switch to Progress tab.
|
||||
fireEvent.click(await findByTestId("project-tab-progress"));
|
||||
|
||||
// Heat-map renders.
|
||||
expect(await findByTestId("mission-progress-heatmap")).toBeTruthy();
|
||||
// Per-slice rollup still present (composition, not replacement).
|
||||
expect(await findByTestId("scope-progress-rollup")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Forward-fix-1: heat-map must render BEFORE the PROGRESS.md
|
||||
// markdown section so the new visual gestalt is the first thing on
|
||||
// the Progress tab. Asserting DOM order via Node.compareDocument-
|
||||
// Position protects against silent reordering that the old
|
||||
// "both render" assertion (HG-3 above) would not catch.
|
||||
it("HG-3 (DOM order): heat-map renders BEFORE the PROGRESS.md markdown section", async () => {
|
||||
const { findByTestId } = renderMissionScope();
|
||||
expect(await findByTestId("mission-overview-panel")).toBeTruthy();
|
||||
fireEvent.click(await findByTestId("project-tab-progress"));
|
||||
|
||||
const heatmap = await findByTestId("mission-progress-heatmap");
|
||||
const panel = await findByTestId("mission-progress-panel");
|
||||
|
||||
// The markdown section only renders when missionProgress.content
|
||||
// is non-empty; the integration mock returns "# Mission progress
|
||||
// goes here" so it should mount. If it doesn't, this assertion
|
||||
// skips (no order to compare).
|
||||
const readme = panel.querySelector(
|
||||
"[data-testid='mission-progress-readme']",
|
||||
);
|
||||
if (readme) {
|
||||
// DOCUMENT_POSITION_FOLLOWING (4) means readme comes AFTER
|
||||
// heatmap in DOM order — i.e., heatmap renders first.
|
||||
expect(heatmap.compareDocumentPosition(readme)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
}
|
||||
|
||||
// Heat-map must also precede the rollup; this is the structural
|
||||
// claim of the slice and the legend depends on it visually.
|
||||
const rollup = await findByTestId("scope-progress-rollup");
|
||||
expect(heatmap.compareDocumentPosition(rollup)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
});
|
||||
|
||||
it("HG-4: Mission Artifacts tab does NOT mount the heat-map", async () => {
|
||||
const { findByTestId, queryByTestId } = renderMissionScope();
|
||||
expect(await findByTestId("mission-overview-panel")).toBeTruthy();
|
||||
|
||||
fireEvent.click(await findByTestId("project-tab-artifacts"));
|
||||
expect(queryByTestId("mission-progress-heatmap")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user