mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 03:31:40 -10:00
feat(topology): seat-overview info table 9-field + 2-row-shape
V0.3.1 slice 25 scope amendment (delta to attempt-1 at f48ea03).
Adds two info-table fields + a row-shape convention without
restructuring the slice's broader Overview/Details architecture.
Two row-shape conventions in one cohesive table:
- COMPACT (2-col key/value) — 7 rows: runtime / model / profile /
spec / activity / context% / total tokens. Same shape as
attempt-1.
- FULL-WIDTH (single cell with inline label) — 2 rows: cwd / current
work. New shape; cwd was a compact row in attempt-1.
New "activity" compact row (HG-3a):
- Reads from data.agentActivity via getActivityState(); same source
LiveNodeCurrentState uses on the same page, same baseline the
topology graph/table read. No parallel source of truth.
- State "running" maps to the label "active" so the seat page agrees
with the topology graph/table naming convention.
- HG-3c shimmer reuse: applies the slice-14
.topology-table-active-shimmer CSS class on the value span when
state === "running". The class honors prefers-reduced-motion. Slice
14's CSS module is imported into SeatOverviewTable directly; no
copy.
New "current work" full-width row (HG-3b):
- Reads from data.currentQitems[0] — already exposed by NodeDetailData
via /api/rigs/<rigId>/nodes/<logicalId>; same source
LiveNodeCurrentState's Current Work pane uses. No new daemon
endpoint; no parallel fetch.
- Renders qitem id + body excerpt when present; em-dash placeholder
when no in-progress qitem.
- Real-time refresh: react-query's existing useNodeDetail refetch
cadence on this page propagates qitem changes; no additional SSE
wiring needed at this layer.
CWD becomes full-width (HG-3d):
- Was a compact 2-col row in attempt-1 with the path crushed into the
value cell. Now a full-width row with truncate-ellipsis on the
value + title attribute carrying the full path for tooltip-on-hover
inspection of long paths.
Tests added (extend live-node-details.test.tsx):
- HG-3: shape now asserts 9 fields total with explicit data-row-shape
(compact for 7, full-width for cwd + current-work).
- HG-3a: activity row shows "active" + shimmer class when
agentActivity.state="running".
- HG-3a (idle): activity row shows "idle" without shimmer when
state="idle".
- HG-3b: current-work row surfaces qitem id + body excerpt when
in-progress.
- HG-3b (empty): current-work renders em-dash when currentQitems[]
is empty.
- HG-3d: cwd row is data-row-shape="full-width" + title attribute set
+ value cell carries truncate class.
Forensic-trace findings (per scope-amendment dispatch):
- Activity source: NodeDetailData.agentActivity, read via
getActivityState() in lib/activity-visuals.ts. Same source the
existing LiveNodeCurrentState pane uses. The topology graph + table
use the same baseline fused with stream events via
useTopologyActivity; the seat detail page reads the baseline only,
which matches LiveNodeCurrentState's existing behavior on this
surface (no new subscription wiring required for slice 25 scope).
- Current-work source: NodeDetailData.currentQitems[], already shaped
by the /api/rigs/<rigId>/nodes/<logicalId> endpoint. LiveNodeCurrentState
already reads it. Reusing in-place.
- Shimmer source: packages/ui/src/components/topology/topology-table-shimmer.css
declares .topology-table-active-shimmer + the prefers-reduced-motion
override. Imported directly into SeatOverviewTable for class reuse.
Verification:
- live-node-details.test.tsx: 21/21 PASS (was 16; +5 amendment tests).
- Full UI suite: 1000 passed / 1 skipped / 4 failed (same 4
pre-existing failures as main; zero new failures from this slice).
Hard gates (revised):
- HG-3 9 fields total: PASS.
- HG-3a activity wired live: PASS.
- HG-3b current-work wired live: PASS.
- HG-3c shimmer on active: PASS (slice-14 class reused).
- HG-3d cwd full-width with truncate: PASS.
SC-29 EXCEPTION declared: NONE.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
f48ea03040
commit
46b5132e62
2 files changed
+260
-51
No files matched your search
@@ -1,22 +1,43 @@
|
||||
// V0.3.1 slice 25 node-page Overview/Details consolidation.
|
||||
//
|
||||
// Dense 7-field info table at the top of the seat-detail Overview tab.
|
||||
// Renders the most-asked questions about a seat at a single glance:
|
||||
// runtime, model, profile, spec (name + version), cwd, context%, and
|
||||
// total tokens. Density anchor: TopologyTableView (per ui.md §Topology)
|
||||
// — same compact mono cells, 1px outline-variant cell borders, vellum
|
||||
// Dense 9-row info table at the top of the seat-detail Overview tab.
|
||||
// Renders the most-asked questions about a seat at a single glance.
|
||||
// Two row-shape conventions in one visually-cohesive table:
|
||||
//
|
||||
// COMPACT (2-col key/value) — 7 rows:
|
||||
// runtime / model / profile / spec / activity / context% / total tokens
|
||||
//
|
||||
// FULL-WIDTH (single cell with inline label) — 2 rows:
|
||||
// cwd / current work
|
||||
//
|
||||
// Density anchor: TopologyTableView (per ui.md §Topology) — same
|
||||
// compact mono cells, 1px outline-variant cell borders, vellum
|
||||
// surface chrome.
|
||||
//
|
||||
// Reuses the shared token / runtime helpers so the values rendered
|
||||
// here are the SAME values rendered in topology graph + table +
|
||||
// terminal views (slice 25 HG-3 — single source of truth across
|
||||
// surfaces).
|
||||
// Data sources (single source of truth across surfaces):
|
||||
// - runtime / model / profile / spec / cwd — NodeDetailData directly
|
||||
// - activity — getActivityState(data.agentActivity); same helper
|
||||
// LiveNodeCurrentState uses, same source the topology table
|
||||
// baseline reads. State "running" maps to label "active" so the
|
||||
// seat page agrees with the topology graph/table naming.
|
||||
// - context% / total tokens — data.contextUsage.usedPercentage +
|
||||
// sumTokenCounts(input, output); same helpers TopologyTableView
|
||||
// uses for the topology table.
|
||||
// - current work — first entry of data.currentQitems[]; NodeDetailData
|
||||
// surfaces this via the /api/rigs/<rigId>/nodes/<logicalId>
|
||||
// endpoint. The qitem refreshes when react-query refetches the
|
||||
// useNodeDetail query (default refetch interval).
|
||||
//
|
||||
// Graceful absence rule: when a field is null/undefined/empty the
|
||||
// cell renders an em-dash placeholder. Model in particular is
|
||||
// commonly absent (not every RigSpec member declares one); the row
|
||||
// stays visible with a dash rather than rendering "undefined" or
|
||||
// being hidden — HG-4 from the slice 25 spec.
|
||||
// Shimmer (slice 25 HG-3c): when activity state is "running" the
|
||||
// activity value picks up the slice-14 .topology-table-active-shimmer
|
||||
// CSS class, giving the same subtle left-to-right sweep TopologyTableView
|
||||
// uses on active-status text. The class honors prefers-reduced-motion
|
||||
// (suppressed under reduce per DESIGN.md §Motion).
|
||||
//
|
||||
// Graceful absence: any null/undefined/empty cell renders an em-dash
|
||||
// placeholder. Model + current-work in particular are commonly absent;
|
||||
// the row stays visible with a dash rather than being hidden or
|
||||
// rendering "undefined" — HG-4 from the slice 25 spec.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { NodeDetailData } from "../hooks/useNodeDetail.js";
|
||||
@@ -25,22 +46,38 @@ import {
|
||||
formatCompactTokenCount,
|
||||
sumTokenCounts,
|
||||
} from "../lib/token-format.js";
|
||||
import {
|
||||
getActivityLabel,
|
||||
getActivityState,
|
||||
type ActivityState,
|
||||
} from "../lib/activity-visuals.js";
|
||||
// Slice 14 shimmer CSS — reused on the activity value when state is
|
||||
// "running" so the seat-page activity reads with the same visual
|
||||
// vocabulary as the topology table.
|
||||
import "./topology/topology-table-shimmer.css";
|
||||
|
||||
interface SeatOverviewTableProps {
|
||||
data: NodeDetailData;
|
||||
}
|
||||
|
||||
type RowShape = "compact" | "full-width";
|
||||
|
||||
interface Row {
|
||||
/** Stable kebab-case key used as testid suffix + render key. */
|
||||
key: string;
|
||||
/** Display label in the left column. Lowercase mono. */
|
||||
/** Display label. Lowercase mono. */
|
||||
label: string;
|
||||
/** Cell content. May be string OR a ReactNode (e.g., RuntimeBadge).
|
||||
* null/undefined/empty-string renders as "—" placeholder. */
|
||||
value: ReactNode | null | undefined;
|
||||
shape: RowShape;
|
||||
/** Use the body-font (sans) for human-readable values; mono for IDs
|
||||
* + numeric metrics. Per DESIGN.md §Typography. */
|
||||
* + numeric metrics. Per DESIGN.md §Typography. Compact rows
|
||||
* default to mono; full-width rows default to mono too. */
|
||||
mono?: boolean;
|
||||
/** When present, the truncated value cell gets `title={titleAttr}`
|
||||
* so hovering shows the full string. Used for cwd. */
|
||||
titleAttr?: string;
|
||||
}
|
||||
|
||||
function placeholderOrValue(value: ReactNode | null | undefined): ReactNode {
|
||||
@@ -49,7 +86,20 @@ function placeholderOrValue(value: ReactNode | null | undefined): ReactNode {
|
||||
return value;
|
||||
}
|
||||
|
||||
function activityLabelFromState(state: ActivityState): string {
|
||||
// The seat page uses the topology-naming convention: "running" reads
|
||||
// as "active" so the cell text agrees with the topology graph/table
|
||||
// (where the "active" label is the one that catches the eye + carries
|
||||
// the shimmer). Other states pass through the canonical label.
|
||||
if (state === "running") return "active";
|
||||
return getActivityLabel(state);
|
||||
}
|
||||
|
||||
export function SeatOverviewTable({ data }: SeatOverviewTableProps) {
|
||||
const activityState = getActivityState(data.agentActivity);
|
||||
const activityLabel = activityLabelFromState(activityState);
|
||||
const activityIsActive = activityState === "running";
|
||||
|
||||
const contextPercentage =
|
||||
data.contextUsage?.availability === "known" &&
|
||||
typeof data.contextUsage.usedPercentage === "number"
|
||||
@@ -65,10 +115,39 @@ export function SeatOverviewTable({ data }: SeatOverviewTableProps) {
|
||||
? `${data.resolvedSpecName}@${data.resolvedSpecVersion}`
|
||||
: data.resolvedSpecName ?? null;
|
||||
|
||||
const currentQitem = data.currentQitems?.[0] ?? null;
|
||||
const currentWorkValue: ReactNode | null = currentQitem ? (
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="shrink-0 font-mono text-[10px] text-stone-500">
|
||||
{currentQitem.qitemId}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[11px] text-stone-900">
|
||||
{currentQitem.bodyExcerpt}
|
||||
</span>
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const activityValue: ReactNode = (
|
||||
<span className="inline-flex items-baseline gap-2">
|
||||
<span
|
||||
data-testid="seat-overview-activity-state"
|
||||
data-activity-state={activityState}
|
||||
className={
|
||||
activityIsActive
|
||||
? "topology-table-active-shimmer text-emerald-600"
|
||||
: "text-stone-700"
|
||||
}
|
||||
>
|
||||
{activityLabel}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const rows: Row[] = [
|
||||
{
|
||||
key: "runtime",
|
||||
label: "runtime",
|
||||
shape: "compact",
|
||||
value: data.runtime ? (
|
||||
<RuntimeBadge
|
||||
runtime={data.runtime}
|
||||
@@ -80,22 +159,38 @@ export function SeatOverviewTable({ data }: SeatOverviewTableProps) {
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
{ key: "model", label: "model", value: data.model, mono: true },
|
||||
{ key: "profile", label: "profile", value: data.profile, mono: true },
|
||||
{ key: "spec", label: "spec", value: specCell, mono: true },
|
||||
{ key: "cwd", label: "cwd", value: data.cwd, mono: true },
|
||||
{ key: "model", label: "model", shape: "compact", value: data.model, mono: true },
|
||||
{ key: "profile", label: "profile", shape: "compact", value: data.profile, mono: true },
|
||||
{ key: "spec", label: "spec", shape: "compact", value: specCell, mono: true },
|
||||
{ key: "activity", label: "activity", shape: "compact", value: activityValue },
|
||||
{
|
||||
key: "context-percent",
|
||||
label: "context %",
|
||||
shape: "compact",
|
||||
value: contextPercentage,
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: "total-tokens",
|
||||
label: "total tokens",
|
||||
shape: "compact",
|
||||
value: tokenLabel,
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
key: "cwd",
|
||||
label: "cwd",
|
||||
shape: "full-width",
|
||||
value: data.cwd,
|
||||
mono: true,
|
||||
titleAttr: data.cwd ?? undefined,
|
||||
},
|
||||
{
|
||||
key: "current-work",
|
||||
label: "current work",
|
||||
shape: "full-width",
|
||||
value: currentWorkValue,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -108,30 +203,74 @@ export function SeatOverviewTable({ data }: SeatOverviewTableProps) {
|
||||
</div>
|
||||
<table className="w-full text-left">
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.key}
|
||||
data-testid={`seat-overview-row-${row.key}`}
|
||||
className="border-b border-outline-variant/55 last:border-b-0"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-32 shrink-0 bg-stone-50/30 px-3 py-1.5 text-left align-top font-mono text-[10px] font-normal lowercase tracking-[0.04em] text-stone-500"
|
||||
>
|
||||
{row.label}
|
||||
</th>
|
||||
<td
|
||||
data-testid={`seat-overview-cell-${row.key}`}
|
||||
className={`min-w-0 px-3 py-1.5 align-middle ${
|
||||
row.mono ? "font-mono text-[11px]" : "text-[11px]"
|
||||
} text-stone-900`}
|
||||
>
|
||||
<div className="truncate">{placeholderOrValue(row.value)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.map((row) =>
|
||||
row.shape === "compact" ? (
|
||||
<CompactRow key={row.key} row={row} />
|
||||
) : (
|
||||
<FullWidthRow key={row.key} row={row} />
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactRow({ row }: { row: Row }) {
|
||||
return (
|
||||
<tr
|
||||
data-testid={`seat-overview-row-${row.key}`}
|
||||
data-row-shape="compact"
|
||||
className="border-b border-outline-variant/55 last:border-b-0"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-32 shrink-0 bg-stone-50/30 px-3 py-1.5 text-left align-top font-mono text-[10px] font-normal lowercase tracking-[0.04em] text-stone-500"
|
||||
>
|
||||
{row.label}
|
||||
</th>
|
||||
<td
|
||||
data-testid={`seat-overview-cell-${row.key}`}
|
||||
className={`min-w-0 px-3 py-1.5 align-middle ${
|
||||
row.mono ? "font-mono text-[11px]" : "text-[11px]"
|
||||
} text-stone-900`}
|
||||
>
|
||||
<div className="truncate">{placeholderOrValue(row.value)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function FullWidthRow({ row }: { row: Row }) {
|
||||
const hasValue =
|
||||
row.value !== null &&
|
||||
row.value !== undefined &&
|
||||
!(typeof row.value === "string" && row.value.trim() === "");
|
||||
return (
|
||||
<tr
|
||||
data-testid={`seat-overview-row-${row.key}`}
|
||||
data-row-shape="full-width"
|
||||
className="border-b border-outline-variant/55 last:border-b-0"
|
||||
>
|
||||
<td
|
||||
colSpan={2}
|
||||
data-testid={`seat-overview-cell-${row.key}`}
|
||||
className="bg-white/15 px-3 py-1.5 align-middle"
|
||||
title={row.titleAttr}
|
||||
>
|
||||
<div className="flex min-w-0 items-baseline gap-3">
|
||||
<span className="shrink-0 font-mono text-[10px] lowercase tracking-[0.04em] text-stone-500">
|
||||
{row.label}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${
|
||||
row.mono ? "font-mono text-[11px]" : "text-[11px]"
|
||||
} text-stone-900`}
|
||||
>
|
||||
{hasValue ? row.value : <span className="text-stone-400">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -154,19 +154,24 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
|
||||
expect(terminal.compareDocumentPosition(activity)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
// HG-3 — 7 info-table fields. Every row testid present.
|
||||
it("HG-3: info table renders all 7 fields (runtime, model, profile, spec, cwd, context%, total tokens)", async () => {
|
||||
// HG-3 — 9 info-table fields (7 compact + 2 full-width). Every row
|
||||
// testid present.
|
||||
it("HG-3: info table renders all 9 fields (7 compact + 2 full-width)", async () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
await screen.findByTestId("seat-overview-table");
|
||||
|
||||
expect(screen.getByTestId("seat-overview-row-runtime")).toBeDefined();
|
||||
expect(screen.getByTestId("seat-overview-row-model")).toBeDefined();
|
||||
expect(screen.getByTestId("seat-overview-row-profile")).toBeDefined();
|
||||
expect(screen.getByTestId("seat-overview-row-spec")).toBeDefined();
|
||||
expect(screen.getByTestId("seat-overview-row-cwd")).toBeDefined();
|
||||
expect(screen.getByTestId("seat-overview-row-context-percent")).toBeDefined();
|
||||
expect(screen.getByTestId("seat-overview-row-total-tokens")).toBeDefined();
|
||||
// 7 compact rows
|
||||
expect(screen.getByTestId("seat-overview-row-runtime").getAttribute("data-row-shape")).toBe("compact");
|
||||
expect(screen.getByTestId("seat-overview-row-model").getAttribute("data-row-shape")).toBe("compact");
|
||||
expect(screen.getByTestId("seat-overview-row-profile").getAttribute("data-row-shape")).toBe("compact");
|
||||
expect(screen.getByTestId("seat-overview-row-spec").getAttribute("data-row-shape")).toBe("compact");
|
||||
expect(screen.getByTestId("seat-overview-row-activity").getAttribute("data-row-shape")).toBe("compact");
|
||||
expect(screen.getByTestId("seat-overview-row-context-percent").getAttribute("data-row-shape")).toBe("compact");
|
||||
expect(screen.getByTestId("seat-overview-row-total-tokens").getAttribute("data-row-shape")).toBe("compact");
|
||||
// 2 full-width rows
|
||||
expect(screen.getByTestId("seat-overview-row-cwd").getAttribute("data-row-shape")).toBe("full-width");
|
||||
expect(screen.getByTestId("seat-overview-row-current-work").getAttribute("data-row-shape")).toBe("full-width");
|
||||
|
||||
expect(screen.getByTestId("seat-overview-cell-model").textContent).toContain("opus");
|
||||
expect(screen.getByTestId("seat-overview-cell-profile").textContent).toContain("default");
|
||||
@@ -175,6 +180,71 @@ describe("LiveNodeDetails (slice 25 Overview + Details)", () => {
|
||||
expect(screen.getByTestId("seat-overview-cell-context-percent").textContent).toContain("42%");
|
||||
});
|
||||
|
||||
// HG-3a — activity row wires to data.agentActivity via
|
||||
// getActivityState (the SAME helper LiveNodeCurrentState uses; the
|
||||
// SAME source the topology baseline reads). When agentActivity.state
|
||||
// is "running", the cell shows label "active" matching topology
|
||||
// graph/table naming.
|
||||
it("HG-3a: activity row wires live and shows 'active' for state=running with shimmer", async () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
await screen.findByTestId("seat-overview-table");
|
||||
|
||||
const cell = screen.getByTestId("seat-overview-cell-activity");
|
||||
expect(cell.textContent?.trim()).toContain("active");
|
||||
const stateEl = screen.getByTestId("seat-overview-activity-state");
|
||||
expect(stateEl.getAttribute("data-activity-state")).toBe("running");
|
||||
// HG-3c shimmer reuse: slice-14 shimmer class applied on active.
|
||||
expect(stateEl.className).toContain("topology-table-active-shimmer");
|
||||
});
|
||||
|
||||
it("HG-3a: activity row shows 'idle' label and NO shimmer when agentActivity.state=idle", async () => {
|
||||
mockNodeDetail({
|
||||
...NODE_DETAIL,
|
||||
agentActivity: { ...NODE_DETAIL.agentActivity, state: "idle" },
|
||||
});
|
||||
renderDetails();
|
||||
await screen.findByTestId("seat-overview-table");
|
||||
const cell = screen.getByTestId("seat-overview-cell-activity");
|
||||
expect(cell.textContent?.trim()).toContain("idle");
|
||||
const stateEl = screen.getByTestId("seat-overview-activity-state");
|
||||
expect(stateEl.getAttribute("data-activity-state")).toBe("idle");
|
||||
expect(stateEl.className).not.toContain("topology-table-active-shimmer");
|
||||
});
|
||||
|
||||
// HG-3b — current-work row wires live to data.currentQitems[0].
|
||||
// Shows qitemId + bodyExcerpt when present.
|
||||
it("HG-3b: current-work row wires live and surfaces the in-progress qitem", async () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
await screen.findByTestId("seat-overview-table");
|
||||
const cell = screen.getByTestId("seat-overview-cell-current-work");
|
||||
expect(cell.textContent).toContain("qitem-20260504001234-driver");
|
||||
expect(cell.textContent).toContain("Implement PL-019 edge activity pulse");
|
||||
});
|
||||
|
||||
it("HG-3b: current-work row renders em-dash when no in-progress qitem", async () => {
|
||||
mockNodeDetail({ ...NODE_DETAIL, currentQitems: [] });
|
||||
renderDetails();
|
||||
await screen.findByTestId("seat-overview-table");
|
||||
const cell = screen.getByTestId("seat-overview-cell-current-work");
|
||||
expect(cell.textContent).toContain("—");
|
||||
});
|
||||
|
||||
// HG-3d — cwd full-width with truncate-ellipsis + title attribute.
|
||||
it("HG-3d: cwd renders as full-width row with truncate-ellipsis + tooltip", async () => {
|
||||
const longCwd = "/Users/example/very/long/workspace/path/that/should/truncate/at/the/end";
|
||||
mockNodeDetail({ ...NODE_DETAIL, cwd: longCwd });
|
||||
renderDetails();
|
||||
const row = await screen.findByTestId("seat-overview-row-cwd");
|
||||
expect(row.getAttribute("data-row-shape")).toBe("full-width");
|
||||
const cell = screen.getByTestId("seat-overview-cell-cwd");
|
||||
expect(cell.getAttribute("title")).toBe(longCwd);
|
||||
// The inner truncate span carries the truncate class so the cwd
|
||||
// doesn't overflow the row.
|
||||
expect(cell.innerHTML).toContain("truncate");
|
||||
});
|
||||
|
||||
// HG-4 — model graceful absence: row visible with em-dash, NOT
|
||||
// "undefined" rendered as a string.
|
||||
it("HG-4: model row renders em-dash gracefully when model field is absent", async () => {
|
||||
|
||||
Reference in new issue
Block a user