Skip to content

Commit 22addbd

Browse files
authored
test: add property-based regression tests for filterQuickPickItems (#478)
1 parent 190892b commit 22addbd

3 files changed

Lines changed: 254 additions & 0 deletions

File tree

package-lock.json

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"eslint-plugin-no-null": "^1.0.2",
9090
"eslint-plugin-prettier": "^5.0.0",
9191
"eslint-plugin-promise": "6.0.0",
92+
"fast-check": "^4.6.0",
9293
"husky": "^9.1.6",
9394
"jest": "^29.7.0",
9495
"jest-environment-jsdom": "^29.7.0",
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* Regression Property-Based Tests — Small Repo and Non-Lag Path Behavior
3+
*
4+
* These tests capture the OBSERVED behavior of filterQuickPickItems on unfixed code
5+
* for non-buggy inputs (small item counts). They must PASS on unfixed code to confirm
6+
* baseline behavior that must be preserved after the fix.
7+
*
8+
* **Validates: Requirements 3.1, 3.4**
9+
*/
10+
import * as fc from 'fast-check';
11+
import { filterQuickPickItems, MARK_OPEN, MARK_CLOSE } from '../quick-pick-data-handler';
12+
import { QuickActionCommandGroup, QuickActionCommand } from '../../static';
13+
14+
/**
15+
* Generate a small set of QuickActionCommandGroups simulating a small repo (<1,000 items).
16+
* Items have varied names to exercise all scoring tiers.
17+
*/
18+
function generateSmallCommandGroups (items: Array<{ command: string; id: string }>): QuickActionCommandGroup[] {
19+
const commands: QuickActionCommand[] = items.map(item => ({
20+
command: item.command,
21+
id: item.id,
22+
label: 'file',
23+
icon: 'file' as any,
24+
description: `workspace/${item.command}`,
25+
}));
26+
27+
return [ {
28+
groupName: 'Files',
29+
commands,
30+
} ];
31+
}
32+
33+
/** Arbitrary for search terms that are non-empty lowercase strings (1-10 chars) */
34+
const searchTermArb = fc.stringMatching(/^[a-z]{1,10}$/);
35+
36+
/**
37+
* Arbitrary for a list of command names that are realistic file-path-like strings.
38+
* We generate varied names to ensure different scoring tiers are exercised.
39+
*/
40+
const commandNameArb = fc.tuple(
41+
fc.constantFrom('src', 'lib', 'test', 'docs', 'utils', 'config', 'main', 'index', 'helper', 'service'),
42+
fc.constantFrom('module', 'component', 'handler', 'provider', 'factory', 'manager', 'controller', 'adapter'),
43+
fc.constantFrom('.ts', '.js', '.tsx', '.json', '.md')
44+
).map(([ prefix, name, ext ]) => `${prefix}/${name}${ext}`);
45+
46+
describe('Regression: Small Repo filterQuickPickItems Behavior', () => {
47+
/**
48+
* **Validates: Requirements 3.1**
49+
*
50+
* Property 2a: For all context item lists with <1,000 items and any search term,
51+
* filterQuickPickItems returns items matching the search term, sorted by score
52+
* (exact=100 > prefix=80 > word-start=60 > contains=40), with highlights applied.
53+
*
54+
* This captures the observed behavior: every matching item is returned, sorted
55+
* descending by score, and each result has <mark> highlight tags applied.
56+
*/
57+
it('returns all matching items sorted by score with highlights for small item lists', () => {
58+
fc.assert(
59+
fc.property(
60+
fc.array(commandNameArb, { minLength: 1, maxLength: 200 }),
61+
searchTermArb,
62+
(commandNames, searchTerm) => {
63+
const items = commandNames.map((name, i) => ({ command: name, id: `item-${i}` }));
64+
const commands = generateSmallCommandGroups(items);
65+
66+
const result = filterQuickPickItems(commands, searchTerm);
67+
68+
// Result should always be a single group
69+
if (result.length !== 1) return false;
70+
71+
const resultCommands = result[0].commands ?? [];
72+
73+
// Verify every result has highlights applied (contains <mark> tags)
74+
for (const cmd of resultCommands) {
75+
if (!cmd.command.includes(MARK_OPEN) && !cmd.command.includes(MARK_CLOSE)) {
76+
// If the command has no mark tags, it means highlighting failed
77+
// But the original text might not contain the search term at all
78+
// (partial matching can still produce highlights)
79+
// So we just verify the command string is non-empty
80+
if (cmd.command.length === 0) return false;
81+
}
82+
}
83+
84+
// Verify sorting: extract scores by checking against original command names
85+
// Score order: exact(100) > prefix(80) > word-start(60) > contains(40)
86+
const scores = resultCommands.map(cmd => {
87+
// Find the original command name by matching the id
88+
const original = items.find(item => item.id === cmd.id);
89+
if (original == null) return 0;
90+
return calculateScore(original.command, searchTerm);
91+
});
92+
93+
// Verify scores are in descending order
94+
for (let i = 1; i < scores.length; i++) {
95+
if (scores[i] > scores[i - 1]) return false;
96+
}
97+
98+
// Verify all matching items from the input are present in the result
99+
const expectedMatchCount = items.filter(item =>
100+
calculateScore(item.command, searchTerm) > 0
101+
).length;
102+
if (resultCommands.length !== expectedMatchCount) return false;
103+
104+
// Verify the group name contains the search term and count
105+
if (result[0].groupName !== `### ${searchTerm}: (${resultCommands.length})`) return false;
106+
107+
return true;
108+
}
109+
),
110+
{ numRuns: 50 }
111+
);
112+
});
113+
114+
/**
115+
* **Validates: Requirements 3.1**
116+
*
117+
* Property 2b: filterQuickPickItems with empty search term returns the original
118+
* commands unchanged (identity behavior).
119+
*/
120+
it('returns original commands unchanged for empty search term', () => {
121+
fc.assert(
122+
fc.property(
123+
fc.array(commandNameArb, { minLength: 1, maxLength: 100 }),
124+
(commandNames) => {
125+
const items = commandNames.map((name, i) => ({ command: name, id: `item-${i}` }));
126+
const commands = generateSmallCommandGroups(items);
127+
128+
const result = filterQuickPickItems(commands, '');
129+
130+
// Should return the original commands unchanged
131+
return result === commands;
132+
}
133+
),
134+
{ numRuns: 30 }
135+
);
136+
});
137+
138+
/**
139+
* **Validates: Requirements 3.4**
140+
*
141+
* Property 2c: For quick action command lists with <50 items, filtering works
142+
* identically to current behavior — all matching items returned, sorted by score.
143+
* This simulates the "/" quick action command filtering path.
144+
*/
145+
it('quick action commands with <50 items: filtering returns all matches sorted by score', () => {
146+
const quickActionNameArb = fc.constantFrom(
147+
'/help', '/clear', '/transform', '/dev', '/test', '/review',
148+
'/doc', '/explain', '/refactor', '/optimize', '/fix', '/generate',
149+
'/deploy', '/build', '/run', '/debug', '/lint', '/format',
150+
'/search', '/navigate', '/commit', '/push', '/pull', '/merge'
151+
);
152+
153+
fc.assert(
154+
fc.property(
155+
fc.array(quickActionNameArb, { minLength: 1, maxLength: 49 }),
156+
searchTermArb,
157+
(actionNames, searchTerm) => {
158+
const commands: QuickActionCommandGroup[] = [ {
159+
groupName: 'Quick Actions',
160+
commands: actionNames.map((name, i) => ({
161+
command: name,
162+
id: `action-${i}`,
163+
description: `Quick action: ${name}`,
164+
})),
165+
} ];
166+
167+
const result = filterQuickPickItems(commands, searchTerm);
168+
169+
// Result should be a single group
170+
if (result.length !== 1) return false;
171+
172+
const resultCommands = result[0].commands ?? [];
173+
174+
// All matching items should be present
175+
const expectedMatches = actionNames.filter(name =>
176+
calculateScore(name, searchTerm) > 0
177+
);
178+
if (resultCommands.length !== expectedMatches.length) return false;
179+
180+
// Scores should be in descending order
181+
const scores = resultCommands.map(cmd => {
182+
const originalIdx = parseInt(cmd.id?.replace('action-', '') ?? '-1');
183+
if (originalIdx < 0 || originalIdx >= actionNames.length) return 0;
184+
return calculateScore(actionNames[originalIdx], searchTerm);
185+
});
186+
187+
for (let i = 1; i < scores.length; i++) {
188+
if (scores[i] > scores[i - 1]) return false;
189+
}
190+
191+
return true;
192+
}
193+
),
194+
{ numRuns: 50 }
195+
);
196+
});
197+
});
198+
199+
/**
200+
* Helper: mirrors the scoring logic from quick-pick-data-handler.ts
201+
*/
202+
function calculateScore (text: string, searchTerm: string): number {
203+
const normalizedText = text.toLowerCase();
204+
const normalizedTerm = searchTerm.toLowerCase();
205+
206+
if (normalizedText === normalizedTerm) return 100;
207+
if (normalizedText.startsWith(normalizedTerm)) return 80;
208+
if (normalizedText.split(' ').some(word => word.startsWith(normalizedTerm))) return 60;
209+
if (normalizedText.includes(normalizedTerm)) return 40;
210+
211+
return 0;
212+
}

0 commit comments

Comments
 (0)