markupr 2.1.8
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/.claude/commands/review-feedback.md +47 -0
- package/.eslintrc.json +35 -0
- package/.github/CODEOWNERS +16 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
- package/.github/dependabot.yml +70 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/deploy-landing.yml +134 -0
- package/.github/workflows/nightly.yml +288 -0
- package/.github/workflows/release.yml +318 -0
- package/CHANGELOG.md +127 -0
- package/CLAUDE.md +137 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +390 -0
- package/LICENSE +21 -0
- package/PRODUCT_VISION.md +277 -0
- package/README.md +517 -0
- package/SECURITY.md +51 -0
- package/SIGNING_INSTRUCTIONS.md +284 -0
- package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
- package/assets/svg-source/dmg-background.svg +70 -0
- package/assets/svg-source/icon.svg +20 -0
- package/assets/svg-source/tray-icon-processing.svg +7 -0
- package/assets/svg-source/tray-icon-recording.svg +7 -0
- package/assets/svg-source/tray-icon.svg +6 -0
- package/assets/tray-complete.png +0 -0
- package/assets/tray-complete@2x.png +0 -0
- package/assets/tray-completeTemplate.png +0 -0
- package/assets/tray-completeTemplate@2x.png +0 -0
- package/assets/tray-error.png +0 -0
- package/assets/tray-error@2x.png +0 -0
- package/assets/tray-errorTemplate.png +0 -0
- package/assets/tray-errorTemplate@2x.png +0 -0
- package/assets/tray-icon-processing.png +0 -0
- package/assets/tray-icon-processing@2x.png +0 -0
- package/assets/tray-icon-processingTemplate.png +0 -0
- package/assets/tray-icon-processingTemplate@2x.png +0 -0
- package/assets/tray-icon-recording.png +0 -0
- package/assets/tray-icon-recording@2x.png +0 -0
- package/assets/tray-icon-recordingTemplate.png +0 -0
- package/assets/tray-icon-recordingTemplate@2x.png +0 -0
- package/assets/tray-icon.png +0 -0
- package/assets/tray-icon@2x.png +0 -0
- package/assets/tray-iconTemplate.png +0 -0
- package/assets/tray-iconTemplate@2x.png +0 -0
- package/assets/tray-idle.png +0 -0
- package/assets/tray-idle@2x.png +0 -0
- package/assets/tray-idleTemplate.png +0 -0
- package/assets/tray-idleTemplate@2x.png +0 -0
- package/assets/tray-processing-0.png +0 -0
- package/assets/tray-processing-0@2x.png +0 -0
- package/assets/tray-processing-0Template.png +0 -0
- package/assets/tray-processing-0Template@2x.png +0 -0
- package/assets/tray-processing-1.png +0 -0
- package/assets/tray-processing-1@2x.png +0 -0
- package/assets/tray-processing-1Template.png +0 -0
- package/assets/tray-processing-1Template@2x.png +0 -0
- package/assets/tray-processing-2.png +0 -0
- package/assets/tray-processing-2@2x.png +0 -0
- package/assets/tray-processing-2Template.png +0 -0
- package/assets/tray-processing-2Template@2x.png +0 -0
- package/assets/tray-processing-3.png +0 -0
- package/assets/tray-processing-3@2x.png +0 -0
- package/assets/tray-processing-3Template.png +0 -0
- package/assets/tray-processing-3Template@2x.png +0 -0
- package/assets/tray-processing.png +0 -0
- package/assets/tray-processing@2x.png +0 -0
- package/assets/tray-processingTemplate.png +0 -0
- package/assets/tray-processingTemplate@2x.png +0 -0
- package/assets/tray-recording.png +0 -0
- package/assets/tray-recording@2x.png +0 -0
- package/assets/tray-recordingTemplate.png +0 -0
- package/assets/tray-recordingTemplate@2x.png +0 -0
- package/build/DMG_BACKGROUND_SPEC.md +50 -0
- package/build/dmg-background.png +0 -0
- package/build/dmg-background@2x.png +0 -0
- package/build/entitlements.mac.inherit.plist +27 -0
- package/build/entitlements.mac.plist +41 -0
- package/build/favicon-16.png +0 -0
- package/build/favicon-180.png +0 -0
- package/build/favicon-192.png +0 -0
- package/build/favicon-32.png +0 -0
- package/build/favicon-48.png +0 -0
- package/build/favicon-512.png +0 -0
- package/build/favicon-64.png +0 -0
- package/build/icon-128.png +0 -0
- package/build/icon-16.png +0 -0
- package/build/icon-24.png +0 -0
- package/build/icon-256.png +0 -0
- package/build/icon-32.png +0 -0
- package/build/icon-48.png +0 -0
- package/build/icon-64.png +0 -0
- package/build/icon.icns +0 -0
- package/build/icon.ico +0 -0
- package/build/icon.iconset/icon_128x128.png +0 -0
- package/build/icon.iconset/icon_128x128@2x.png +0 -0
- package/build/icon.iconset/icon_16x16.png +0 -0
- package/build/icon.iconset/icon_16x16@2x.png +0 -0
- package/build/icon.iconset/icon_256x256.png +0 -0
- package/build/icon.iconset/icon_256x256@2x.png +0 -0
- package/build/icon.iconset/icon_32x32.png +0 -0
- package/build/icon.iconset/icon_32x32@2x.png +0 -0
- package/build/icon.iconset/icon_512x512.png +0 -0
- package/build/icon.iconset/icon_512x512@2x.png +0 -0
- package/build/icon.png +0 -0
- package/build/installer-header.bmp +0 -0
- package/build/installer-header.png +0 -0
- package/build/installer-sidebar.bmp +0 -0
- package/build/installer-sidebar.png +0 -0
- package/build/installer.nsh +45 -0
- package/build/overlay-processing.png +0 -0
- package/build/overlay-recording.png +0 -0
- package/build/toolbar-record.png +0 -0
- package/build/toolbar-screenshot.png +0 -0
- package/build/toolbar-settings.png +0 -0
- package/build/toolbar-stop.png +0 -0
- package/dist/main/index.mjs +12612 -0
- package/dist/preload/index.mjs +907 -0
- package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
- package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
- package/dist/renderer/index.html +27 -0
- package/docs/AI_AGENT_QUICKSTART.md +42 -0
- package/docs/AI_PIPELINE_DESIGN.md +595 -0
- package/docs/API.md +514 -0
- package/docs/ARCHITECTURE.md +460 -0
- package/docs/CONFIGURATION.md +336 -0
- package/docs/DEVELOPMENT.md +508 -0
- package/docs/EXPORT_FORMATS.md +451 -0
- package/docs/GETTING_STARTED.md +236 -0
- package/docs/KEYBOARD_SHORTCUTS.md +334 -0
- package/docs/TROUBLESHOOTING.md +418 -0
- package/docs/landing/index.html +672 -0
- package/docs/landing/script.js +342 -0
- package/docs/landing/styles.css +1543 -0
- package/electron-builder.yml +140 -0
- package/electron.vite.config.ts +63 -0
- package/package.json +108 -0
- package/railway.json +12 -0
- package/scripts/build.mjs +51 -0
- package/scripts/generate-icons.mjs +314 -0
- package/scripts/generate-installer-images.cjs +253 -0
- package/scripts/generate-tray-icons.mjs +258 -0
- package/scripts/notarize.cjs +180 -0
- package/scripts/one-click-clean-test.sh +147 -0
- package/scripts/postinstall.mjs +36 -0
- package/scripts/setup-markupr.sh +55 -0
- package/setup +17 -0
- package/site/index.html +1835 -0
- package/site/package.json +11 -0
- package/site/railway.json +12 -0
- package/site/server.js +31 -0
- package/src/main/AutoUpdater.ts +392 -0
- package/src/main/CrashRecovery.ts +655 -0
- package/src/main/ErrorHandler.ts +703 -0
- package/src/main/HotkeyManager.ts +399 -0
- package/src/main/MenuManager.ts +529 -0
- package/src/main/PermissionManager.ts +420 -0
- package/src/main/SessionController.ts +1465 -0
- package/src/main/TrayManager.ts +540 -0
- package/src/main/ai/AIPipelineManager.ts +199 -0
- package/src/main/ai/ClaudeAnalyzer.ts +339 -0
- package/src/main/ai/ImageOptimizer.ts +176 -0
- package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
- package/src/main/ai/index.ts +16 -0
- package/src/main/ai/types.ts +258 -0
- package/src/main/analysis/ClarificationGenerator.ts +385 -0
- package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
- package/src/main/analysis/index.ts +19 -0
- package/src/main/audio/AudioCapture.ts +978 -0
- package/src/main/audio/audioUtils.ts +100 -0
- package/src/main/audio/index.ts +20 -0
- package/src/main/capture/index.ts +1 -0
- package/src/main/index.ts +1693 -0
- package/src/main/ipc/captureHandlers.ts +272 -0
- package/src/main/ipc/index.ts +45 -0
- package/src/main/ipc/outputHandlers.ts +302 -0
- package/src/main/ipc/sessionHandlers.ts +56 -0
- package/src/main/ipc/settingsHandlers.ts +471 -0
- package/src/main/ipc/types.ts +56 -0
- package/src/main/ipc/windowHandlers.ts +277 -0
- package/src/main/output/ClipboardService.ts +369 -0
- package/src/main/output/ExportService.ts +539 -0
- package/src/main/output/FileManager.ts +416 -0
- package/src/main/output/MarkdownGenerator.ts +791 -0
- package/src/main/output/MarkdownPatcher.ts +299 -0
- package/src/main/output/index.ts +186 -0
- package/src/main/output/sessionAdapter.ts +207 -0
- package/src/main/output/templates/html-template.ts +553 -0
- package/src/main/pipeline/FrameExtractor.ts +330 -0
- package/src/main/pipeline/PostProcessor.ts +399 -0
- package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
- package/src/main/pipeline/index.ts +36 -0
- package/src/main/platform/WindowsTaskbar.ts +600 -0
- package/src/main/platform/index.ts +16 -0
- package/src/main/settings/SettingsManager.ts +730 -0
- package/src/main/settings/index.ts +19 -0
- package/src/main/transcription/ModelDownloadManager.ts +494 -0
- package/src/main/transcription/TierManager.ts +219 -0
- package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
- package/src/main/transcription/WhisperService.ts +748 -0
- package/src/main/transcription/index.ts +56 -0
- package/src/main/transcription/types.ts +135 -0
- package/src/main/windows/PopoverManager.ts +284 -0
- package/src/main/windows/TaskbarIntegration.ts +452 -0
- package/src/main/windows/index.ts +23 -0
- package/src/preload/index.ts +1047 -0
- package/src/renderer/App.tsx +515 -0
- package/src/renderer/AppWrapper.tsx +28 -0
- package/src/renderer/assets/logo-dark.svg +7 -0
- package/src/renderer/assets/logo.svg +7 -0
- package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
- package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
- package/src/renderer/components/AnnotationOverlay.tsx +836 -0
- package/src/renderer/components/AudioWaveform.tsx +811 -0
- package/src/renderer/components/ClarificationQuestions.tsx +656 -0
- package/src/renderer/components/CountdownTimer.tsx +495 -0
- package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
- package/src/renderer/components/DonateButton.tsx +127 -0
- package/src/renderer/components/ErrorBoundary.tsx +308 -0
- package/src/renderer/components/ExportDialog.tsx +872 -0
- package/src/renderer/components/HotkeyHint.tsx +261 -0
- package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
- package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
- package/src/renderer/components/Onboarding.tsx +1830 -0
- package/src/renderer/components/ProcessingOverlay.tsx +157 -0
- package/src/renderer/components/RecordingOverlay.tsx +423 -0
- package/src/renderer/components/SessionHistory.tsx +1746 -0
- package/src/renderer/components/SessionReview.tsx +1321 -0
- package/src/renderer/components/SettingsPanel.tsx +217 -0
- package/src/renderer/components/Skeleton.tsx +347 -0
- package/src/renderer/components/StatusIndicator.tsx +86 -0
- package/src/renderer/components/ThemeProvider.tsx +429 -0
- package/src/renderer/components/Tooltip.tsx +370 -0
- package/src/renderer/components/TranscriptionPreview.tsx +183 -0
- package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
- package/src/renderer/components/UpdateNotification.tsx +377 -0
- package/src/renderer/components/WindowSelector.tsx +947 -0
- package/src/renderer/components/index.ts +99 -0
- package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
- package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
- package/src/renderer/components/primitives/DangerButton.tsx +45 -0
- package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
- package/src/renderer/components/primitives/Dropdown.tsx +34 -0
- package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
- package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
- package/src/renderer/components/primitives/Slider.tsx +43 -0
- package/src/renderer/components/primitives/Toggle.tsx +36 -0
- package/src/renderer/components/primitives/index.ts +10 -0
- package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
- package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
- package/src/renderer/components/settings/GeneralTab.tsx +40 -0
- package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
- package/src/renderer/components/settings/RecordingTab.tsx +84 -0
- package/src/renderer/components/settings/index.ts +9 -0
- package/src/renderer/components/settings/settingsStyles.ts +673 -0
- package/src/renderer/components/settings/tabConfig.tsx +85 -0
- package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
- package/src/renderer/contexts/ProcessingContext.tsx +227 -0
- package/src/renderer/contexts/RecordingContext.tsx +683 -0
- package/src/renderer/contexts/UIContext.tsx +326 -0
- package/src/renderer/contexts/index.ts +24 -0
- package/src/renderer/donateMessages.ts +69 -0
- package/src/renderer/hooks/index.ts +75 -0
- package/src/renderer/hooks/useAnimation.tsx +544 -0
- package/src/renderer/hooks/useTheme.ts +313 -0
- package/src/renderer/index.html +26 -0
- package/src/renderer/main.tsx +52 -0
- package/src/renderer/styles/animations.css +1093 -0
- package/src/renderer/styles/app-shell.css +662 -0
- package/src/renderer/styles/globals.css +515 -0
- package/src/renderer/styles/theme.ts +578 -0
- package/src/renderer/types/electron.d.ts +385 -0
- package/src/shared/hotkeys.ts +283 -0
- package/src/shared/types.ts +809 -0
- package/tests/clipboard.test.ts +228 -0
- package/tests/e2e/criticalPaths.test.ts +594 -0
- package/tests/feedbackAnalyzer.test.ts +303 -0
- package/tests/integration/sessionFlow.test.ts +583 -0
- package/tests/markdownGenerator.test.ts +418 -0
- package/tests/output.test.ts +96 -0
- package/tests/setup.ts +486 -0
- package/tests/unit/appIntegration.test.ts +676 -0
- package/tests/unit/appViewState.test.ts +281 -0
- package/tests/unit/audioIpcChannels.test.ts +17 -0
- package/tests/unit/exportService.test.ts +492 -0
- package/tests/unit/hotkeys.test.ts +92 -0
- package/tests/unit/navigationPreload.test.ts +94 -0
- package/tests/unit/onboardingFlow.test.ts +345 -0
- package/tests/unit/permissionManager.test.ts +175 -0
- package/tests/unit/permissionManagerExpanded.test.ts +296 -0
- package/tests/unit/screenRecordingRenderer.test.ts +368 -0
- package/tests/unit/sessionController.test.ts +515 -0
- package/tests/unit/tierManager.test.ts +61 -0
- package/tests/unit/tierManagerExpanded.test.ts +142 -0
- package/tests/unit/transcriptAnalyzer.test.ts +64 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +46 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionController Unit Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the core session orchestration logic:
|
|
5
|
+
* - State machine transitions
|
|
6
|
+
* - Session lifecycle (start, stop, cancel)
|
|
7
|
+
* - Status tracking
|
|
8
|
+
* - Error handling
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { EventEmitter } from 'events';
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Mocks
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
// Mock audio capture service
|
|
19
|
+
const createMockAudioCapture = () => ({
|
|
20
|
+
_events: new EventEmitter(),
|
|
21
|
+
start: vi.fn(() => Promise.resolve()),
|
|
22
|
+
stop: vi.fn(),
|
|
23
|
+
setMainWindow: vi.fn(),
|
|
24
|
+
onAudioChunk: vi.fn((callback) => {
|
|
25
|
+
createMockAudioCapture._events.on('audioChunk', callback);
|
|
26
|
+
return () => createMockAudioCapture._events.off('audioChunk', callback);
|
|
27
|
+
}),
|
|
28
|
+
onVoiceActivity: vi.fn((callback) => {
|
|
29
|
+
createMockAudioCapture._events.on('voiceActivity', callback);
|
|
30
|
+
return () => createMockAudioCapture._events.off('voiceActivity', callback);
|
|
31
|
+
}),
|
|
32
|
+
onError: vi.fn((callback) => {
|
|
33
|
+
createMockAudioCapture._events.on('error', callback);
|
|
34
|
+
return () => createMockAudioCapture._events.off('error', callback);
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Shared event emitter for mocks
|
|
39
|
+
createMockAudioCapture._events = new EventEmitter();
|
|
40
|
+
|
|
41
|
+
// Mock screen capture service
|
|
42
|
+
const createMockScreenCapture = () => ({
|
|
43
|
+
capture: vi.fn(() =>
|
|
44
|
+
Promise.resolve({
|
|
45
|
+
id: `screenshot-${Date.now()}`,
|
|
46
|
+
buffer: Buffer.from('mock-image'),
|
|
47
|
+
width: 1920,
|
|
48
|
+
height: 1080,
|
|
49
|
+
timestamp: Date.now(),
|
|
50
|
+
})
|
|
51
|
+
),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Mock transcription service
|
|
55
|
+
const createMockTranscriptionService = () => {
|
|
56
|
+
const events = new EventEmitter();
|
|
57
|
+
return {
|
|
58
|
+
_events: events,
|
|
59
|
+
configure: vi.fn(),
|
|
60
|
+
start: vi.fn(() => Promise.resolve()),
|
|
61
|
+
stop: vi.fn(),
|
|
62
|
+
sendAudio: vi.fn(),
|
|
63
|
+
onTranscript: vi.fn((callback) => {
|
|
64
|
+
events.on('transcript', callback);
|
|
65
|
+
return () => events.off('transcript', callback);
|
|
66
|
+
}),
|
|
67
|
+
onUtteranceEnd: vi.fn((callback) => {
|
|
68
|
+
events.on('utteranceEnd', callback);
|
|
69
|
+
return () => events.off('utteranceEnd', callback);
|
|
70
|
+
}),
|
|
71
|
+
onError: vi.fn((callback) => {
|
|
72
|
+
events.on('error', callback);
|
|
73
|
+
return () => events.off('error', callback);
|
|
74
|
+
}),
|
|
75
|
+
emitTranscript: (result: { text: string; isFinal: boolean; confidence: number; timestamp: number }) => {
|
|
76
|
+
events.emit('transcript', result);
|
|
77
|
+
},
|
|
78
|
+
emitUtteranceEnd: (timestamp: number) => {
|
|
79
|
+
events.emit('utteranceEnd', timestamp);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Mock electron-store
|
|
85
|
+
vi.mock('electron-store', () => ({
|
|
86
|
+
default: vi.fn().mockImplementation(() => ({
|
|
87
|
+
get: vi.fn((key: string, defaultValue?: unknown) => defaultValue),
|
|
88
|
+
set: vi.fn(),
|
|
89
|
+
delete: vi.fn(),
|
|
90
|
+
clear: vi.fn(),
|
|
91
|
+
})),
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
// Mock electron
|
|
95
|
+
vi.mock('electron', () => ({
|
|
96
|
+
BrowserWindow: vi.fn(),
|
|
97
|
+
app: {
|
|
98
|
+
getPath: vi.fn(() => '/tmp'),
|
|
99
|
+
getName: vi.fn(() => 'markupr'),
|
|
100
|
+
getVersion: vi.fn(() => '0.4.0'),
|
|
101
|
+
},
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
// =============================================================================
|
|
105
|
+
// Mock Session Controller (Isolated for Testing)
|
|
106
|
+
// =============================================================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Simplified SessionController for testing state machine logic
|
|
110
|
+
* without Electron dependencies
|
|
111
|
+
*/
|
|
112
|
+
class TestableSessionController {
|
|
113
|
+
private state: 'idle' | 'recording' | 'processing' | 'complete' = 'idle';
|
|
114
|
+
private session: {
|
|
115
|
+
id: string;
|
|
116
|
+
startTime: number;
|
|
117
|
+
endTime?: number;
|
|
118
|
+
state: string;
|
|
119
|
+
sourceId: string;
|
|
120
|
+
feedbackItems: Array<{ id: string; timestamp: number; text: string; confidence: number }>;
|
|
121
|
+
transcriptBuffer: Array<{ text: string; isFinal: boolean; confidence: number; timestamp: number }>;
|
|
122
|
+
screenshotBuffer: Array<{ id: string; timestamp: number; buffer: Buffer; width: number; height: number }>;
|
|
123
|
+
metadata: { sourceId: string; sourceName?: string };
|
|
124
|
+
} | null = null;
|
|
125
|
+
|
|
126
|
+
private audioCapture = createMockAudioCapture();
|
|
127
|
+
private screenCapture = createMockScreenCapture();
|
|
128
|
+
private transcriptionService = createMockTranscriptionService();
|
|
129
|
+
|
|
130
|
+
// Valid state transitions
|
|
131
|
+
private readonly STATE_TRANSITIONS: Record<string, string[]> = {
|
|
132
|
+
idle: ['recording'],
|
|
133
|
+
recording: ['processing', 'idle'],
|
|
134
|
+
processing: ['complete', 'idle'],
|
|
135
|
+
complete: ['idle'],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
getState() {
|
|
139
|
+
return this.state;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getSession() {
|
|
143
|
+
return this.session;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getStatus() {
|
|
147
|
+
return {
|
|
148
|
+
state: this.state,
|
|
149
|
+
duration: this.session ? Date.now() - this.session.startTime : 0,
|
|
150
|
+
feedbackCount: this.session?.feedbackItems.length ?? 0,
|
|
151
|
+
screenshotCount: this.session?.screenshotBuffer.length ?? 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private transition(newState: 'idle' | 'recording' | 'processing' | 'complete'): boolean {
|
|
156
|
+
const validTransitions = this.STATE_TRANSITIONS[this.state];
|
|
157
|
+
if (!validTransitions.includes(newState)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
this.state = newState;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async start(sourceId: string, sourceName?: string): Promise<void> {
|
|
165
|
+
if (this.state !== 'idle') {
|
|
166
|
+
throw new Error(`Cannot start session from state: ${this.state}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.session = {
|
|
170
|
+
id: `session-${Date.now()}`,
|
|
171
|
+
startTime: Date.now(),
|
|
172
|
+
state: 'recording',
|
|
173
|
+
sourceId,
|
|
174
|
+
feedbackItems: [],
|
|
175
|
+
transcriptBuffer: [],
|
|
176
|
+
screenshotBuffer: [],
|
|
177
|
+
metadata: { sourceId, sourceName },
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (!this.transition('recording')) {
|
|
181
|
+
this.session = null;
|
|
182
|
+
throw new Error('Failed to transition to recording state');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Start mock services
|
|
186
|
+
await this.audioCapture.start();
|
|
187
|
+
await this.transcriptionService.start();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async stop(): Promise<typeof this.session> {
|
|
191
|
+
if (this.state !== 'recording') {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!this.session) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.transition('processing');
|
|
200
|
+
this.session.state = 'processing';
|
|
201
|
+
|
|
202
|
+
// Stop services
|
|
203
|
+
this.audioCapture.stop();
|
|
204
|
+
this.transcriptionService.stop();
|
|
205
|
+
|
|
206
|
+
// Set end time
|
|
207
|
+
this.session.endTime = Date.now();
|
|
208
|
+
|
|
209
|
+
// Transition to complete
|
|
210
|
+
this.transition('complete');
|
|
211
|
+
this.session.state = 'complete';
|
|
212
|
+
|
|
213
|
+
return { ...this.session };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
cancel(): void {
|
|
217
|
+
if (this.state !== 'recording' && this.state !== 'processing') {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.audioCapture.stop();
|
|
222
|
+
this.transcriptionService.stop();
|
|
223
|
+
this.session = null;
|
|
224
|
+
this.state = 'idle';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
reset(): void {
|
|
228
|
+
this.audioCapture.stop();
|
|
229
|
+
this.transcriptionService.stop();
|
|
230
|
+
this.session = null;
|
|
231
|
+
this.state = 'idle';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
addFeedbackItem(item: { text: string; confidence?: number }): { id: string; timestamp: number; text: string; confidence: number } {
|
|
235
|
+
if (!this.session) {
|
|
236
|
+
throw new Error('No active session');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const feedbackItem = {
|
|
240
|
+
id: `fb-${Date.now()}`,
|
|
241
|
+
timestamp: Date.now(),
|
|
242
|
+
text: item.text,
|
|
243
|
+
confidence: item.confidence ?? 1.0,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
this.session.feedbackItems.push(feedbackItem);
|
|
247
|
+
return feedbackItem;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
deleteFeedbackItem(id: string): boolean {
|
|
251
|
+
if (!this.session) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const index = this.session.feedbackItems.findIndex((item) => item.id === id);
|
|
256
|
+
if (index === -1) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this.session.feedbackItems.splice(index, 1);
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Expose mocks for testing
|
|
265
|
+
getMocks() {
|
|
266
|
+
return {
|
|
267
|
+
audioCapture: this.audioCapture,
|
|
268
|
+
screenCapture: this.screenCapture,
|
|
269
|
+
transcriptionService: this.transcriptionService,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// =============================================================================
|
|
275
|
+
// Tests
|
|
276
|
+
// =============================================================================
|
|
277
|
+
|
|
278
|
+
describe('SessionController', () => {
|
|
279
|
+
let controller: TestableSessionController;
|
|
280
|
+
|
|
281
|
+
beforeEach(() => {
|
|
282
|
+
controller = new TestableSessionController();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
afterEach(() => {
|
|
286
|
+
controller.reset();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('State Machine', () => {
|
|
290
|
+
it('should start in idle state', () => {
|
|
291
|
+
expect(controller.getState()).toBe('idle');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should transition idle -> recording on start', async () => {
|
|
295
|
+
await controller.start('screen:0:0', 'Test Screen');
|
|
296
|
+
|
|
297
|
+
expect(controller.getState()).toBe('recording');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should transition recording -> processing -> complete on stop', async () => {
|
|
301
|
+
await controller.start('screen:0:0', 'Test Screen');
|
|
302
|
+
|
|
303
|
+
const session = await controller.stop();
|
|
304
|
+
|
|
305
|
+
expect(controller.getState()).toBe('complete');
|
|
306
|
+
expect(session).not.toBeNull();
|
|
307
|
+
expect(session?.state).toBe('complete');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should transition back to idle on cancel', async () => {
|
|
311
|
+
await controller.start('screen:0:0', 'Test Screen');
|
|
312
|
+
|
|
313
|
+
controller.cancel();
|
|
314
|
+
|
|
315
|
+
expect(controller.getState()).toBe('idle');
|
|
316
|
+
expect(controller.getSession()).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should not allow invalid state transitions', async () => {
|
|
320
|
+
// Cannot start from recording state
|
|
321
|
+
await controller.start('screen:0:0');
|
|
322
|
+
|
|
323
|
+
await expect(controller.start('screen:0:0')).rejects.toThrow();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should not allow stopping from idle state', async () => {
|
|
327
|
+
const result = await controller.stop();
|
|
328
|
+
|
|
329
|
+
expect(result).toBeNull();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('Session Lifecycle', () => {
|
|
334
|
+
it('should create a session on start', async () => {
|
|
335
|
+
await controller.start('screen:0:0', 'Primary Display');
|
|
336
|
+
|
|
337
|
+
const session = controller.getSession();
|
|
338
|
+
|
|
339
|
+
expect(session).not.toBeNull();
|
|
340
|
+
expect(session?.sourceId).toBe('screen:0:0');
|
|
341
|
+
expect(session?.metadata.sourceName).toBe('Primary Display');
|
|
342
|
+
expect(session?.startTime).toBeDefined();
|
|
343
|
+
expect(session?.feedbackItems).toHaveLength(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should set end time on stop', async () => {
|
|
347
|
+
await controller.start('screen:0:0');
|
|
348
|
+
|
|
349
|
+
const session = await controller.stop();
|
|
350
|
+
|
|
351
|
+
expect(session?.endTime).toBeDefined();
|
|
352
|
+
expect(session!.endTime!).toBeGreaterThanOrEqual(session!.startTime);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should track duration during recording', async () => {
|
|
356
|
+
await controller.start('screen:0:0');
|
|
357
|
+
|
|
358
|
+
// Wait a bit
|
|
359
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
360
|
+
|
|
361
|
+
const status = controller.getStatus();
|
|
362
|
+
|
|
363
|
+
expect(status.duration).toBeGreaterThan(0);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should preserve session data through state transitions', async () => {
|
|
367
|
+
await controller.start('screen:0:0', 'Test');
|
|
368
|
+
|
|
369
|
+
// Add feedback
|
|
370
|
+
controller.addFeedbackItem({ text: 'Test feedback 1' });
|
|
371
|
+
controller.addFeedbackItem({ text: 'Test feedback 2' });
|
|
372
|
+
|
|
373
|
+
const session = await controller.stop();
|
|
374
|
+
|
|
375
|
+
expect(session?.feedbackItems).toHaveLength(2);
|
|
376
|
+
expect(session?.feedbackItems[0].text).toBe('Test feedback 1');
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('Status Tracking', () => {
|
|
381
|
+
it('should return correct status when idle', () => {
|
|
382
|
+
const status = controller.getStatus();
|
|
383
|
+
|
|
384
|
+
expect(status.state).toBe('idle');
|
|
385
|
+
expect(status.duration).toBe(0);
|
|
386
|
+
expect(status.feedbackCount).toBe(0);
|
|
387
|
+
expect(status.screenshotCount).toBe(0);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('should track feedback count', async () => {
|
|
391
|
+
await controller.start('screen:0:0');
|
|
392
|
+
|
|
393
|
+
controller.addFeedbackItem({ text: 'Feedback 1' });
|
|
394
|
+
controller.addFeedbackItem({ text: 'Feedback 2' });
|
|
395
|
+
controller.addFeedbackItem({ text: 'Feedback 3' });
|
|
396
|
+
|
|
397
|
+
const status = controller.getStatus();
|
|
398
|
+
|
|
399
|
+
expect(status.feedbackCount).toBe(3);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should update state in status', async () => {
|
|
403
|
+
expect(controller.getStatus().state).toBe('idle');
|
|
404
|
+
|
|
405
|
+
await controller.start('screen:0:0');
|
|
406
|
+
expect(controller.getStatus().state).toBe('recording');
|
|
407
|
+
|
|
408
|
+
await controller.stop();
|
|
409
|
+
expect(controller.getStatus().state).toBe('complete');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe('Feedback Item Management', () => {
|
|
414
|
+
beforeEach(async () => {
|
|
415
|
+
await controller.start('screen:0:0');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('should add feedback items', () => {
|
|
419
|
+
const item = controller.addFeedbackItem({ text: 'Test feedback', confidence: 0.95 });
|
|
420
|
+
|
|
421
|
+
expect(item.id).toBeDefined();
|
|
422
|
+
expect(item.text).toBe('Test feedback');
|
|
423
|
+
expect(item.confidence).toBe(0.95);
|
|
424
|
+
expect(item.timestamp).toBeDefined();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should default confidence to 1.0', () => {
|
|
428
|
+
const item = controller.addFeedbackItem({ text: 'Test' });
|
|
429
|
+
|
|
430
|
+
expect(item.confidence).toBe(1.0);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should delete feedback items', () => {
|
|
434
|
+
const item = controller.addFeedbackItem({ text: 'To delete' });
|
|
435
|
+
|
|
436
|
+
const deleted = controller.deleteFeedbackItem(item.id);
|
|
437
|
+
|
|
438
|
+
expect(deleted).toBe(true);
|
|
439
|
+
expect(controller.getStatus().feedbackCount).toBe(0);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should return false when deleting non-existent item', () => {
|
|
443
|
+
const deleted = controller.deleteFeedbackItem('non-existent-id');
|
|
444
|
+
|
|
445
|
+
expect(deleted).toBe(false);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('should throw when adding items without active session', async () => {
|
|
449
|
+
await controller.stop();
|
|
450
|
+
controller.reset();
|
|
451
|
+
|
|
452
|
+
expect(() => controller.addFeedbackItem({ text: 'Test' })).toThrow('No active session');
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe('Service Integration', () => {
|
|
457
|
+
it('should start audio capture on session start', async () => {
|
|
458
|
+
const { audioCapture } = controller.getMocks();
|
|
459
|
+
|
|
460
|
+
await controller.start('screen:0:0');
|
|
461
|
+
|
|
462
|
+
expect(audioCapture.start).toHaveBeenCalled();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('should start transcription service on session start', async () => {
|
|
466
|
+
const { transcriptionService } = controller.getMocks();
|
|
467
|
+
|
|
468
|
+
await controller.start('screen:0:0');
|
|
469
|
+
|
|
470
|
+
expect(transcriptionService.start).toHaveBeenCalled();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('should stop services on session stop', async () => {
|
|
474
|
+
const { audioCapture, transcriptionService } = controller.getMocks();
|
|
475
|
+
|
|
476
|
+
await controller.start('screen:0:0');
|
|
477
|
+
await controller.stop();
|
|
478
|
+
|
|
479
|
+
expect(audioCapture.stop).toHaveBeenCalled();
|
|
480
|
+
expect(transcriptionService.stop).toHaveBeenCalled();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('should stop services on cancel', async () => {
|
|
484
|
+
const { audioCapture, transcriptionService } = controller.getMocks();
|
|
485
|
+
|
|
486
|
+
await controller.start('screen:0:0');
|
|
487
|
+
controller.cancel();
|
|
488
|
+
|
|
489
|
+
expect(audioCapture.stop).toHaveBeenCalled();
|
|
490
|
+
expect(transcriptionService.stop).toHaveBeenCalled();
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe('Reset', () => {
|
|
495
|
+
it('should reset to idle state', async () => {
|
|
496
|
+
await controller.start('screen:0:0');
|
|
497
|
+
controller.addFeedbackItem({ text: 'Test' });
|
|
498
|
+
|
|
499
|
+
controller.reset();
|
|
500
|
+
|
|
501
|
+
expect(controller.getState()).toBe('idle');
|
|
502
|
+
expect(controller.getSession()).toBeNull();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should stop all services on reset', async () => {
|
|
506
|
+
const { audioCapture, transcriptionService } = controller.getMocks();
|
|
507
|
+
|
|
508
|
+
await controller.start('screen:0:0');
|
|
509
|
+
controller.reset();
|
|
510
|
+
|
|
511
|
+
expect(audioCapture.stop).toHaveBeenCalled();
|
|
512
|
+
expect(transcriptionService.stop).toHaveBeenCalled();
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TierManager } from '../../src/main/transcription/TierManager';
|
|
3
|
+
import type { TierStatus } from '../../src/main/transcription/types';
|
|
4
|
+
|
|
5
|
+
function makeStatuses(
|
|
6
|
+
overrides: Partial<Record<TierStatus['tier'], TierStatus>>
|
|
7
|
+
): TierStatus[] {
|
|
8
|
+
const defaults: TierStatus[] = [
|
|
9
|
+
{ tier: 'whisper', available: false, reason: 'Model not downloaded' },
|
|
10
|
+
{ tier: 'timer-only', available: true },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
return defaults.map((status) => overrides[status.tier] ?? status);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('TierManager preference selection', () => {
|
|
17
|
+
it('prefers Whisper by default when auto mode and Whisper is available', async () => {
|
|
18
|
+
const manager = new TierManager();
|
|
19
|
+
vi.spyOn(manager, 'getTierStatuses').mockResolvedValue(
|
|
20
|
+
makeStatuses({
|
|
21
|
+
whisper: { tier: 'whisper', available: true },
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const selected = await manager.selectBestTier();
|
|
26
|
+
expect(selected).toBe('whisper');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('uses preferred Whisper when available', async () => {
|
|
30
|
+
const manager = new TierManager();
|
|
31
|
+
manager.setPreferredTier('whisper');
|
|
32
|
+
vi.spyOn(manager, 'getTierStatuses').mockResolvedValue(
|
|
33
|
+
makeStatuses({
|
|
34
|
+
whisper: { tier: 'whisper', available: true },
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const selected = await manager.selectBestTier();
|
|
39
|
+
expect(selected).toBe('whisper');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('falls back to timer-only when preferred tier is unavailable', async () => {
|
|
43
|
+
const manager = new TierManager();
|
|
44
|
+
manager.setPreferredTier('whisper');
|
|
45
|
+
vi.spyOn(manager, 'getTierStatuses').mockResolvedValue(
|
|
46
|
+
makeStatuses({
|
|
47
|
+
whisper: { tier: 'whisper', available: false, reason: 'No model' },
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const selected = await manager.selectBestTier();
|
|
52
|
+
expect(selected).toBe('timer-only');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects non-transcribing preferred tiers in strict feedback mode', () => {
|
|
56
|
+
const manager = new TierManager();
|
|
57
|
+
expect(() => manager.setPreferredTier('timer-only')).toThrow(
|
|
58
|
+
'does not provide transcription'
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|