diff --git a/manual-testing/eliminated-candidate-tracking.md b/manual-testing/eliminated-candidate-tracking.md new file mode 100644 index 0000000..08bd247 --- /dev/null +++ b/manual-testing/eliminated-candidate-tracking.md @@ -0,0 +1,39 @@ +# Eliminated Candidate Tracking + +## Issue +Previously, when a candidate was eliminated from contention, the system would stop updating its upvotes and downvotes count. Additionally, eliminated candidates would not appear in the `matchingRegexes` field in the export, making it unclear which eliminated candidates matched specific words. + +## Fix +Modified the `applyClassification` method in `pickController.ts` to: +1. Continue tracking all candidates (including eliminated ones) in the `matchingRegexes` field +2. Continue updating `positiveVotes` and `negativeVotes` for eliminated candidates +3. Prevent re-elimination by checking `!candidate.eliminated` before setting `eliminated = true` + +## Changes Made +- [src/pickController.ts](../src/pickController.ts#L385-L387): Removed filter that excluded eliminated candidates from `matchingRegexes` +- [src/pickController.ts](../src/pickController.ts#L407-L429): Removed `continue` statement that skipped eliminated candidates in ACCEPT classification +- [src/pickController.ts](../src/pickController.ts#L436-L456): Removed `continue` statement that skipped eliminated candidates in REJECT classification +- [src/test/pickController.test.ts](../src/test/pickController.test.ts): Added comprehensive test suite "Eliminated Candidate Tracking" with 4 tests + +## Tests Added +All tests pass (57/57): +1. **Eliminated candidates should continue to have votes updated** - Verifies negative votes continue to accumulate +2. **Eliminated candidates should appear in matchingRegexes for export** - Ensures export includes all matching candidates +3. **Eliminated candidates should continue to receive positive votes** - Verifies positive votes are tracked +4. **Eliminated candidates should be properly tracked through REJECT classifications** - Verifies REJECT path works correctly + +## Behavior +- **UI Display**: Eliminated candidates will show updated vote counts in real-time as users continue classifying words +- **Export**: The JSON export will include eliminated candidates in the `matchingRegexes` array for each classification, providing complete tracking of which candidates (active or eliminated) matched each word + +## Verification +Run automated tests: +```bash +npm test +``` + +To manually verify: +1. Generate candidates and eliminate one by classifying words it doesn't match/matches incorrectly +2. Continue classifying more words that would match/not match the eliminated candidate +3. Observe that the eliminated candidate's vote counts continue to update in the UI +4. Export the history and verify that `matchingRegexes` includes the eliminated candidate where applicable diff --git a/manual-testing/load-session.md b/manual-testing/load-session.md new file mode 100644 index 0000000..f976d28 --- /dev/null +++ b/manual-testing/load-session.md @@ -0,0 +1,107 @@ +# Load Session Feature + +## Feature +Added the ability to load a previously exported session from a JSON file. This is gated power-user functionality that allows users to: +- Start a session without using an LLM +- Share sessions with others +- Resume work on complex regex patterns +- Reproduce and debug specific scenarios +- Use exported data for testing or documentation + +## Key Improvements +1. **Session load is accessible from the initial prompt screen** - Users can load a session before ever calling an LLM +2. **Used words are tracked** - Words from loaded sessions are tracked as "used" to prevent them from being resurfaced in pair generation + +## Implementation + +### UI Changes +- **[media/pickView.html](../media/pickView.html#L95-L103)**: Added "Load Session" button on the initial prompt screen + - Placed below the prompt input with explanatory text "Or load a previous session" + - Allows users to start with candidates + classifications without calling LLM +- **[media/pickView.html](../media/pickView.html#L219-L221)**: "Load" button in the Word Classification History section (during voting) + - Allows reloading a session mid-workflow + +### Backend (pickController.ts) +- **[addUsedWords method](../src/pickController.ts#L976-L984)**: New method to add words to the used set + - Prevents loaded session words from being resurfaced in pair generation + - Used for programmatic addition of used words + +### Backend (pickViewProvider.ts) +- **[handleLoadSession](../src/pickViewProvider.ts#L1024-L1155)**: Session loading handler + - Validates data structure + - Generates candidates from loaded data + - Applies classifications (which automatically marks words as used) + - Sends `showVoting` message to transition UI + +### Format Compatibility +The loader accepts the same format as the export: +```json +{ + "candidates": [ + { + "regex": "[a-z]+", + "explanation": "lowercase letters", + "confidence": 0.9, + "equivalents": ["[a-z]*[a-z]"] + } + ], + "classifications": [ + { + "word": "abc", + "classification": "in", + "matchingRegexes": ["[a-z]+"] + } + ] +} +``` + +**Classification format conversion**: +- Export: `"in"` / `"out"` / `"unsure"` +- Internal: `ACCEPT` / `REJECT` / `UNSURE` + +### Status Updates +Enhanced `setHistoryCopyStatus` function to support different styling: +- Normal messages (default color) +- Error messages (red) +- Muted messages (description foreground) + +## Tests +- **[src/test/loadSession.test.ts](../src/test/loadSession.test.ts)**: 6 comprehensive tests covering: + - Session data structure validation + - Classification format conversion + - Empty classifications handling + - Optional candidate fields (explanation, confidence, equivalents) + - Case-insensitive classification normalization + - Invalid input handling + +All tests pass (136/136 in pickController suite, 6/6 in loadSession suite). + +## Usage + +1. **Export a session**: Click the "Export" button in the Word Classification History section +2. **Save the JSON file**: Copy is saved to clipboard, paste into a `.json` file +3. **Load the session**: Click the "Load" button and select the JSON file +4. **Session restored**: All candidates and classifications are restored, and you can continue voting or see the final result + +## Error Handling + +Clear error messages are shown for: +- Invalid JSON syntax +- Missing required fields (candidates, classifications) +- Empty candidates array +- Invalid data types +- File read failures + +## Manual Verification + +To manually verify: +1. Start a PICK session and classify several words +2. Export the history to clipboard +3. Save to a JSON file +4. Reset PICK +5. Click "Load" and select the saved JSON file +6. Verify: + - All candidates are restored with correct explanations and confidence scores + - All classifications are restored and reflected in vote counts + - UI shows correct active/eliminated candidate states + - Can continue voting if not in final state diff --git a/manual-testing/session.json b/manual-testing/session.json new file mode 100644 index 0000000..6996de4 --- /dev/null +++ b/manual-testing/session.json @@ -0,0 +1,54 @@ +{ + "candidates": [ + { + "regex": "(?:\\./|/)?(?:[\\w.-]+/)*[\\w.-]+", + "explanation": "Matches relative or absolute Unix filepaths, allowing dot, dash, and underscore in names. Handles paths like './file', '/usr/bin', or 'folder/file'.", + "confidence": 0.8 + }, + { + "regex": "/(?:[^/]+/)*[^/]+", + "explanation": "Matches absolute Unix filepaths starting with '/', with segments separated by '/'. Does not match relative paths.", + "confidence": 0.7 + }, + { + "regex": "(?:[\\w.-]+/)*[\\w.-]+", + "explanation": "Matches relative filepaths without leading './' or '/', allowing multiple segments. Does not match absolute paths.", + "confidence": 0.7, + "equivalents": [ + "(?:\\./)?(?:[\\w.-]+/)*[\\w.-]+(?:\\.[\\w]+)?" + ] + }, + { + "regex": "(?:/|\\./)?(?:[^/]+/)*[^/]+", + "explanation": "Matches both absolute and relative filepaths, allowing any character except '/' in segments. More permissive than others.", + "confidence": 0.6 + } + ], + "classifications": [ + { + "word": "./a", + "classification": "in", + "matchingRegexes": [ + "(?:\\./|/)?(?:[\\w.-]+/)*[\\w.-]+", + "(?:[\\w.-]+/)*[\\w.-]+", + "(?:/|\\./)?(?:[^/]+/)*[^/]+" + ] + }, + { + "word": "/a\u0000", + "classification": "in", + "matchingRegexes": [ + "/(?:[^/]+/)*[^/]+", + "(?:/|\\./)?(?:[^/]+/)*[^/]+" + ] + }, + { + "word": "/a/a\u0000", + "classification": "out", + "matchingRegexes": [ + "/(?:[^/]+/)*[^/]+", + "(?:/|\\./)?(?:[^/]+/)*[^/]+" + ] + } + ] +} \ No newline at end of file diff --git a/media/pickView.html b/media/pickView.html index 8185e06..8a10c42 100644 --- a/media/pickView.html +++ b/media/pickView.html @@ -60,6 +60,18 @@ >
+ + +
+ @@ -209,10 +222,13 @@

Should the generated regex match these words?

Word Classification History

- + +
diff --git a/media/pickView.js b/media/pickView.js index ec9a2f6..e864ffa 100644 --- a/media/pickView.js +++ b/media/pickView.js @@ -152,6 +152,11 @@ const wordHistory = document.getElementById('wordHistory'); const historyItems = document.getElementById('historyItems'); const copyHistoryBtn = document.getElementById('copyHistoryBtn'); + const loadSessionBtn = document.getElementById('loadSessionBtn'); + const loadSessionFile = document.getElementById('loadSessionFile'); + const loadSessionBtnPrompt = document.getElementById('loadSessionBtnPrompt'); + const loadSessionFilePrompt = document.getElementById('loadSessionFilePrompt'); + const loadSessionStatus = document.getElementById('loadSessionStatus'); const historyCopyStatus = document.getElementById('historyCopyStatus'); const finalRegex = document.getElementById('finalRegex'); const wordsIn = document.getElementById('wordsIn'); @@ -179,12 +184,103 @@ copyHistoryBtn.addEventListener('click', copyHistoryToClipboard); } + if (loadSessionBtn && loadSessionFile) { + loadSessionBtn.addEventListener('click', function() { + loadSessionFile.click(); + }); + + loadSessionFile.addEventListener('change', function(event) { + handleSessionFileLoad(event.target.files[0], setHistoryCopyStatus, loadSessionFile); + }); + } + if (customExamplesCancel) { customExamplesCancel.addEventListener('click', function() { toggleExamplesPanel(false); }); } + // Helper function to handle session file loading + function handleSessionFileLoad(file, statusCallback, fileInputElement) { + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = function(e) { + try { + const content = e.target.result; + const data = JSON.parse(content); + + // Validate the format + if (!data.candidates || !Array.isArray(data.candidates)) { + statusCallback('Invalid format: missing candidates array', 'error'); + return; + } + + if (!data.classifications || !Array.isArray(data.classifications)) { + statusCallback('Invalid format: missing classifications array', 'error'); + return; + } + + // Send to backend + vscode.postMessage({ + type: 'loadSession', + data: data + }); + + statusCallback('Loading session...'); + } catch (error) { + console.error('Failed to parse session file', error); + statusCallback('Failed to parse JSON file', 'error'); + } finally { + // Reset file input so the same file can be loaded again + if (fileInputElement) { + fileInputElement.value = ''; + } + } + }; + + reader.onerror = function() { + statusCallback('Failed to read file', 'error'); + if (fileInputElement) { + fileInputElement.value = ''; + } + }; + + reader.readAsText(file); + } + + // Status callback for prompt screen load button + function setLoadSessionStatus(message, type) { + if (!loadSessionStatus) { + return; + } + + loadSessionStatus.textContent = message || ''; + loadSessionStatus.style.color = type === 'error' + ? 'var(--vscode-errorForeground)' + : ''; + + if (message) { + setTimeout(function() { + loadSessionStatus.textContent = ''; + loadSessionStatus.style.color = ''; + }, 3000); + } + } + + // Wire up prompt screen load button + if (loadSessionBtnPrompt && loadSessionFilePrompt) { + loadSessionBtnPrompt.addEventListener('click', function() { + loadSessionFilePrompt.click(); + }); + + loadSessionFilePrompt.addEventListener('change', function(event) { + handleSessionFileLoad(event.target.files[0], setLoadSessionStatus, loadSessionFilePrompt); + }); + } + function submitSingleExample(classification) { if (!customExamplesInput) { return; @@ -1357,6 +1453,12 @@ case 'cancelled': handleCancelled(message.message); break; + case 'sessionLoaded': + handleSessionLoaded(message); + break; + case 'showVoting': + showSection('voting'); + break; } }); @@ -2081,7 +2183,7 @@ decorateWordCardsWithMatches(status.wordHistory, fallbackMatches); } - function setHistoryCopyStatus(message) { + function setHistoryCopyStatus(message, type) { if (!historyCopyStatus) { return; } @@ -2091,10 +2193,21 @@ } historyCopyStatus.textContent = message || ''; + + // Apply styling based on type + historyCopyStatus.className = 'history-copy-status'; + if (type === 'error') { + historyCopyStatus.style.color = 'var(--vscode-errorForeground)'; + } else if (type === 'muted') { + historyCopyStatus.style.color = 'var(--vscode-descriptionForeground)'; + } else { + historyCopyStatus.style.color = ''; + } if (message) { historyStatusTimeout = setTimeout(function() { historyCopyStatus.textContent = ''; + historyCopyStatus.style.color = ''; historyStatusTimeout = null; }, 3000); } @@ -2576,6 +2689,28 @@ }, 2000); } + function handleSessionLoaded(message) { + clearStatusMessage(); + + // Update UI with loaded session data + if (message.status) { + latestCandidates = Array.isArray(message.status.candidateDetails) + ? message.status.candidateDetails.slice() + : []; + updateStatus(message.status); + } + + // Show success message + const candidateText = message.candidateCount === 1 ? 'candidate' : 'candidates'; + const classificationText = message.classificationCount === 1 ? 'classification' : 'classifications'; + const successMsg = 'Loaded ' + message.candidateCount + ' ' + candidateText + + ' and ' + message.classificationCount + ' ' + classificationText + '.'; + setHistoryCopyStatus(successMsg); + + log('info', 'Session loaded: ' + message.candidateCount + ' candidates, ' + + message.classificationCount + ' classifications'); + } + // Functions no longer need to be global since we use addEventListener instead of inline handlers // Keeping for backwards compatibility or debugging if needed window.copyRegex = copyRegex; diff --git a/package.json b/package.json index 91d3fa2..ca02234 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "publisher": "SiddharthaPrasad", "icon": "images/icon.png", "license": "MIT", - "version": "0.5.3", + "version": "0.5.4", "repository": { "type": "git", "url": "https://github.com/sidprasad/pick-regex" diff --git a/src/pickController.ts b/src/pickController.ts index 1429894..6e8a546 100644 --- a/src/pickController.ts +++ b/src/pickController.ts @@ -381,9 +381,9 @@ export class PickController { * @param fromPair Whether this classification is part of the current pair flow */ private applyClassification(word: string, classification: WordClassification, fromPair: boolean = false): void { - // Get matching regexes for this word + // Get matching regexes for this word (including eliminated candidates) const matchingRegexes = this.candidates - .filter(c => !c.eliminated && this.analyzer.verifyMatch(word, c.pattern)) + .filter(c => this.analyzer.verifyMatch(word, c.pattern)) .map(c => c.pattern); // Track the word as used so it is not resurfaced in later generated pairs @@ -407,9 +407,6 @@ export class PickController { `Classified "${word}" as ACCEPT. Updating ${this.candidates.length} candidates.` ); for (const candidate of this.candidates) { - if (candidate.eliminated) { - continue; - } const matches = this.analyzer.verifyMatch(word, candidate.pattern); if (matches) { @@ -419,8 +416,8 @@ export class PickController { // Candidate fails to match the accepted word - it's missing something it should have candidate.negativeVotes++; - // Eliminate if threshold reached - if (candidate.negativeVotes >= candidate.eliminationThreshold) { + // Eliminate if threshold reached (only if not already eliminated) + if (!candidate.eliminated && candidate.negativeVotes >= candidate.eliminationThreshold) { candidate.eliminated = true; logger.info( `Eliminated candidate "${candidate.pattern}" after ${candidate.negativeVotes} negative votes (failed to match accepted word "${word}" with threshold ${candidate.eliminationThreshold}).` @@ -436,17 +433,14 @@ export class PickController { `Classified "${word}" as REJECT. Applying elimination threshold ${this.thresholdVotes}.` ); for (const candidate of this.candidates) { - if (candidate.eliminated) { - continue; - } const matches = this.analyzer.verifyMatch(word, candidate.pattern); if (matches) { // Candidate incorrectly matches the rejected word candidate.negativeVotes++; - // Eliminate if threshold reached - if (candidate.negativeVotes >= candidate.eliminationThreshold) { + // Eliminate if threshold reached (only if not already eliminated) + if (!candidate.eliminated && candidate.negativeVotes >= candidate.eliminationThreshold) { candidate.eliminated = true; logger.info( `Eliminated candidate "${candidate.pattern}" after ${candidate.negativeVotes} negative votes (incorrectly matched rejected word "${word}" with threshold ${candidate.eliminationThreshold}).` @@ -971,6 +965,18 @@ export class PickController { this.maxPairsWithoutProgress = Math.max(1, Math.trunc(limit)); } + /** + * Add words to the set of used words (prevents them from being resurfaced in pair generation) + */ + addUsedWords(words: string[]): void { + for (const word of words) { + if (word && typeof word === 'string') { + this.usedWords.add(word); + } + } + logger.info(`Added ${words.length} words to used words set. Total: ${this.usedWords.size}`); + } + /** * Set elimination threshold for each candidate based on pairwise distinguishing words. * diff --git a/src/pickViewProvider.ts b/src/pickViewProvider.ts index a085ed4..5e84cf1 100644 --- a/src/pickViewProvider.ts +++ b/src/pickViewProvider.ts @@ -246,6 +246,11 @@ export class PickViewProvider implements vscode.WebviewViewProvider { this.sendMessage({ type: 'error', message: 'Failed to open issue report' }); } break; + case 'loadSession': + this.handleLoadSession(data.data).catch(error => { + logger.error(error, 'Error in handleLoadSession'); + }); + break; } }); } @@ -985,6 +990,140 @@ export class PickViewProvider implements vscode.WebviewViewProvider { return normalized; } + /** + * Load a previously exported session from JSON data + */ + private async handleLoadSession(data: any) { + try { + logger.info('Loading session from exported JSON'); + + // Validate the data structure + if (!data || typeof data !== 'object') { + this.sendMessage({ + type: 'error', + message: 'Invalid session data: not an object' + }); + return; + } + + if (!Array.isArray(data.candidates) || data.candidates.length === 0) { + this.sendMessage({ + type: 'error', + message: 'Invalid session data: candidates must be a non-empty array' + }); + return; + } + + if (!Array.isArray(data.classifications)) { + this.sendMessage({ + type: 'error', + message: 'Invalid session data: classifications must be an array' + }); + return; + } + + // Extract candidates + const candidatePatterns: Array<{ pattern: string; explanation?: string; confidence?: number }> = []; + const equivalenceMap = new Map(); + + for (const candidate of data.candidates) { + if (!candidate.regex || typeof candidate.regex !== 'string') { + logger.warn('Skipping candidate with missing or invalid regex'); + continue; + } + + candidatePatterns.push({ + pattern: candidate.regex, + explanation: candidate.explanation || undefined, + confidence: typeof candidate.confidence === 'number' ? candidate.confidence : undefined + }); + + // Store equivalents if present + if (Array.isArray(candidate.equivalents) && candidate.equivalents.length > 0) { + equivalenceMap.set(candidate.regex, candidate.equivalents); + } + } + + if (candidatePatterns.length === 0) { + this.sendMessage({ + type: 'error', + message: 'No valid candidate regexes found in session data' + }); + return; + } + + // Convert classification format from export ('in'/'out'/'unsure') to internal format + const normalizeClassification = (classification: string): WordClassification => { + const normalized = (classification || '').toLowerCase(); + if (normalized === 'in') { return WordClassification.ACCEPT; } + if (normalized === 'out') { return WordClassification.REJECT; } + return WordClassification.UNSURE; + }; + + const classifications: Array<{ word: string; classification: WordClassification }> = []; + + for (const item of data.classifications) { + if (!item.word || typeof item.word !== 'string') { + logger.warn('Skipping classification with missing or invalid word'); + continue; + } + + classifications.push({ + word: item.word, + classification: normalizeClassification(item.classification) + }); + } + + // Reset controller and generate candidates + this.controller.reset(false); + this.sendMessage({ type: 'status', message: 'Loading session...' }); + + await this.controller.generateCandidates( + 'Loaded session', + candidatePatterns, + equivalenceMap + ); + + logger.info(`Generated ${candidatePatterns.length} candidates from loaded session`); + + // Apply all classifications (this also marks words as used via applyClassification) + if (classifications.length > 0) { + this.sendMessage({ type: 'status', message: `Applying ${classifications.length} classification(s)...` }); + const applied = this.controller.classifyDirectWords(classifications); + logger.info(`Applied ${applied} classification(s) from loaded session`); + } + + const state = this.controller.getState(); + const status = this.controller.getStatus(); + + this.sendMessage({ + type: 'sessionLoaded', + status, + candidateCount: candidatePatterns.length, + classificationCount: classifications.length + }); + + // If we reached final state, handle it + if (state === PickState.FINAL_RESULT) { + await this.handleFinalResult(); + } else { + // Show the voting view + this.sendMessage({ type: 'showVoting' }); + + // Generate next pair to start voting + this.handleRequestNextPair(); + } + + } catch (error) { + logger.error(error, 'Error loading session'); + const errorMessage = error instanceof Error ? error.message : String(error); + this.sendMessage({ + type: 'error', + message: `Error loading session: ${errorMessage}` + }); + } + } + /** * Handle word edit in the current voting pair */ diff --git a/src/test/loadSession.test.ts b/src/test/loadSession.test.ts new file mode 100644 index 0000000..5d41adc --- /dev/null +++ b/src/test/loadSession.test.ts @@ -0,0 +1,145 @@ +import * as assert from 'assert'; +import { PickController, WordClassification } from '../pickController'; + +suite('Load Session Functionality', () => { + test('should validate session data structure', () => { + // Test that valid session data structure is recognized + const validSessionData = { + candidates: [ + { regex: '[a-z]+', explanation: 'lowercase letters', confidence: 0.9 } + ], + classifications: [ + { word: 'abc', classification: 'in', matchingRegexes: ['[a-z]+'] } + ] + }; + + // Verify structure + assert.ok(Array.isArray(validSessionData.candidates)); + assert.ok(Array.isArray(validSessionData.classifications)); + assert.strictEqual(validSessionData.candidates.length, 1); + assert.strictEqual(validSessionData.classifications.length, 1); + }); + + test('should handle classification format conversion from export to internal', () => { + // Test that export format ('in'/'out'/'unsure') maps correctly to internal format + const exportFormats = ['in', 'out', 'unsure']; + const expectedInternal = ['accept', 'reject', 'unsure']; + + // Normalize function (same logic as in handleLoadSession) + const normalize = (classification: string): string => { + const normalized = (classification || '').toLowerCase(); + if (normalized === 'in') { return 'accept'; } + if (normalized === 'out') { return 'reject'; } + return 'unsure'; + }; + + exportFormats.forEach((exportFormat, index) => { + const internal = normalize(exportFormat); + assert.strictEqual(internal, expectedInternal[index], + `Export format "${exportFormat}" should map to "${expectedInternal[index]}"`); + }); + }); + + test('should handle empty classifications array', () => { + const sessionData = { + candidates: [ + { regex: '[0-9]+', explanation: 'digits' } + ], + classifications: [] + }; + + assert.ok(Array.isArray(sessionData.classifications)); + assert.strictEqual(sessionData.classifications.length, 0); + }); + + test('should handle candidates with optional fields', () => { + const sessionData = { + candidates: [ + { regex: '[a-z]+' }, // no explanation or confidence + { regex: '[0-9]+', explanation: 'digits' }, // no confidence + { regex: '[A-Z]+', confidence: 0.8 }, // no explanation + { regex: '\\w+', explanation: 'word chars', confidence: 0.9, equivalents: ['[a-zA-Z0-9_]+'] } + ], + classifications: [] + }; + + assert.strictEqual(sessionData.candidates.length, 4); + assert.ok(sessionData.candidates.every(c => typeof c.regex === 'string')); + }); + + test('should validate equivalents field structure', () => { + const candidate = { + regex: '\\w+', + explanation: 'word characters', + equivalents: ['[a-zA-Z0-9_]+', '[[:word:]]+'] + }; + + assert.ok(Array.isArray(candidate.equivalents)); + assert.strictEqual(candidate.equivalents.length, 2); + assert.ok(candidate.equivalents.every(eq => typeof eq === 'string')); + }); + + test('should handle case-insensitive classification normalization', () => { + const normalize = (classification: string): string => { + const normalized = (classification || '').toLowerCase(); + if (normalized === 'in') { return 'accept'; } + if (normalized === 'out') { return 'reject'; } + return 'unsure'; + }; + + // Test various case combinations + assert.strictEqual(normalize('IN'), 'accept'); + assert.strictEqual(normalize('In'), 'accept'); + assert.strictEqual(normalize('OUT'), 'reject'); + assert.strictEqual(normalize('Out'), 'reject'); + assert.strictEqual(normalize('UNSURE'), 'unsure'); + assert.strictEqual(normalize('Unsure'), 'unsure'); + assert.strictEqual(normalize(''), 'unsure'); + assert.strictEqual(normalize('invalid'), 'unsure'); + }); + + test('words from loaded session should be tracked as used words', async () => { + const controller = new PickController(); + + // Simulate loading a session: generate candidates and apply classifications + await controller.generateCandidates('test', ['[a-z]+', '[0-9]+']); + + // Apply classifications like a loaded session would + controller.classifyDirectWords([ + { word: 'abc', classification: WordClassification.ACCEPT }, + { word: '123', classification: WordClassification.REJECT } + ]); + + // Verify the words are tracked as used + const status = controller.getStatus(); + assert.strictEqual(status.usedWords, 2, 'Both classified words should be tracked as used'); + + // Also verify word history contains both words + const history = controller.getWordHistory(); + const words = history.map(h => h.word); + assert.ok(words.includes('abc'), 'Word history should include "abc"'); + assert.ok(words.includes('123'), 'Word history should include "123"'); + }); + + test('addUsedWords should add words to the used set', async () => { + const controller = new PickController(); + await controller.generateCandidates('test', ['[a-z]+', '[0-9]+']); + + // Initially no used words + assert.strictEqual(controller.getStatus().usedWords, 0, 'Should start with no used words'); + + // Add some words + controller.addUsedWords(['word1', 'word2', 'word3']); + + // Verify they're tracked + assert.strictEqual(controller.getStatus().usedWords, 3, 'Should have 3 used words'); + + // Adding duplicates shouldn't increase the count + controller.addUsedWords(['word1', 'word2']); + assert.strictEqual(controller.getStatus().usedWords, 3, 'Duplicates should not be added again'); + + // Add more unique words + controller.addUsedWords(['word4']); + assert.strictEqual(controller.getStatus().usedWords, 4, 'Should have 4 used words now'); + }); +}); diff --git a/src/test/pickController.test.ts b/src/test/pickController.test.ts index 4167111..4e62f03 100644 --- a/src/test/pickController.test.ts +++ b/src/test/pickController.test.ts @@ -1461,6 +1461,121 @@ suite('PickController Test Suite', () => { }); }); + // Test that eliminated candidates continue to receive vote updates + suite('Eliminated Candidate Tracking', () => { + test('Eliminated candidates should continue to have votes updated', async () => { + const patterns = ['[a-z]+', '[0-9]+', '[a-zA-Z]+']; + await controller.generateCandidates('test', patterns); + + // Set low threshold to trigger elimination quickly + controller.setThreshold(1); + + // Classify a word that eliminates [0-9]+ (it doesn't match 'abc') + controller.classifyDirectWords([{ word: 'abc', classification: WordClassification.ACCEPT }]); + + const statusAfterFirst = controller.getStatus(); + const pattern2 = statusAfterFirst.candidateDetails.find(c => c.pattern === '[0-9]+'); + assert.ok(pattern2, 'Pattern [0-9]+ should exist'); + assert.strictEqual(pattern2.eliminated, true, '[0-9]+ should be eliminated'); + assert.strictEqual(pattern2.negativeVotes, 1, '[0-9]+ should have 1 negative vote'); + + // Classify another word that [0-9]+ doesn't match + controller.classifyDirectWords([{ word: 'xyz', classification: WordClassification.ACCEPT }]); + + const statusAfterSecond = controller.getStatus(); + const pattern2Updated = statusAfterSecond.candidateDetails.find(c => c.pattern === '[0-9]+'); + assert.ok(pattern2Updated, 'Pattern [0-9]+ should still exist'); + assert.strictEqual(pattern2Updated.eliminated, true, '[0-9]+ should still be eliminated'); + assert.strictEqual(pattern2Updated.negativeVotes, 2, '[0-9]+ should have 2 negative votes (was updated even though eliminated)'); + }); + + test('Eliminated candidates should appear in matchingRegexes for export', async () => { + const patterns = ['[a-z]+', '[0-9]+', '[a-zA-Z0-9]+']; + await controller.generateCandidates('test', patterns); + + // Set low threshold to trigger elimination quickly + controller.setThreshold(1); + + // Classify 'abc' as ACCEPT - this should eliminate [0-9]+ + controller.classifyDirectWords([{ word: 'abc', classification: WordClassification.ACCEPT }]); + + const statusAfterElimination = controller.getStatus(); + const pattern2 = statusAfterElimination.candidateDetails.find(c => c.pattern === '[0-9]+'); + assert.strictEqual(pattern2?.eliminated, true, '[0-9]+ should be eliminated'); + + // Now classify '123' as ACCEPT - [0-9]+ matches this even though it's eliminated + controller.classifyDirectWords([{ word: '123', classification: WordClassification.ACCEPT }]); + + const history = controller.getWordHistory(); + const record123 = history.find(r => r.word === '123'); + assert.ok(record123, 'Should find classification for "123"'); + assert.ok( + record123.matchingRegexes.includes('[0-9]+'), + 'matchingRegexes should include [0-9]+ even though it was eliminated' + ); + assert.ok( + record123.matchingRegexes.includes('[a-zA-Z0-9]+'), + 'matchingRegexes should include [a-zA-Z0-9]+' + ); + }); + + test('Eliminated candidates should continue to receive positive votes', async () => { + const patterns = ['[a-z]+', '[0-9]+', '[a-zA-Z]+']; + await controller.generateCandidates('test', patterns); + + // Set low threshold to trigger elimination quickly + controller.setThreshold(1); + + // Eliminate [0-9]+ by accepting 'abc' (which it doesn't match) + controller.classifyDirectWords([{ word: 'abc', classification: WordClassification.ACCEPT }]); + + const statusAfterElimination = controller.getStatus(); + const pattern2Eliminated = statusAfterElimination.candidateDetails.find(c => c.pattern === '[0-9]+'); + assert.strictEqual(pattern2Eliminated?.eliminated, true, '[0-9]+ should be eliminated'); + assert.strictEqual(pattern2Eliminated?.positiveVotes, 0, '[0-9]+ should have 0 positive votes initially'); + + // Now accept '123' which [0-9]+ matches + controller.classifyDirectWords([{ word: '123', classification: WordClassification.ACCEPT }]); + + const statusAfterPositive = controller.getStatus(); + const pattern2WithPositive = statusAfterPositive.candidateDetails.find(c => c.pattern === '[0-9]+'); + assert.strictEqual(pattern2WithPositive?.eliminated, true, '[0-9]+ should still be eliminated'); + assert.strictEqual(pattern2WithPositive?.positiveVotes, 1, '[0-9]+ should have 1 positive vote (updated even though eliminated)'); + }); + + test('Eliminated candidates should be properly tracked through REJECT classifications', async () => { + const patterns = ['[a-z]+', '[0-9]+', '[a-zA-Z0-9]+']; + await controller.generateCandidates('test', patterns); + + // Set low threshold + controller.setThreshold(1); + + // Eliminate [a-z]+ by rejecting 'abc' (which it matches) + controller.classifyDirectWords([{ word: 'abc', classification: WordClassification.REJECT }]); + + const statusAfterElimination = controller.getStatus(); + const pattern1 = statusAfterElimination.candidateDetails.find(c => c.pattern === '[a-z]+'); + assert.strictEqual(pattern1?.eliminated, true, '[a-z]+ should be eliminated'); + assert.strictEqual(pattern1?.negativeVotes, 1, '[a-z]+ should have 1 negative vote'); + + // Reject 'xyz' which [a-z]+ also matches + controller.classifyDirectWords([{ word: 'xyz', classification: WordClassification.REJECT }]); + + const history = controller.getWordHistory(); + const recordXyz = history.find(r => r.word === 'xyz'); + assert.ok(recordXyz, 'Should find classification for "xyz"'); + assert.ok( + recordXyz.matchingRegexes.includes('[a-z]+'), + 'matchingRegexes should include [a-z]+ even though it was eliminated' + ); + + const statusAfterSecondReject = controller.getStatus(); + const pattern1Updated = statusAfterSecondReject.candidateDetails.find(c => c.pattern === '[a-z]+'); + assert.strictEqual(pattern1Updated?.eliminated, true, '[a-z]+ should still be eliminated'); + assert.strictEqual(pattern1Updated?.negativeVotes, 2, '[a-z]+ should have 2 negative votes (updated even though eliminated)'); + }); + }); + // Test for the bug where changing a vote from REJECT to ACCEPT doesn't update state suite('Vote Change State Update', () => { test('Should transition from FINAL_RESULT back to VOTING when classification change makes candidates active', async () => {