vimonkey 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/chaos/index.d.ts +81 -0
- package/dist/chaos/index.d.ts.map +1 -0
- package/dist/chaos/index.js +148 -0
- package/dist/env.d.ts +38 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +52 -0
- package/dist/fuzz/context.d.ts +36 -0
- package/dist/fuzz/context.d.ts.map +1 -0
- package/dist/fuzz/context.js +43 -0
- package/dist/fuzz/gen.d.ts +70 -0
- package/dist/fuzz/gen.d.ts.map +1 -0
- package/dist/fuzz/gen.js +113 -0
- package/dist/fuzz/index.d.ts +57 -0
- package/dist/fuzz/index.d.ts.map +1 -0
- package/dist/fuzz/index.js +62 -0
- package/dist/fuzz/regression.d.ts +47 -0
- package/dist/fuzz/regression.d.ts.map +1 -0
- package/dist/fuzz/regression.js +91 -0
- package/dist/fuzz/shrink.d.ts +41 -0
- package/dist/fuzz/shrink.d.ts.map +1 -0
- package/dist/fuzz/shrink.js +80 -0
- package/dist/fuzz/test-fuzz.d.ts +55 -0
- package/dist/fuzz/test-fuzz.d.ts.map +1 -0
- package/dist/fuzz/test-fuzz.js +178 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/plugin.d.ts +74 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +41 -0
- package/dist/random.d.ts +66 -0
- package/dist/random.d.ts.map +1 -0
- package/dist/random.js +137 -0
- package/package.json +60 -3
- package/src/chaos/index.ts +180 -0
- package/src/env.ts +63 -0
- package/src/fuzz/context.ts +59 -0
- package/src/fuzz/gen.ts +157 -0
- package/src/fuzz/index.ts +90 -0
- package/src/fuzz/regression.ts +115 -0
- package/src/fuzz/shrink.ts +115 -0
- package/src/fuzz/test-fuzz.ts +241 -0
- package/src/index.ts +37 -0
- package/src/plugin.ts +96 -0
- package/src/random.ts +181 -0
package/src/fuzz/gen.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ergonomic generator API for fuzz testing
|
|
3
|
+
*
|
|
4
|
+
* gen(picker) creates an infinite async generator from a picker.
|
|
5
|
+
* take(generator, n) limits iterations and auto-tracks in test.fuzz().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createSeededRandom, weightedPickFromTuples, type SeededRandom } from "../random.js"
|
|
9
|
+
import { fuzzContext } from "./context.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Picker context passed to picker functions
|
|
13
|
+
*/
|
|
14
|
+
export interface PickerContext {
|
|
15
|
+
/** Seeded random number generator */
|
|
16
|
+
random: SeededRandom
|
|
17
|
+
/** Current iteration (0-indexed) */
|
|
18
|
+
iteration: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Result type for picker functions - can return single value, array, or iterable
|
|
23
|
+
*/
|
|
24
|
+
type PickerResult<T> = T | T[] | Iterable<T>
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Sync picker function
|
|
28
|
+
*/
|
|
29
|
+
type SyncPickerFn<T> = (ctx: PickerContext) => PickerResult<T>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Async picker function (for AI mode)
|
|
33
|
+
*/
|
|
34
|
+
type AsyncPickerFn<T> = (ctx: PickerContext) => Promise<PickerResult<T>>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Picker types:
|
|
38
|
+
* - T[] - random from array
|
|
39
|
+
* - [number, T][] - weighted random (pairs of [weight, value])
|
|
40
|
+
* - (ctx) => T | T[] | Iterable<T> - sync function
|
|
41
|
+
* - (ctx) => Promise<T | T[] | Iterable<T>> - async function
|
|
42
|
+
*/
|
|
43
|
+
export type Picker<T> = T[] | [number, T][] | SyncPickerFn<T> | AsyncPickerFn<T>
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Type guard for weighted tuples
|
|
47
|
+
*/
|
|
48
|
+
function isWeightedTuple<T>(picker: unknown): picker is [number, T][] {
|
|
49
|
+
return Array.isArray(picker) && picker.length > 0 && Array.isArray(picker[0]) && typeof picker[0][0] === "number"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Type guard for iterables (excluding strings)
|
|
54
|
+
*/
|
|
55
|
+
function isIterable<T>(value: unknown): value is Iterable<T> {
|
|
56
|
+
return value !== null && typeof value === "object" && Symbol.iterator in value && typeof value !== "string"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Flatten picker result: single value, array, or iterable → individual items
|
|
61
|
+
*/
|
|
62
|
+
function* flatten<T>(result: PickerResult<T>): Generator<T> {
|
|
63
|
+
if (Array.isArray(result)) {
|
|
64
|
+
for (const item of result) yield item
|
|
65
|
+
} else if (isIterable(result)) {
|
|
66
|
+
for (const item of result) yield item
|
|
67
|
+
} else {
|
|
68
|
+
yield result
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a picker function from various picker specs
|
|
74
|
+
*/
|
|
75
|
+
function createPicker<T>(
|
|
76
|
+
picker: Picker<T>,
|
|
77
|
+
random: SeededRandom,
|
|
78
|
+
): (ctx: PickerContext) => PickerResult<T> | Promise<PickerResult<T>> {
|
|
79
|
+
// Function picker - use as-is
|
|
80
|
+
if (typeof picker === "function") {
|
|
81
|
+
return picker
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Weighted tuple picker
|
|
85
|
+
if (isWeightedTuple<T>(picker)) {
|
|
86
|
+
const items = picker
|
|
87
|
+
return () => weightedPickFromTuples(items, random.float())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Array picker - random from array
|
|
91
|
+
return () => picker[Math.floor(random.float() * picker.length)]!
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create an infinite async generator from a picker
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* // Random from array
|
|
99
|
+
* gen(['j', 'k', 'h', 'l'])
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* // Weighted random
|
|
103
|
+
* gen([[40, 'j'], [40, 'k'], [20, 'Enter']])
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* // Custom picker function
|
|
107
|
+
* gen(({ random }) => random.pick(['j', 'k']))
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* // Picker returns array (flattened)
|
|
111
|
+
* gen(() => ['j', 'j', 'Enter']) // yields: j, j, Enter, j, j, Enter, ...
|
|
112
|
+
*/
|
|
113
|
+
export async function* gen<T>(picker: Picker<T>, seed?: number): AsyncGenerator<T> {
|
|
114
|
+
// Use context seed if available, otherwise provided seed or Date.now()
|
|
115
|
+
const ctx = fuzzContext.getStore()
|
|
116
|
+
const random = createSeededRandom(seed ?? ctx?.seed ?? Date.now())
|
|
117
|
+
const pick = createPicker(picker, random)
|
|
118
|
+
let iteration = 0
|
|
119
|
+
|
|
120
|
+
while (true) {
|
|
121
|
+
const pickerCtx: PickerContext = { random, iteration: iteration++ }
|
|
122
|
+
const result = await pick(pickerCtx)
|
|
123
|
+
yield* flatten(result)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Limit an async generator to n iterations
|
|
129
|
+
*
|
|
130
|
+
* When running inside test.fuzz(), automatically tracks yielded values
|
|
131
|
+
* for shrinking and regression testing.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* for await (const key of take(gen(['j', 'k']), 100)) {
|
|
135
|
+
* await handle.press(key)
|
|
136
|
+
* }
|
|
137
|
+
*/
|
|
138
|
+
export async function* take<T>(generator: AsyncIterable<T>, n: number): AsyncGenerator<T> {
|
|
139
|
+
const ctx = fuzzContext.getStore()
|
|
140
|
+
let i = 0
|
|
141
|
+
|
|
142
|
+
// Replay mode: yield from saved sequence instead of generator
|
|
143
|
+
if (ctx?.replaySequence) {
|
|
144
|
+
while (i < n && ctx.replayIndex < ctx.replaySequence.length) {
|
|
145
|
+
yield ctx.replaySequence[ctx.replayIndex++] as T
|
|
146
|
+
i++
|
|
147
|
+
}
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Normal mode: yield from generator, optionally record
|
|
152
|
+
for await (const item of generator) {
|
|
153
|
+
if (i++ >= n) break
|
|
154
|
+
ctx?.history.push(item) // Track if in fuzz context
|
|
155
|
+
yield item
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzz testing API
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* import { test, gen, take, createSeededRandom } from 'vimonkey/fuzz'
|
|
7
|
+
*
|
|
8
|
+
* // Simple random from array
|
|
9
|
+
* test('navigation', async () => {
|
|
10
|
+
* const handle = await run(<Board />, { cols: 80, rows: 24 })
|
|
11
|
+
* for await (const key of take(gen(['j','k','h','l']), 100)) {
|
|
12
|
+
* await handle.press(key)
|
|
13
|
+
* expect(handle.locator('[data-cursor]').count()).toBe(1)
|
|
14
|
+
* }
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* // Weighted random
|
|
18
|
+
* test('weighted', async () => {
|
|
19
|
+
* for await (const key of take(gen([[40,'j'], [40,'k'], [20,'Enter']]), 100)) {
|
|
20
|
+
* await handle.press(key)
|
|
21
|
+
* }
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* // Stateful with closure
|
|
25
|
+
* test('stateful', async () => {
|
|
26
|
+
* const handle = await app.run(<Board />)
|
|
27
|
+
* const random = createSeededRandom(Date.now())
|
|
28
|
+
*
|
|
29
|
+
* const keys = async function*() {
|
|
30
|
+
* while (true) {
|
|
31
|
+
* const state = handle.store.getState()
|
|
32
|
+
* yield state.cursor === 0 ? random.pick(['j','l']) : random.pick(['j','k','h','l'])
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* for await (const key of take(keys(), 100)) {
|
|
37
|
+
* await handle.press(key)
|
|
38
|
+
* }
|
|
39
|
+
* })
|
|
40
|
+
*
|
|
41
|
+
* // With auto-tracking and shrinking
|
|
42
|
+
* test.fuzz('cursor invariants', async () => {
|
|
43
|
+
* for await (const key of take(gen(['j','k']), 100)) {
|
|
44
|
+
* await handle.press(key)
|
|
45
|
+
* expect(...) // On failure: shrinks, saves to __fuzz_cases__/
|
|
46
|
+
* }
|
|
47
|
+
* })
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
// Core API
|
|
52
|
+
export { gen, take, type Picker, type PickerContext } from "./gen.js"
|
|
53
|
+
|
|
54
|
+
// test.fuzz wrapper
|
|
55
|
+
export { test, FuzzError, type FuzzTestOptions } from "./test-fuzz.js"
|
|
56
|
+
export { describe, expect, it, beforeAll, afterAll, beforeEach, afterEach } from "./test-fuzz.js"
|
|
57
|
+
|
|
58
|
+
// Context (for advanced use)
|
|
59
|
+
export {
|
|
60
|
+
fuzzContext,
|
|
61
|
+
getFuzzContext,
|
|
62
|
+
isInFuzzContext,
|
|
63
|
+
createFuzzContext,
|
|
64
|
+
createReplayContext,
|
|
65
|
+
type FuzzContext,
|
|
66
|
+
} from "./context.js"
|
|
67
|
+
|
|
68
|
+
// Shrinking (for advanced use)
|
|
69
|
+
export { shrinkSequence, formatShrinkResult, type ShrinkOptions, type ShrinkResult } from "./shrink.js"
|
|
70
|
+
|
|
71
|
+
// Regression (for advanced use)
|
|
72
|
+
export {
|
|
73
|
+
saveCase,
|
|
74
|
+
loadCases,
|
|
75
|
+
loadCasesForTest,
|
|
76
|
+
deleteCase,
|
|
77
|
+
clearCases,
|
|
78
|
+
getFuzzCasesDir,
|
|
79
|
+
type SavedCase,
|
|
80
|
+
} from "./regression.js"
|
|
81
|
+
|
|
82
|
+
// Re-export random utilities
|
|
83
|
+
export {
|
|
84
|
+
createSeededRandom,
|
|
85
|
+
weightedPickFromTuples,
|
|
86
|
+
parseSeed,
|
|
87
|
+
parseRepeats,
|
|
88
|
+
deriveSeeds,
|
|
89
|
+
type SeededRandom,
|
|
90
|
+
} from "../random.js"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression testing - save and load failing sequences
|
|
3
|
+
*
|
|
4
|
+
* Failing fuzz test sequences are saved to __fuzz_cases__/ directory
|
|
5
|
+
* like Jest/Vitest snapshots. On subsequent runs, saved sequences
|
|
6
|
+
* are replayed first to ensure bugs don't regress.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from "node:fs"
|
|
10
|
+
import { dirname, join, basename } from "node:path"
|
|
11
|
+
|
|
12
|
+
/** Failure case saved to disk */
|
|
13
|
+
export interface SavedCase {
|
|
14
|
+
/** Test name */
|
|
15
|
+
test: string
|
|
16
|
+
/** Seed used for generation */
|
|
17
|
+
seed: number
|
|
18
|
+
/** Minimal failing sequence */
|
|
19
|
+
sequence: unknown[]
|
|
20
|
+
/** Error message */
|
|
21
|
+
error: string
|
|
22
|
+
/** When the failure was recorded */
|
|
23
|
+
timestamp: string
|
|
24
|
+
/** Original sequence length before shrinking */
|
|
25
|
+
originalLength?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the __fuzz_cases__ directory path for a test file
|
|
30
|
+
*/
|
|
31
|
+
export function getFuzzCasesDir(testFilePath: string): string {
|
|
32
|
+
const dir = dirname(testFilePath)
|
|
33
|
+
const file = basename(testFilePath)
|
|
34
|
+
return join(dir, "__fuzz_cases__", file)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate a filename for a saved case
|
|
39
|
+
*/
|
|
40
|
+
function getCaseFilename(testName: string): string {
|
|
41
|
+
// Sanitize test name for filesystem
|
|
42
|
+
const safe = testName
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
45
|
+
.replace(/^-|-$/g, "")
|
|
46
|
+
.slice(0, 50)
|
|
47
|
+
const timestamp = Date.now()
|
|
48
|
+
return `${safe}-${timestamp}.json`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Save a failing case to __fuzz_cases__/
|
|
53
|
+
*/
|
|
54
|
+
export function saveCase(testFilePath: string, testName: string, failure: SavedCase): string {
|
|
55
|
+
const dir = getFuzzCasesDir(testFilePath)
|
|
56
|
+
mkdirSync(dir, { recursive: true })
|
|
57
|
+
|
|
58
|
+
const filename = getCaseFilename(testName)
|
|
59
|
+
const filepath = join(dir, filename)
|
|
60
|
+
|
|
61
|
+
writeFileSync(filepath, JSON.stringify(failure, null, 2))
|
|
62
|
+
return filepath
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load all saved cases for a test file
|
|
67
|
+
*/
|
|
68
|
+
export function loadCases(testFilePath: string): SavedCase[] {
|
|
69
|
+
const dir = getFuzzCasesDir(testFilePath)
|
|
70
|
+
if (!existsSync(dir)) return []
|
|
71
|
+
|
|
72
|
+
const cases: SavedCase[] = []
|
|
73
|
+
for (const file of readdirSync(dir)) {
|
|
74
|
+
if (!file.endsWith(".json")) continue
|
|
75
|
+
try {
|
|
76
|
+
const content = readFileSync(join(dir, file), "utf-8")
|
|
77
|
+
cases.push(JSON.parse(content) as SavedCase)
|
|
78
|
+
} catch {
|
|
79
|
+
// Skip invalid files
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return cases
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Load saved cases for a specific test name
|
|
87
|
+
*/
|
|
88
|
+
export function loadCasesForTest(testFilePath: string, testName: string): SavedCase[] {
|
|
89
|
+
return loadCases(testFilePath).filter((c) => c.test === testName)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Delete a saved case (when bug is fixed)
|
|
94
|
+
*/
|
|
95
|
+
export function deleteCase(testFilePath: string, filename: string): void {
|
|
96
|
+
const dir = getFuzzCasesDir(testFilePath)
|
|
97
|
+
const filepath = join(dir, filename)
|
|
98
|
+
if (existsSync(filepath)) {
|
|
99
|
+
unlinkSync(filepath)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Clear all saved cases for a test file
|
|
105
|
+
*/
|
|
106
|
+
export function clearCases(testFilePath: string): void {
|
|
107
|
+
const dir = getFuzzCasesDir(testFilePath)
|
|
108
|
+
if (!existsSync(dir)) return
|
|
109
|
+
|
|
110
|
+
for (const file of readdirSync(dir)) {
|
|
111
|
+
if (file.endsWith(".json")) {
|
|
112
|
+
unlinkSync(join(dir, file))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shrinking - find minimal failing sequence via delta debugging
|
|
3
|
+
*
|
|
4
|
+
* Uses binary search to reduce a failing sequence to the smallest
|
|
5
|
+
* subsequence that still triggers the failure.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ShrinkOptions {
|
|
9
|
+
/** Maximum shrinking attempts (default: 100) */
|
|
10
|
+
maxAttempts?: number
|
|
11
|
+
/** Minimum sequence length to try (default: 1) */
|
|
12
|
+
minLength?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ShrinkResult<T> {
|
|
16
|
+
/** Original sequence */
|
|
17
|
+
original: T[]
|
|
18
|
+
/** Shrunk (minimal) sequence */
|
|
19
|
+
shrunk: T[]
|
|
20
|
+
/** Number of shrinking attempts made */
|
|
21
|
+
attempts: number
|
|
22
|
+
/** Whether shrinking found a smaller sequence */
|
|
23
|
+
reduced: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shrink a failing sequence to its minimal form
|
|
28
|
+
*
|
|
29
|
+
* Uses delta debugging algorithm:
|
|
30
|
+
* 1. Try removing first half - if still fails, keep only first half
|
|
31
|
+
* 2. Try removing second half - if still fails, keep only second half
|
|
32
|
+
* 3. Try removing individual elements from start/end
|
|
33
|
+
* 4. Repeat until no reduction possible
|
|
34
|
+
*
|
|
35
|
+
* @param sequence - The original failing sequence
|
|
36
|
+
* @param runTest - Function that returns true if sequence passes, false if fails
|
|
37
|
+
* @param options - Shrinking options
|
|
38
|
+
*/
|
|
39
|
+
export async function shrinkSequence<T>(
|
|
40
|
+
sequence: T[],
|
|
41
|
+
runTest: (seq: T[]) => Promise<boolean>,
|
|
42
|
+
options: ShrinkOptions = {},
|
|
43
|
+
): Promise<ShrinkResult<T>> {
|
|
44
|
+
const { maxAttempts = 100, minLength = 1 } = options
|
|
45
|
+
|
|
46
|
+
let current = [...sequence]
|
|
47
|
+
let attempts = 0
|
|
48
|
+
let changed = true
|
|
49
|
+
|
|
50
|
+
while (changed && attempts < maxAttempts && current.length > minLength) {
|
|
51
|
+
changed = false
|
|
52
|
+
|
|
53
|
+
// Try removing first half
|
|
54
|
+
if (current.length > 1) {
|
|
55
|
+
const half = Math.ceil(current.length / 2)
|
|
56
|
+
const secondHalf = current.slice(half)
|
|
57
|
+
attempts++
|
|
58
|
+
|
|
59
|
+
if (secondHalf.length >= minLength && !(await runTest(secondHalf))) {
|
|
60
|
+
// Second half alone still fails
|
|
61
|
+
current = secondHalf
|
|
62
|
+
changed = true
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Try removing second half
|
|
68
|
+
if (current.length > 1) {
|
|
69
|
+
const half = Math.floor(current.length / 2)
|
|
70
|
+
const firstHalf = current.slice(0, half)
|
|
71
|
+
attempts++
|
|
72
|
+
|
|
73
|
+
if (firstHalf.length >= minLength && !(await runTest(firstHalf))) {
|
|
74
|
+
// First half alone still fails
|
|
75
|
+
current = firstHalf
|
|
76
|
+
changed = true
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Try removing individual elements
|
|
82
|
+
for (let i = 0; i < current.length && attempts < maxAttempts; i++) {
|
|
83
|
+
const without = [...current.slice(0, i), ...current.slice(i + 1)]
|
|
84
|
+
attempts++
|
|
85
|
+
|
|
86
|
+
if (without.length >= minLength && !(await runTest(without))) {
|
|
87
|
+
// Removing element i still fails
|
|
88
|
+
current = without
|
|
89
|
+
changed = true
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
original: sequence,
|
|
97
|
+
shrunk: current,
|
|
98
|
+
attempts,
|
|
99
|
+
reduced: current.length < sequence.length,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Format shrink result for display
|
|
105
|
+
*/
|
|
106
|
+
export function formatShrinkResult<T>(result: ShrinkResult<T>): string {
|
|
107
|
+
const reduction = result.original.length - result.shrunk.length
|
|
108
|
+
const percent = Math.round((reduction / result.original.length) * 100)
|
|
109
|
+
|
|
110
|
+
if (!result.reduced) {
|
|
111
|
+
return `Could not reduce sequence (${result.original.length} steps, ${result.attempts} attempts)`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return `Shrunk from ${result.original.length} to ${result.shrunk.length} steps (${percent}% reduction, ${result.attempts} attempts)`
|
|
115
|
+
}
|