Ctrl K
ComunidadGuías…

tests/output-parity.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: GPL-3.0-or-later
/**
 * Output parity — the Phase 2 contract: history numbers are computed ONCE
 * (attachHistory) and every channel renders exactly those numbers. The CLI
 * text report, --plain, and --json must agree; the dashboard consumes the
 * same JSON payload, so parity with --json is parity with the dashboard.
 */
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderReport, renderPlain, renderJson, pickNextFinding } from "../src/report.js";
import { score, scoreBreakdown } from "../src/history.js";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const SYSTEM = { distro: "Test 1", kernel: "6.1.0", cores: 4, uptime: "1h" };

const FINDINGS = [
  { id: 1, check: "services", code: "services/failed", severity: "medium", title: "2 services failed to start", detail: null, evidence: null, fix: "restart them", isNew: false },
  { id: 2, check: "disk", code: "disk/full", severity: "high", title: "Disk nearly full", detail: null, evidence: null, fix: "clean space", isNew: true },
];

const DIFF = {
  added: [{ code: "disk/full", severity: "high", title: "Disk nearly full" }],
  fixed: [{ code: "audio/no-server", severity: "medium", title: "old issue gone" }],
  unchanged: 1,
};

const OPTS = {
  system: SYSTEM,
  score: 77,
  scoreDelta: 5,
  newCount: DIFF.added.length,
  fixedCount: DIFF.fixed.length,
  unchanged: DIFF.unchanged,
  diffSinceLast: DIFF,
};

test("parity: a first run says so instead of claiming everything is unchanged", async () => {
  // First contact with the tool: no history, so nothing can be "unchanged".
  const pretty = await renderReport(FINDINGS, { ...OPTS, firstRun: true });
  const plain = renderPlain(FINDINGS, { ...OPTS, firstRun: true });
  assert.match(pretty, /SINCE LAST RUN   first run — no baseline to compare against/);
  assert.match(plain, /^# since: first run \(no baseline\)$/m);
});

test("parity: CLI, --plain and --json carry the same new/fixed/unchanged", async () => {
  const pretty = await renderReport(FINDINGS, { ...OPTS });
  const plain = renderPlain(FINDINGS, { ...OPTS });
  const json = JSON.parse(renderJson(FINDINGS, SYSTEM, { ...OPTS, generatedAt: "2026-08-22T00:00:00Z" }));

  // Header line in the pretty report.
  assert.match(pretty, /SINCE LAST RUN   1 new · 1 fixed · 1 unchanged/);
  // # comment lines in plain.
  assert.match(plain, /^# since: 1 new, 1 fixed, 1 unchanged$/m);
  assert.match(plain, /^# new: 1$/m);
  assert.match(plain, /^# fixed: 1$/m);
  // Structured fields in JSON.
  assert.equal(json.newCount, 1);
  assert.equal(json.fixedCount, 1);
  assert.equal(json.unchanged, 1);
  assert.deepEqual(json.diffSinceLast.added.map((f) => f.code), ["disk/full"]);
});

test("parity: NEW badge appears on exactly the added findings in both text channels", async () => {
  const pretty = await renderReport(FINDINGS, { ...OPTS });
  const plain = renderPlain(FINDINGS, { ...OPTS });
  assert.match(pretty, /Disk nearly full\s+🆕 NEW/);
  assert.doesNotMatch(pretty, /failed to start\s+🆕 NEW/);
  assert.match(plain, /^high\t1\tDisk nearly full \(new\)$/m);
  assert.match(plain, /^medium\t2\t2 services failed to start$/m);
});

test("parity: score is severity-driven only — history content cannot bend it", () => {
  // The score function takes findings alone; runs/diffs are never an input.
  // A pathological history cannot change today's verdict for same findings.
  const a = score([{ severity: "high" }, { severity: "medium" }]);
  const b = score([{ severity: "medium" }, { severity: "high" }]);
  assert.equal(a, b);
});

// ---------------------------------------------------------------------------
// Phase 5: the score's arithmetic travels with the report — same numbers in
// every channel, derived once from scoreBreakdown.

test("parity: score breakdown agrees across CLI, --plain and --json", async () => {
  const bd = scoreBreakdown(FINDINGS);
  const opts = { ...OPTS, scoreBreakdown: bd };
  const pretty = await renderReport(FINDINGS, { ...opts });
  const plain = renderPlain(FINDINGS, { ...opts });
  const json = JSON.parse(renderJson(FINDINGS, SYSTEM, { ...opts, generatedAt: "2026-08-22T00:00:00Z" }));

  // The fixture itself must be internally consistent: penalties sum to the
  // score's distance from 100.
  const total = bd.reduce((acc, b) => acc + b.penalty, 0);
  assert.equal(100 - total, OPTS.score);

  // Display order (most expensive first) and content agree everywhere.
  assert.match(pretty, /SCORE     77\/100 = 100 −15 disk\/full −8 services\/failed/);
  // Score delta uses the same "(+N)" convention in pretty and plain.
  assert.match(pretty, /health 77\/100 \(\+5\)/);
  assert.match(plain, /^# score: 77\/100 \(\+5\)$/m);
  assert.match(plain, /^# score-breakdown: -15 disk\/full \| -8 services\/failed$/m);
  assert.deepEqual(
    json.scoreBreakdown.map((b) => [b.code, b.penalty]),
    [["services/failed", 8], ["disk/full", 15]] // data keeps input order
  );
});

test("scoreBreakdown: clean system renders no SCORE line at all", async () => {
  const pretty = await renderReport([], { system: SYSTEM, score: 100, scoreBreakdown: [], newCount: 0, fixedCount: 0, unchanged: 0 });
  assert.ok(!pretty.includes("SCORE "), "no arithmetic line when nothing was penalized");
});

test("trend: the current run is part of the sparkline, not one run behind", async () => {
  const history = [
    { at: "a", score: 61, counts: { high: 1, medium: 2, info: 0 } },
    { at: "b", score: 67, counts: { high: 0, medium: 3, info: 0 } },
  ];
  // Current verdict is 76 — the trend must END at 76, not trail at 67.
  const pretty = await renderReport([], { system: SYSTEM, score: 76, newCount: 0, fixedCount: 0, unchanged: 0, history });
  assert.match(pretty, /TREND    ▅▆▆  last 3 run\(s\) · 61 → 76 ▲/);
});

// ---------------------------------------------------------------------------
// Phase 4: shared vocabulary + nextAction agreement.

const HTML = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "src-gui", "index.html"), "utf8");

test("vocabulary: the same phrases exist in CLI output and the dashboard HTML", async () => {
  const mixed = [
    { id: 1, check: "disk", code: "disk/full", severity: "high", title: "Disk nearly full", fix: "clean space" },
    { id: 2, check: "zram", code: "zram/ok", severity: "info", title: "zram is enabled" },
  ];
  const pretty = await renderReport(mixed, { system: SYSTEM, score: 70, newCount: 0, fixedCount: 0, unchanged: 2, checksRun: 30, skippedChecks: [{ id: "reboot", title: "Reboot", reason: "Not applicable on an immutable/atomic system." }], checkErrors: [{ check: "smart", error: "smartctl unavailable" }] });
  // Info-only systems read the honest "notes below" line…
  const infoOnly = await renderReport(
    [{ id: 2, check: "zram", code: "zram/ok", severity: "info", title: "zram is enabled" }],
    { system: SYSTEM, score: 100, newCount: 0, fixedCount: 0, unchanged: 1 }
  );
  // …while a spotless machine with history earns the streak phrasing.
  const streaky = await renderReport([], {
    system: SYSTEM, score: 100, newCount: 0, fixedCount: 0, unchanged: 0,
    history: [
      { at: "a", score: 100, counts: { high: 0, medium: 0, info: 0 } },
      { at: "b", score: 100, counts: { high: 0, medium: 0, info: 0 } },
    ],
  });

  const phrases = [
    ["SINCE LAST RUN", pretty, /SINCE LAST RUN/],
    ["Changes since last run (GUI)", null, /Changes since last run/],
    ["START HERE (CLI)", pretty, /START HERE/],
    ["START HERE (GUI)", null, /START HERE/],
    ["No high or medium issues (CLI)", infoOnly, /No high or medium issues/],
    ["No high or medium issues (GUI)", null, /No high or medium issues/],
    ["Skipped checks (CLI)", pretty, /not applicable on this immutable/],
    ["checkErrors (CLI)", pretty, /failed to run: smart/],
    ["Skipped checks (GUI)", null, /Skipped checks/],
    ["Failed checks (CLI)", pretty, /failed to run|failed\(/],
    ["Failed checks (GUI)", null, /Failed checks/],
    ["clean run streak (CLI)", streaky, /3 clean run\(s\) in a row/],
    ["clean run streak (GUI)", null, /clean run/],
    ["humanized group labels (GUI)", null, /issues need attention/],
    ["card actions (GUI)", null, /Copy fix/],
    ["dismiss action (GUI)", null, /Dismiss/],
    ["recommended next step (GUI)", null, /Recommended next step/],
    ["what happened (GUI)", null, /What happened/],
    ["evidence label (GUI)", null, /Evidence — how we detected it/],
    ["inspect action (GUI)", null, /Inspect details/],
    ["view tabs (GUI)", null, /data-view="overview"/],
    ["brand home-link (GUI)", null, /id="brand"/],
    ["views panels (GUI)", null, /id="view-history"/],
    ["all five views (GUI)", null, /data-view="schedule"/],
    ["system view (GUI)", null, /Operating system/],
    ["system wiki hardware (GUI)", null, /What this machine is/],
    ["schedule view (GUI)", null, /Scheduled checks/],
    ["run ledger (GUI)", null, /Past runs/],
  ];
  for (const [name, text, re] of phrases) {
    if (text !== null) assert.ok(re.test(text), `CLI report missing "${name}"`);
    else assert.ok(re.test(HTML), `dashboard HTML missing "${name}"`);
  }
});

test("nextAction: --json pick matches the ▶ START HERE line in the report", async () => {
  const cat = new Map([["services", "system"], ["memory", "performance"]]);
  const findings = [
    { id: 1, check: "services", code: "services/failed", severity: "medium", title: "Services failed", fix: "restart them" },
    { id: 2, check: "memory", code: "memory/low", severity: "medium", title: "Low memory", fix: "close apps" },
    { id: 3, check: "disk", code: "disk/full", severity: "high", title: "Disk nearly full", fix: "clean space" },
  ];
  const next = pickNextFinding(findings, cat);
  assert.equal(next.code, "disk/full");

  const pretty = await renderReport(findings, { system: SYSTEM, score: 60, categoryByCheck: cat });
  const startLine = pretty.split("\n").find((l) => l.includes("▶ START HERE"));
  assert.match(startLine, new RegExp("#\\d+ " + next.title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")));

  // And exactly what --json ships (renderJson is a pass-through for extra).
  const json = JSON.parse(renderJson(findings, SYSTEM, { nextAction: next }));
  assert.equal(json.nextAction.title, next.title);
  assert.equal(json.nextAction.fix, "clean space");
});

test("nextAction: null when nothing has a fix (banner hides too)", async () => {
  assert.equal(pickNextFinding([{ id: 1, code: "x/y", severity: "high", title: "t" }]), null);
});