('.eval-grid6')]
+ .find((candidate) => candidate.firstElementChild?.textContent?.trim() === group);
+ return row?.lastElementChild as HTMLElement | undefined;
+ };
+
+ expect(deltaCell('Improved')?.textContent).toBe('▲ +100%');
+ expect(deltaCell('Improved')?.style.color).toBe('var(--status-success-background-3)');
+ expect(deltaCell('Regressed')?.textContent).toBe('▼ −100%');
+ expect(deltaCell('Regressed')?.style.color).toBe('var(--status-danger-background-3)');
+ expect(deltaCell('Stable')?.textContent).toBe('—');
+ expect(deltaCell('Stable')?.style.color).toBe('var(--neutral-foreground-3)');
+ });
+});
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/prettifyJson.test.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/prettifyJson.test.tsx
new file mode 100644
index 00000000000..1547712aafa
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/prettifyJson.test.tsx
@@ -0,0 +1,85 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import { useEffect } from 'react';
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import {
+ ReportContextProvider,
+ useReportContext,
+ createScoreSummary,
+ getConversationDisplay,
+ TranscriptBlock,
+} from '../components';
+import { toolCallDataset } from './fixtures/richDataset';
+
+// prettifyJson defaults to true in ReportContext and there is no prop override, so this
+// helper flips it from inside the provider to exercise the "off" branch.
+const SetPretty = ({ value }: { value: boolean }) => {
+ const { prettifyJson, setPrettifyJson } = useReportContext();
+ useEffect(() => {
+ if (prettifyJson !== value) setPrettifyJson(value);
+ }, [value, prettifyJson, setPrettifyJson]);
+ return null;
+};
+
+const renderMessages = (messages: ChatMessage[], prettify: boolean) => {
+ const scoreSummary = createScoreSummary(toolCallDataset);
+ const { messages: display } = getConversationDisplay(messages);
+ return render(
+
+
+
+ ,
+ );
+};
+
+const text = (value: string): AIContent => ({ $type: 'text', text: value }) as unknown as AIContent;
+const functionCall = (callId: string, name: string, args: unknown): AIContent =>
+ ({ $type: 'functionCall', callId, name, arguments: args, informationalOnly: false }) as unknown as AIContent;
+const functionResult = (callId: string, result: unknown): AIContent =>
+ ({ $type: 'functionResult', callId, result }) as unknown as AIContent;
+
+const preTexts = (container: HTMLElement): string[] =>
+ Array.from(container.querySelectorAll('pre')).map((p) => p.textContent ?? '');
+
+describe('TranscriptBlock — JSON-in-text branch (TextNode)', () => {
+ const jsonMessage: ChatMessage[] = [
+ { role: 'assistant', authorName: 'gpt-4o', contents: [text('{"city":"Seattle","temp":14}')] },
+ ];
+
+ it('pretty-prints JSON-parseable text when prettifyJson is on', () => {
+ const { container } = renderMessages(jsonMessage, true);
+ const pre = container.querySelector('pre');
+ expect(pre?.textContent).toContain('\n');
+ expect(pre?.textContent).toContain(' "city": "Seattle"');
+ });
+
+ it('renders JSON-parseable text compactly when prettifyJson is off', () => {
+ const { container } = renderMessages(jsonMessage, false);
+ const pre = container.querySelector('pre');
+ expect(pre?.textContent).toBe('{"city":"Seattle","temp":14}');
+ expect(pre?.textContent).not.toContain('\n');
+ });
+});
+
+describe('TranscriptBlock — tool call/result JSON (safeJson / safeJsonMaybeString)', () => {
+ const toolMessages: ChatMessage[] = [
+ { role: 'assistant', authorName: 'gpt-4o', contents: [functionCall('c1', 'lookup', { q: 'x' })] },
+ { role: 'tool', contents: [functionResult('c1', { ok: true, n: 5 })] },
+ ];
+
+ it('pretty-prints call arguments and results when prettifyJson is on', () => {
+ const { container } = renderMessages(toolMessages, true);
+ const texts = preTexts(container);
+ expect(texts.some((t) => /\n {2}"q": "x"/.test(t))).toBe(true);
+ expect(texts.some((t) => /\n {2}"ok": true/.test(t))).toBe(true);
+ });
+
+ it('compacts call arguments and results when prettifyJson is off', () => {
+ const { container } = renderMessages(toolMessages, false);
+ const texts = preTexts(container);
+ expect(texts).toContain('{"q":"x"}');
+ expect(texts).toContain('{"ok":true,"n":5}');
+ });
+});
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/setup.ts b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/setup.ts
new file mode 100644
index 00000000000..e548951e83e
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/setup.ts
@@ -0,0 +1,4 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import '@testing-library/jest-dom';
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/summary.smoke.test.ts b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/summary.smoke.test.ts
new file mode 100644
index 00000000000..f0e8fe0eaa6
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/summary.smoke.test.ts
@@ -0,0 +1,71 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import { describe, it, expect } from 'vitest';
+import { createScoreSummary } from '../components';
+import { singleExecutionDataset, twoExecutionDataset } from './fixtures/richDataset';
+
+
+const makeScenario = (
+ scenarioName: string,
+ iterationName: string,
+ executionName: string,
+ failed: boolean,
+): ScenarioRunResult => ({
+ scenarioName,
+ iterationName,
+ executionName,
+ creationTime: new Date().toISOString(),
+ messages: [],
+ modelResponse: { messages: [] },
+ evaluationResult: {
+ metrics: {
+ coherence: {
+ $type: 'numeric',
+ name: 'coherence',
+ value: failed ? 1 : 5,
+ interpretation: { rating: failed ? 'unacceptable' : 'exceptional', failed },
+ } as NumericMetric,
+ },
+ },
+ formatVersion: 1,
+});
+
+const dataset: Dataset = {
+ scenarioRunResults: [
+ makeScenario('GroupA.ScenarioX', 'iteration1', 'exec1', false),
+ makeScenario('GroupA.ScenarioY', 'iteration1', 'exec1', true),
+ makeScenario('GroupB.ScenarioZ', 'iteration1', 'exec1', false),
+ ],
+ createdAt: new Date().toISOString(),
+ generatorVersion: '0.0.0-test',
+};
+
+describe('createScoreSummary — aggregate pass/fail counts', () => {
+ it('returns the correct passing and failing iteration counts at the root', () => {
+ const summary = createScoreSummary(dataset);
+ const root = summary.primaryResult;
+
+ expect(root.numPassingIterations).toBe(2);
+ expect(root.numFailingIterations).toBe(1);
+ });
+
+ it('marks the root as failed when any child fails', () => {
+ const summary = createScoreSummary(dataset);
+ expect(summary.primaryResult.failed).toBe(true);
+ });
+});
+
+describe('createScoreSummary — report history flag', () => {
+ it('a single-execution report has one execution and no report history', () => {
+ const summary = createScoreSummary(singleExecutionDataset);
+ expect(summary.executionHistory.size).toBe(1);
+ expect(summary.includesReportHistory).toBe(false);
+ });
+
+ it('a multi-execution report exposes its execution history', () => {
+ const summary = createScoreSummary(twoExecutionDataset);
+ expect(summary.executionHistory.size).toBe(2);
+ expect(summary.includesReportHistory).toBe(true);
+ });
+});
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/summaryCollision.test.ts b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/summaryCollision.test.ts
new file mode 100644
index 00000000000..f0ed6c564e2
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/summaryCollision.test.ts
@@ -0,0 +1,112 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import { describe, it, expect } from 'vitest';
+import { createScoreSummary } from '../components';
+
+const makeScenario = (
+ scenarioName: string,
+ iterationName: string,
+ failed: boolean = false,
+): ScenarioRunResult => ({
+ scenarioName,
+ iterationName,
+ executionName: 'exec1',
+ creationTime: new Date().toISOString(),
+ messages: [],
+ modelResponse: { messages: [] },
+ evaluationResult: {
+ metrics: {
+ coherence: {
+ $type: 'numeric',
+ name: 'coherence',
+ value: failed ? 1 : 5,
+ interpretation: { rating: failed ? 'unacceptable' : 'exceptional', failed },
+ } as NumericMetric,
+ },
+ },
+ formatVersion: 1,
+});
+
+const asDataset = (scenarioRunResults: ScenarioRunResult[]): Dataset => ({
+ scenarioRunResults,
+ createdAt: new Date().toISOString(),
+ generatorVersion: '0.0.0-test',
+});
+
+describe('createScoreSummary — scenario/iteration name collisions', () => {
+ it('counts a run whose tree node is also a parent of another run', () => {
+ const summary = createScoreSummary(asDataset([
+ makeScenario('Coherence', 'Test1'),
+ makeScenario('Coherence.Test1', 'X', true),
+ ]));
+
+ const root = summary.primaryResult;
+ expect(root.numPassingIterations).toBe(1);
+ expect(root.numFailingIterations).toBe(1);
+ expect(root.failed).toBe(true);
+ });
+
+ it('counts both runs regardless of insertion order', () => {
+ const summary = createScoreSummary(asDataset([
+ makeScenario('Coherence.Test1', 'X', true),
+ makeScenario('Coherence', 'Test1'),
+ ]));
+
+ const root = summary.primaryResult;
+ expect(root.numPassingIterations).toBe(1);
+ expect(root.numFailingIterations).toBe(1);
+ });
+
+ it('exposes both colliding runs as leaves with distinct node keys', () => {
+ const summary = createScoreSummary(asDataset([
+ makeScenario('Coherence', 'Test1'),
+ makeScenario('Coherence.Test1', 'X', true),
+ ]));
+
+ const leaves = summary.primaryResult.flattenedNodes.filter((n) => n.isLeafNode);
+ expect(leaves).toHaveLength(2);
+ expect(new Set(leaves.map((n) => n.nodeKey)).size).toBe(2);
+ });
+
+ it('keeps both results when the same scenario and iteration is reported twice', () => {
+ const summary = createScoreSummary(asDataset([
+ makeScenario('GroupA.ScenarioX', 'iteration1'),
+ makeScenario('GroupA.ScenarioX', 'iteration1', true),
+ ]));
+
+ const root = summary.primaryResult;
+ expect(root.numPassingIterations).toBe(1);
+ expect(root.numFailingIterations).toBe(1);
+
+ const keys = [...summary.nodesByKey.get('exec1')!.keys()];
+ expect(new Set(keys).size).toBe(keys.length);
+ });
+
+ it('indexes every duplicated node under its own key', () => {
+ const summary = createScoreSummary(asDataset([
+ makeScenario('GroupA.ScenarioX', 'iteration1'),
+ makeScenario('GroupA.ScenarioX', 'iteration1', true),
+ ]));
+
+ const leaves = summary.primaryResult.flattenedNodes.filter((n) => n.isLeafNode);
+ expect(leaves).toHaveLength(2);
+ for (const leaf of leaves) {
+ expect(summary.nodesByKey.get('exec1')!.get(leaf.nodeKey)).toBe(leaf);
+ }
+ });
+
+ it('distinguishes scenario path segments from dotted iteration names', () => {
+ const summary = createScoreSummary(asDataset([
+ makeScenario('A.B', 'C'),
+ makeScenario('A', 'B.C', true),
+ ]));
+
+ const leaves = summary.primaryResult.flattenedNodes.filter((node) => node.isLeafNode);
+ expect(leaves).toHaveLength(2);
+ expect(new Set(leaves.map((node) => node.nodeKey)).size).toBe(2);
+ for (const leaf of leaves) {
+ expect(summary.nodesByKey.get('exec1')!.get(leaf.nodeKey)).toBe(leaf);
+ }
+ });
+});
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/summaryEmpty.test.ts b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/summaryEmpty.test.ts
new file mode 100644
index 00000000000..eb02f88f0f1
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/summaryEmpty.test.ts
@@ -0,0 +1,37 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import { describe, it, expect } from 'vitest';
+import { createScoreSummary } from '../components/core/Summary';
+
+const emptyDataset: Dataset = {
+ generatorVersion: '0.0.1',
+ createdAt: '2026-06-30T10:00:00.000Z',
+ scenarioRunResults: [],
+};
+
+describe('createScoreSummary — empty-dataset guard', () => {
+ it('returns a well-formed empty summary (no history, zero iterations)', () => {
+ const summary = createScoreSummary(emptyDataset);
+
+ expect(summary.includesReportHistory).toBe(false);
+ expect(summary.executionHistory.size).toBe(0);
+ expect(summary.nodesByKey.size).toBe(0);
+
+ // A safe, empty root ScoreNode stands in for primaryResult.
+ expect(summary.primaryResult).toBeDefined();
+ expect(summary.primaryResult.numPassingIterations).toBe(0);
+ expect(summary.primaryResult.numFailingIterations).toBe(0);
+ expect(
+ summary.primaryResult.numPassingIterations + summary.primaryResult.numFailingIterations,
+ ).toBe(0);
+ });
+
+ it('does not throw when passed a bare empty array in place of a dataset', () => {
+ // Reproduces the original crash surface (dataset.scenarioRunResults undefined).
+ expect(() => createScoreSummary([] as unknown as Dataset)).not.toThrow();
+ const summary = createScoreSummary([] as unknown as Dataset);
+ expect(summary.includesReportHistory).toBe(false);
+ expect(summary.executionHistory.size).toBe(0);
+ });
+});
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/transcript.test.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/transcript.test.tsx
new file mode 100644
index 00000000000..9f55454a6de
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/transcript.test.tsx
@@ -0,0 +1,128 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { ReportContextProvider, createScoreSummary, getConversationDisplay, TranscriptBlock } from '../components';
+import { toolCallDataset, toolCallScenario } from './fixtures/richDataset';
+
+const renderTranscript = (messages: ChatMessage[], modelResponse?: ChatResponse) => {
+ const scoreSummary = createScoreSummary(toolCallDataset);
+ const { messages: display } = getConversationDisplay(messages, modelResponse);
+ return render(
+
+
+ ,
+ );
+};
+
+describe('TranscriptBlock — functionCall / functionResult discrimination', () => {
+ it('renders a merged tool section with the function name, Input caption and arguments', () => {
+ renderTranscript(toolCallScenario.messages, toolCallScenario.modelResponse);
+
+ expect(screen.getByText('Tool call: get_current_weather')).toBeInTheDocument();
+ // The arguments render as a JSON body (distinct from the title above): exactly one
+ // carries both call parameters, so this asserts the body — not the fn name again.
+ const argsBody = screen.getByText(
+ (_content, el) =>
+ el?.tagName === 'PRE' &&
+ /"location": "Seattle, WA"/.test(el.textContent ?? '') &&
+ /"unit": "celsius"/.test(el.textContent ?? ''),
+ );
+ expect(argsBody).toBeInTheDocument();
+ expect(screen.getByText('Input')).toBeInTheDocument();
+ expect(screen.getByText(/Seattle, WA/)).toBeInTheDocument();
+ });
+
+ it('renders the result payload under an Output caption in the same section', () => {
+ renderTranscript(toolCallScenario.messages, toolCallScenario.modelResponse);
+
+ expect(screen.getByText('Output')).toBeInTheDocument();
+ expect(screen.getByText(/Partly cloudy/)).toBeInTheDocument();
+ });
+
+ it('still renders ordinary text content alongside tool blocks', () => {
+ renderTranscript(toolCallScenario.messages, toolCallScenario.modelResponse);
+
+ expect(screen.getByText(/Let me look that up for you\./)).toBeInTheDocument();
+ expect(screen.getByText(/What is the weather in Seattle right now\?/)).toBeInTheDocument();
+ });
+});
+
+describe('TranscriptBlock — unknown $type degrades gracefully', () => {
+ const mysteryContent = {
+ $type: 'mysteryWidget',
+ payload: { sentinel: 'UNKNOWN_TYPE_SENTINEL', nested: [1, 2, 3] },
+ } as unknown as AIContent;
+
+ const mysteryMessages: ChatMessage[] = [
+ { role: 'user', contents: [{ $type: 'text', text: 'Trigger the mystery.' } as unknown as AIContent] },
+ { role: 'assistant', authorName: 'gpt-4o', contents: [mysteryContent] },
+ ];
+
+ it('serializes the unknown content so its data is still visible', () => {
+ renderTranscript(mysteryMessages);
+ expect(screen.getByText(/UNKNOWN_TYPE_SENTINEL/)).toBeInTheDocument();
+ });
+
+ it('renders the transcript shell (header) even with only unknown content', () => {
+ renderTranscript(mysteryMessages);
+ expect(screen.getByText('Transcript')).toBeInTheDocument();
+ });
+});
+
+describe('TranscriptBlock — malformed content degrades gracefully', () => {
+ it.each([
+ ['null', null],
+ ['primitive', 'MALFORMED_PRIMITIVE_SENTINEL'],
+ ['non-string text', { $type: 'text', text: 42 }],
+ ['non-string data URI', { $type: 'data', uri: 42 }],
+ ['non-string media type', { $type: 'uri', uri: 'https://example.com/image.png', mediaType: 42 }],
+ ])('serializes %s content without throwing', (_name, malformedContent) => {
+ const messages: ChatMessage[] = [{
+ role: 'assistant',
+ contents: [malformedContent as unknown as AIContent],
+ }];
+
+ expect(() => renderTranscript(messages)).not.toThrow();
+ });
+
+ it('continues treating forward-compatible reasoning content as text', () => {
+ const reasoningContent = {
+ $type: 'reasoning',
+ text: 'FORWARD_REASONING_SENTINEL',
+ } as unknown as AIContent;
+ const messages: ChatMessage[] = [{ role: 'assistant', contents: [reasoningContent] }];
+
+ renderTranscript(messages);
+
+ expect(screen.getByText('FORWARD_REASONING_SENTINEL')).toBeInTheDocument();
+ });
+});
+
+describe('TranscriptBlock — image content renders as
with derived alt text', () => {
+ it('renders a UriContent image (mediaType image/*) with the user-side alt text', () => {
+ const uriImage = {
+ $type: 'uri',
+ uri: 'https://example.com/cat.png',
+ mediaType: 'image/png',
+ } as unknown as AIContent;
+ const messages: ChatMessage[] = [{ role: 'user', contents: [uriImage] }];
+
+ renderTranscript(messages);
+
+ const img = screen.getByRole('img', { name: 'Image shared by the user' });
+ expect(img).toHaveAttribute('src', 'https://example.com/cat.png');
+ });
+
+ it('renders a DataContent image (data:image/ URI) with the assistant-side alt text', () => {
+ const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCA',
+ dataImage = { $type: 'data', uri: dataUri } as unknown as AIContent;
+ const messages: ChatMessage[] = [{ role: 'assistant', authorName: 'gpt-4o', contents: [dataImage] }];
+
+ renderTranscript(messages);
+
+ const img = screen.getByRole('img', { name: 'Image shared by the assistant' });
+ expect(img).toHaveAttribute('src', dataUri);
+ });
+});
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/trendChart.test.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/trendChart.test.tsx
new file mode 100644
index 00000000000..b74e09a839f
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/trendChart.test.tsx
@@ -0,0 +1,151 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import React from 'react';
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { createScoreSummary, ReportContextProvider } from '../components';
+import { TrendChart, type BandPoint } from '../components/history/TrendChart';
+import { axisDomain } from '../components/history/axisDomain';
+import { singleExecutionDataset } from './fixtures/richDataset';
+
+const renderChart = (ui: React.ReactElement) => {
+ const scoreSummary = createScoreSummary(singleExecutionDataset);
+ return render(
+
+ {ui}
+ ,
+ );
+};
+
+const bp = (mean: number, extra: Partial = {}): BandPoint => ({
+ mean,
+ median: mean,
+ lo: mean,
+ hi: mean,
+ n: 1,
+ ...extra,
+});
+
+const meanDots = (c: HTMLElement): SVGCircleElement[] =>
+ [...c.querySelectorAll('circle')].filter((el) => el.querySelector('title')) as SVGCircleElement[];
+
+const plotBounds = (container: HTMLElement) => {
+ const gridLines = [...container.querySelectorAll('svg > line')]
+ .filter((line) => line.getAttribute('x1') !== line.getAttribute('x2'));
+ const x = gridLines.flatMap((line) => [Number(line.getAttribute('x1')), Number(line.getAttribute('x2'))]);
+ const y = gridLines.map((line) => Number(line.getAttribute('y1')));
+ return { left: Math.min(...x), right: Math.max(...x), top: Math.min(...y), bottom: Math.max(...y) };
+};
+
+const scoreDomain = axisDomain([1, 5]);
+
+describe('TrendChart — point geometry', () => {
+ it('orders two points left-to-right and low-to-high within the chart bounds', () => {
+ const { container } = renderChart(
+ ,
+ );
+ const dots = meanDots(container);
+ const { left, right, top, bottom } = plotBounds(container);
+ expect(dots).toHaveLength(2);
+ const [firstX, secondX] = dots.map((dot) => Number(dot.getAttribute('cx')));
+ const [firstY, secondY] = dots.map((dot) => Number(dot.getAttribute('cy')));
+ expect(firstX).toBeGreaterThanOrEqual(left);
+ expect(secondX).toBeLessThanOrEqual(right);
+ expect(firstX).toBeLessThan(secondX);
+ expect(firstY).toBeLessThanOrEqual(bottom);
+ expect(secondY).toBeGreaterThanOrEqual(top);
+ expect(firstY).toBeGreaterThan(secondY);
+ });
+
+ it('centres a single point horizontally', () => {
+ const { container } = renderChart(
+ ,
+ );
+ const dots = meanDots(container);
+ const { left, right, top, bottom } = plotBounds(container);
+ expect(dots).toHaveLength(1);
+ expect(Number(dots[0].getAttribute('cx'))).toBe((left + right) / 2);
+ expect(Number(dots[0].getAttribute('cy'))).toBeGreaterThan(top);
+ expect(Number(dots[0].getAttribute('cy'))).toBeLessThan(bottom);
+ });
+
+ it('draws the min–max spread as a filled band polygon for a multi-point series', () => {
+ const { container } = renderChart(
+ ,
+ );
+ expect(container.querySelector('polygon')).not.toBeNull();
+ });
+});
+
+describe('TrendChart — accessibility + legend toggle', () => {
+ it('exposes the SVG as role=img with the supplied aria-label', () => {
+ renderChart();
+ expect(screen.getByRole('img', { name: 'Latency trend' })).toBeInTheDocument();
+ });
+
+ it('renders nothing when there are no points', () => {
+ const { container } = renderChart();
+ expect(container.querySelector('svg')).toBeNull();
+ });
+
+ it('shows the three-item legend by default', () => {
+ renderChart();
+ expect(screen.getByText('Mean per run')).toBeInTheDocument();
+ expect(screen.getByText('Median per run')).toBeInTheDocument();
+ expect(screen.getByText(/spread across cases/)).toBeInTheDocument();
+ });
+
+ it('omits the legend when showLegend is false', () => {
+ renderChart();
+ expect(screen.queryByText('Mean per run')).not.toBeInTheDocument();
+ expect(screen.queryByText('Median per run')).not.toBeInTheDocument();
+ });
+});
+
+describe('TrendChart — axisDomain framing (anchored domain, no squashing, no clipping)', () => {
+ it('keeps all points within the plot rect for an out-of-range series, and the axis max grows to fit it', () => {
+ const values = [2, 6, 4];
+ const domain = axisDomain(values);
+ expect(domain.max).toBeGreaterThanOrEqual(6);
+
+ const { container } = renderChart(
+ bp(v))} domain={domain} ariaLabel="Outlier" />,
+ );
+ const dots = meanDots(container);
+ const { top, bottom } = plotBounds(container);
+ expect(dots).toHaveLength(3);
+ for (const dot of dots) {
+ const cy = Number(dot.getAttribute('cy'));
+ expect(cy).toBeGreaterThanOrEqual(top);
+ expect(cy).toBeLessThanOrEqual(bottom);
+ }
+ });
+
+ it('frames a genuine [0,1] fraction series to the unit interval without squashing it to the plot floor', () => {
+ const values = [0.2, 0.5, 0.8];
+ const domain = axisDomain(values);
+ expect(domain.min).toBe(0);
+ expect(domain.max).toBe(1);
+
+ const { container } = renderChart(
+ bp(v))} domain={domain} ariaLabel="Fraction" />,
+ );
+ const ys = meanDots(container).map((d) => Number(d.getAttribute('cy')));
+ const span = Math.max(...ys) - Math.min(...ys);
+ expect(span).toBeGreaterThan(120);
+ });
+
+ it('clamps a point outside the supplied domain into the plot rect instead of rendering off-canvas', () => {
+ const narrowDomain = { min: 1, max: 5, ticks: 4, fmt: (v: number) => String(v) };
+ const { container } = renderChart(
+ ,
+ );
+ const dots = meanDots(container);
+ const { top, bottom } = plotBounds(container);
+ expect(dots).toHaveLength(1);
+ const cy = Number(dots[0].getAttribute('cy'));
+ expect(cy).toBeGreaterThanOrEqual(top);
+ expect(cy).toBeLessThanOrEqual(bottom);
+ });
+});
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/viewModels.test.ts b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/viewModels.test.ts
new file mode 100644
index 00000000000..4eabff5d887
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/viewModels.test.ts
@@ -0,0 +1,228 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import { describe, it, expect } from 'vitest';
+import { createScoreSummary } from '../components/core/Summary';
+import {
+ isLeafFailed,
+ passRateByScenarioGroup,
+ kpiCountsFromNode,
+ ratingBucket,
+ bucketMetrics,
+ chronologicalExecutions,
+} from '../components/core/viewModels';
+import {
+ multiGroupDataset,
+ diagnosticsErrorDataset,
+ twoExecutionDataset,
+} from './fixtures/richDataset';
+
+describe('passRateByScenarioGroup — group rows reconcile to the whole (multiGroupDataset)', () => {
+ it('sum of per-group passing/total equals the tree iteration totals', () => {
+ const summary = createScoreSummary(multiGroupDataset);
+ const root = summary.primaryResult;
+
+ const rows = passRateByScenarioGroup(multiGroupDataset);
+ const sumPassing = rows.reduce((acc, r) => acc + r.passing, 0);
+ const sumTotal = rows.reduce((acc, r) => acc + r.total, 0);
+
+ const treePassing = root.numPassingIterations;
+ const treeFailing = root.numFailingIterations;
+ const treeTotal = treePassing + treeFailing;
+
+ expect(sumTotal).toBe(treeTotal);
+ expect(sumPassing).toBe(treePassing);
+ expect(sumTotal - sumPassing).toBe(treeFailing);
+ });
+
+ it('produces one row per distinct scenario group with literal pass rates', () => {
+ const rows = passRateByScenarioGroup(multiGroupDataset);
+
+ expect(rows.map(r => r.group).sort()).toEqual(['GroupA', 'GroupB', 'GroupC']);
+
+ const byGroup = (g: string) => rows.find(r => r.group === g)!;
+ expect(byGroup('GroupA')).toMatchObject({ passing: 2, total: 2 });
+ expect(byGroup('GroupA').passRate).toBeCloseTo(1, 10);
+ expect(byGroup('GroupB')).toMatchObject({ passing: 2, total: 2 });
+ expect(byGroup('GroupB').passRate).toBeCloseTo(1, 10);
+ expect(byGroup('GroupC')).toMatchObject({ passing: 2, total: 3 });
+ expect(byGroup('GroupC').passRate).toBeCloseTo(2 / 3, 10);
+ });
+});
+
+describe('Overview derivations == Cases-tree counts (diagnosticsErrorDataset)', () => {
+ it('KPI counts and group-sum counts equal the Cases-tree numPassing/numFailing', () => {
+ const summary = createScoreSummary(diagnosticsErrorDataset);
+ const tree = summary.primaryResult;
+
+ expect(tree.numPassingIterations).toBe(2);
+ expect(tree.numFailingIterations).toBe(1);
+
+ const kpi = kpiCountsFromNode(tree);
+ expect(kpi.passing).toBe(2);
+ expect(kpi.failing).toBe(1);
+ expect(kpi.total).toBe(3);
+ expect(kpi.passRate).toBeCloseTo(2 / 3, 10);
+
+ const rows = passRateByScenarioGroup(diagnosticsErrorDataset);
+ const groupPassing = rows.reduce((acc, r) => acc + r.passing, 0);
+ const groupTotal = rows.reduce((acc, r) => acc + r.total, 0);
+ expect(groupPassing).toBe(2);
+ expect(groupTotal).toBe(3);
+ });
+
+ it('isLeafFailed alone flags the diagnostics-only failure and clears the clean pass', () => {
+ const failing = diagnosticsErrorDataset.scenarioRunResults.find(
+ s => s.scenarioName === 'DiagTest.DiagnosticFailOnly',
+ )!;
+ const clean = diagnosticsErrorDataset.scenarioRunResults.find(
+ s => s.scenarioName === 'DiagTest.CleanPass',
+ )!;
+ const info = diagnosticsErrorDataset.scenarioRunResults.find(
+ s => s.scenarioName === 'DiagTest.InfoDiagnosticPass',
+ )!;
+
+ expect(failing.evaluationResult.metrics['mathematicalAccuracy'].interpretation?.failed).toBe(false);
+ expect(isLeafFailed(failing)).toBe(true);
+ expect(isLeafFailed(clean)).toBe(false);
+ expect(isLeafFailed(info)).toBe(false);
+ });
+});
+
+describe('isLeafFailed — empty-metrics semantics', () => {
+ it('absent/empty metrics → false (uses .some, never .every)', () => {
+ const noMetrics = {
+ scenarioName: 'Empty.NoMetrics',
+ iterationName: 'iteration1',
+ executionName: 'exec-empty',
+ creationTime: '2026-06-30T10:00:00.000Z',
+ messages: [],
+ modelResponse: { messages: [] },
+ evaluationResult: { metrics: {} },
+ formatVersion: 1,
+ } as ScenarioRunResult;
+ expect(isLeafFailed(noMetrics)).toBe(false);
+ });
+});
+
+describe('ratingBucket + bucketMetrics', () => {
+ it('maps ratings into good/fair/weak/unknown buckets', () => {
+ expect(ratingBucket('exceptional')).toBe('good');
+ expect(ratingBucket('good')).toBe('good');
+ expect(ratingBucket('average')).toBe('fair');
+ expect(ratingBucket('poor')).toBe('weak');
+ expect(ratingBucket('unacceptable')).toBe('weak');
+ expect(ratingBucket('unknown')).toBe('unknown');
+ expect(ratingBucket('inconclusive')).toBe('unknown');
+ expect(ratingBucket(undefined)).toBe('unknown');
+ });
+
+ it('buckets every metric into the exact good/fair/weak/unknown distribution', () => {
+ const primary = multiGroupDataset.scenarioRunResults;
+ const counts = bucketMetrics(primary);
+
+ expect(counts).toEqual({ good: 3, fair: 3, weak: 2, unknown: 2 });
+
+ // Conservation: all 10 metrics counted exactly once.
+ const totalMetrics = primary.reduce(
+ (acc, s) => acc + Object.values(s.evaluationResult.metrics).length,
+ 0,
+ );
+ expect(totalMetrics).toBe(10);
+ expect(counts.good + counts.fair + counts.weak + counts.unknown).toBe(totalMetrics);
+ });
+});
+
+describe('passRateByScenarioGroup — deltaRun compares against the chronologically previous run', () => {
+ it('deltaRun is NEGATIVE (−0.5) when the active exec underperforms the previous run', () => {
+ const rows = passRateByScenarioGroup(twoExecutionDataset, 'exec-v2');
+ const comparison = rows.find(r => r.group === 'Comparison')!;
+ expect(comparison).toMatchObject({ passing: 1, total: 2 });
+ expect(comparison.passRate).toBeCloseTo(0.5, 10);
+ expect(comparison.deltaRun).toBeCloseTo(-0.5, 10);
+ });
+
+ it('deltaRun is POSITIVE (+0.5) when a later exec outperforms the previous run', () => {
+ // Both executions share a creationTime, so chronological order falls back to insertion
+ // order; reversing it makes exec-v1 (2/2 passing) the later run, after exec-v2 (1/2).
+ const reversed: Dataset = {
+ ...twoExecutionDataset,
+ scenarioRunResults: [...twoExecutionDataset.scenarioRunResults].reverse(),
+ };
+ const rows = passRateByScenarioGroup(reversed, 'exec-v1');
+ const comparison = rows.find(r => r.group === 'Comparison')!;
+ expect(comparison.passRate).toBeCloseTo(1, 10);
+ expect(comparison.deltaRun).toBeCloseTo(0.5, 10);
+ });
+
+ it('deltaRun is undefined for the earliest execution (no previous run to compare)', () => {
+ const rows = passRateByScenarioGroup(twoExecutionDataset); // default active = earliest exec-v1
+ const comparison = rows.find(r => r.group === 'Comparison')!;
+ expect(comparison).toMatchObject({ passing: 2, total: 2 });
+ expect(comparison.deltaRun).toBeUndefined();
+ });
+
+ it('deltaRun is undefined for a single-execution dataset', () => {
+ const single: Dataset = {
+ ...twoExecutionDataset,
+ scenarioRunResults: twoExecutionDataset.scenarioRunResults.filter(
+ s => s.executionName === 'exec-v1',
+ ),
+ };
+ for (const r of passRateByScenarioGroup(single)) {
+ expect(r.deltaRun).toBeUndefined();
+ }
+ });
+});
+
+describe('passRateByScenarioGroup — out-of-order insertion', () => {
+ const G1 = 'exec-earliest';
+ const G2 = 'exec-middle';
+ const G3 = 'exec-latest';
+
+ const OT1 = '2026-04-01T00:00:00.000Z';
+ const OT2 = '2026-05-01T00:00:00.000Z';
+ const OT3 = '2026-06-01T00:00:00.000Z';
+
+ const orderingMetric = (value: number, failed: boolean): NumericMetric =>
+ ({
+ $type: 'numeric',
+ name: 'accuracy',
+ value,
+ interpretation: { rating: failed ? 'poor' : 'good', failed },
+ }) as NumericMetric;
+
+ const orderingScenario = (executionName: string, creationTime: string, value: number, failed: boolean): ScenarioRunResult =>
+ ({
+ scenarioName: 'Ordering.Case',
+ iterationName: 'iteration1',
+ executionName,
+ creationTime,
+ messages: [],
+ modelResponse: { messages: [] },
+ evaluationResult: { metrics: { accuracy: orderingMetric(value, failed) } },
+ formatVersion: 1,
+ }) as ScenarioRunResult;
+
+ const outOfOrderDataset: Dataset = {
+ generatorVersion: '0.0.1',
+ createdAt: OT3,
+ scenarioRunResults: [
+ orderingScenario(G3, OT3, 5, false),
+ orderingScenario(G1, OT1, 1, true),
+ orderingScenario(G2, OT2, 3, false),
+ ],
+ };
+
+ it('passRateByScenarioGroup resolves the chronological predecessor, not the insertion predecessor', () => {
+ const chrono = chronologicalExecutions(outOfOrderDataset);
+ expect(chrono).toEqual([G1, G2, G3]);
+
+ const previous = chrono[chrono.indexOf(G3) - 1];
+ expect(previous).toBe(G2);
+
+ const rows = passRateByScenarioGroup(outOfOrderDataset, G3);
+ const group = rows.find(r => r.group === 'Ordering')!;
+ expect(group.deltaRun).toBeCloseTo(0, 10);
+ });
+});
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/viewRouter.test.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/viewRouter.test.tsx
new file mode 100644
index 00000000000..c5190d2914c
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/viewRouter.test.tsx
@@ -0,0 +1,67 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import React, { useEffect } from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import { createScoreSummary, ReportContextProvider, useReportContext, type ReportView } from '../components';
+import { ViewRouter } from '../components/shell/ViewRouter';
+import { twoExecutionDataset } from './fixtures/richDataset';
+
+// ViewRouter reads `view` from ReportContext and returns exactly one view component:
+// 'cases' -> CasesView, 'history' -> HistoryView, 'comparison' -> ComparisonView,
+// 'overview' | default -> OverviewView.
+// Each view exposes a unique text marker, so asserting that marker is enough to prove routing.
+const MARKER: Record = {
+ overview: /overall pass rate/i, // OverviewView SummaryCard eyebrow
+ cases: /show failed/i, // CasesView failed-only switch label
+ history: /run history/i, // HistoryView run-history section
+ comparison: /per-metric change/i, // ComparisonView per-metric section
+};
+
+// Drives the context to `view` after mount (mirrors the useEffect setter pattern in views.test.tsx).
+// `view` is typed loosely so the "unexpected value" fallback case can force a non-ReportView string.
+const RouteAt = ({ view }: { view?: string }) => {
+ const { view: current, setView } = useReportContext();
+ useEffect(() => {
+ if (view !== undefined && current !== view) {
+ setView(view as ReportView);
+ }
+ }, [view, current, setView]);
+ return ;
+};
+
+const renderRouter = (ui: React.ReactElement) => {
+ const scoreSummary = createScoreSummary(twoExecutionDataset);
+ return render(
+
+ {ui}
+ ,
+ );
+};
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('ViewRouter — routing per ReportView', () => {
+ it.each([
+ ['overview', undefined],
+ ['cases', 'cases'],
+ ['history', 'history'],
+ ['comparison', 'comparison'],
+ ] satisfies [ReportView, ReportView | undefined][])('renders %s for its valid route', async (expected, view) => {
+ renderRouter(view === undefined ? : );
+ expect(await screen.findByText(MARKER[expected])).toBeInTheDocument();
+ for (const [candidate, marker] of Object.entries(MARKER)) {
+ if (candidate !== expected) expect(screen.queryByText(marker)).not.toBeInTheDocument();
+ }
+ });
+
+ it('renders OverviewView (default branch) for an unexpected view value', async () => {
+ // The switch default falls through to OverviewView for any value outside the union.
+ renderRouter();
+ expect(await screen.findByText(MARKER.overview)).toBeInTheDocument();
+ expect(screen.queryByText(MARKER.cases)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/views.test.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/views.test.tsx
new file mode 100644
index 00000000000..798bb0037d3
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/test/views.test.tsx
@@ -0,0 +1,153 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import React, { useEffect } from 'react';
+import { describe, it, expect } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { createScoreSummary, ReportContextProvider, useReportContext, HistoryView, ComparisonView } from '../components';
+import { twoExecutionDataset, singleExecutionDataset } from './fixtures/richDataset';
+
+const renderWith = (dataset: Dataset, ui: React.ReactElement) => {
+ const scoreSummary = createScoreSummary(dataset);
+ return render(
+
+ {ui}
+ ,
+ );
+};
+
+const ScenarioSelector = ({ scenarioName, children }: { scenarioName: string; children: React.ReactNode }) => {
+ const { activeNode, selectedScenarioLevel, selectScenarioLevel } = useReportContext();
+ const targetKey = activeNode.flattenedNodes.find(
+ (n) => n.isLeafNode && n.scenario?.scenarioName === scenarioName,
+ )?.nodeKey;
+ useEffect(() => {
+ if (targetKey && selectedScenarioLevel !== targetKey) {
+ selectScenarioLevel(targetKey);
+ }
+ }, [targetKey, selectedScenarioLevel]);
+ return <>{children}>;
+};
+
+describe('HistoryView — twoExecutionDataset', () => {
+ it('renders exactly one trend chart (role=img) labelled for the active metric + scenario', () => {
+ renderWith(twoExecutionDataset, );
+ const charts = screen.getAllByRole('img');
+ expect(charts.length).toBe(1);
+ expect(charts[0]).toHaveAttribute(
+ 'aria-label',
+ expect.stringMatching(/trend across executions for Comparison\./),
+ );
+ });
+
+});
+
+describe('HistoryView — singleExecutionDataset (empty state)', () => {
+ it('renders the "Needs at least 2 executions" message', () => {
+ renderWith(singleExecutionDataset, );
+ expect(screen.getByText(/needs at least 2 executions/i)).toBeInTheDocument();
+ });
+
+});
+
+describe('ComparisonView — twoExecutionDataset', () => {
+ it('renders execution dropdowns for A and B', () => {
+ renderWith(twoExecutionDataset, );
+ expect(screen.getByLabelText(/baseline execution/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/current execution/i)).toBeInTheDocument();
+ });
+
+ const metricRowNames = (container: HTMLElement): string[] =>
+ [...container.querySelectorAll('[role="rowgroup"] .eval-grid3[role="row"]')].map(
+ (row) => row.firstElementChild?.textContent?.trim() ?? '',
+ );
+
+ it('renders all metric rows when no scenario is selected', () => {
+ const { container } = renderWith(twoExecutionDataset, );
+ expect(metricRowNames(container).sort()).toEqual(
+ ['accuracy', 'coherence', 'fluency', 'safety'],
+ );
+ });
+
+ it('hides other scenarios once a sidebar scenario is selected', async () => {
+ const { container } = renderWith(
+ twoExecutionDataset,
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(metricRowNames(container).sort()).toEqual(['coherence', 'safety']),
+ );
+ });
+});
+
+describe('ComparisonView — value deltas with no inferable direction stay directional and unjudged', () => {
+ const inv = (name: string, value: number): NumericMetric =>
+ ({
+ $type: 'numeric',
+ name,
+ value,
+ reason: 'test',
+ interpretation: { rating: 'good', failed: false },
+ }) as NumericMetric;
+
+ const invRow = (executionName: string, creationTime: string, metrics: Record): ScenarioRunResult =>
+ ({
+ scenarioName: 'Inv.Scenario',
+ iterationName: 'iteration1',
+ executionName,
+ creationTime,
+ messages: [],
+ modelResponse: { messages: [] },
+ evaluationResult: { metrics },
+ formatVersion: 1,
+ }) as ScenarioRunResult;
+
+ // Every metric here holds a constant 'good' rating, so no better-direction can be inferred from
+ // the data. With no direction signal the deltas stay purely directional: toxicity DROPS 5→2 and
+ // flat RISES 3→5, and neither is judged "improved" nor "regressed".
+ const inversionDataset: Dataset = {
+ generatorVersion: '0.0.1',
+ createdAt: '2026-04-01T00:00:00.000Z',
+ scenarioRunResults: [
+ invRow('exec-old', '2026-03-01T00:00:00.000Z', { toxicity: inv('toxicity', 5), flat: inv('flat', 3) }),
+ invRow('exec-new', '2026-04-01T00:00:00.000Z', { toxicity: inv('toxicity', 2), flat: inv('flat', 5) }),
+ ],
+ };
+
+ it('reports the raw direction of every value delta when no direction can be inferred', () => {
+ renderWith(inversionDataset, );
+ expect(screen.queryByText('Metrics improved')).not.toBeInTheDocument();
+ expect(screen.queryByText('Metrics regressed')).not.toBeInTheDocument();
+ expect(screen.getByText('Metrics increased').nextElementSibling?.textContent).toBe('1');
+ expect(screen.getByText('Metrics decreased').nextElementSibling?.textContent).toBe('1');
+ });
+
+ it('surfaces the biggest raw delta by magnitude, not by a "better" judgment', () => {
+ renderWith(inversionDataset, );
+ const biggest = screen.getByText('Biggest change');
+ expect(biggest.nextElementSibling?.textContent).toContain('▼');
+ expect(biggest.nextElementSibling?.nextElementSibling?.textContent).toBe('toxicity');
+ });
+
+ it('never announces "improved"/"regressed" in the accessible per-metric delta text', () => {
+ renderWith(inversionDataset, );
+ expect(screen.getByText('decreased by 3')).toBeInTheDocument();
+ expect(screen.getByText('increased by 2')).toBeInTheDocument();
+ expect(screen.queryByText(/improved|regressed/i)).not.toBeInTheDocument();
+ });
+});
+
+describe('ComparisonView — singleExecutionDataset (empty state)', () => {
+ it('renders the "Needs at least 2 executions" message', () => {
+ renderWith(singleExecutionDataset, );
+ expect(screen.getByText(/needs at least 2 executions/i)).toBeInTheDocument();
+ });
+
+ it('does NOT render execution dropdowns', () => {
+ renderWith(singleExecutionDataset, );
+ expect(screen.queryByLabelText(/baseline execution/i)).not.toBeInTheDocument();
+ expect(screen.queryByLabelText(/current execution/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/tsconfig.app.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/tsconfig.app.json
index 9ff0ee198a8..1d5987f3e90 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/tsconfig.app.json
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/tsconfig.app.json
@@ -21,5 +21,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["components", "html-report", "azure-devops-report"]
+ "include": ["components", "html-report", "azure-devops-report", "test"]
}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/vitest.config.ts b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/vitest.config.ts
new file mode 100644
index 00000000000..4e1566b3f73
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/vitest.config.ts
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./test/setup.ts'],
+ },
+});
diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs
index 637678f6be2..e213017476c 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI.Evaluation.Reporting.Formats;
@@ -207,6 +208,121 @@ public void SerializeDatasetCompact()
ValidateEquivalence(entry.EvaluationResult, deserialized.ScenarioRunResults[0].EvaluationResult);
}
+ [Fact]
+ public void SerializeDatasetForTypeScriptContract()
+ {
+ var metricWithNoValue = new EvaluationMetric("none", reason: "No score was produced.");
+ var nullableNumericMetric = new NumericMetric("nullableNumeric");
+ var nullableBooleanMetric = new BooleanMetric("nullableBoolean");
+ var nullableStringMetric = new StringMetric("nullableString");
+
+ var functionCall = new FunctionCallContent(
+ callId: "call-contract-001",
+ name: "lookup",
+ arguments: new Dictionary { ["query"] = "fixed" })
+ {
+ InformationalOnly = true
+ };
+
+ var entry = new ScenarioRunResult(
+ scenarioName: "Contract.Witness",
+ iterationName: "iteration-fixed",
+ executionName: "execution-fixed",
+ creationTime: new DateTime(2026, 7, 21, 12, 34, 56, DateTimeKind.Utc),
+ messages:
+ [
+ new ChatMessage(
+ ChatRole.User,
+ [
+ new AIContent(),
+ new DataContent(new byte[] { 0x89, 0x50, 0x4E, 0x47 }, "image/png"),
+ functionCall
+ ])
+ ],
+ modelResponse: new ChatResponse(
+ new ChatMessage(
+ ChatRole.Assistant,
+ [new FunctionResultContent("call-contract-001", result: "fixed-result")])),
+ evaluationResult: new EvaluationResult(
+ metricWithNoValue,
+ nullableNumericMetric,
+ nullableBooleanMetric,
+ nullableStringMetric))
+ {
+ FormatVersion = null
+ };
+
+ var dataset = new Dataset(
+ [entry],
+ createdAt: new DateTime(2026, 7, 21, 12, 35, 0, DateTimeKind.Utc),
+ generatorVersion: null);
+
+ string json = JsonSerializer.Serialize(dataset, JsonUtilities.Compact.DatasetTypeInfo);
+ using JsonDocument document = JsonDocument.Parse(json);
+
+ JsonElement scenario = document.RootElement.GetProperty("scenarioRunResults")[0];
+ JsonElement metrics = scenario.GetProperty("evaluationResult").GetProperty("metrics");
+ Assert.False(metrics.GetProperty("none").TryGetProperty("value", out _));
+ Assert.False(metrics.GetProperty("nullableNumeric").TryGetProperty("value", out _));
+ Assert.False(metrics.GetProperty("nullableBoolean").TryGetProperty("value", out _));
+ Assert.False(metrics.GetProperty("nullableString").TryGetProperty("value", out _));
+ Assert.False(scenario.TryGetProperty("formatVersion", out _));
+
+ JsonElement contents = scenario.GetProperty("messages")[0].GetProperty("contents");
+ Assert.False(contents[0].TryGetProperty("$type", out _));
+ Assert.False(contents[1].TryGetProperty("mediaType", out _));
+ Assert.True(contents[2].GetProperty("informationalOnly").GetBoolean());
+
+ string? witnessPath = Environment.GetEnvironmentVariable("AI_EVALUATION_DATASET_WITNESS_PATH");
+ if (!string.IsNullOrEmpty(witnessPath))
+ {
+ string? witnessDirectory = Path.GetDirectoryName(witnessPath);
+ if (!string.IsNullOrEmpty(witnessDirectory))
+ {
+ Directory.CreateDirectory(witnessDirectory);
+ }
+
+ string testModule = $$"""
+ // Licensed to the .NET Foundation under one or more agreements.
+ // The .NET Foundation licenses this file to you under the MIT license.
+
+ import { describe, expect, it } from 'vitest';
+ import { createScoreSummary } from '../../components';
+
+ const dataset = {{json}} satisfies Dataset;
+
+ describe('production Dataset contract witness', () => {
+ it('matches the TypeScript contract and shared summary boundary', () => {
+ const scenario = dataset.scenarioRunResults[0];
+ const metrics = scenario.evaluationResult.metrics;
+ const contents = scenario.messages[0].contents;
+
+ expect(metrics.none).not.toHaveProperty('value');
+ expect(metrics.nullableNumeric).not.toHaveProperty('value');
+ expect(metrics.nullableBoolean).not.toHaveProperty('value');
+ expect(metrics.nullableString).not.toHaveProperty('value');
+ expect(scenario).not.toHaveProperty('formatVersion');
+ expect(contents[0]).not.toHaveProperty('$type');
+ expect(contents[1]).toEqual({ $type: 'data', uri: 'data:image/png;base64,iVBORw==' });
+ expect(contents[1]).not.toHaveProperty('mediaType');
+ expect(contents[2]).toMatchObject({
+ $type: 'functionCall',
+ callId: 'call-contract-001',
+ name: 'lookup',
+ informationalOnly: true,
+ });
+
+ const summary = createScoreSummary(dataset);
+ expect(summary.primaryResult.flattenedNodes.some(node =>
+ node.scenario?.scenarioName === 'Contract.Witness')).toBe(true);
+ });
+ });
+ """;
+
+ File.WriteAllText(witnessPath, testModule);
+ }
+ }
+
[Fact]
public void VerifyCompactSerialization()
{