diff --git a/CHANGELOG.md b/CHANGELOG.md index 4feb9d5b..0594d776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Changed +- **Grafana-grid KPI tiles are polished in both Dashboard modes** (#316, + follow-on to #291). Edit mode keeps the full editing shell but drops the + never-populated tile footer (no more phantom separator line). View mode + strips all outer tile chrome — header, border, background, radius, body + padding — leaving the KPI cards (or the loading/unfilled/error state card, + now full-width with `role="status"`/`role="alert"` and the tile title in + its accessible name) as the only visible surfaces; the tile element stays + the CSS-grid item and carries `role="group"` + the query title as its + accessible name. KPI cards inside grid tiles lay out as an equal-width + responsive grid (`auto-fill`, min 150px — a lone last-row card no longer + stretches) with container-query value typography + (`clamp(16px, 14cqi, 38px)`). Shared card polish everywhere (workbench, + flow bands, grid tiles — owner decision pinned on #316): the value's + number+unit render as glued spans that never orphan the unit to its own + line, descriptions clamp to two visual lines (full text stays in the DOM), + and delta rows anchor to the card bottom for consistent rhythm. Flow-band + stream layout, ordinary tiles, schemas, persistence, and KPI value + semantics are unchanged. New real-browser e2e suite + (`tests/e2e/dashboard-grid-kpi.spec.js`) covers the frameless view, equal + widths, unit orphaning, delta alignment, 12/6/4/2 responsive columns, + themes, and 360px no-overflow. + ### Added - **Line/area/bar/hbar charts over a time-role X column now draw a genuine Chart.js `time` scale** (#309, follow-up to #310's category-axis diff --git a/src/core/kpi.ts b/src/core/kpi.ts index 01b4e022..1263e465 100644 --- a/src/core/kpi.ts +++ b/src/core/kpi.ts @@ -106,11 +106,19 @@ interface KpiValuePresentation { noValue?: string; } -export function formatKpiValue( +/** `formatKpiValue`'s rendering split into its two visual pieces (#316) — the + * digits/no-value text and the unit suffix — so kpi-panel.ts's `.kpi-value` + * can wrap each in its own `` (keeping them visually glued together + * via CSS) without duplicating any of the numeric-formatting logic below. + * `formatKpiValue` itself is exactly `rendered + unit` concatenated, so every + * existing caller that only wants the flat string keeps the identical + * output. */ +export function formatKpiValueParts( { value, clickhouseType, presentation = {} }: { value: unknown; clickhouseType?: string | null; presentation?: KpiValuePresentation }, -): string { - if (value == null) return presentation.noValue ?? '—'; +): { rendered: string; unit: string } { + const unit = typeof presentation.unit === 'string' ? presentation.unit : ''; + if (value == null) return { rendered: presentation.noValue ?? '—', unit: '' }; const parsedType = parseClickHouseType(clickhouseType); // `!`: see isKpiNumericType above — a truthy `parsedType` always unwraps to // a real TypeNode. @@ -129,11 +137,18 @@ export function formatKpiValue( else if (exactDecimal) rendered = decimalString(value, explicit ?? 2, explicit == null)!; else { const number = numericValue(value); - if (number == null) return presentation.noValue ?? '—'; + if (number == null) return { rendered: presentation.noValue ?? '—', unit: '' }; const fixed = explicit != null ? number.toFixed(explicit) : trimFixed(number, 2); rendered = /^-0(?:\.0+)?$/.test(fixed) ? fixed.slice(1) : fixed; } - return rendered + (typeof presentation.unit === 'string' ? presentation.unit : ''); + return { rendered, unit }; +} + +export function formatKpiValue( + args: { value: unknown; clickhouseType?: string | null; presentation?: KpiValuePresentation }, +): string { + const { rendered, unit } = formatKpiValueParts(args); + return rendered + unit; } /** One diagnostic as `readKpiFields`/kpi-panel.js's `renderKpiCards` produce it. */ diff --git a/src/styles.css b/src/styles.css index f0c8f1ab..ce96aa83 100644 --- a/src/styles.css +++ b/src/styles.css @@ -19,6 +19,8 @@ html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; } } .kpi-card { --kpi-accent: var(--accent); + display: flex; + flex-direction: column; min-width: 0; padding: 14px 16px; border: 1px solid var(--border); @@ -27,9 +29,31 @@ html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; } background: var(--bg-modal); } .kpi-label { color: var(--fg-mute); font-size: 12px; font-weight: 600; letter-spacing: .02em; } +/* #316: value + unit render as two spans (kpi-panel.ts) but must read as one + visual value. No whitespace exists BETWEEN the spans (any separator space is + part of the author's unit string, inside the unit span), and the unit span + is `white-space: nowrap`, so normal wrapping can never split value from + unit — while `overflow-wrap: anywhere` on the value still allows a + last-resort break inside an exceptionally long number rather than ever + clipping/ellipsizing/scrolling. (`nowrap` on `.kpi-value` itself would + defeat that: `overflow-wrap` only applies where `white-space` allows + wrapping at all.) No margin between the spans — the unit string's own + leading space (or absence: `%`) is the author-controlled gap. */ .kpi-value { margin-top: 6px; font-size: clamp(24px, 4vw, 38px); font-weight: 700; line-height: 1.08; overflow-wrap: anywhere; } -.kpi-description { margin-top: 7px; color: var(--fg-mute); font-size: 12px; line-height: 1.4; } -.kpi-delta { margin-top: 9px; font-size: 13px; font-weight: 600; } +.kpi-value-unit { white-space: nowrap; } +.kpi-description { + margin-top: 7px; color: var(--fg-mute); font-size: 12px; line-height: 1.4; + /* Clamp to 2 visual lines; the full text stays in the DOM for assistive + tech (#316) — only the rendered box is truncated. */ + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +/* `margin-top: auto` bottom-anchors a present delta row inside the now-flex + `.kpi-card` column (#316); `padding-top` (not another margin) keeps the + original 9px gap above it without a collapsing-margin surprise. A card + with no delta renders no third row/placeholder at all — nothing forces + one; the label/value/description block simply sits at the card's own + height. */ +.kpi-delta { margin-top: auto; padding-top: 9px; font-size: 13px; font-weight: 600; } .kpi-delta.is-good { color: var(--success, #238636); } .kpi-delta.is-bad { color: var(--danger, #cf222e); } .kpi-delta.is-neutral { color: var(--fg-mute); } @@ -2354,6 +2378,15 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } border-top: 1px solid var(--border-faint); font-family: var(--mono); font-size: 10.5px; color: var(--fg-faint); } +/* #316: a KPI grid tile's footer is built (ensureTileEl, ui/dashboard.ts) but + never populated — `[hidden]` alone loses the cascade to the class rule + above (equal specificity, this rule comes first in source order), so an + explicit override is needed to actually collapse the border/reserved + height it would otherwise leave behind. `ui/dashboard.ts` toggles the + `hidden` DOM property both ways on every reconcile (never just sets it + once), so switching a tile's panel type can't leave a stale hidden/visible + footer either way. */ +.dash-tile-foot[hidden] { display: none; } /* KPI bands (#240): a full-width composition primitive, not a tile — spans every Dashboard grid column regardless of the selected Full width/Report/ 2 columns/3 columns layout, so several consecutive explicit KPI favorites @@ -2492,7 +2525,37 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } /* A grid tile's KPI content (#291: KPI tiles render inline, no band) fills the tile body like a chart/table does. */ .dash-gg-tile .dash-tile-body { flex-wrap: wrap; gap: 8px; } -.dash-gg-tile .dash-tile-body > .kpi-card { flex: 1; min-width: 0; max-width: none; inline-size: auto; } +/* #316: KPI cards in a Grafana-grid tile use an equal-width responsive grid + instead of flex-growing independently — scoped to `.is-kpi` so ordinary + chart/table/logs grid tiles keep the flex-row body above untouched. + `auto-fill` (not `auto-fit`) is deliberate: it keeps the unfilled tracks + of a partial last row instead of collapsing them, so a lone leftover card + stays at one column's width rather than stretching across the tile — the + single-KPI case still "fills" the row because there is only one track to + begin with. `1fr` columns give every card in the same row equal width. */ +.dash-gg-tile.is-kpi .dash-tile-body { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + align-content: start; + gap: 8px; +} +/* A state card (loading/unfilled/zero-data/error) is always the body's sole + content when present (renderKpiInto) — span every track rather than being + squeezed into the first ~150px column of a multi-track tile (#316 review + finding: the unfilled/error message needs the tile's full width). */ +.dash-gg-tile.is-kpi .dash-tile-body > .dash-kpi-state-card { grid-column: 1 / -1; max-width: none; } +/* Container-aware value typography is GRID-TILE-ONLY (#316): `container-type: + inline-size` cannot go on the shared `.kpi-card` base because inline-size + containment sizes the box as if empty — a flow-band card is + `inline-size: fit-content` (see .dash-kpi-stream above) and would collapse + to its 160px minimum, changing the stream layout this issue pins as + untouched. Here the card's width comes from the 1fr track, not its content, + so containment is safe — and `14cqi` then scales the value with the card's + own width (narrow tile columns shrink the number toward 16px instead of + overflowing), while workbench/flow keep the viewport-based clamp on the + base `.kpi-value` rule. */ +.dash-gg-tile.is-kpi .dash-tile-body > .kpi-card { container-type: inline-size; } +.dash-gg-tile.is-kpi .kpi-value { font-size: clamp(16px, 14cqi, 38px); } /* No `@media (max-width: 768px)` override here (#291 review finding F1): unlike flow's binary mobile flip, grafana-grid@1's ENTIRE responsive behavior is the JS container-width clamp (`effectiveGridColumns`, 12/6/4/2 at ≥1160/≥720/≥470) @@ -2501,3 +2564,28 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } media override here does not know about a tile's span — a span>1 tile whose host was forced to `1fr` still tries to occupy N implicit tracks, rendering side-by-side/overflowing instead of the model's actual N-column layout. */ + +/* ── #316: view-mode KPI tiles are frameless ─────────────────────────────── + `.is-view` is a static per-load class (ui/dashboard.ts sets it once per + tile card, from the same `readOnly` that already governs drag/remove/ + resize — it never toggles mid-session; `.is-kpi` is likewise fixed per + session today but is still re-toggled defensively on every reconcile, + ui/dashboard.ts `reconcileGridTile`). Scoped to + `.dash-gg-grid .dash-gg-tile.is-kpi` so: + - a non-KPI grid tile (`.is-view` with no `.is-kpi`) keeps its full frame + — only KPI content gets this treatment; + - a flow-rendered card is never matched — flow never applies + `.dash-gg-tile`/`.is-kpi` (its own KPI tiles render inside the KPI + band, not as `.dash-tile` cards at all). + The card itself stays the CSS grid item (grid-column/height/order are set + directly on `.dash-gg-tile` elsewhere and are untouched here) — this is the + "invisible structural wrapper" the issue asks for, not a new DOM node, and + never `display: contents` (that would drop it out of grid placement). The + accessible name comes from `role="group"`/`aria-label` set directly on this + same element (ui/dashboard.ts) — hiding the header visually here does not + remove it from the accessibility tree. */ +.dash-gg-grid .dash-gg-tile.is-kpi.is-view { + background: transparent; border-color: transparent; border-radius: 0; box-shadow: none; +} +.dash-gg-grid .dash-gg-tile.is-kpi.is-view > .dash-tile-head { display: none; } +.dash-gg-grid .dash-gg-tile.is-kpi.is-view > .dash-tile-body { padding: 0; } diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 0f764017..d2f9e896 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -651,7 +651,13 @@ export async function renderDashboard(app: DashboardApp): Promise { const body = h('div', { class: 'dash-tile-body' }); const foot = h('div', { class: 'dash-tile-foot' }); const resizeHandle = !readOnly ? h('div', { class: 'dash-gg-resize', title: 'Resize' }) : null; - const card = h('div', { class: 'dash-tile', draggable: String(!readOnly) }, head, body, foot, resizeHandle); + // #316: a static, per-load mode class (view mode never toggles mid-session + // — `readOnly` is resolved once above, before any tile is built) — CSS + // scopes the frameless-KPI-in-view-mode treatment to + // `.dash-gg-grid .dash-gg-tile.is-kpi.is-view` (styles.css), so it never + // touches a non-KPI tile or a flow-rendered card (flow never adds + // `.dash-gg-tile`/`.is-kpi` — its own KPI tiles render inside the band). + const card = h('div', { class: 'dash-tile' + (readOnly ? ' is-view' : ''), draggable: String(!readOnly) }, head, body, foot, resizeHandle); // Pointer drag is the sole reorder mechanism (#286 owner override, reused // verbatim for grafana-grid@1 tiles by #291 — same move-tile command, no // engine branching needed): a drop persists the new dashboard.tiles[] @@ -730,13 +736,29 @@ export async function renderDashboard(app: DashboardApp): Promise { paintTileBody(ts, tileEl); } + // A KPI state card's role, per the #316 pinned owner decision: a genuine + // query failure (execution error, or a blocking post-execution diagnostic + // whose severity is 'error' — e.g. the wrong row count, or no eligible KPI + // field) is `alert`; a zero-row result ('kpi-no-data', severity 'info' — + // kpi.js) is expected/quiet, like loading or an unfilled parameter, so it + // gets `status`. + function kpiStateRole(kind: 'loading' | 'unfilled' | 'error' | 'zero-data'): 'status' | 'alert' { + return kind === 'error' ? 'alert' : 'status'; + } + // Render one KPI tile's cards (or its non-ready state) into `host`. On 'ready' - // the viewer guarantees columns/rows (no defensive fallback). + // the viewer guarantees columns/rows (no defensive fallback). Every state + // card carries the tile/query title in its accessible name (#316) — the + // frameless view-mode tile has no visible header, so the state card is the + // only surface that can announce which tile is loading/blocked/failed. function renderKpiInto(host: HTMLElement, ts: ViewerTileState): void { if (ts.status !== 'ready') { - host.replaceChildren(h('div', { class: 'dash-kpi-state-card' }, - ts.status === 'error' ? (ts.error || 'Error') - : ts.status === 'unfilled' ? 'Enter a value for: ' + ts.unfilled.join(', ') : 'Loading…')); + const kind = ts.status === 'error' ? 'error' : ts.status === 'unfilled' ? 'unfilled' : 'loading'; + const message = ts.status === 'error' ? (ts.error || 'Error') + : ts.status === 'unfilled' ? 'Enter a value for: ' + ts.unfilled.join(', ') : 'Loading…'; + host.replaceChildren(h('div', { + class: 'dash-kpi-state-card', role: kpiStateRole(kind), 'aria-label': `${ts.title}: ${message}`, + }, message)); return; } const panel = (ts.panel || {}) as Record; @@ -746,7 +768,10 @@ export async function renderDashboard(app: DashboardApp): Promise { serverVersion: state.serverVersion, }); const { cards, errors } = renderKpiCards(resolved.kpi); - host.replaceChildren(...(errors.length ? errors.map((e) => h('div', { class: 'dash-kpi-state-card' }, e.message)) : cards)); + host.replaceChildren(...(errors.length ? errors.map((e) => h('div', { + class: 'dash-kpi-state-card', role: kpiStateRole(e.code === 'kpi-no-data' ? 'zero-data' : 'error'), + 'aria-label': `${ts.title}: ${e.message}`, + }, e.message)) : cards)); } // ── Grid reconciliation from the flow model ─────────────────────────────── @@ -806,7 +831,39 @@ export async function renderDashboard(app: DashboardApp): Promise { // placement differs. function reconcileGridTile(ts: ViewerTileState): void { const tileEl = ensureTileEl(ts); - if (ts.isKpi) { renderKpiInto(tileEl.body, ts); return; } + // #316: the generic `.dash-tile-foot` (built once per tile, ensureTileEl) + // is never populated for a KPI tile — `paintPanel`/`paintTileBody` (the + // only other writers) never run on this branch — so its border/reserved + // height must be suppressed at the DOM level (`hidden`, backed by a + // styles.css `[hidden]` override strong enough to beat `.dash-tile-foot`'s + // own `display: flex`). Toggled BOTH ways on every reconcile (not just set + // once) so a tile whose `isKpi`/panel type flips leaves no stale hidden + // footer behind on a non-KPI tile, or a stale visible one on a KPI tile. + tileEl.foot.hidden = ts.isKpi; + // The `.is-kpi` frame class and the group role/name live HERE — not in + // `reconcileGrafanaGrid`'s structural loop — because that loop is + // short-circuited by the grid signature (columns/span/height only), while + // this function runs on every publish. Today `isKpi` is fixed per session + // (tile runtimes are built once — dashboard-viewer-session.ts; a real Spec + // change recreates session + tile DOM), so the placement is equivalent — + // but only THIS placement stays correct if tile runtimes ever become + // live-updatable (#287/#288 direction), and it keeps every KPI-gated + // mutation (footer, class, role) in one spot. The card is the named group + // a frameless view-mode KPI tile relies on for its accessible name (the + // visual header is `display: none` in view mode, styles.css). Set in edit + // mode too (harmless — the visible header shows the same title) rather + // than branching on `readOnly`. + tileEl.card.classList.toggle('is-kpi', ts.isKpi); + if (ts.isKpi) { + tileEl.card.setAttribute('role', 'group'); + // (`ts.title` is never empty — the session falls back through query + // name → queryId → tile id when the tile has no explicit title.) + tileEl.card.setAttribute('aria-label', ts.title); + } else { + tileEl.card.removeAttribute('role'); + tileEl.card.removeAttribute('aria-label'); + } + if (ts.isKpi) { tileEl.foot.replaceChildren(); renderKpiInto(tileEl.body, ts); return; } paintTileBody(ts, tileEl); } @@ -833,7 +890,9 @@ export async function renderDashboard(app: DashboardApp): Promise { if (!tileEl) continue; gridPlacementByTile.set(t.tileId, { span: t.span, heightUnits: t.heightUnits, colStart: t.colStart }); tileEl.card.classList.add('dash-gg-tile'); - tileEl.card.classList.toggle('is-kpi', t.isKpi); + // (`is-kpi` + the group role/name are maintained by `reconcileGridTile`, + // which runs on EVERY pass — this loop is signature-gated and would miss + // a panel-type flip with unchanged placement.) tileEl.card.style.gridColumn = `span ${t.span}`; setGridHeightPx(tileEl.card, t.heightUnits); cards.push(tileEl.card); diff --git a/src/ui/kpi-panel.ts b/src/ui/kpi-panel.ts index a2a7cee2..299b0029 100644 --- a/src/ui/kpi-panel.ts +++ b/src/ui/kpi-panel.ts @@ -1,4 +1,4 @@ -import { formatKpiValue, kpiDeltaState } from '../core/kpi.js'; +import { formatKpiValue, formatKpiValueParts, kpiDeltaState } from '../core/kpi.js'; import { h } from './dom.js'; import type { KpiResult } from '../core/panel-cfg.js'; @@ -97,7 +97,14 @@ export function renderKpiCards(normalized?: KpiResult | null): KpiCardsResult { const cards = items.map((item) => { const presentation = item.presentation; const label = h('div', { class: 'kpi-label' }, presentation.displayName); - const value = h('div', { class: 'kpi-value' }, formatKpiValue({ value: item.value, clickhouseType: item.valueType, presentation })); + // #316: number and unit render as separate spans (`.kpi-value-number`/ + // `.kpi-value-unit`) so CSS can keep them visually glued as one value — + // `.kpi-value`'s concatenated textContent still equals formatKpiValue's + // flat string exactly (no space is added between the two spans). + const { rendered, unit } = formatKpiValueParts({ value: item.value, clickhouseType: item.valueType, presentation }); + const valueChildren: HTMLElement[] = [h('span', { class: 'kpi-value-number' }, rendered)]; + if (unit) valueChildren.push(h('span', { class: 'kpi-value-unit' }, unit)); + const value = h('div', { class: 'kpi-value' }, ...valueChildren); const children: HTMLElement[] = [label, value]; if (presentation.description) children.push(h('div', { class: 'kpi-description' }, presentation.description)); const delta = kpiDeltaState(item); diff --git a/tests/e2e/dashboard-grid-kpi.html b/tests/e2e/dashboard-grid-kpi.html new file mode 100644 index 00000000..de9ef77a --- /dev/null +++ b/tests/e2e/dashboard-grid-kpi.html @@ -0,0 +1,325 @@ + + + + + Dashboard grafana-grid KPI harness (#316) + + + + + +
+

edit mode: KPI tile footer suppressed vs a normal tile's visible footer

+
+
+
+
+ +
+

view mode: KPI tile frameless vs a normal tile's kept frame

+
+
+
+
+ +
+

equal-width responsive KPI card grid + partial-last-row wrapping

+
+
+
+
+ +
+

value+unit orphan check at representative narrow widths

+
+
+
+
+ +
+

delta-row bottom alignment across cards with different description lengths

+
+
+
+
+ +
+

responsive 12/6/4/2 columns (host-width driven, no viewport media query)

+
+
+
+
+ +
+

real-viewport 360px overflow + one-per-row check

+
+
+
+
+ + + + diff --git a/tests/e2e/dashboard-grid-kpi.spec.js b/tests/e2e/dashboard-grid-kpi.spec.js new file mode 100644 index 00000000..ee6e66ee --- /dev/null +++ b/tests/e2e/dashboard-grid-kpi.spec.js @@ -0,0 +1,263 @@ +import { test, expect } from '@playwright/test'; + +// Grafana-grid KPI tile polish (#316): real-browser coverage for what +// happy-dom cannot see — actual geometry (footer collapse, frameless view +// mode, equal-width card wrapping, container-query value typography, delta +// bottom-alignment, and the 12/6/4/2 responsive column clamp applied to a +// KPI tile). The pure math (computeGrafanaGridLayout et al.) and the DOM +// shape/attribute contract are already covered under vitest — this suite +// verifies the real browser renders what that contract promises. + +async function openWide(page) { + await page.setViewportSize({ width: 1400, height: 1000 }); + await page.goto('/tests/e2e/dashboard-grid-kpi.html'); + await page.waitForFunction(() => window.__ready === true); +} + +test.describe('Dashboard grafana-grid KPI tiles (#316)', () => { + test('edit mode: KPI tile footer is collapsed (no visible line) while a normal tile keeps its visible footer', async ({ page }) => { + await openWide(page); + const kpiFoot = page.locator('#editcmp-grid [data-tile-id="kpi-edit"] .dash-tile-foot'); + const normalFoot = page.locator('#editcmp-grid [data-tile-id="normal-edit"] .dash-tile-foot'); + + expect(await kpiFoot.evaluate((node) => node.hidden)).toBe(true); + const kpiFootBox = await kpiFoot.evaluate((node) => node.getBoundingClientRect()); + expect(kpiFootBox.height).toBe(0); + expect(await kpiFoot.evaluate((node) => getComputedStyle(node).display)).toBe('none'); + + expect(await normalFoot.evaluate((node) => node.hidden)).toBe(false); + const normalFootBox = await normalFoot.evaluate((node) => node.getBoundingClientRect()); + expect(normalFootBox.height).toBeGreaterThan(0); + expect(await normalFoot.evaluate((node) => getComputedStyle(node).display)).toBe('flex'); + + // The KPI edit tile still has its header + edit affordances (title, grip, + // remove, resize) — only the footer is suppressed. + const kpiCard = page.locator('#editcmp-grid [data-tile-id="kpi-edit"]'); + await expect(kpiCard.locator('.dash-tile-head')).toBeVisible(); + await expect(kpiCard.locator('.dash-tile-name')).toHaveText('Active users'); + await expect(kpiCard.locator('.dash-gg-grip')).toHaveCount(1); + await expect(kpiCard.locator('.dash-gg-del')).toHaveCount(1); + await expect(kpiCard.locator('.dash-gg-resize')).toHaveCount(1); + }); + + test('view mode: KPI tile is frameless (transparent border/background, hidden header) while a normal tile keeps its frame', async ({ page }) => { + await openWide(page); + const kpiCard = page.locator('#viewframeless-grid [data-tile-id="kpi-view"]'); + const normalCard = page.locator('#viewframeless-grid [data-tile-id="normal-view"]'); + + const kpiStyle = await kpiCard.evaluate((node) => { + const cs = getComputedStyle(node); + return { border: cs.borderTopColor, bg: cs.backgroundColor, radius: cs.borderRadius, shadow: cs.boxShadow }; + }); + expect(kpiStyle.border).toBe('rgba(0, 0, 0, 0)'); + expect(kpiStyle.bg).toBe('rgba(0, 0, 0, 0)'); + expect(kpiStyle.radius).toBe('0px'); + expect(kpiStyle.shadow).toBe('none'); + await expect(kpiCard.locator('.dash-tile-head')).toBeHidden(); + expect(await kpiCard.locator('.dash-tile-body').evaluate((node) => getComputedStyle(node).padding)).toBe('0px'); + + // Accessible group name survives the hidden header. + await expect(kpiCard).toHaveAttribute('role', 'group'); + await expect(kpiCard).toHaveAttribute('aria-label', 'Availability'); + + // Grid placement (span) is still on the frameless card. + const gridColumn = await kpiCard.evaluate((node) => node.style.gridColumn); + expect(gridColumn).toMatch(/^span \d+$/); + + // No edit affordances in view mode. + await expect(kpiCard.locator('.dash-gg-grip')).toHaveCount(0); + await expect(kpiCard.locator('.dash-gg-del')).toHaveCount(0); + await expect(kpiCard.locator('.dash-gg-resize')).toHaveCount(0); + + // The neighboring NORMAL view-mode tile is a control: still fully framed. + const normalStyle = await normalCard.evaluate((node) => { + const cs = getComputedStyle(node); + return { border: cs.borderTopColor, bg: cs.backgroundColor }; + }); + expect(normalStyle.border).not.toBe('rgba(0, 0, 0, 0)'); + expect(normalStyle.bg).not.toBe('rgba(0, 0, 0, 0)'); + await expect(normalCard.locator('.dash-tile-head')).toBeVisible(); + }); + + test('loading/unfilled/zero-data/error KPI state cards remain visible and correctly classified, frameless', async ({ page }) => { + await openWide(page); + const loading = page.locator('[data-tile-id="state-loading"] .dash-kpi-state-card'); + const unfilled = page.locator('[data-tile-id="state-unfilled"] .dash-kpi-state-card'); + const zeroData = page.locator('[data-tile-id="state-zero"] .dash-kpi-state-card'); + const failure = page.locator('[data-tile-id="state-error"] .dash-kpi-state-card'); + + await expect(loading).toBeVisible(); + await expect(loading).toHaveAttribute('role', 'status'); + await expect(loading).toHaveAttribute('aria-label', 'Loading metric: Loading…'); + + await expect(unfilled).toBeVisible(); + await expect(unfilled).toHaveAttribute('role', 'status'); + + await expect(zeroData).toBeVisible(); + await expect(zeroData).toHaveAttribute('role', 'status'); + + await expect(failure).toBeVisible(); + await expect(failure).toHaveAttribute('role', 'alert'); + await expect(failure).toHaveAttribute('aria-label', 'Failing metric: Query failed: syntax error'); + + // The state cards' own tile wrapper stays frameless in view mode too. + const wrapper = page.locator('[data-tile-id="state-loading"]'); + const wrapperStyle = await wrapper.evaluate((node) => getComputedStyle(node).backgroundColor); + expect(wrapperStyle).toBe('rgba(0, 0, 0, 0)'); + + // #316 review F2: the state card spans EVERY track of the KPI body grid + // (grid-column: 1 / -1) instead of being squeezed into the first ~150px + // column — a long unfilled/error message needs the tile's full width. + for (const card of [loading, unfilled, zeroData, failure]) { + const { cardW, bodyW } = await card.evaluate((node) => ({ + cardW: node.getBoundingClientRect().width, + bodyW: node.parentElement.getBoundingClientRect().width, + })); + expect(cardW).toBeGreaterThan(bodyW - 2); // full body width (minus rounding) + } + }); + + test('equal-width responsive KPI card grid: same-row cards match widths; a partial last row does not stretch', async ({ page }) => { + await openWide(page); + const count = await page.evaluate(() => window.__ewCardCount); + expect(count).toBe(5); + const boxes = await page.locator('#equalwidth-grid .kpi-card').evaluateAll( + (nodes) => nodes.map((node) => { const r = node.getBoundingClientRect(); return { left: r.left, top: r.top, width: r.width }; }), + ); + expect(boxes).toHaveLength(5); + // Group by row (same top, within 1px). + const rows = []; + for (const box of boxes) { + const row = rows.find((r) => Math.abs(r[0].top - box.top) < 1); + if (row) row.push(box); else rows.push([box]); + } + expect(rows.length).toBeGreaterThan(1); // wraps to at least 2 rows at 500px + for (const row of rows) { + const widths = row.map((b) => b.width); + for (const w of widths) expect(w).toBeCloseTo(widths[0], 0); + } + // The tile's own content width (for comparison against a stretched card). + const tileWidth = await page.locator('#equalwidth-grid .dash-tile-body').evaluate((node) => node.getBoundingClientRect().width); + const lastRow = rows[rows.length - 1]; + if (lastRow.length < rows[0].length) { + // Partial last row: its card width equals the FIRST row's column width, + // not the full tile width. + expect(lastRow[0].width).toBeCloseTo(rows[0][0].width, 0); + expect(lastRow[0].width).toBeLessThan(tileWidth - 1); + } + }); + + test('value + unit stay on one line at representative narrow widths (no orphaned unit)', async ({ page }) => { + await openWide(page); + for (const width of [500, 300, 200]) { + await page.evaluate((px) => window.__setUnitOrphanWidth(px), width); + const cards = page.locator('#unitorphan-grid .kpi-card'); + const count = await cards.count(); + for (let i = 0; i < count; i++) { + const card = cards.nth(i); + const numberBox = await card.locator('.kpi-value-number').evaluate((node) => node.getBoundingClientRect()); + const unitBox = await card.locator('.kpi-value-unit').evaluate((node) => node.getBoundingClientRect()); + // Same visual line: the unit span's top matches the number span's top. + expect(Math.abs(numberBox.top - unitBox.top)).toBeLessThan(2); + // And the unit starts right where the number ends (immediately after, + // not wrapped below) — its left edge is at/after the number's right edge. + expect(unitBox.left).toBeGreaterThanOrEqual(numberBox.right - 1); + } + } + }); + + test('delta rows bottom-align across cards with different description lengths', async ({ page }) => { + await openWide(page); + const cards = page.locator('#delta-grid .kpi-card'); + await expect(cards).toHaveCount(2); + const deltaBoxes = await page.locator('#delta-grid .kpi-delta').evaluateAll( + (nodes) => nodes.map((node) => node.getBoundingClientRect().bottom), + ); + expect(deltaBoxes).toHaveLength(2); + expect(Math.abs(deltaBoxes[0] - deltaBoxes[1])).toBeLessThan(2); + + // The long description is visually clamped (its rendered box is shorter + // than its scrollable content) but the FULL text remains in the DOM. + const longDesc = page.locator('#delta-grid .kpi-card', { hasText: 'Long' }).locator('.kpi-description'); + const overflowing = await longDesc.evaluate((node) => node.scrollHeight > node.clientHeight + 1); + expect(overflowing).toBe(true); + await expect(longDesc).toHaveText(/wrap onto two lines and clamp there in the rendered card\.$/); + }); + + test('clamps effective columns at 12/6/4/2 and a full-span KPI tile goes one-card-per-row when narrow', async ({ page }) => { + await openWide(page); + const cases = [ + { width: 1240, columns: 12, onePerRow: false }, + { width: 840, columns: 6, onePerRow: false }, + { width: 540, columns: 4, onePerRow: false }, + { width: 340, columns: 2, onePerRow: true }, + ]; + for (const { width, columns, onePerRow } of cases) { + await page.evaluate((px) => window.__setResponsiveKpiWidth(px), width); + const model = await page.evaluate(() => window.__responsiveKpiModel); + expect(model.columns).toBe(columns); + const boxes = await page.locator('#responsive-grid2 .kpi-card').evaluateAll( + (nodes) => nodes.map((node) => { const r = node.getBoundingClientRect(); return { left: r.left, top: r.top }; }), + ); + expect(boxes.length).toBe(4); + const uniqueTops = new Set(boxes.map((b) => Math.round(b.top))); + if (onePerRow) { + // 4 cards, each its own row. + expect(uniqueTops.size).toBe(4); + } else { + // More than one card shares a row at wider widths. + expect(uniqueTops.size).toBeLessThan(4); + } + // No horizontal overflow of the tile's own body. + const overflow = await page.evaluate(() => { + const body = document.querySelector('#responsive-grid2 .dash-tile-body'); + return body.scrollWidth - body.clientWidth; + }); + expect(overflow).toBeLessThanOrEqual(1); + } + }); + + test('never overflows the viewport horizontally at a real 360px width, and the KPI cards wrap deterministically without overflowing the tile', async ({ page }) => { + await page.setViewportSize({ width: 360, height: 900 }); + await page.goto('/tests/e2e/dashboard-grid-kpi.html'); + await page.waitForFunction(() => window.__ready === true); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(0); + + const boxes = await page.locator('#realviewport-grid .kpi-card').evaluateAll( + (nodes) => nodes.map((node) => { const r = node.getBoundingClientRect(); return { top: r.top, left: r.left, width: r.width }; }), + ); + expect(boxes).toHaveLength(3); + // At this width the tile is narrow enough that cards wrap onto more than + // one row (deterministic auto-fill wrapping, not a single stretched row). + const rows = []; + for (const box of boxes) { + const row = rows.find((r) => Math.abs(r[0].top - box.top) < 1); + if (row) row.push(box); else rows.push([box]); + } + expect(rows.length).toBeGreaterThan(1); + // No card ever overflows its own tile's right edge. + const tileRight = await page.locator('#realviewport-grid .dash-tile-body').evaluate((node) => node.getBoundingClientRect().right); + for (const box of boxes) expect(box.left + box.width).toBeLessThanOrEqual(tileRight + 1); + // A lone partial-last-row card does not stretch across the full tile width. + const lastRow = rows[rows.length - 1]; + if (lastRow.length === 1 && rows[0].length > 1) { + expect(lastRow[0].width).toBeCloseTo(rows[0][0].width, 0); + } + }); + + test('frameless view mode holds in both light and dark themes', async ({ page }) => { + await openWide(page); + const kpiCard = page.locator('#viewframeless-grid [data-tile-id="kpi-view"]'); + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => window.__setTheme(t), theme); + const style = await kpiCard.evaluate((node) => { + const cs = getComputedStyle(node); + return { border: cs.borderTopColor, bg: cs.backgroundColor }; + }); + expect(style.border).toBe('rgba(0, 0, 0, 0)'); + expect(style.bg).toBe('rgba(0, 0, 0, 0)'); + await expect(kpiCard.locator('.dash-tile-head')).toBeHidden(); + } + }); +}); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index d0c37e5a..25f82daa 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -531,7 +531,7 @@ describe('renderDashboard — KPI bands (#240)', () => { expect(qsa(app.root, '.dash-kpi-stream .kpi-card').length).toBe(2); }); - it('shows a KPI member state card for an errored or unfilled KPI source', async () => { + it('shows a KPI member state card for an errored or unfilled KPI source — error is role=alert, unfilled is role=status, both name their tile (#316)', async () => { const { app } = dashApp({ responder: (sql) => (sql.includes('boom') ? { error: 'kpi down' } : { columns: [{ name: 'value', type: 'UInt64' }], rows: [[1]] }), workspace: wsWith({ @@ -543,18 +543,46 @@ describe('renderDashboard — KPI bands (#240)', () => { }), }); await render(app); - const cards = qsa(app.root, '.dash-kpi-state-card').map((c) => c.textContent); - expect(cards).toContain('kpi down'); - expect(cards.some((c) => /Enter a value/.test(c || ''))).toBe(true); + const cards = qsa(app.root, '.dash-kpi-state-card'); + const errorCard = cards.find((c) => c.textContent === 'kpi down'); + const unfilledCard = cards.find((c) => /Enter a value/.test(c.textContent || '')); + expect(errorCard?.getAttribute('role')).toBe('alert'); // a genuine query failure + expect(errorCard?.getAttribute('aria-label')).toBe('k1: kpi down'); + expect(unfilledCard?.getAttribute('role')).toBe('status'); // blocked on a parameter, not a failure + expect(unfilledCard?.getAttribute('aria-label')).toContain('k2:'); }); - it('shows the KPI zero-data state card when a KPI source returns no rows', async () => { + it('shows the KPI zero-data state card (role=status, not alert) when a KPI source returns no rows (#316)', async () => { const { app } = dashApp({ responder: () => ({ columns: [{ name: 'value', type: 'UInt64' }], rows: [] }), workspace: wsWith({ queries: [q('k1', 'SELECT value', { panel: { cfg: { type: 'kpi' } } })], tiles: [{ id: 't1', queryId: 'k1' }] }), }); await render(app); - expect(qs(app.root, '.dash-kpi-state-card')).not.toBeNull(); + const card = qs(app.root, '.dash-kpi-state-card'); + expect(card).not.toBeNull(); + expect(card.getAttribute('role')).toBe('status'); // zero rows is expected, not a failure + expect(card.getAttribute('aria-label')).toBe('k1: No data'); + }); + + it('shows the KPI loading state card with role=status while a query is in flight (#316)', async () => { + let resolveResponder!: (value: ExecResp) => void; + const pending = new Promise((resolve) => { resolveResponder = resolve; }); + const { app } = dashApp({ + responder: () => pending, + workspace: wsWith({ queries: [q('k1', 'SELECT value', { panel: { cfg: { type: 'kpi' } } })], tiles: [{ id: 't1', queryId: 'k1' }] }), + }); + const rendering = render(app); + // Flush the microtasks up to (but not past) the in-flight `executeRead` + // await — the session sets status 'loading' and publishes synchronously + // before awaiting the responder (dashboard-viewer-session.ts `runTile`). + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + const card = qs(app.root, '.dash-kpi-state-card'); + expect(card).not.toBeNull(); + expect(card.textContent).toBe('Loading…'); + expect(card.getAttribute('role')).toBe('status'); + expect(card.getAttribute('aria-label')).toBe('k1: Loading…'); + resolveResponder({ columns: [{ name: 'value', type: 'UInt64' }], rows: [[1]] }); + await rendering; }); }); @@ -602,6 +630,95 @@ describe('renderDashboard — grafana-grid engine (#291)', () => { expect(qs(card, '.kpi-card')).not.toBeNull(); }); + // #316: the tile shell for a grafana-grid KPI tile — edit mode keeps the + // full editing chrome except the footer (which the generic KPI path never + // populates); view mode strips every visible frame, leaving only the KPI + // cards/state card, behind a still-placed, still-named wrapper. + const kpiGridWs = () => wsWith({ + queries: [q('k1', 'SELECT 1 AS value', { panel: { cfg: { type: 'kpi' } } })], + tiles: [{ id: 't1', queryId: 'k1' }], + layout: { type: 'grafana-grid', version: 1, items: { t1: { span: 4, height: 3 } } }, + }); + const kpiResponder: ExecResponder = () => ({ columns: [{ name: 'value', type: 'UInt64' }], rows: [[42]] }); + + it('edit mode: a KPI grid tile keeps its header and edit controls but hides the footer (#316)', async () => { + const { app } = dashApp({ responder: kpiResponder, workspace: kpiGridWs() }); + await render(app); + const card = qs(app.root, '.dash-gg-tile'); + expect(card.classList.contains('is-kpi')).toBe(true); + expect(card.classList.contains('is-view')).toBe(false); // edit mode — not the view-mode modifier + expect(qs(card, '.dash-tile-head')).not.toBeNull(); // header retained + expect(qs(card, '.dash-tile-name')?.textContent).toBe('k1'); + expect(qs(card, '.dash-gg-grip')).not.toBeNull(); // drag retained + expect(qs(card, '.dash-gg-del')).not.toBeNull(); // remove retained + expect(qs(card, '.dash-gg-resize')).not.toBeNull(); // resize retained + const foot = qs(card, '.dash-tile-foot'); + expect(foot.hidden).toBe(true); // suppressed at the DOM level, not just visually + expect(foot.childNodes.length).toBe(0); + }); + + it('a non-KPI grid tile keeps its footer visible and populated (#316 — the KPI-only fix leaves ordinary tiles alone)', async () => { + const { app } = dashApp({ workspace: twoTilesGrid() }); // q1/q2 — ordinary (non-KPI) queries + await render(app); + for (const card of qsa(app.root, '.dash-gg-tile')) { + const foot = qs(card, '.dash-tile-foot'); + expect(foot.hidden).toBe(false); + expect(foot.childNodes.length).toBeGreaterThan(0); + } + }); + + it('view mode: a KPI grid tile is frameless (.is-view) — header/edit controls hidden, role=group names it by title, placement survives (#316)', async () => { + const detached = kpiGridWs(); + const { app } = modeApp({ + workspace: null, detached, responder: kpiResponder, + openSource: { kind: 'current-workspace', workspaceId: 'w', dashboardId: 'd' }, + }); + await render(app); + const card = qs(app.root, '.dash-gg-tile'); + expect(card.classList.contains('is-kpi')).toBe(true); + expect(card.classList.contains('is-view')).toBe(true); + // No drag/remove/resize affordances in view mode. + expect(qs(card, '.dash-gg-grip')).toBeNull(); + expect(qs(card, '.dash-gg-del')).toBeNull(); + expect(qs(card, '.dash-gg-resize')).toBeNull(); + expect(card.getAttribute('draggable')).toBe('false'); + // The hidden query title survives as the wrapper's accessible group name. + expect(card.getAttribute('role')).toBe('group'); + expect(card.getAttribute('aria-label')).toBe('k1'); + // The footer stays suppressed exactly as in edit mode. + expect(qs(card, '.dash-tile-foot').hidden).toBe(true); + // The wrapper still owns the CSS-grid placement (span + authored height). + expect((card.style as CSSStyleDeclaration).gridColumn).toBe('span 4'); + expect((card.style as CSSStyleDeclaration).height).not.toBe(''); + // The KPI card itself is still rendered inside the frameless wrapper. + expect(qs(card, '.kpi-card')).not.toBeNull(); + }); + + it('switching a tile from KPI to non-KPI (engine republish) leaves no stale hidden footer or group role behind (#316)', async () => { + const { app } = dashApp({ + responder: (sql) => (sql.includes('value') ? { columns: [{ name: 'value', type: 'UInt64' }], rows: [[42]] } : {}), + workspace: wsWith({ + queries: [q('k1', 'SELECT 1 AS value', { panel: { cfg: { type: 'kpi' } } })], + tiles: [{ id: 't1', queryId: 'k1' }], + layout: { type: 'grafana-grid', version: 1, items: { t1: { span: 4 } } }, + }), + }); + await render(app); + let card = qs(app.root, '.dash-gg-tile'); + expect(card.classList.contains('is-kpi')).toBe(true); + expect(qs(card, '.dash-tile-foot').hidden).toBe(true); + expect(card.getAttribute('role')).toBe('group'); + // Round-trip through flow and back to grid (#291's own cached-card-reuse + // path) — a plain re-render exercises the same reconcile functions a + // panel-type flip would, without needing a live Spec-editor change. + pickLayout(app.root, 'full-width'); + pickLayout(app.root, 'grafana-grid'); + card = qs(app.root, '.dash-gg-tile'); + expect(card.classList.contains('is-kpi')).toBe(true); + expect(qs(card, '.dash-tile-foot').hidden).toBe(true); + expect(card.getAttribute('role')).toBe('group'); + }); + it('reflects the active engine in the 5-option layout select and switches engines via change-layout', async () => { const { app, commit } = dashApp({ workspace: wsWith({ diff --git a/tests/unit/kpi-panel.test.ts b/tests/unit/kpi-panel.test.ts index 40f0bd99..6359142a 100644 --- a/tests/unit/kpi-panel.test.ts +++ b/tests/unit/kpi-panel.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { formatKpiValue } from '../../src/core/kpi.js'; import { renderKpiCards, renderKpiPanel } from '../../src/ui/kpi-panel.js'; const item = (over: Record = {}) => ({ @@ -19,11 +20,34 @@ describe('renderKpiPanel', () => { expect(node.querySelector('.kpi-card')!.getAttribute('aria-label')).toBe('Availability'); expect((node.querySelector('.kpi-card') as HTMLElement).style.getPropertyValue('--kpi-accent')).toBe('#123456'); expect(node.querySelector('.kpi-value')!.textContent).toBe('12.4%'); + // #316: number and unit are separate spans, but concatenate back to the + // exact same flat value text — no space is introduced between them. + expect(node.querySelector('.kpi-value-number')!.textContent).toBe('12.4'); + expect(node.querySelector('.kpi-value-unit')!.textContent).toBe('%'); expect(node.querySelector('.kpi-description')!.textContent).toBe('Current service level'); expect(node.querySelector('.kpi-delta')!.classList.contains('is-good')).toBe(true); expect(node.querySelector('.kpi-delta')!.textContent).toBe('↓ Change 1.5 pp'); expect(node.querySelector('.kpi-warnings')!.textContent).toContain('Ignored region'); }); + it('renders no unit span when the field has no unit, and no delta element when there is no delta (#316)', () => { + const node = renderKpiPanel({ + items: [item({ presentation: { displayName: 'Rows', noValue: '—', delta: {} } })], + diagnostics: [], + }); + const value = node.querySelector('.kpi-value')!; + expect(value.querySelector('.kpi-value-number')!.textContent).toBe('12.4'); + expect(value.querySelector('.kpi-value-unit')).toBeNull(); + expect(value.textContent).toBe(formatKpiValue({ value: 12.4, clickhouseType: 'Float64', presentation: { noValue: '—' } })); + expect(node.querySelector('.kpi-delta')).toBeNull(); + }); + it('keeps the full description text in the DOM even though CSS visually clamps it (#316)', () => { + const longDescription = 'A very long description that would visually wrap past two lines in a narrow KPI card, but the complete text must still be readable by assistive technology.'; + const node = renderKpiPanel({ + items: [item({ presentation: { displayName: 'Rows', description: longDescription, noValue: '—', delta: {} } })], + diagnostics: [], + }); + expect(node.querySelector('.kpi-description')!.textContent).toBe(longDescription); + }); it('renders no-data and errors as visible states', () => { const noData = renderKpiPanel({ items: [], diagnostics: [{ severity: 'info', code: 'kpi-no-data', message: 'No data' }] }); expect(noData.querySelector('[role="status"]')!.textContent).toBe('No data'); diff --git a/tests/unit/kpi.test.ts b/tests/unit/kpi.test.ts index 9a8afb8c..46b3afeb 100644 --- a/tests/unit/kpi.test.ts +++ b/tests/unit/kpi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatKpiValue, isKpiNumericType, kpiDeltaState, parseKpiTupleType, readKpiFields, resolveKpiPresentation } from '../../src/core/kpi.js'; +import { formatKpiValue, formatKpiValueParts, isKpiNumericType, kpiDeltaState, parseKpiTupleType, readKpiFields, resolveKpiPresentation } from '../../src/core/kpi.js'; describe('KPI ClickHouse types', () => { it('recognizes numeric families and nullable wrappers only', () => { @@ -67,6 +67,23 @@ describe('KPI presentation and formatting', () => { expect(formatKpiValue({ value: false, clickhouseType: 'UInt8' })).toBe('—'); expect(formatKpiValue({ value: 5n, clickhouseType: 'UInt64' })).toBe('5'); }); + it('formatKpiValueParts splits the same rendering formatKpiValue concatenates (#316)', () => { + const cases: { args: Parameters[0] }[] = [ + { args: { value: 12.345, clickhouseType: 'Float64', presentation: { decimals: 2, unit: '%' } } }, + { args: { value: 1_500_000, clickhouseType: 'UInt64' } }, + { args: { value: null, clickhouseType: 'UInt64', presentation: { noValue: 'None', unit: 'MiB' } } }, + { args: { value: Infinity, clickhouseType: 'Float64', presentation: { unit: 'ms' } } }, + { args: { value: '12.40', clickhouseType: 'Decimal(10,2)', presentation: { unit: ' MiB' } } }, + ]; + for (const { args } of cases) { + const parts = formatKpiValueParts(args); + expect(parts.rendered + parts.unit).toBe(formatKpiValue(args)); + } + // A no-value/invalid-number result never carries a unit onto the noValue text. + expect(formatKpiValueParts({ value: null, clickhouseType: 'UInt64', presentation: { unit: 'MiB' } })).toEqual({ rendered: '—', unit: '' }); + expect(formatKpiValueParts({ value: 'nope', clickhouseType: 'Float64', presentation: { unit: 'MiB' } })).toEqual({ rendered: '—', unit: '' }); + expect(formatKpiValueParts({ value: 999, clickhouseType: 'UInt64', presentation: { unit: 'MiB' } })).toEqual({ rendered: '999', unit: 'MiB' }); + }); it('derives delta direction and good/bad/neutral semantics', () => { const item = (delta: unknown, config: { positiveIsGood?: boolean; show?: boolean } = {}) => ({ delta, presentation: { delta: config } }); expect(kpiDeltaState(item(2, { positiveIsGood: true }))).toEqual({ value: 2, direction: 'up', semantic: 'good' });