diff --git a/src/vault-mcp/obsidian-markdown/__tests__/headings-linear.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/headings-linear.test.ts index 2cb6d679c..c04261c85 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/headings-linear.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/headings-linear.test.ts @@ -6,9 +6,21 @@ import { describe, expect, it } from "vitest" import { parseHeadings, type HeadingInfo } from "../headings.js" import * as lineParsers from "../lines.js" import { tasks } from "../tasks.js" - -/** Allows scheduling noise across the full input range. */ -const LINEAR_GROWTH_ALLOWANCE = 2 +import { + CHILD_DEADLINE_MS, + CHILD_TEST_TIMEOUT_MS, + LINEAR_GROWTH_ALLOWANCE, +} from "./helpers/linear-timing.js" + +/** + * Below this many milliseconds a median is mostly scheduling noise, so it sets + * no tighter growth bound. It is lower than the 25 ms in tasks-linear and + * tasks-clear-linear, because a 25 ms floor would let a quadratic slowdown pass: + * - The traversal fixture's smallest median sits near 10 ms on a fast laptop. + * - A 25 ms floor would set the bound at 200 ms, above a quadratic slowdown's + * 165 ms. + */ +const TIMING_FLOOR_MS = 10 type HeadingParser = (lines: readonly string[]) => HeadingInfo[] @@ -112,7 +124,7 @@ describe("heading traversal compatibility", () => { }) } } - }) + }, 30_000) it.each([ { newline: "\n", label: "LF" }, @@ -171,17 +183,19 @@ describe("heading traversal compatibility", () => { ]) }) - it("bounds closing-hash and complete heading-task traversal adversaries in a child", () => { - const headingsUrl = new URL("../headings.ts", import.meta.url).href - const tasksUrl = new URL("../tasks.ts", import.meta.url).href - const output = execFileSync( - process.execPath, - [ - "--import", - "tsx", - "--input-type=module", - "--eval", - ` + it( + "bounds closing-hash and complete heading-task traversal adversaries in a child", + () => { + const headingsUrl = new URL("../headings.ts", import.meta.url).href + const tasksUrl = new URL("../tasks.ts", import.meta.url).href + const output = execFileSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + ` import assert from 'node:assert/strict'; import { parseHeadings } from ${JSON.stringify(headingsUrl)}; import { tasks } from ${JSON.stringify(tasksUrl)}; @@ -201,19 +215,31 @@ describe("heading traversal compatibility", () => { const samples=Array.from({length:5},()=>{const start=performance.now();check();return performance.now()-start}).toSorted((a,b)=>a-b); medians.push(samples[2]); } - assert.ok(medians.every(value=>value<1000),variant+' exceeded ceiling '+medians); - assert.ok(medians.at(-1)<=Math.max(10,medians[0])*(sizes.at(-1)/sizes[0])*${LINEAR_GROWTH_ALLOWANCE},variant+' scaled superlinearly '+medians); + assert.ok(medians.at(-1)<=Math.max(${TIMING_FLOOR_MS},medians[0])*(sizes.at(-1)/sizes[0])*${LINEAR_GROWTH_ALLOWANCE},variant+' scaled superlinearly '+medians); measurements.push({variant,sizes,medians}); } process.stdout.write(JSON.stringify(measurements)); `, - ], - { encoding: "utf8", timeout: 30_000, killSignal: "SIGKILL" }, - ) - - expect( - JSON.parse(output).map((measurement: { variant: string }) => measurement.variant), - ).toEqual(["closing", "traversal"]) - process.stdout.write(`Heading parser timings ${output}\n`) - }, 35_000) + ], + { encoding: "utf8", timeout: CHILD_DEADLINE_MS, killSignal: "SIGKILL" }, + ) + + const measurements: unknown = JSON.parse(output) + + expect(measurements).toEqual([ + { + variant: "closing", + sizes: [25_000, 50_000, 100_000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }, + { + variant: "traversal", + sizes: [16_000, 32_000, 64_000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }, + ]) + process.stdout.write(`Heading parser timings ${output}\n`) + }, + CHILD_TEST_TIMEOUT_MS, + ) }) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/helpers/linear-timing.ts b/src/vault-mcp/obsidian-markdown/__tests__/helpers/linear-timing.ts new file mode 100644 index 000000000..96f15a4dc --- /dev/null +++ b/src/vault-mcp/obsidian-markdown/__tests__/helpers/linear-timing.ts @@ -0,0 +1,12 @@ +/** How far a timing series may exceed linear growth before a test calls it superlinear, to allow for scheduling noise. */ +export const LINEAR_GROWTH_ALLOWANCE = 2 + +/** + * Stops a timing child that never finishes. The growth check is the speed + * test, so this only has to outlast a slow CI runner: the slowest child takes + * about 7 s on a fast laptop, and CI runners have taken over four times as long. + */ +export const CHILD_DEADLINE_MS = 60_000 + +/** Sits above the child deadline, so vitest never reports a child that finished within it as a test timeout. */ +export const CHILD_TEST_TIMEOUT_MS = CHILD_DEADLINE_MS + 10_000 diff --git a/src/vault-mcp/obsidian-markdown/__tests__/tasks-clear-linear.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/tasks-clear-linear.test.ts index 3cbf30cab..3c612f164 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/tasks-clear-linear.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/tasks-clear-linear.test.ts @@ -3,9 +3,12 @@ import { readFileSync } from "node:fs" import { runInNewContext } from "node:vm" import ts from "typescript" import { describe, expect, it } from "vitest" +import { + CHILD_DEADLINE_MS, + CHILD_TEST_TIMEOUT_MS, + LINEAR_GROWTH_ALLOWANCE, +} from "./helpers/linear-timing.js" -/** Allows scheduling noise across the full input range. */ -const LINEAR_GROWTH_ALLOWANCE = 2 /** Below this many milliseconds a median is mostly scheduling noise, so it sets no tighter growth bound. */ const TIMING_FLOOR_MS = 25 @@ -138,18 +141,20 @@ describe("task metadata clear compatibility", () => { }) } } - }, 20_000) + }, 30_000) - it("clears twenty to eighty thousand priority fields within a child deadline", () => { - const parserUrl = new URL("../tasks.ts", import.meta.url).href - const output = execFileSync( - process.execPath, - [ - "--import", - "tsx", - "--input-type=module", - "--eval", - ` + it( + "clears twenty to eighty thousand priority fields in time linear in their count", + () => { + const parserUrl = new URL("../tasks.ts", import.meta.url).href + const output = execFileSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + ` import assert from 'node:assert/strict'; import { tasks, DEFAULT_STATUS_REGISTRY } from ${JSON.stringify(parserUrl)}; const config={taskFormat:'emoji',setDoneDate:true,setCancelledDate:true,setCreatedDate:false,recurrenceOnNextLine:false,removeScheduledDateOnRecurrence:false,statusRegistry:DEFAULT_STATUS_REGISTRY}; @@ -162,29 +167,32 @@ describe("task metadata clear compatibility", () => { const samples=Array.from({length:5},()=>{const start=performance.now();check();return performance.now()-start;}).toSorted((a,b)=>a-b); medians.push(samples[2]); } - assert.ok(medians.every(value=>value<1000),'metadata clear exceeded ceiling '+medians); assert.ok(medians.at(-1)<=Math.max(${TIMING_FLOOR_MS},medians[0])*(sizes.at(-1)/sizes[0])*${LINEAR_GROWTH_ALLOWANCE},'metadata clear scaled superlinearly '+medians); process.stdout.write(JSON.stringify({sizes,medians})); `, - ], - { encoding: "utf8", timeout: 15_000, killSignal: "SIGKILL" }, - ) + ], + { encoding: "utf8", timeout: CHILD_DEADLINE_MS, killSignal: "SIGKILL" }, + ) - expect(JSON.parse(output)).toEqual({ - sizes: [20000, 40000, 80000], - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - }) - process.stdout.write(`Metadata clear timings ${output}\n`) - }, 20_000) + expect(JSON.parse(output)).toEqual({ + sizes: [20000, 40000, 80000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }) + process.stdout.write(`Metadata clear timings ${output}\n`) + }, + CHILD_TEST_TIMEOUT_MS, + ) - it("bounds every field grammar and newly exposed nested fields at increasing sizes", () => { - const compiled = compileRemovalGrammar() - const output = execFileSync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` + it( + "bounds every field grammar and newly exposed nested fields at increasing sizes", + () => { + const compiled = compileRemovalGrammar() + const output = execFileSync( + process.execPath, + [ + "--input-type=module", + "--eval", + ` import assert from 'node:assert/strict'; import { runInThisContext } from 'node:vm'; const grammar=runInThisContext(${JSON.stringify(compiled)}); @@ -213,7 +221,6 @@ describe("task metadata clear compatibility", () => { const samples=Array.from({length:5},()=>{const start=performance.now();check();return performance.now()-start;}).toSorted((a,b)=>a-b); medians.push(samples[2]); } - assert.ok(medians.every(value=>value<2000),name+' exceeded ceiling '+medians); assert.ok(medians.at(-1)<=Math.max(${TIMING_FLOOR_MS},medians[0])*(sizes.at(-1)/sizes[0])*${LINEAR_GROWTH_ALLOWANCE},name+' scaled superlinearly '+medians); timings.push({name,medians}); }; @@ -222,32 +229,47 @@ describe("task metadata clear compatibility", () => { checkFixture('nested-dataview-date',2,size=>'#'+'[due::'.repeat(size)+'2026-01-02'+']2026-01-02'.repeat(size-1)+']'); process.stdout.write(JSON.stringify({sizes,timings})); `, - ], - { encoding: "utf8", timeout: 30_000, killSignal: "SIGKILL" }, - ) + ], + { encoding: "utf8", timeout: CHILD_DEADLINE_MS, killSignal: "SIGKILL" }, + ) - const result: unknown = JSON.parse(output) + const result: unknown = JSON.parse(output) - expect(result).toEqual({ - sizes: [10000, 20000, 40000], - timings: Array.from({ length: 24 }, () => ({ - name: expect.any(String), - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - })), - }) - process.stdout.write(`All metadata grammar timings ${output}\n`) - }, 35_000) + // The child times each of the eleven fields in emoji format (suffix 0) and Dataview format + // (suffix 1), then the two nested fixtures. + const fixtureNames = [ + ...Array.from({ length: 11 }, (_, fieldIndex) => [ + `${fieldIndex}-0`, + `${fieldIndex}-1`, + ]).flat(), + "nested-emoji-date", + "nested-dataview-date", + ] - it("clears duplicate completion stamps through status mutations within a child deadline", () => { - const parserUrl = new URL("../tasks.ts", import.meta.url).href - const output = execFileSync( - process.execPath, - [ - "--import", - "tsx", - "--input-type=module", - "--eval", - ` + expect(result).toEqual({ + sizes: [10000, 20000, 40000], + timings: fixtureNames.map((name) => ({ + name, + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + })), + }) + process.stdout.write(`All metadata grammar timings ${output}\n`) + }, + CHILD_TEST_TIMEOUT_MS, + ) + + it( + "clears duplicate completion stamps through status mutations in time linear in their count", + () => { + const parserUrl = new URL("../tasks.ts", import.meta.url).href + const output = execFileSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + ` import assert from 'node:assert/strict'; import { tasks, DEFAULT_STATUS_REGISTRY } from ${JSON.stringify(parserUrl)}; const config={taskFormat:'emoji',setDoneDate:true,setCancelledDate:true,setCreatedDate:false,recurrenceOnNextLine:false,removeScheduledDateOnRecurrence:false,statusRegistry:DEFAULT_STATUS_REGISTRY}; @@ -260,17 +282,19 @@ describe("task metadata clear compatibility", () => { const samples=Array.from({length:5},()=>{const start=performance.now();check();return performance.now()-start;}).toSorted((a,b)=>a-b); medians.push(samples[2]); } - assert.ok(medians.every(value=>value<2000),'status stamp clear exceeded ceiling '+medians); assert.ok(medians.at(-1)<=Math.max(${TIMING_FLOOR_MS},medians[0])*(sizes.at(-1)/sizes[0])*${LINEAR_GROWTH_ALLOWANCE},'status stamp clear scaled superlinearly '+medians); process.stdout.write(JSON.stringify({sizes,medians})); `, - ], - { encoding: "utf8", timeout: 15_000, killSignal: "SIGKILL" }, - ) + ], + { encoding: "utf8", timeout: CHILD_DEADLINE_MS, killSignal: "SIGKILL" }, + ) - expect(JSON.parse(output)).toEqual({ - sizes: [20000, 40000, 80000], - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - }) - }, 20_000) + expect(JSON.parse(output)).toEqual({ + sizes: [20000, 40000, 80000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }) + process.stdout.write(`Status stamp clear timings ${output}\n`) + }, + CHILD_TEST_TIMEOUT_MS, + ) }) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/tasks-linear.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/tasks-linear.test.ts index 4adb66a16..275355167 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/tasks-linear.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/tasks-linear.test.ts @@ -6,9 +6,12 @@ import ts from "typescript" import { describe, expect, it } from "vitest" import { tasks } from "../tasks.js" import { createScannerDocuments } from "./helpers/link-scanner-corpus.js" +import { + CHILD_DEADLINE_MS, + CHILD_TEST_TIMEOUT_MS, + LINEAR_GROWTH_ALLOWANCE, +} from "./helpers/linear-timing.js" -/** Allows scheduling noise across the full input range. */ -const LINEAR_GROWTH_ALLOWANCE = 2 /** Below this many milliseconds a median is mostly scheduling noise, so it sets no tighter growth bound. */ const TIMING_FLOOR_MS = 25 @@ -192,18 +195,20 @@ describe("task recognizer compatibility", () => { }) } expect(grammar.trailingTags("#a\t#b #a")).toEqual(["#a", "#b", "#a"]) - }) + }, 30_000) - it("bounds deduplication with tens of thousands of trailing description tags", () => { - const parserUrl = new URL("../tasks.ts", import.meta.url).href - const output = execFileSync( - process.execPath, - [ - "--import", - "tsx", - "--input-type=module", - "--eval", - ` + it( + "bounds deduplication with tens of thousands of trailing description tags", + () => { + const parserUrl = new URL("../tasks.ts", import.meta.url).href + const output = execFileSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + ` import assert from 'node:assert/strict'; import { tasks } from ${JSON.stringify(parserUrl)}; const sizes=[20000,40000,80000]; @@ -215,20 +220,21 @@ describe("task recognizer compatibility", () => { const samples=Array.from({length:5},()=>{const start=performance.now();check();return performance.now()-start;}).toSorted((a,b)=>a-b); medians.push(samples[2]); } - assert.ok(medians.every(value=>value<500),'trailing tag deduplication exceeded ceiling '+medians); assert.ok(medians.at(-1)<=Math.max(${TIMING_FLOOR_MS},medians[0])*(sizes.at(-1)/sizes[0])*${LINEAR_GROWTH_ALLOWANCE},'trailing tag deduplication scaled superlinearly '+medians); process.stdout.write(JSON.stringify({sizes,medians})); `, - ], - { encoding: "utf8", timeout: 10_000, killSignal: "SIGKILL" }, - ) + ], + { encoding: "utf8", timeout: CHILD_DEADLINE_MS, killSignal: "SIGKILL" }, + ) - expect(JSON.parse(output)).toEqual({ - sizes: [20000, 40000, 80000], - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - }) - process.stdout.write(`Trailing tag deduplication timings ${output}\n`) - }, 15_000) + expect(JSON.parse(output)).toEqual({ + sizes: [20000, 40000, 80000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }) + process.stdout.write(`Trailing tag deduplication timings ${output}\n`) + }, + CHILD_TEST_TIMEOUT_MS, + ) it("preserves metadata boundaries over fixed-seed bodies and every literal task fixture", () => { const grammar = readGrammar() @@ -390,23 +396,27 @@ describe("task recognizer compatibility", () => { expect(grammar.metadataStart("#📅2026-01-02 (repeat:: every day)")).toBe(1) }) - it("bounds task edits whose hashtags each pack hundreds of signifiers", () => { - const parserUrl = new URL("../tasks.ts", import.meta.url).href - const output = execFileSync( - process.execPath, - [ - "--import", - "tsx", - "--input-type=module", - "--eval", - ` + it( + "bounds task edits whose hashtags each pack hundreds of signifiers", + () => { + const parserUrl = new URL("../tasks.ts", import.meta.url).href + const output = execFileSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + ` import assert from 'node:assert/strict'; import { tasks } from ${JSON.stringify(parserUrl)}; const config = {taskFormat:'emoji',setDoneDate:true,setCancelledDate:true}; const sizes = [120,240,480]; + const lineLengths = []; const medians = []; for (const size of sizes) { const taskLine = '- [ ] ' + Array.from({length:size},()=> '#'+ '📅'.repeat(size+2)).join(' '); + lineLengths.push(taskLine.length); const check = () => { assert.equal(tasks.updateTaskLineTaskId({taskLine,taskId:'probe',config}),taskLine+' 🆔 probe'); assert.deepEqual(tasks.diffTaskRoundTrip({taskLine,priorTaskLine:taskLine,submitted:{}}),[]); @@ -415,30 +425,36 @@ describe("task recognizer compatibility", () => { const samples = Array.from({length:3},()=>{const start=performance.now();check();return performance.now()-start;}).toSorted((a,b)=>a-b); medians.push(samples[1]); } - assert.ok(medians.every(value=>value<1000),'packed hashtag edits exceeded ceiling '+medians); + // Size sets both the hashtag count and each hashtag's length, so the line grows with + // the square of size and the growth bound follows the line's length instead. + assert.ok(medians.at(-1)<=Math.max(${TIMING_FLOOR_MS},medians[0])*(lineLengths.at(-1)/lineLengths[0])*${LINEAR_GROWTH_ALLOWANCE},'packed hashtag edits scaled superlinearly '+medians); process.stdout.write(JSON.stringify({sizes,medians})); `, - ], - { encoding: "utf8", timeout: 10_000, killSignal: "SIGKILL" }, - ) + ], + { encoding: "utf8", timeout: CHILD_DEADLINE_MS, killSignal: "SIGKILL" }, + ) - expect(JSON.parse(output)).toEqual({ - sizes: [120, 240, 480], - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - }) - process.stdout.write(`Packed hashtag edit timings ${output}\n`) - }, 15_000) - - it("bounds signifier-dense task edits and their growth in a child process", () => { - const parserUrl = new URL("../tasks.ts", import.meta.url).href - const output = execFileSync( - process.execPath, - [ - "--import", - "tsx", - "--input-type=module", - "--eval", - ` + expect(JSON.parse(output)).toEqual({ + sizes: [120, 240, 480], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }) + process.stdout.write(`Packed hashtag edit timings ${output}\n`) + }, + CHILD_TEST_TIMEOUT_MS, + ) + + it( + "bounds signifier-dense task edits and their growth in a child process", + () => { + const parserUrl = new URL("../tasks.ts", import.meta.url).href + const output = execFileSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + ` import assert from 'node:assert/strict'; import { tasks } from ${JSON.stringify(parserUrl)}; const config = {taskFormat:'emoji',setDoneDate:true,setCancelledDate:true}; @@ -455,21 +471,22 @@ describe("task recognizer compatibility", () => { const samples = Array.from({length:5}, () => {const start=performance.now();check();return performance.now()-start;}).toSorted((first,second)=>first-second); medians.push(samples[2]); } - assert.ok(medians.every(value => value < 250), 'dense edits exceeded 250ms ' + medians); assert.ok(medians.at(-1) <= Math.max(${TIMING_FLOOR_MS},medians[0])*(sizes.at(-1)/sizes[0])*${LINEAR_GROWTH_ALLOWANCE}, 'dense edits scaled superlinearly ' + medians); process.stdout.write(JSON.stringify({sizes,medians})); `, - ], - { encoding: "utf8", timeout: 10_000, killSignal: "SIGKILL" }, - ) - const measurements: unknown = JSON.parse(output) + ], + { encoding: "utf8", timeout: CHILD_DEADLINE_MS, killSignal: "SIGKILL" }, + ) + const measurements: unknown = JSON.parse(output) - expect(measurements).toEqual({ - sizes: [1000, 2000, 4000, 5000], - medians: [expect.any(Number), expect.any(Number), expect.any(Number), expect.any(Number)], - }) - process.stdout.write(`Task metadata edit timings ${output}\n`) - }, 15_000) + expect(measurements).toEqual({ + sizes: [1000, 2000, 4000, 5000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number), expect.any(Number)], + }) + process.stdout.write(`Task metadata edit timings ${output}\n`) + }, + CHILD_TEST_TIMEOUT_MS, + ) it("preserves Dataview field spans and consumed captures over fixed-seed variants", () => { const grammar = readGrammar() @@ -523,7 +540,7 @@ describe("task recognizer compatibility", () => { }) } } - }) + }, 30_000) it("preserves emoji recurrence spans and consumed captures over fixed-seed variants", () => { const grammar = readGrammar() @@ -543,7 +560,7 @@ describe("task recognizer compatibility", () => { match: consumedMatch(oldEmoji.exec(input)), }) } - }) + }, 30_000) it("compares real Dataview dependency fields with the frozen suffix grammar", () => { const grammar = readGrammar() @@ -629,16 +646,18 @@ describe("task recognizer compatibility", () => { ).toEqual([]) }) - it("bounds task, recurrence and Dataview adversaries in a child process", () => { - const parserUrl = new URL("../tasks.ts", import.meta.url).href - const output = execFileSync( - process.execPath, - [ - "--import", - "tsx", - "--input-type=module", - "--eval", - ` + it( + "bounds task, recurrence and Dataview adversaries in a child process", + () => { + const parserUrl = new URL("../tasks.ts", import.meta.url).href + const output = execFileSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + ` import assert from 'node:assert/strict'; import { tasks } from ${JSON.stringify(parserUrl)}; const fixtures = [ @@ -663,44 +682,45 @@ describe("task recognizer compatibility", () => { const samples = Array.from({length:5}, () => { const start=performance.now(); check(); return performance.now()-start; }).toSorted((first,second)=>first-second); medians.push(samples[2]); } - assert.ok(medians.every(value => value < 250), fixture.name + ' exceeded 250ms ' + medians); assert.ok(medians.at(-1) <= Math.max(${TIMING_FLOOR_MS},medians[0])*(fixture.sizes.at(-1)/fixture.sizes[0])*${LINEAR_GROWTH_ALLOWANCE}, fixture.name + ' scaled superlinearly ' + medians); measurements.push({name:fixture.name,sizes:fixture.sizes,medians}); } process.stdout.write(JSON.stringify(measurements)); `, - ], - { encoding: "utf8", timeout: 30_000, killSignal: "SIGKILL" }, - ) - const measurements: unknown = JSON.parse(output) - - expect(measurements).toEqual([ - { - name: "task-u2028", - sizes: [10_000, 20_000, 40_000], - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - }, - { - name: "task-u2029", - sizes: [10_000, 20_000, 40_000], - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - }, - { - name: "emoji-recurrence", - sizes: [20_000, 40_000, 80_000], - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - }, - { - name: "dataview-recurrence", - sizes: [1_000, 2_000, 4_000], - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - }, - { - name: "dataview-lookahead", - sizes: [25_000, 50_000, 100_000], - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - }, - ]) - process.stdout.write(`Task parser timings ${output}\n`) - }, 35_000) + ], + { encoding: "utf8", timeout: CHILD_DEADLINE_MS, killSignal: "SIGKILL" }, + ) + const measurements: unknown = JSON.parse(output) + + expect(measurements).toEqual([ + { + name: "task-u2028", + sizes: [10_000, 20_000, 40_000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }, + { + name: "task-u2029", + sizes: [10_000, 20_000, 40_000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }, + { + name: "emoji-recurrence", + sizes: [20_000, 40_000, 80_000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }, + { + name: "dataview-recurrence", + sizes: [1_000, 2_000, 4_000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }, + { + name: "dataview-lookahead", + sizes: [25_000, 50_000, 100_000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }, + ]) + process.stdout.write(`Task parser timings ${output}\n`) + }, + CHILD_TEST_TIMEOUT_MS, + ) }) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/tasks-tag-dedup.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/tasks-tag-dedup.test.ts index bd75dd52c..253c67817 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/tasks-tag-dedup.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/tasks-tag-dedup.test.ts @@ -5,9 +5,23 @@ import { DateTime } from "luxon" import ts from "typescript" import { describe, expect, it } from "vitest" import { tasks } from "../tasks.js" +import { + CHILD_DEADLINE_MS, + CHILD_TEST_TIMEOUT_MS, + LINEAR_GROWTH_ALLOWANCE, +} from "./helpers/linear-timing.js" -/** Allows scheduling noise across the full input range. */ -const LINEAR_GROWTH_ALLOWANCE = 2 +/** + * Below this many milliseconds a median is mostly scheduling noise, so it sets + * no tighter growth bound. It is lower than the 25 ms in tasks-linear and + * tasks-clear-linear, because a 25 ms floor would barely catch a quadratic + * slowdown: + * - This fixture's smallest median sits near 14 ms on a fast laptop. + * - A 25 ms floor would set the bound at 200 ms, only about 10% under a + * quadratic slowdown's 224 ms, so scheduling noise could let the slowdown pass. + * - This floor sets the bound at about half of 224 ms. + */ +const TIMING_FLOOR_MS = 10 type Deduplication = ReturnType @@ -80,18 +94,20 @@ describe("task description tag deduplication", () => { result: legacyDeduplication(taskLine), }) } - }) + }, 30_000) - it("bounds distinct description tags against a long metadata tail in a child process", () => { - const parserUrl = new URL("../tasks.ts", import.meta.url).href - const output = execFileSync( - process.execPath, - [ - "--import", - "tsx", - "--input-type=module", - "--eval", - ` + it( + "bounds distinct description tags against a long metadata tail in a child process", + () => { + const parserUrl = new URL("../tasks.ts", import.meta.url).href + const output = execFileSync( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + ` import assert from 'node:assert/strict'; import { tasks } from ${JSON.stringify(parserUrl)}; const sizes = [20000,40000,80000]; @@ -104,18 +120,19 @@ describe("task description tag deduplication", () => { const samples = Array.from({length:5},()=>{const start=performance.now();check();return performance.now()-start;}).toSorted((a,b)=>a-b); medians.push(samples[2]); } - assert.ok(medians.every(value=>value<350),'description tag searches exceeded ceiling '+medians); - assert.ok(medians.at(-1)<=Math.max(10,medians[0])*(sizes.at(-1)/sizes[0])*${LINEAR_GROWTH_ALLOWANCE},'description tag searches scaled superlinearly '+medians); + assert.ok(medians.at(-1)<=Math.max(${TIMING_FLOOR_MS},medians[0])*(sizes.at(-1)/sizes[0])*${LINEAR_GROWTH_ALLOWANCE},'description tag searches scaled superlinearly '+medians); process.stdout.write(JSON.stringify({sizes,medians})); `, - ], - { encoding: "utf8", timeout: 10_000, killSignal: "SIGKILL" }, - ) + ], + { encoding: "utf8", timeout: CHILD_DEADLINE_MS, killSignal: "SIGKILL" }, + ) - expect(JSON.parse(output)).toEqual({ - sizes: [20000, 40000, 80000], - medians: [expect.any(Number), expect.any(Number), expect.any(Number)], - }) - process.stdout.write(`Distinct description tag timings ${output}\n`) - }, 15_000) + expect(JSON.parse(output)).toEqual({ + sizes: [20000, 40000, 80000], + medians: [expect.any(Number), expect.any(Number), expect.any(Number)], + }) + process.stdout.write(`Distinct description tag timings ${output}\n`) + }, + CHILD_TEST_TIMEOUT_MS, + ) })