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 () => {