Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
- 'Docs/pages/**'
- 'Pipeline/Build.Pages.cs'
- 'Pipeline/Build.Benchmarks.cs'
- 'Pipeline/Build.MockolateBenchmarks.cs'
- '.github/workflows/pages.yml'
repository_dispatch:
types: [ extension-documentation-updated-event ]
Expand Down
1 change: 1 addition & 0 deletions .nuke/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"type": "string",
"enum": [
"Benchmarks",
"MockolateBenchmarks",
"Pages"
]
},
Expand Down
6 changes: 5 additions & 1 deletion Docs/pages/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ const config: Config = {
// original single-library sites have not yet been fixed up.
onBrokenLinks: 'warn',
onBrokenAnchors: 'warn',
onBrokenMarkdownLinks: 'warn',
markdown: {
hooks: {
onBrokenMarkdownLinks: 'warn',
},
},

i18n: {
defaultLocale: 'en',
Expand Down
286 changes: 286 additions & 0 deletions Docs/pages/src/components/MockolateBenchmarkResult/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
/**
* Renders the latest Mockolate-vs-other-mocking-libraries snapshot for a
* single benchmark scenario (e.g. "Method", "Property", "Callback").
* Reads from `src/data/mockolate/benchmarks.json` — refreshed at deploy
* time by Testably.Site's docs pipeline (the `MockolateBenchmarks` Nuke
* target) from the `Testably/Mockolate` benchmarks branch. The committed
* file doubles as a fallback so `npm start`/`npm run build` work in a
* clean checkout.
*
* For benchmarks parameterised with `[Params(1, 10)]`, the component
* renders inline N=1 / N=10 buttons so readers can switch between
* fixed-cost and per-call regimes.
*/
import React, {type ReactElement, useState} from 'react';
import benchmarks from '@site/src/data/mockolate/benchmarks.json';
import styles from './styles.module.css';

const BASELINE_LIBRARY = 'Mockolate';
const LIBRARY_ORDER = [
'Mockolate',
'Moq',
'NSubstitute',
'FakeItEasy',
'TUnitMocks',
'Imposter',
] as const;
const LIBRARY_DISPLAY_NAMES: Record<string, string> = {
TUnitMocks: 'TUnit.Mocks',
};

type Sample = {
timeNs: number;
memoryBytes?: number;
history?: {
timeNs: number[];
memoryBytes: number[];
};
};

type Scenario = {
parameters: string[];
samples: Record<string, Record<string, Sample>>;
};

type Snapshot = {
capturedAt: {
sha: string;
shortSha: string;
date: string;
message?: string;
};
environment?: {
runner?: string;
toolchain?: string;
};
benchmarks: Record<string, Scenario>;
};

const data = benchmarks as Snapshot;

function formatNs(ns: number): string {
if (ns < 1_000) return `${ns.toFixed(1)} ns`;
if (ns < 1_000_000) return `${(ns / 1_000).toFixed(2)} µs`;
return `${(ns / 1_000_000).toFixed(2)} ms`;
}

function formatBytes(b: number): string {
if (b < 1_024) return `${b} B`;
if (b < 1_024 * 1_024) return `${(b / 1_024).toFixed(2)} KiB`;
return `${(b / (1_024 * 1_024)).toFixed(2)} MiB`;
}

function formatRatio(value: number, baseline: number): string {
if (baseline === 0) return value === 0 ? '1.00x' : '∞';
return `${(value / baseline).toFixed(2)}x`;
}

const SPARK_WIDTH = 90;
const SPARK_HEIGHT = 16;
const SPARK_PADDING = 2;

type SparklineProps = {
values: number[] | undefined;
className: string;
};

function Sparkline({values, className}: SparklineProps): ReactElement | null {
if (!values || values.length < 2) return null;

const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min;
const innerHeight = SPARK_HEIGHT - SPARK_PADDING * 2;

const points = values.map((v, i) => {
const x = (i / (values.length - 1)) * SPARK_WIDTH;
const y = range === 0
? SPARK_HEIGHT / 2
: SPARK_PADDING + innerHeight - ((v - min) / range) * innerHeight;
return [x, y] as const;
});

const d = points
.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`)
.join(' ');
const [lastX, lastY] = points[points.length - 1];

return (
<svg
className={className}
width={SPARK_WIDTH}
height={SPARK_HEIGHT}
viewBox={`0 0 ${SPARK_WIDTH} ${SPARK_HEIGHT}`}
aria-hidden="true">
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={lastX} cy={lastY} r="1.6" fill="currentColor" />
</svg>
);
}

type LibraryRow = {
library: string;
value: number | null;
history?: number[];
};

type MetricBlockProps = {
label: string;
rows: LibraryRow[];
baselineValue: number;
format: (n: number) => string;
};

function MetricBlock({label, rows, baselineValue, format}: MetricBlockProps): ReactElement {
const max = Math.max(...rows.map(r => r.value ?? 0), 0);

return (
<div className={styles.metricBlock}>
<div className={styles.metricLabel}>{label}</div>
<div className={styles.bars}>
{rows.map(row => {
const isBaseline = row.library === BASELINE_LIBRARY;
const isMissing = row.value === null;
const width = isMissing || max === 0 ? 0 : (row.value! / max) * 100;
const rowClass = [
styles.barRow,
isBaseline && styles.barRowBaseline,
isMissing && styles.barRowMissing,
].filter(Boolean).join(' ');
return (
<div key={row.library} className={rowClass}>
<span className={styles.libraryLabel}>
{LIBRARY_DISPLAY_NAMES[row.library] ?? row.library}
</span>
<span className={styles.bar}>
{!isMissing && (
<span
className={isBaseline ? styles.barFillBaseline : styles.barFill}
style={{width: `${width}%`}}
/>
)}
</span>
<span className={styles.barValue}>
{isMissing ? 'not available' : format(row.value!)}
</span>
<span className={styles.ratio}>
{isMissing ? '—' : isBaseline ? 'baseline' : formatRatio(row.value!, baselineValue)}
</span>
{!isMissing && (
<Sparkline
values={row.history}
className={isBaseline ? `${styles.sparkline} ${styles.sparklineBaseline}` : styles.sparkline}
/>
)}
</div>
);
})}
</div>
</div>
);
}

function buildRows(
samples: Record<string, Sample>,
picker: (s: Sample) => number | undefined,
history: (s: Sample) => number[] | undefined,
): LibraryRow[] {
// Render every library in LIBRARY_ORDER so absences are visible: a library
// with no sample for this scenario / metric appears as a "not available"
// placeholder rather than silently disappearing from the comparison.
// Present rows sort by value ascending (fastest first); missing rows fall
// through to the bottom in declared order so the chart's shape stays stable
// across scenarios.
const present: LibraryRow[] = [];
const missing: LibraryRow[] = [];
for (const library of LIBRARY_ORDER) {
const sample = samples[library];
const value = sample ? picker(sample) : undefined;
if (value === undefined) {
missing.push({library, value: null});
} else {
present.push({library, value, history: sample ? history(sample) : undefined});
}
}
present.sort((a, b) => (a.value as number) - (b.value as number));
return [...present, ...missing];
}

type Props = {
name: string;
};

export default function MockolateBenchmarkResult({name}: Props): ReactElement {
const scenario = data.benchmarks[name];
if (!scenario) {
return (
<div className={styles.error}>
No benchmark data for <code>{name}</code>.
</div>
);
}

const params = scenario.parameters;
const [activeParam, setActiveParam] = useState(params[0]);
const samples = scenario.samples[activeParam] ?? {};
const baselineSample = samples[BASELINE_LIBRARY];

const timeRows = buildRows(samples, s => s.timeNs, s => s.history?.timeNs);
const memoryRows = buildRows(samples,
s => s.memoryBytes,
s => s.history?.memoryBytes);

const showParamTabs = params.length > 1 || (params.length === 1 && params[0] !== '');

return (
<div className={styles.result}>
{showParamTabs && (
<div className={styles.paramTabs} role="group" aria-label={`${name} parameter`}>
{params.map(p => (
<button
type="button"
key={p || '_default'}
aria-pressed={p === activeParam}
className={p === activeParam ? `${styles.paramTab} ${styles.paramTabActive}` : styles.paramTab}
onClick={() => setActiveParam(p)}>
{p || 'default'}
</button>
))}
</div>
)}
<MetricBlock
label="Time"
rows={timeRows}
baselineValue={baselineSample?.timeNs ?? 0}
format={formatNs}
/>
{memoryRows.length > 0 && baselineSample?.memoryBytes !== undefined && (
<MetricBlock
label="Memory"
rows={memoryRows}
baselineValue={baselineSample.memoryBytes}
format={formatBytes}
/>
)}
<div className={styles.caption}>
Captured on commit{' '}
<a
href={`https://github.com/Testably/Mockolate/commit/${data.capturedAt.sha}`}
target="_blank"
rel="noreferrer"
title={data.capturedAt.message}>
<code>{data.capturedAt.shortSha}</code>
</a>{' '}
({data.capturedAt.date})
{data.environment?.runner ? ` on ${data.environment.runner}` : ''}.
</div>
</div>
);
}
Loading
Loading