mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
test: keep explicit regressions without legacy algorithm copies (#32218)
* test: keep explicit regressions without legacy algorithm copies * test: use Number.NaN in latency fixtures --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -821,109 +821,7 @@ describe('relay observability', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The pre-change implementation, kept verbatim as the differential oracle. Both
|
||||
// ranks sorted their own copy and the maximum was a zero-seeded fold.
|
||||
function legacyPercentile(values: number[], percentileRank: number): number {
|
||||
if (values.length === 0) return 0
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
return sorted[Math.ceil(percentileRank * sorted.length) - 1] ?? 0
|
||||
}
|
||||
|
||||
function legacyLatencySummary(samples: number[]): { p50: number; p95: number; max: number } {
|
||||
const round = (value: number): number => Number(value.toFixed(3))
|
||||
return {
|
||||
p50: round(legacyPercentile(samples, 0.5)),
|
||||
p95: round(legacyPercentile(samples, 0.95)),
|
||||
max: round(samples.reduce((highest, sample) => Math.max(highest, sample), 0))
|
||||
}
|
||||
}
|
||||
|
||||
// `-0` and `NaN` both survive a string round trip, unlike a bare equality check.
|
||||
function describeNumber(value: number): string {
|
||||
return Object.is(value, -0) ? '-0' : String(value)
|
||||
}
|
||||
|
||||
function expectSameNumber(actual: number, expected: number, label: string): void {
|
||||
expect(`${label} = ${describeNumber(actual)}`).toBe(`${label} = ${describeNumber(expected)}`)
|
||||
}
|
||||
|
||||
function sparseWindow(size: number, filled: Record<number, number>): number[] {
|
||||
const values: number[] = new Array<number>(size)
|
||||
for (const [index, value] of Object.entries(filled)) values[Number(index)] = value
|
||||
return values
|
||||
}
|
||||
|
||||
// Lehmer generator: stays inside the safe-integer range so the window is
|
||||
// byte-identical on every engine the relay runs on.
|
||||
function deterministicWindow(size: number): number[] {
|
||||
let seed = 20_260_912
|
||||
return Array.from({ length: size }, () => {
|
||||
seed = (seed * 48_271) % 2_147_483_647
|
||||
return (seed % 4_000_000) / 1_000
|
||||
})
|
||||
}
|
||||
|
||||
const DENSE_WINDOWS: Array<{ name: string; values: number[] }> = [
|
||||
{ name: 'empty', values: [] },
|
||||
{ name: 'single', values: [7.5] },
|
||||
{ name: 'single negative', values: [-7.5] },
|
||||
{ name: 'ascending', values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] },
|
||||
{ name: 'descending', values: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] },
|
||||
{ name: 'duplicates', values: [4, 4, 4, 4, 4] },
|
||||
// The trap: a sorted last element reads -1 here, the zero-seeded fold reads 0.
|
||||
{ name: 'all negative', values: [-5, -1, -9, -3, -2] },
|
||||
{ name: 'mixed signs', values: [-2, 3, -7, 0, 11, -0.5] },
|
||||
{ name: 'signed zero', values: [0, -0, -0, 0] },
|
||||
{ name: 'negative then signed zero', values: [-3, -0, -1] },
|
||||
{ name: 'nan leading', values: [NaN, 5, 1, 9] },
|
||||
{ name: 'nan trailing', values: [5, 1, 9, NaN] },
|
||||
{ name: 'nan interleaved', values: [5, NaN, 1, NaN, 9] },
|
||||
{ name: 'all nan', values: [NaN, NaN, NaN] },
|
||||
{ name: 'positive infinity', values: [Infinity, 3, 1] },
|
||||
{ name: 'negative infinity', values: [-Infinity, -3, -1] },
|
||||
{ name: 'both infinities', values: [Infinity, -Infinity, 3, -Infinity] },
|
||||
{ name: 'infinities and nan', values: [Infinity, NaN, -Infinity, 0] },
|
||||
{ name: 'sub-millisecond rounding', values: [0.00049, 0.0005, 0.00051, 0.9995] },
|
||||
{ name: 'reservoir sized', values: deterministicWindow(CONTROL_RTT_RESERVOIR_LIMIT) }
|
||||
]
|
||||
|
||||
// Holes cannot reach the recorders, so they are exercised through `percentile`
|
||||
// alone — the surface `host-session-registry` also calls.
|
||||
const SPARSE_WINDOWS: Array<{ name: string; values: number[] }> = [
|
||||
{ name: 'all holes', values: sparseWindow(4, {}) },
|
||||
{ name: 'leading hole', values: sparseWindow(5, { 3: 8, 4: 2 }) },
|
||||
{ name: 'trailing hole', values: sparseWindow(5, { 0: 8, 1: 2 }) },
|
||||
{ name: 'interleaved holes', values: sparseWindow(6, { 0: 3, 2: -4, 5: 1 }) },
|
||||
{ name: 'holes with nan', values: sparseWindow(5, { 1: NaN, 3: 6 }) }
|
||||
]
|
||||
|
||||
const PERCENTILE_RANKS = [0, 0.05, 0.5, 0.9, 0.95, 0.99, 1]
|
||||
|
||||
type SortWork = { sorts: number; comparisons: number; copiedElements: number }
|
||||
|
||||
// Every sorted array here is a fresh spread copy, so its length is the number of
|
||||
// elements copied to produce it.
|
||||
function countSortWork(run: () => void): SortWork {
|
||||
const work: SortWork = { sorts: 0, comparisons: 0, copiedElements: 0 }
|
||||
const original = Array.prototype.sort
|
||||
const patched = Array.prototype as { sort: unknown }
|
||||
patched.sort = function <T>(this: T[], compare?: (left: T, right: T) => number): T[] {
|
||||
work.sorts++
|
||||
work.copiedElements += this.length
|
||||
return original.call(this, (left: T, right: T) => {
|
||||
work.comparisons++
|
||||
return compare ? compare(left, right) : String(left) < String(right) ? -1 : 1
|
||||
})
|
||||
}
|
||||
try {
|
||||
run()
|
||||
} finally {
|
||||
patched.sort = original
|
||||
}
|
||||
return work
|
||||
}
|
||||
|
||||
function summaryThroughFlush(samples: number[]): { p50: number; p95: number; max: number } {
|
||||
function summaryThroughFlush(samples: number[]) {
|
||||
const entries: Array<Record<string, unknown>> = []
|
||||
const observability = new RelayObservability(
|
||||
{ role: 'cell', cellId: 'staging-c1', region: 'us-central1' },
|
||||
@@ -933,68 +831,76 @@ function summaryThroughFlush(samples: number[]): { p50: number; p95: number; max
|
||||
observability.flush(counts)
|
||||
const entry = entries[0]!
|
||||
return {
|
||||
p50: entry.controlRenewalLatencyMsP50 as number,
|
||||
p95: entry.controlRenewalLatencyMsP95 as number,
|
||||
max: entry.controlRenewalLatencyMsMax as number
|
||||
p50: entry.controlRenewalLatencyMsP50,
|
||||
p95: entry.controlRenewalLatencyMsP95,
|
||||
max: entry.controlRenewalLatencyMsMax
|
||||
}
|
||||
}
|
||||
|
||||
describe('latency window summarisation', () => {
|
||||
it('matches the pre-change percentile on every edge-case window', () => {
|
||||
let compared = 0
|
||||
for (const { name, values } of [...DENSE_WINDOWS, ...SPARSE_WINDOWS]) {
|
||||
for (const rank of PERCENTILE_RANKS) {
|
||||
expectSameNumber(
|
||||
percentile(values, rank),
|
||||
legacyPercentile(values, rank),
|
||||
`${name} @ p${rank}`
|
||||
)
|
||||
compared++
|
||||
}
|
||||
}
|
||||
expect(compared).toBe((DENSE_WINDOWS.length + SPARSE_WINDOWS.length) * PERCENTILE_RANKS.length)
|
||||
it.each([
|
||||
{ values: [], rank: 0.5, expected: 0 },
|
||||
{ values: [10, 1, 5, 3], rank: 0.5, expected: 3 },
|
||||
{ values: [10, 1, 5, 3], rank: 0.95, expected: 10 },
|
||||
{ values: [10, 1, 5, 3], rank: 0, expected: 0 },
|
||||
{ values: [-5, -1, -9, -3, -2], rank: 1, expected: -1 },
|
||||
{ values: [0, -0, -0, 0], rank: 0.5, expected: -0 },
|
||||
{ values: new Array<number>(4), rank: 0.5, expected: 0 },
|
||||
{ values: [Number.NaN, Number.NaN], rank: 1, expected: Number.NaN },
|
||||
{ values: [Infinity, -Infinity], rank: 0.5, expected: -Infinity }
|
||||
])('selects the nearest rank without interpolating (case %#)', ({ values, rank, expected }) => {
|
||||
expect(percentile(values, rank)).toBe(expected)
|
||||
})
|
||||
|
||||
it('matches the pre-change p50, p95 and maximum through a flush', () => {
|
||||
let compared = 0
|
||||
for (const { name, values } of DENSE_WINDOWS) {
|
||||
const actual = summaryThroughFlush(values)
|
||||
const expected = legacyLatencySummary(values)
|
||||
expectSameNumber(actual.p50, expected.p50, `${name} p50`)
|
||||
expectSameNumber(actual.p95, expected.p95, `${name} p95`)
|
||||
// The zero-seeded fold, not the sorted last element: all-negative and NaN
|
||||
// windows disagree between the two.
|
||||
expectSameNumber(actual.max, expected.max, `${name} max`)
|
||||
compared += 3
|
||||
}
|
||||
expect(compared).toBe(DENSE_WINDOWS.length * 3)
|
||||
// The trap, spelled out: the sorted window ends at -1 but the fold reports 0.
|
||||
expect(summaryThroughFlush([-5, -1, -9, -3, -2]).max).toBe(0)
|
||||
expect(Number.isNaN(summaryThroughFlush([5, NaN, 1]).max)).toBe(true)
|
||||
it.each([
|
||||
{ name: 'empty', values: [], expected: { p50: 0, p95: 0, max: 0 } },
|
||||
{ name: 'single', values: [7.5], expected: { p50: 7.5, p95: 7.5, max: 7.5 } },
|
||||
{ name: 'unsorted', values: [10, 1, 5, 3], expected: { p50: 3, p95: 10, max: 10 } },
|
||||
{ name: 'duplicates', values: [4, 4, 4], expected: { p50: 4, p95: 4, max: 4 } },
|
||||
{ name: 'negative', values: [-5, -1, -9, -3, -2], expected: { p50: -3, p95: -1, max: 0 } },
|
||||
{ name: 'mixed signs', values: [-2, 3, -7, 0, 11, -0.5], expected: { p50: -0.5, p95: 11, max: 11 } },
|
||||
{ name: 'signed zero', values: [0, -0, -0, 0], expected: { p50: 0, p95: 0, max: 0 } },
|
||||
{
|
||||
name: 'NaN',
|
||||
values: [Number.NaN, Number.NaN],
|
||||
expected: { p50: Number.NaN, p95: Number.NaN, max: Number.NaN }
|
||||
},
|
||||
{ name: 'positive infinity', values: [Infinity, 3, 1], expected: { p50: 3, p95: Infinity, max: Infinity } },
|
||||
{ name: 'negative infinity', values: [-Infinity, -3, -1], expected: { p50: -3, p95: -1, max: 0 } },
|
||||
{ name: 'both infinities', values: [Infinity, -Infinity, 3, -Infinity], expected: { p50: -Infinity, p95: Infinity, max: Infinity } },
|
||||
{ name: 'rounding', values: [0.00049, 0.0005, 0.00051, 0.9995], expected: { p50: 0.001, p95: 1, max: 1 } }
|
||||
])('publishes explicit p50, p95 and maximum for $name samples', ({ values, expected }) => {
|
||||
expect(summaryThroughFlush(values)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('sorts each latency window once instead of once per rank', () => {
|
||||
const samples = deterministicWindow(CONTROL_RTT_RESERVOIR_LIMIT)
|
||||
const before = countSortWork(() => legacyLatencySummary(samples))
|
||||
const after = countSortWork(() => summaryThroughFlush(samples))
|
||||
|
||||
expect(before.sorts).toBe(2)
|
||||
expect(after.sorts).toBe(1)
|
||||
expect(before.copiedElements).toBe(2 * CONTROL_RTT_RESERVOIR_LIMIT)
|
||||
expect(after.copiedElements).toBe(CONTROL_RTT_RESERVOIR_LIMIT)
|
||||
// Identical input and comparator, so the dropped sort is exactly half the
|
||||
// comparator calls rather than an engine-specific constant.
|
||||
expect(before.comparisons).toBeGreaterThan(CONTROL_RTT_RESERVOIR_LIMIT)
|
||||
expect(after.comparisons).toBe(before.comparisons / 2)
|
||||
it('sorts a full latency reservoir once for all ranks', () => {
|
||||
const samples = Array.from({ length: CONTROL_RTT_RESERVOIR_LIMIT }, (_, index) =>
|
||||
CONTROL_RTT_RESERVOIR_LIMIT - index
|
||||
)
|
||||
const sort = vi.spyOn(Array.prototype, 'sort')
|
||||
try {
|
||||
summaryThroughFlush(samples)
|
||||
const sortedWindows = [...sort.mock.contexts]
|
||||
expect(sortedWindows).toHaveLength(1)
|
||||
expect(sortedWindows[0]).toHaveLength(CONTROL_RTT_RESERVOIR_LIMIT)
|
||||
} finally {
|
||||
sort.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('never sorts an empty window and leaves the caller window untouched', () => {
|
||||
const samples = [5, -1, NaN, 3, -0]
|
||||
const before = samples.map(describeNumber)
|
||||
expect(countSortWork(() => summaryThroughFlush([])).sorts).toBe(0)
|
||||
expect(countSortWork(() => percentile([], 0.95)).sorts).toBe(0)
|
||||
countSortWork(() => summaryThroughFlush(samples))
|
||||
const sort = vi.spyOn(Array.prototype, 'sort')
|
||||
try {
|
||||
summaryThroughFlush([])
|
||||
percentile([], 0.95)
|
||||
expect(sort).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
sort.mockRestore()
|
||||
}
|
||||
const samples = [5, -1, Number.NaN, 3, -0]
|
||||
const before = [...samples]
|
||||
summaryThroughFlush(samples)
|
||||
percentile(samples, 0.5)
|
||||
expect(samples.map(describeNumber)).toEqual(before)
|
||||
expect(samples).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,11 +12,6 @@ afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function originalFence(code: string): string {
|
||||
const longest = (code.match(/`+/g) ?? []).reduce((run, match) => Math.max(run, match.length), 0)
|
||||
return '`'.repeat(Math.max(3, longest + 1))
|
||||
}
|
||||
|
||||
describe('code-block fence sizing', () => {
|
||||
it.each([
|
||||
{ code: '', length: 3 },
|
||||
@@ -30,23 +25,6 @@ describe('code-block fence sizing', () => {
|
||||
expect(codeFenceFor(code)).toBe('`'.repeat(length))
|
||||
})
|
||||
|
||||
it('matches the previous result across raw UTF-16 text and consecutive calls', () => {
|
||||
let seed = 0x823517
|
||||
const random = () => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
|
||||
return seed
|
||||
}
|
||||
const atoms = ['`', '`', '````', 'a', '\n', '\r', '\u0000', '\uD800', '\uDC00', '😀', 'é', ' ']
|
||||
for (let sample = 0; sample < 3000; sample += 1) {
|
||||
let code = ''
|
||||
const length = random() % 500
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
code += atoms[random() % atoms.length]
|
||||
}
|
||||
expect(codeFenceFor(code)).toBe(originalFence(code))
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes an admitted editable document without collecting every backtick run', () => {
|
||||
document.body.innerHTML = RICH_MARKDOWN_EDITOR_MARKUP
|
||||
const posted: MobileRichMarkdownEditorMessage[] = []
|
||||
|
||||
@@ -1,43 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { findTextMatchRanges } from './markdown-preview-search'
|
||||
|
||||
function referenceMatchRanges(text: string, query: string): { start: number; end: number }[] {
|
||||
let normalized = ''
|
||||
let originalOffset = 0
|
||||
const starts: number[] = []
|
||||
const ends: number[] = []
|
||||
for (const char of text) {
|
||||
const lowercase = char.toLocaleLowerCase()
|
||||
for (let index = 0; index < lowercase.length; index += 1) {
|
||||
starts.push(originalOffset)
|
||||
ends.push(originalOffset + char.length)
|
||||
}
|
||||
normalized += lowercase
|
||||
originalOffset += char.length
|
||||
}
|
||||
const normalizedQuery = Array.from(query, (char) => char.toLocaleLowerCase()).join('')
|
||||
const matches: { start: number; end: number }[] = []
|
||||
let searchStart = 0
|
||||
while (searchStart <= normalized.length - normalizedQuery.length) {
|
||||
const start = normalized.indexOf(normalizedQuery, searchStart)
|
||||
if (start === -1) {
|
||||
break
|
||||
}
|
||||
const end = start + normalizedQuery.length
|
||||
matches.push({ start: starts[start] ?? text.length, end: ends[end - 1] ?? text.length })
|
||||
searchStart = end + (normalizedQuery.length === 0 ? 1 : 0)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
function createRandom(seed: number): (max: number) => number {
|
||||
let state = seed
|
||||
return (max) => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
|
||||
return state % max
|
||||
}
|
||||
}
|
||||
|
||||
function mockLowercaseLocale(locale: string): void {
|
||||
const lowercase = String.prototype.toLocaleLowerCase
|
||||
vi.spyOn(String.prototype, 'toLocaleLowerCase').mockImplementation(function (this: string) {
|
||||
@@ -57,9 +20,16 @@ describe('case-insensitive Markdown search offsets', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['İ', '😊', '𐐀'])('keeps offsets across an ordinary prefix and %s', (character) => {
|
||||
it.each([
|
||||
{ character: 'İ', first: 9, second: 18 },
|
||||
{ character: '😊', first: 10, second: 20 },
|
||||
{ character: '𐐀', first: 10, second: 20 }
|
||||
])('keeps offsets across an ordinary prefix and $character', ({ character, first, second }) => {
|
||||
const text = `Prefix ${character} NEEDLE ${character} needle`
|
||||
expect(findTextMatchRanges(text, 'needle')).toEqual(referenceMatchRanges(text, 'needle'))
|
||||
expect(findTextMatchRanges(text, 'needle')).toEqual([
|
||||
{ start: first, end: first + 6 },
|
||||
{ start: second, end: second + 6 }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves whole code-point ranges for lone surrogate queries', () => {
|
||||
@@ -75,49 +45,19 @@ describe('case-insensitive Markdown search offsets', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['en-US', 'tr', 'az', 'lt', 'el'])(
|
||||
'preserves every ASCII character in locale %s',
|
||||
(locale) => {
|
||||
mockLowercaseLocale(locale)
|
||||
const ascii = Array.from({ length: 128 }, (_, index) => String.fromCharCode(index)).join('')
|
||||
const text = `${ascii} IJI iIj ${ascii}`
|
||||
for (const query of [...ascii, 'IJI', 'iij', 'j']) {
|
||||
expect(findTextMatchRanges(text, query)).toEqual(referenceMatchRanges(text, query))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['en-US', 'tr', 'az', 'lt', 'el'])(
|
||||
'matches reference code-point folding in locale %s',
|
||||
(locale) => {
|
||||
mockLowercaseLocale(locale)
|
||||
const random = createRandom(101)
|
||||
const characters = [
|
||||
'a',
|
||||
'B',
|
||||
' ',
|
||||
'İ',
|
||||
'I',
|
||||
'ı',
|
||||
'\u0307',
|
||||
'Σ',
|
||||
'σ',
|
||||
'ς',
|
||||
'𐐀',
|
||||
'𐐨',
|
||||
'😊',
|
||||
'\ud800',
|
||||
'\udc00'
|
||||
]
|
||||
for (let sample = 0; sample < 250; sample += 1) {
|
||||
let text = ''
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
text += characters[random(characters.length)]
|
||||
}
|
||||
const start = random(text.length)
|
||||
const query = text.slice(start, start + random(4) + 1)
|
||||
expect(findTextMatchRanges(text, query)).toEqual(referenceMatchRanges(text, query))
|
||||
}
|
||||
}
|
||||
)
|
||||
it.each([
|
||||
{ locale: 'en-US', dotted: [0, 2, 4], dotless: [6] },
|
||||
{ locale: 'tr', dotted: [2, 4], dotless: [0, 6] },
|
||||
{ locale: 'az', dotted: [2, 4], dotless: [0, 6] },
|
||||
{ locale: 'lt', dotted: [0, 2, 4], dotless: [6] },
|
||||
{ locale: 'el', dotted: [0, 2, 4], dotless: [6] }
|
||||
])('respects dotted and dotless I in $locale', ({ locale, dotted, dotless }) => {
|
||||
mockLowercaseLocale(locale)
|
||||
expect(findTextMatchRanges('I i İ ı', 'i')).toEqual(
|
||||
dotted.map((start) => ({ start, end: start + 1 }))
|
||||
)
|
||||
expect(findTextMatchRanges('I i İ ı', 'ı')).toEqual(
|
||||
dotless.map((start) => ({ start, end: start + 1 }))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
expandDraggedWorktreeIdsForVisibleLineage,
|
||||
type WorktreeDragLineageRow
|
||||
} from './worktree-manual-order'
|
||||
|
||||
// Preserve the original traversal as an independent ordering oracle.
|
||||
function referenceExpansion(
|
||||
rows: readonly WorktreeDragLineageRow[],
|
||||
draggedIds: readonly string[]
|
||||
) {
|
||||
const dragged = new Set(draggedIds)
|
||||
const expanded = new Set(draggedIds)
|
||||
const rowIds = new Set(rows.map((row) => row.worktreeId))
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index]
|
||||
if (!dragged.has(row.worktreeId)) {
|
||||
continue
|
||||
}
|
||||
for (let cursor = index + 1; cursor < rows.length; cursor++) {
|
||||
const child = rows[cursor]
|
||||
if (child.depth <= row.depth) {
|
||||
break
|
||||
}
|
||||
expanded.add(child.worktreeId)
|
||||
}
|
||||
}
|
||||
const result = rows.filter((row) => expanded.has(row.worktreeId)).map((row) => row.worktreeId)
|
||||
for (const id of draggedIds) {
|
||||
if (!rowIds.has(id) && !result.includes(id)) {
|
||||
result.push(id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
import { expandDraggedWorktreeIdsForVisibleLineage } from './worktree-manual-order'
|
||||
|
||||
describe('visible lineage expansion scaling', () => {
|
||||
it('reads each depth once even when every ancestor is selected', () => {
|
||||
@@ -45,9 +12,6 @@ describe('visible lineage expansion scaling', () => {
|
||||
}
|
||||
}))
|
||||
const selected = rows.map((row) => row.worktreeId)
|
||||
expect(referenceExpansion(rows, selected)).toEqual(selected)
|
||||
expect(depthReads).toBe(rows.length * (rows.length - 1))
|
||||
depthReads = 0
|
||||
expect(expandDraggedWorktreeIdsForVisibleLineage(rows, selected)).toEqual(selected)
|
||||
expect(depthReads).toBe(rows.length)
|
||||
})
|
||||
@@ -66,23 +30,4 @@ describe('visible lineage expansion scaling', () => {
|
||||
expect(result).toEqual(selected)
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('matches the previous algorithm across duplicate rows, missing IDs and depth boundaries', () => {
|
||||
let seed = 12345
|
||||
function random(limit: number): number {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
|
||||
return seed % limit
|
||||
}
|
||||
const depths = [-1, 0, 1, 2, 3, 20, Infinity, -Infinity, Number.NaN]
|
||||
for (let sample = 0; sample < 500; sample++) {
|
||||
const rows = Array.from({ length: random(40) }, () => ({
|
||||
worktreeId: `wt-${random(20)}`,
|
||||
depth: depths[random(depths.length)]
|
||||
}))
|
||||
const selected = Array.from({ length: random(20) }, () => `wt-${random(25)}`)
|
||||
expect(expandDraggedWorktreeIdsForVisibleLineage(rows, selected)).toEqual(
|
||||
referenceExpansion(rows, selected)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import {
|
||||
selectForegroundProcessCandidate,
|
||||
type ForegroundProcessCandidate
|
||||
} from './foreground-process-selection'
|
||||
import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition'
|
||||
|
||||
/** The pre-memo lineage walk, with a step cap standing in for its missing cycle guard. */
|
||||
function referenceIsAncestorOrSelf(
|
||||
ancestorPid: number,
|
||||
descendant: ForegroundProcessCandidate,
|
||||
byPid: ReadonlyMap<number, ForegroundProcessCandidate>
|
||||
): boolean {
|
||||
let currentPid = descendant.pid
|
||||
for (let steps = 0; steps <= byPid.size + 1; steps += 1) {
|
||||
if (currentPid === ancestorPid) {
|
||||
return true
|
||||
}
|
||||
const current = byPid.get(currentPid)
|
||||
if (!current) {
|
||||
return false
|
||||
}
|
||||
currentPid = current.ppid
|
||||
}
|
||||
// Only reachable on a ppid cycle, where the original spun forever.
|
||||
return false
|
||||
}
|
||||
|
||||
function referenceSelect(
|
||||
candidates: readonly ForegroundProcessCandidate[],
|
||||
ancestryCandidates: readonly ForegroundProcessCandidate[] = candidates
|
||||
): ForegroundProcessCandidate | null {
|
||||
const recognized = candidates.flatMap((candidate) => {
|
||||
const agent = recognizeAgentProcessFromCommandLine(candidate.command)
|
||||
return agent ? [{ candidate, agent }] : []
|
||||
})
|
||||
if (recognized.length === 0) {
|
||||
return null
|
||||
}
|
||||
const names = new Set(recognized.map((entry) => entry.agent.agent))
|
||||
if (names.size > 1) {
|
||||
const byPid = new Map(ancestryCandidates.map((candidate) => [candidate.pid, candidate]))
|
||||
const outer = [...recognized].sort(
|
||||
(left, right) => left.candidate.depth - right.candidate.depth
|
||||
)[0]
|
||||
if (
|
||||
!outer ||
|
||||
!recognized.every((entry) =>
|
||||
referenceIsAncestorOrSelf(outer.candidate.pid, entry.candidate, byPid)
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return outer.candidate
|
||||
}
|
||||
const score = (candidate: ForegroundProcessCandidate): number =>
|
||||
(candidate.stat?.includes('+') ? 10_000 : 0) + candidate.depth
|
||||
return recognized.reduce((best, current) =>
|
||||
score(current.candidate) > score(best.candidate) ? current : best
|
||||
).candidate
|
||||
}
|
||||
|
||||
function makeRandom(seed: number): () => number {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0
|
||||
return state / 0x100000000
|
||||
}
|
||||
}
|
||||
|
||||
const COMMANDS = ['claude', 'codex', 'opencode', 'bash -lc build', 'node server.js']
|
||||
|
||||
/** A random process table: some rows reparented, some parents missing, some cycles. */
|
||||
function makeTable(random: () => number, size: number): ForegroundProcessCandidate[] {
|
||||
const rows: ForegroundProcessCandidate[] = []
|
||||
for (let index = 0; index < size; index += 1) {
|
||||
const pid = index + 1
|
||||
const roll = random()
|
||||
let ppid: number
|
||||
if (index === 0) {
|
||||
ppid = 0
|
||||
} else if (roll < 0.15) {
|
||||
ppid = 9000 + index // parent absent from the table
|
||||
} else if (roll < 0.25) {
|
||||
ppid = 1 + Math.floor(random() * size) // arbitrary reparent, may form a cycle
|
||||
} else {
|
||||
ppid = index // straight chain
|
||||
}
|
||||
rows.push({
|
||||
pid,
|
||||
ppid,
|
||||
depth: index,
|
||||
stat: random() < 0.5 ? 'S+' : 'S',
|
||||
command: COMMANDS[Math.floor(random() * COMMANDS.length)]!
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
it('picks the same foreground agent as the unmemoized lineage walk', () => {
|
||||
let multiAgentCases = 0
|
||||
let selectedCases = 0
|
||||
for (let seed = 1; seed <= 3000; seed += 1) {
|
||||
const random = makeRandom(seed)
|
||||
const table = makeTable(random, 1 + Math.floor(random() * 10))
|
||||
// Also exercise the split candidate/ancestry inputs the batch caller uses.
|
||||
const foreground = table.filter((_, index) => index % 3 !== 2)
|
||||
for (const [candidates, ancestry] of [
|
||||
[table, table],
|
||||
[foreground, table]
|
||||
] as const) {
|
||||
const actual = selectForegroundProcessCandidate(candidates, ancestry)
|
||||
const expected = referenceSelect(candidates, ancestry)
|
||||
expect(actual?.candidate ?? null, `seed ${seed}`).toEqual(expected)
|
||||
if (
|
||||
new Set(candidates.map((row) => recognizeAgentProcessFromCommandLine(row.command)?.agent))
|
||||
.size > 2
|
||||
) {
|
||||
multiAgentCases += 1
|
||||
}
|
||||
if (actual) {
|
||||
selectedCases += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
// The mixed-agent ancestry branch (the only path the memo touches) must be hit.
|
||||
expect(multiAgentCases).toBeGreaterThan(500)
|
||||
expect(selectedCases).toBeGreaterThan(500)
|
||||
})
|
||||
|
||||
// The pre-memo walk had no cycle guard, so a ppid loop that never reaches the outer
|
||||
// agent spun forever and hung the caller reporting the foreground process.
|
||||
it('terminates on a ppid cycle that never reaches the outer agent', () => {
|
||||
const cycle: ForegroundProcessCandidate[] = [
|
||||
{ pid: 1, ppid: 0, depth: 0, stat: 'S+', command: 'claude' },
|
||||
{ pid: 2, ppid: 3, depth: 1, stat: 'S+', command: 'codex' },
|
||||
{ pid: 3, ppid: 2, depth: 2, stat: 'S+', command: 'bash -lc build' }
|
||||
]
|
||||
expect(selectForegroundProcessCandidate(cycle)).toBeNull()
|
||||
})
|
||||
|
||||
it('reflects a reparent, a spawn and an exit on the next capture', () => {
|
||||
const shell: ForegroundProcessCandidate = {
|
||||
pid: 10,
|
||||
ppid: 1,
|
||||
depth: 0,
|
||||
stat: 'S+',
|
||||
command: 'claude'
|
||||
}
|
||||
const helper: ForegroundProcessCandidate = {
|
||||
pid: 11,
|
||||
ppid: 10,
|
||||
depth: 1,
|
||||
stat: 'S+',
|
||||
command: 'codex'
|
||||
}
|
||||
// Nested lineage: the outer agent wins.
|
||||
expect(selectForegroundProcessCandidate([shell, helper])?.candidate.pid).toBe(10)
|
||||
|
||||
// Reparent the helper to a pid outside the capture: the lineage no longer holds.
|
||||
const reparented = { ...helper, ppid: 999 }
|
||||
expect(selectForegroundProcessCandidate([shell, reparented])).toBeNull()
|
||||
|
||||
// Spawn a sibling agent under an unrelated parent: still untrustworthy.
|
||||
const sibling: ForegroundProcessCandidate = {
|
||||
pid: 12,
|
||||
ppid: 1,
|
||||
depth: 1,
|
||||
stat: 'S+',
|
||||
command: 'opencode'
|
||||
}
|
||||
expect(selectForegroundProcessCandidate([shell, helper, sibling])).toBeNull()
|
||||
|
||||
// The sibling exits: the surviving nested lineage resolves again on the new capture.
|
||||
expect(selectForegroundProcessCandidate([shell, helper])?.candidate.pid).toBe(10)
|
||||
})
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectForegroundProcessCandidate } from './foreground-process-selection'
|
||||
import {
|
||||
selectForegroundProcessCandidate,
|
||||
type ForegroundProcessCandidate
|
||||
} from './foreground-process-selection'
|
||||
|
||||
describe('selectForegroundProcessCandidate', () => {
|
||||
it('keeps a recognized ancestor over a different agent helper below a non-agent', () => {
|
||||
@@ -37,14 +40,17 @@ describe('selectForegroundProcessCandidate', () => {
|
||||
expect(selectForegroundProcessCandidate(candidates)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the deepest process when one recognized agent owns the lineage', () => {
|
||||
it.each([
|
||||
{ helperStat: 'S+', selectedPid: 102 },
|
||||
{ helperStat: 'S', selectedPid: 101 }
|
||||
])('prefers foreground processes, then depth ($helperStat)', ({ helperStat, selectedPid }) => {
|
||||
const candidates = [
|
||||
{ pid: 101, ppid: 100, depth: 1, stat: 'S+', command: 'node /opt/bin/codex' },
|
||||
{ pid: 102, ppid: 101, depth: 2, stat: 'S+', command: '/opt/vendor/bin/codex' }
|
||||
{ pid: 102, ppid: 101, depth: 2, stat: helperStat, command: '/opt/vendor/bin/codex' }
|
||||
]
|
||||
|
||||
expect(selectForegroundProcessCandidate(candidates)).toMatchObject({
|
||||
candidate: { pid: 102 },
|
||||
candidate: { pid: selectedPid },
|
||||
recognized: { agent: 'codex' }
|
||||
})
|
||||
})
|
||||
@@ -65,3 +71,44 @@ it('memoizes shared ancestry while validating a long agent/helper lineage', () =
|
||||
expect(selectForegroundProcessCandidate(candidates)?.candidate.pid).toBe(1)
|
||||
expect(reads).toBeLessThanOrEqual(1000)
|
||||
})
|
||||
|
||||
it('terminates on a ppid cycle that never reaches the outer agent', () => {
|
||||
const cycle: ForegroundProcessCandidate[] = [
|
||||
{ pid: 1, ppid: 0, depth: 0, stat: 'S+', command: 'claude' },
|
||||
{ pid: 2, ppid: 3, depth: 1, stat: 'S+', command: 'codex' },
|
||||
{ pid: 3, ppid: 2, depth: 2, stat: 'S+', command: 'bash -lc build' }
|
||||
]
|
||||
expect(selectForegroundProcessCandidate(cycle)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not reuse ancestry after reparenting, spawning or exit', () => {
|
||||
const shell: ForegroundProcessCandidate = {
|
||||
pid: 10,
|
||||
ppid: 1,
|
||||
depth: 0,
|
||||
stat: 'S+',
|
||||
command: 'claude'
|
||||
}
|
||||
const helper: ForegroundProcessCandidate = {
|
||||
pid: 11,
|
||||
ppid: 10,
|
||||
depth: 1,
|
||||
stat: 'S+',
|
||||
command: 'codex'
|
||||
}
|
||||
expect(selectForegroundProcessCandidate([shell, helper])?.candidate.pid).toBe(10)
|
||||
|
||||
const reparented = { ...helper, ppid: 999 }
|
||||
expect(selectForegroundProcessCandidate([shell, reparented])).toBeNull()
|
||||
|
||||
const sibling: ForegroundProcessCandidate = {
|
||||
pid: 12,
|
||||
ppid: 1,
|
||||
depth: 1,
|
||||
stat: 'S+',
|
||||
command: 'opencode'
|
||||
}
|
||||
expect(selectForegroundProcessCandidate([shell, helper, sibling])).toBeNull()
|
||||
|
||||
expect(selectForegroundProcessCandidate([shell, helper])?.candidate.pid).toBe(10)
|
||||
})
|
||||
|
||||
@@ -1,35 +1,12 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { compareFileNames } from './file-name-sort'
|
||||
import {
|
||||
prepareQuickOpenFiles,
|
||||
QuickOpenPathRanker,
|
||||
rankQuickOpenFiles,
|
||||
type QuickOpenIndexedFile
|
||||
} from './quick-open-path-search'
|
||||
import { prepareQuickOpenFiles, rankQuickOpenFiles } from './quick-open-path-search'
|
||||
|
||||
function referenceScore(query: string, file: QuickOpenIndexedFile): number | null {
|
||||
let qi = 0
|
||||
let score = 0
|
||||
let lastMatch = -1
|
||||
for (let ti = 0; ti < file.lowerPath.length && qi < query.length; ti++) {
|
||||
if (file.lowerPath[ti] !== query[qi]) {
|
||||
continue
|
||||
}
|
||||
score += lastMatch === -1 ? 0 : ti - lastMatch - 1
|
||||
if (ti > 0 && '/.-'.includes(file.lowerPath[ti - 1])) {
|
||||
score -= 5
|
||||
}
|
||||
lastMatch = ti
|
||||
qi++
|
||||
}
|
||||
if (qi < query.length) {
|
||||
return null
|
||||
}
|
||||
return score - (file.lowerFilename.includes(query) ? 100 : 0)
|
||||
}
|
||||
|
||||
it.each(['az', 'aq'])('skips nonmatching path spans for %s', (query) => {
|
||||
const [prepared] = prepareQuickOpenFiles([`a/${'x'.repeat(4000)}/z.ts`])
|
||||
it.each([
|
||||
{ query: 'az', score: 3997 },
|
||||
{ query: 'aq', score: null }
|
||||
])('skips nonmatching path spans for $query', ({ query, score }) => {
|
||||
const path = `a/${'x'.repeat(4000)}/z.ts`
|
||||
const [prepared] = prepareQuickOpenFiles([path])
|
||||
let pathReads = 0
|
||||
const measured = {
|
||||
...prepared,
|
||||
@@ -38,85 +15,6 @@ it.each(['az', 'aq'])('skips nonmatching path spans for %s', (query) => {
|
||||
return prepared.lowerPath
|
||||
}
|
||||
}
|
||||
expect(rankQuickOpenFiles(query, [measured])).toEqual(rankQuickOpenFiles(query, [prepared]))
|
||||
expect(rankQuickOpenFiles(query, [measured])).toEqual(score === null ? [] : [{ path, score }])
|
||||
expect(pathReads).toBeLessThanOrEqual(20)
|
||||
})
|
||||
|
||||
it('preserves code-unit scores and ordering for generated Unicode paths and queries', () => {
|
||||
const alphabet = [
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'/',
|
||||
'\\',
|
||||
'.',
|
||||
'-',
|
||||
'2',
|
||||
'0',
|
||||
'é',
|
||||
'e\u0301',
|
||||
'İ',
|
||||
'😀',
|
||||
'\ud800',
|
||||
'\udc00'
|
||||
]
|
||||
let seed = 43
|
||||
const next = () => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
|
||||
return seed
|
||||
}
|
||||
const paths = ['a.../b', 'a/x/z', 'a/z', '😀.ts', 'a'.repeat(110), 'aa', 'same', 'same', '']
|
||||
for (let index = 0; index < 1000; index++) {
|
||||
const length = next() % 80
|
||||
let path = ''
|
||||
for (let offset = 0; offset < length; offset++) {
|
||||
path += alphabet[next() % alphabet.length]
|
||||
}
|
||||
paths.push(path)
|
||||
}
|
||||
const files = prepareQuickOpenFiles(paths)
|
||||
const queries = [
|
||||
'',
|
||||
' ',
|
||||
'az',
|
||||
'ab',
|
||||
'a',
|
||||
'aa',
|
||||
'q',
|
||||
'😀',
|
||||
'\ud800',
|
||||
'\udc00',
|
||||
'é',
|
||||
'e\u0301',
|
||||
'İ',
|
||||
'a\\b',
|
||||
' A '
|
||||
]
|
||||
for (let index = 0; index < 100; index++) {
|
||||
queries.push(`${alphabet[next() % alphabet.length]}${alphabet[next() % alphabet.length]}`)
|
||||
}
|
||||
for (const query of queries) {
|
||||
// Separator queries now include word-boundary matches.
|
||||
if (/[-_]/.test(query)) {
|
||||
continue
|
||||
}
|
||||
const normalized = query.trim().replace(/\\/g, '/').toLowerCase()
|
||||
const expected = files
|
||||
.map((file) => ({ ...file, score: normalized ? referenceScore(normalized, file) : 0 }))
|
||||
.filter((file): file is typeof file & { score: number } => file.score !== null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex
|
||||
)
|
||||
for (const limit of [1, 16, 50]) {
|
||||
const selected = expected.slice(0, limit).map(({ path, score }) => ({ path, score }))
|
||||
expect(rankQuickOpenFiles(query, files, limit)).toEqual(selected)
|
||||
const streamed = new QuickOpenPathRanker(query, limit)
|
||||
paths.forEach((path) => streamed.consider(path))
|
||||
expect(streamed.result()).toEqual({
|
||||
paths: selected.map((entry) => entry.path),
|
||||
totalCount: expected.length
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user