Skip to content

Commit 17a5093

Browse files
committed
test: stand elapsed-time bounds down under coverage
- add packages/mejiro/tests/timing.ts exporting expectElapsedUnder(), which skips the wall-clock bound while coverage is being collected; the surrounding test still runs and still asserts what the operation produced, so the correctness checks and their coverage are unaffected - V8 instrumentation multiplies elapsed time by a factor that varies with the input, so a budget or a scaling ratio measured under it describes the instrumentation rather than the code: tokenizeManuscriptSource is cleanly linear uninstrumented (11.9ms -> 22.8ms -> 48.7ms -> 94.4ms as the input doubles from 160k to 1.28M characters) yet reads as nearly quadratic with coverage on - expose whether the run collects coverage to the tests through a MEJIRO_COVERAGE env var in vitest.config.ts - route the eight elapsed-time assertions in chapter-layout-cost, manuscript-tokens and epub/xml-utils through the helper, the xml-utils pair included since it is the same construction - leave the regression coverage on those runs to the deterministic guards that do not depend on machine speed: the characters fed to computeBreaks and the buildRenderPage call count - raise testTimeout to 30s, since several tests parse every source file in the workspace or build EPUB archives from scratch and exceed the 5s default under instrumentation
1 parent 3e88ad4 commit 17a5093

5 files changed

Lines changed: 57 additions & 8 deletions

File tree

packages/mejiro/tests/book/chapter-layout-cost.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type CachedParagraph, ChapterLayout } from '../../src/book/chapter-layo
33
import { computeBreaks } from '../../src/layout.js';
44
import type { RenderEntry } from '../../src/render/types.js';
55
import { toCodepoints, uniformAdvances } from '../helpers.js';
6+
import { expectElapsedUnder } from '../timing.js';
67

78
const counters = vi.hoisted(() => ({ buildRenderPage: 0, breakChars: 0 }));
89

@@ -108,7 +109,7 @@ function dragBreakChars(layout: ChapterLayout): number {
108109
describe('image reflow cost', () => {
109110
it('reflows a book-length chapter within a frame budget', () => {
110111
// The frame budget is 16.7ms; the bound is loosened for slower machines.
111-
expect(medianDragMs(makeBookLayout(80_000))).toBeLessThan(50);
112+
expectElapsedUnder(medianDragMs(makeBookLayout(80_000)), 50);
112113
}, 30_000);
113114

114115
it('scales with the line count rather than the character count', () => {
@@ -157,6 +158,6 @@ describe('selectionRects cost', () => {
157158
const pagesSpanned = new Set(deep.map((r) => r.pageIdx)).size;
158159
expect(counters.buildRenderPage).toBe(pagesSpanned);
159160
expect(deep).toHaveLength(100);
160-
expect(elapsed).toBeLessThan(50);
161+
expectElapsedUnder(elapsed, 50);
161162
});
162163
});

packages/mejiro/tests/epub/xml-utils.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22
import { extractStylesheetLinks, stripStylesheetLinks } from '../../src/epub/xml-utils.js';
3+
import { expectElapsedUnder } from '../timing.js';
34

45
describe('stripStylesheetLinks', () => {
56
it('removes stylesheet links written as self-closing, bare and explicitly closed tags', () => {
@@ -51,7 +52,7 @@ describe('stripStylesheetLinks', () => {
5152

5253
expect(stripped).toBe(xhtml);
5354
expect(links).toEqual([]);
54-
expect(elapsed).toBeLessThan(1_000);
55+
expectElapsedUnder(elapsed, 1_000);
5556
});
5657

5758
it('completes in linear time on many unterminated tags followed by a stylesheet link', () => {
@@ -64,6 +65,6 @@ describe('stripStylesheetLinks', () => {
6465

6566
// The unterminated starts are part of the tag that finally closes.
6667
expect(stripped).toBe('');
67-
expect(elapsed).toBeLessThan(1_000);
68+
expectElapsedUnder(elapsed, 1_000);
6869
});
6970
});

packages/mejiro/tests/manuscript-tokens.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22
import { parseManuscript } from '../src/manuscript.js';
33
import { tokenizeManuscriptSource } from '../src/manuscript-tokens.js';
4+
import { expectElapsedUnder } from './timing.js';
45

56
describe('tokenizeManuscriptSource', () => {
67
it('detects bar-notation ruby spans with correct source ranges', () => {
@@ -87,7 +88,7 @@ describe('tokenizeManuscriptSource', () => {
8788
const elapsed = performance.now() - start;
8889

8990
expect(tokens).toEqual([]);
90-
expect(elapsed).toBeLessThan(500);
91+
expectElapsedUnder(elapsed, 500);
9192
});
9293

9394
it('tokenizes text dense with unclosed markers in linear time', () => {
@@ -98,9 +99,9 @@ describe('tokenizeManuscriptSource', () => {
9899
const fullElapsed = fastestRun(() => tokenizeManuscriptSource(full));
99100

100101
expect(tokenizeManuscriptSource(full)).toEqual([]);
101-
expect(fullElapsed).toBeLessThan(500);
102+
expectElapsedUnder(fullElapsed, 500);
102103
// Quadratic scanning would roughly quadruple when the source doubles.
103-
expect(fullElapsed).toBeLessThan(halfElapsed * 3 + 5);
104+
expectElapsedUnder(fullElapsed, halfElapsed * 3 + 5);
104105
});
105106

106107
it('scans a long base run without ruby in linear time', () => {
@@ -109,7 +110,7 @@ describe('tokenizeManuscriptSource', () => {
109110
const elapsed = fastestRun(() => tokenizeManuscriptSource(text));
110111

111112
expect(tokenizeManuscriptSource(text)).toEqual([]);
112-
expect(elapsed).toBeLessThan(500);
113+
expectElapsedUnder(elapsed, 500);
113114
});
114115

115116
it('still matches markers on later lines after an unclosed marker', () => {

packages/mejiro/tests/timing.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { expect } from 'vitest';
2+
3+
/**
4+
* Whether the current run collects coverage, as set by `vitest.config.ts`.
5+
*
6+
* V8 instrumentation multiplies elapsed time by a factor that varies with the
7+
* input, so a wall-clock budget or a scaling ratio measured under it describes
8+
* the instrumentation rather than the code.
9+
*/
10+
const underCoverage = process.env.MEJIRO_COVERAGE === '1';
11+
12+
/**
13+
* Asserts that an operation stayed inside an elapsed-time bound, except while
14+
* coverage is being collected.
15+
*
16+
* The surrounding test still runs and still checks what the operation actually
17+
* produced; only the timing bound stands down, because a threshold that has to
18+
* be widened for instrumentation or a slow runner stops being a signal. The
19+
* algorithmic guards that hold regardless of machine speed — counting the work
20+
* fed to a hot function rather than timing it — carry the regression coverage
21+
* on those runs.
22+
*
23+
* @param elapsed - Measured duration in milliseconds.
24+
* @param limit - Upper bound the duration must stay below.
25+
*/
26+
export function expectElapsedUnder(elapsed: number, limit: number): void {
27+
if (underCoverage) return;
28+
expect(elapsed).toBeLessThan(limit);
29+
}

vitest.config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import { fileURLToPath } from 'node:url';
22
import { defineConfig } from 'vitest/config';
33

4+
/**
5+
* Whether this run collects coverage.
6+
*
7+
* V8 instrumentation inflates elapsed time by a factor that varies with the
8+
* input, which makes wall-clock budgets and scaling ratios meaningless — a
9+
* tokenizer measured as linear without coverage reads as superlinear with it.
10+
* Tests that assert on elapsed time read this flag and stand down, so the
11+
* coverage run stays a binary signal instead of a machine-speed gate.
12+
*/
13+
const coverageEnabled = process.argv.includes('--coverage');
14+
415
export default defineConfig({
516
resolve: {
617
alias: [
@@ -21,6 +32,12 @@ export default defineConfig({
2132
test: {
2233
include: ['packages/*/tests/**/*.test.{ts,tsx}'],
2334
setupFiles: ['tests/vitest.setup.ts'],
35+
// biome-ignore lint/style/useNamingConvention: environment variable names are SCREAMING_SNAKE_CASE
36+
env: { MEJIRO_COVERAGE: coverageEnabled ? '1' : '' },
37+
// Several tests parse every source file in the workspace or build EPUB
38+
// archives from scratch; under coverage they run several times longer than
39+
// the 5s default allows.
40+
testTimeout: 30_000,
2441
benchmark: {
2542
include: ['packages/*/bench/**/*.bench.ts'],
2643
},

0 commit comments

Comments
 (0)