diff --git a/src/transform/render-evidence.ts b/src/transform/render-evidence.ts index 0b9270c..4967684 100644 --- a/src/transform/render-evidence.ts +++ b/src/transform/render-evidence.ts @@ -35,7 +35,9 @@ import { table, tableRow, tableCell, + carrierDiv, } from './ast-helpers.js'; +import { readMetric } from './resolved-store.js'; import type { ArtifactResolver } from '../loader.js'; import type { ProseParser } from './prose.js'; import { fileExt, parseTableData, type TableData } from './parse-table-data.js'; @@ -184,12 +186,15 @@ export function renderOneOutput( opts?: EvidenceRenderOptions, ): any[] { const resultPath = results(artifactId); + const identifier = `output-${artifactId}`; if (!resultPath) { - return [pendingOutput(artifactId)]; + // Wrap the pending admonition in the identifier-bearing carrier div (the + // same contract as decisions/findings since #11): cross-references and + // rich-theme joins resolve even before the artifact is produced — the + // store entry exists (label, description, provenance) without it. + return [carrierDiv([pendingOutput(artifactId)], identifier)]; } - const identifier = `output-${artifactId}`; - switch (output.type) { case 'figure': return figureBlock( @@ -215,8 +220,14 @@ export function renderOneOutput( fallback.label = identifier; return [fallback]; } + case 'metric': + // One `div` carrier holds the whole neutral fallback (a readable + // "label: value ± uncertainty unit" sentence), so a rich theme that + // overrides the carrier renders its big-stat from `store.metric` and + // replaces the fallback wholesale — the #11 pattern. + return [carrierDiv(metricFallback(output, artifactId, resultPath, opts?.vfile), identifier)]; default: { - // metric / data / report: render inline, then tag the first node with + // data / report: render inline, then tag the first node with // the `output-` carrier so cross-references resolve to it. const nodes = renderInlineArtifact(output, artifactId, resultPath, undefined, opts?.vfile); if (nodes.length > 0 && !nodes[0].identifier) { @@ -228,6 +239,36 @@ export function renderOneOutput( } } +/** + * The neutral fallback for a produced metric output: a plain sentence + * ("**Summary metric:** 1.5 ± 0.3 Mpc") built from the parsed artifact — + * readable on a stock theme, entirely replaceable by a rich one. A JSON that + * doesn't parse as a metric shape (scalar / [value, uncertainty] / {value,…}) + * falls back to the tabular rendering the other inline artifact types use. + */ +function metricFallback( + output: Output, + artifactId: string, + resultPath: string, + vfile?: any, +): any[] { + const metric = readMetric(resultPath); + if (metric?.value !== undefined) { + const parts: any[] = [ + strong([text(`${output.label ?? artifactId}: `)]), + text(String(metric.value)), + ]; + const uncertainty = metric.uncertainty ?? metric.error; + if (uncertainty !== undefined && uncertainty !== '') { + parts.push(text(` \u00B1 ${uncertainty}`)); + } + const unit = metric.unit ?? metric.units; + if (unit) parts.push(text(` ${unit}`)); + return [paragraph(parts)]; + } + return renderTabularFile(resultPath, artifactId, output.label ?? artifactId, vfile); +} + function renderArtifactEvidence( evidence: Evidence, results: ArtifactResolver, diff --git a/tests/plugin-core.test.ts b/tests/plugin-core.test.ts index e59cc88..1579823 100644 --- a/tests/plugin-core.test.ts +++ b/tests/plugin-core.test.ts @@ -291,8 +291,26 @@ describe('directive — elements', () => { expect(findFirst(nodes, (n) => n.type === 'table')).toBeDefined(); }); - it('outputs. metric → carrier tagged astra-output--metric', () => { - expect(hasClass(byIdentifier(runAstra('outputs.summary_metric'), 'output-summary_metric'), 'astra-output--metric')).toBe(true); + it('outputs. metric → div carrier tagged astra-output--metric, sentence fallback nested inside', () => { + const nodes = runAstra('outputs.summary_metric'); + const carrier = byIdentifier(nodes, 'output-summary_metric'); + expect(carrier?.type).toBe('div'); + expect(hasClass(carrier, 'astra-output--metric')).toBe(true); + // the neutral fallback is a readable sentence built from the artifact, + // nested inside the carrier (the #11 contract) — not a details/table + expect(textOf(carrier!.children as Node[])).toBe('Summary metric: 1.5 \u00B1 0.3 Mpc'); + expect(findFirst(carrier!.children as Node[], (n) => n.type === 'details')).toBeUndefined(); + }); + + it('an unproduced output embed → div carrier with identifier, pending admonition nested', () => { + const nodes = runAstra('outputs.unproduced_metric'); + const carrier = byIdentifier(nodes, 'output-unproduced_metric'); + expect(carrier?.type).toBe('div'); + expect(hasClass(carrier, 'astra-output--metric')).toBe(true); + // the pending state stays visible on neutral themes… + expect(findFirst(carrier!.children as Node[], (n) => n.type === 'admonition')).toBeDefined(); + // …while the identifier keeps cross-references and store joins working + expect(textOf(carrier!.children as Node[])).toContain('has not been produced yet'); }); it('aliased output (from: sub.sub_plot) resolves the source type → figure', () => {