chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
@@ -0,0 +1,35 @@
{
"soundEffectsEnabled": false,
"hotkey": {
"key": null,
"modifiers": {
"modifiers": [
"option"
]
}
},
"openOnLogin": true,
"showDockIcon": false,
"selectedModel": "whisper-large-v3",
"useClipboardPaste": false,
"preventSystemSleep": true,
"pauseMediaOnRecord": true,
"minimumKeyTime": 0.25,
"copyToClipboard": true,
"useDoubleTapOnly": true,
"outputLanguage": "en",
"selectedMicrophoneID": "builtin:mic",
"saveTranscriptionHistory": false,
"maxHistoryEntries": 10,
"pasteLastTranscriptHotkey": {
"key": "v",
"modifiers": {
"modifiers": [
"option",
"shift"
]
}
},
"hasCompletedModelBootstrap": true,
"hasCompletedStorageMigration": true
}
@@ -0,0 +1,84 @@
import XCTest
@testable import HexCore
final class HexSettingsMigrationTests: XCTestCase {
func testV1FixtureMigratesToCurrentDefaults() throws {
let data = try loadFixture(named: "v1")
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
XCTAssertEqual(decoded.recordingAudioBehavior, .pauseMedia, "Legacy pauseMediaOnRecord bool should map to pauseMedia behavior")
XCTAssertEqual(decoded.soundEffectsEnabled, false)
XCTAssertEqual(decoded.soundEffectsVolume, HexSettings.baseSoundEffectsVolume)
XCTAssertEqual(decoded.openOnLogin, true)
XCTAssertEqual(decoded.showDockIcon, false)
XCTAssertEqual(decoded.selectedModel, "whisper-large-v3")
XCTAssertEqual(decoded.useClipboardPaste, false)
XCTAssertEqual(decoded.preventSystemSleep, true)
XCTAssertEqual(decoded.minimumKeyTime, 0.25)
XCTAssertEqual(decoded.copyToClipboard, true)
XCTAssertTrue(decoded.superFastModeEnabled)
XCTAssertEqual(decoded.useDoubleTapOnly, true)
XCTAssertEqual(decoded.doubleTapLockEnabled, true)
XCTAssertEqual(decoded.outputLanguage, "en")
XCTAssertEqual(decoded.selectedMicrophoneID, "builtin:mic")
XCTAssertEqual(decoded.saveTranscriptionHistory, false)
XCTAssertEqual(decoded.maxHistoryEntries, 10)
XCTAssertEqual(decoded.hasCompletedModelBootstrap, true)
XCTAssertEqual(decoded.hasCompletedStorageMigration, true)
XCTAssertFalse(decoded.lowercaseTranscripts)
XCTAssertFalse(decoded.removePunctuation)
}
func testEncodeDecodeRoundTripPreservesDefaults() throws {
let settings = HexSettings()
let data = try JSONEncoder().encode(settings)
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
XCTAssertEqual(decoded, settings)
}
func testNewSettingsEnableSuperFastModeByDefault() {
XCTAssertTrue(HexSettings().superFastModeEnabled)
}
func testInitNormalizesDoubleTapOnlyWhenLockDisabled() {
let settings = HexSettings(useDoubleTapOnly: true, doubleTapLockEnabled: false)
XCTAssertFalse(settings.useDoubleTapOnly)
XCTAssertFalse(settings.doubleTapLockEnabled)
}
func testDecodeNormalizesDoubleTapOnlyWhenLockDisabled() throws {
let payload = "{\"useDoubleTapOnly\":true,\"doubleTapLockEnabled\":false}"
guard let data = payload.data(using: .utf8) else {
XCTFail("Failed to encode JSON payload")
return
}
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
XCTAssertFalse(decoded.useDoubleTapOnly)
XCTAssertFalse(decoded.doubleTapLockEnabled)
}
func testEncodeDecodeRoundTripPreservesNormalizedDoubleTapValues() throws {
let settings = HexSettings(useDoubleTapOnly: true, doubleTapLockEnabled: false)
let data = try JSONEncoder().encode(settings)
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
XCTAssertFalse(settings.useDoubleTapOnly)
XCTAssertFalse(decoded.useDoubleTapOnly)
XCTAssertEqual(decoded, settings)
}
private func loadFixture(named name: String) throws -> Data {
guard let url = Bundle.module.url(
forResource: name,
withExtension: "json",
subdirectory: "Fixtures/HexSettings"
) else {
XCTFail("Missing fixture \(name).json")
throw NSError(domain: "Fixture", code: 0)
}
return try Data(contentsOf: url)
}
}
@@ -0,0 +1,934 @@
//
// HotKeyProcessorTests.swift
// HexCoreTests
//
// Created by Kit Langton on 1/27/25.
//
import Dependencies
import Foundation
@testable import HexCore
import Sauce
import Testing
struct HotKeyProcessorTests {
// MARK: - Standard HotKey (key + modifiers) Tests
// Tests a single key press that matches the hotkey
@Test
func pressAndHold_startsRecordingOnHotkey_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
@Test
func pressAndHold_startsRecordingOnHotkey_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
// Tests releasing the hotkey stops recording
@Test
func pressAndHold_stopsRecordingOnHotkeyRelease_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
ScenarioStep(time: 0.2, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
@Test
func pressAndHold_stopsRecordingOnHotkeyRelease_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
ScenarioStep(time: 0.2, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
@Test
func pressAndHold_stopsRecordingOnHotkeyRelease_multipleModifiers() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option, .command]),
steps: [
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
ScenarioStep(time: 0.1, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
ScenarioStep(time: 0.2, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
@Test
func pressAndHold_releasingModifierBeforeKeyStillStops() throws {
runScenario(
hotkey: HotKey(key: .u, modifiers: [.option]),
steps: [
// Press modifier first (Option)
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
// Then press the key to start recording
ScenarioStep(time: 0.05, key: .u, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Release the modifier while holding the key
ScenarioStep(time: 1.5, key: .u, modifiers: [], expectedOutput: nil, expectedIsMatched: true),
// Release the key a beat later — should stop recording automatically
ScenarioStep(time: 1.55, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// Tests pressing a different key cancels recording
@Test
func pressAndHold_cancelsOnOtherKeyPress_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// Initial hotkey press
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Different key press within cancel threshold
ScenarioStep(time: 0.5, key: .b, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// For modifier-only hotkeys, extra modifiers after threshold are ignored
@Test
func pressAndHold_ignoresExtraModifierAfterThreshold_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// Initial hotkey press (option)
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Press a different modifier after threshold (0.5s > 0.3s) - should be ignored
ScenarioStep(time: 0.5, key: nil, modifiers: [.option, .command], expectedOutput: nil, expectedIsMatched: true),
]
)
}
// Tests that pressing a different key after threshold doesn't cancel
@Test
func pressAndHold_doesNotCancelAfterThreshold_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// Initial hotkey press
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Different key press after cancel threshold
ScenarioStep(time: 1.5, key: .b, modifiers: [.command], expectedOutput: nil, expectedIsMatched: true),
]
)
}
@Test
func pressAndHold_doesNotCancelAfterThreshold_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// Initial hotkey press
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Different modifier press after cancel threshold
ScenarioStep(time: 1.5, key: nil, modifiers: [.option, .command], expectedOutput: nil, expectedIsMatched: true),
]
)
}
// The user cannot "backslide" into pressing the hotkey. If the user is chording extra modifiers,
// everything must be released before a hotkey can trigger
@Test
func pressAndHold_doesNotTriggerOnBackslide_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// They press the hotkey with an extra modifier
ScenarioStep(time: 0.0, key: .a, modifiers: [.command, .shift], expectedOutput: nil, expectedIsMatched: false),
// And then release the extra modifier, nothing should happen
ScenarioStep(time: 0.1, key: .a, modifiers: [.command], expectedOutput: nil, expectedIsMatched: false),
// Then if they release everything, the hotkey should trigger
ScenarioStep(time: 0.2, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// And try to press the hotkey again, it should start recording
ScenarioStep(time: 0.3, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
// Tests double-tap to lock recording
@Test
func doubleTapLock_startsRecordingOnDoubleTap_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
// Release all modifiers
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Press modifier again
ScenarioStep(time: 0.15, key: nil, modifiers: [.command], expectedOutput: nil, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release (should stay recording)
ScenarioStep(time: 0.3, key: nil, modifiers: [.command], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
]
)
}
@Test
func doubleTapLock_startsRecordingOnDoubleTap_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release (should stay recording)
ScenarioStep(time: 0.3, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
]
)
}
@Test
func doubleTapLock_startsRecordingOnDoubleTap_multipleModifiers() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option, .command]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
ScenarioStep(time: 0.05, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [.option], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release (should stay recording)
ScenarioStep(time: 0.3, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
]
)
}
// Tests that a slow double tap doesn't lock recording
@Test
func doubleTapLock_ignoresSlowDoubleTap_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap after threshold
ScenarioStep(time: 0.4, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
@Test
func doubleTapLock_ignoresSlowDoubleTap_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap after threshold
ScenarioStep(time: 0.4, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
// Tests that tapping again after double-tap lock stops recording
@Test
func doubleTapLock_stopsRecordingOnNextTap_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release (should stay recording)
ScenarioStep(time: 0.3, key: nil, modifiers: [.command], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
// Third tap to stop recording
ScenarioStep(time: 1.0, key: .a, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
@Test
func doubleTapLock_stopsRecordingOnNextTap_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release (should stay recording)
ScenarioStep(time: 0.3, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
// Third tap to stop recording
ScenarioStep(time: 1.0, key: nil, modifiers: [.option], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
@Test
func doubleTapLock_disabled_staysPressAndHold_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
doubleTapLockEnabled: false,
steps: [
// First tap
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
// Release all modifiers
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Press modifier again
ScenarioStep(time: 0.15, key: nil, modifiers: [.command], expectedOutput: nil, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release should stop normally (no lock)
ScenarioStep(time: 0.3, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false, expectedState: .idle),
]
)
}
@Test
func doubleTapOnly_ignoredWhenDoubleTapLockDisabled() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
useDoubleTapOnly: true,
doubleTapLockEnabled: false,
steps: [
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
ScenarioStep(time: 0.2, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// MARK: - Edge Cases
// Tests that after pressing a key with option, releasing the key but keeping option pressed
// does not restart recording due to the "dirty" state
@Test
func pressAndHold_stopsRecordingOnKeyPressAndStaysDirty() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// Initial hotkey press (option)
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Press a different key within cancel threshold - should discard silently since < minimumKeyTime
ScenarioStep(time: 0.1, key: .c, modifiers: [.option], expectedOutput: .discard, expectedIsMatched: false),
// Release the C
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
]
)
}
// MARK: - Fn + Arrow Regression
// After using Fn with another key (e.g., Arrow), then fully releasing,
// a subsequent standalone Fn press should be recognized and start recording.
// This guards against the state getting "stuck" after Fn+Arrow usage (Issue #81).
@Test
func modifierOnly_fn_triggersAfterFnPlusKeyThenFullRelease() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.fn]),
steps: [
// Simulate using an Arrow with Fn held (use .c as a stand-in key for arrows in unit tests)
ScenarioStep(time: 0.00, key: .c, modifiers: [.fn], expectedOutput: nil, expectedIsMatched: false),
// Fully release everything
ScenarioStep(time: 0.05, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Next standalone Fn press should trigger recording
ScenarioStep(time: 0.20, key: nil, modifiers: [.fn], expectedOutput: .startRecording, expectedIsMatched: true),
// Release Fn should stop recording (must exceed modifierOnlyMinimumDuration)
ScenarioStep(time: 0.40, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// If the user uses Fn+Key and releases only the key (keeps Fn held),
// we must NOT trigger — no standalone Fn edge occurred.
@Test
func modifierOnly_fn_doesNotTriggerWhenFnRemainsHeldAfterKeyRelease() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.fn]),
steps: [
// Use Fn with another key (stand-in for arrow)
ScenarioStep(time: 0.00, key: .c, modifiers: [.fn], expectedOutput: nil, expectedIsMatched: false),
// Release the key but keep Fn held — should not start
ScenarioStep(time: 0.05, key: nil, modifiers: [.fn], expectedOutput: nil, expectedIsMatched: false),
// Only once the user fully releases and presses Fn again should it start
ScenarioStep(time: 0.10, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
ScenarioStep(time: 0.25, key: nil, modifiers: [.fn], expectedOutput: .startRecording, expectedIsMatched: true),
// Must exceed modifierOnlyMinimumDuration before stopping
ScenarioStep(time: 0.60, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// The user presses and holds options, therefore it should start recording and then after two seconds he also presses command, which should not do anything.
@Test
func pressAndHold_staysDirtyAfterTwoSeconds() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// Initial hotkey press (option)
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Press command after two seconds
ScenarioStep(time: 2.0, key: nil, modifiers: [.option, .command], expectedOutput: nil, expectedIsMatched: true),
// Release command
ScenarioStep(time: 2.1, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: true),
// Release option
ScenarioStep(time: 2.2, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// Tests that double-tap lock only engages after the second release, not the second press
@Test
func doubleTap_onlyLocksAfterSecondRelease() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap within threshold - should start a new recording but not lock yet
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true, expectedState: .pressAndHold(startTime: Date(timeIntervalSince1970: 0.2))),
// Second release - NOW it should lock
ScenarioStep(time: 0.3, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
]
)
}
// Tests that if second tap is held too long, it's treated as a new press-and-hold instead of double-tap
@Test
func doubleTap_secondTapHeldTooLongBecomesHold() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second press within threshold
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Hold for 2 seconds (should stay in press-and-hold mode)
ScenarioStep(time: 2.2, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: true),
// Release - should stop recording since it was a hold
ScenarioStep(time: 2.3, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// MARK: - Additional Coverage Tests
// Tests ESC cancellation from hold state
@Test
func escape_cancelsFromHold() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// Start recording
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Press ESC
ScenarioStep(time: 0.5, key: .escape, modifiers: [], expectedOutput: .cancel, expectedIsMatched: false),
]
)
}
// Tests ESC cancellation from lock state
@Test
func escape_cancelsFromLock() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap (locks)
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
ScenarioStep(time: 0.3, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: true),
// Now locked - press ESC
ScenarioStep(time: 1.0, key: .escape, modifiers: [], expectedOutput: .cancel, expectedIsMatched: false),
]
)
}
// Tests that ESC while holding hotkey doesn't restart recording (issue #36)
@Test
func escape_whileHoldingHotkey_doesNotRestart() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// Start recording
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Press ESC while still holding hotkey
ScenarioStep(time: 0.5, key: .escape, modifiers: [.command], expectedOutput: .cancel, expectedIsMatched: false),
// Hotkey still held - should be ignored (dirty)
ScenarioStep(time: 0.6, key: .a, modifiers: [.command], expectedOutput: nil, expectedIsMatched: false),
// Full release
ScenarioStep(time: 0.7, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Now pressing hotkey should work again
ScenarioStep(time: 0.8, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
// Tests that modifier-only hotkey doesn't trigger when used with other keys (issue #87)
@Test
func modifierOnly_doesNotTriggerWithOtherKeys() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.command, .option]),
steps: [
// User presses cmd-option-T (keyboard shortcut)
ScenarioStep(time: 0.0, key: .t, modifiers: [.command, .option], expectedOutput: nil, expectedIsMatched: false),
// Release T but keep modifiers held
ScenarioStep(time: 0.1, key: nil, modifiers: [.command, .option], expectedOutput: nil, expectedIsMatched: false),
// Full release
ScenarioStep(time: 0.2, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Now press just cmd-option (no key) - should trigger
ScenarioStep(time: 0.3, key: nil, modifiers: [.command, .option], expectedOutput: .startRecording, expectedIsMatched: true),
// Release
ScenarioStep(time: 0.4, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// Tests that partially releasing multiple modifiers counts as full release
@Test
func multipleModifiers_partialRelease() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option, .command]),
steps: [
// Press both modifiers
ScenarioStep(time: 0.0, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
// Release Command (keep Option) - should stop recording
ScenarioStep(time: 0.5, key: nil, modifiers: [.option], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// Tests that adding extra modifier to multiple-modifier hotkey after threshold is ignored
@Test
func multipleModifiers_addingExtra_ignoredAfterThreshold() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option, .command]),
steps: [
// Press both required modifiers
ScenarioStep(time: 0.0, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
// Add Shift after threshold (0.5s > 0.3s) - should be ignored
ScenarioStep(time: 0.5, key: nil, modifiers: [.option, .command, .shift], expectedOutput: nil, expectedIsMatched: true),
]
)
}
// Tests that changing modifiers on same key cancels within 1s
@Test
func keyModifier_changingModifiers_cancelsWithin1s() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// Initial hotkey press
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Add Shift modifier while keeping same key, within 1s
ScenarioStep(time: 0.5, key: .a, modifiers: [.command, .shift], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// Tests that dirty state blocks all input until full release
@Test
func dirtyState_blocksInputUntilFullRelease() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// Start recording
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Press extra modifier - discards silently since < minimumKeyTime and goes dirty
ScenarioStep(time: 0.1, key: nil, modifiers: [.option, .command], expectedOutput: .discard, expectedIsMatched: false),
// Try pressing hotkey again - should be ignored (dirty)
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
// Try pressing different keys - should be ignored (dirty)
ScenarioStep(time: 0.3, key: .c, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
// Full release - clears dirty
ScenarioStep(time: 0.4, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Now hotkey works again
ScenarioStep(time: 0.5, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
// Tests that you can't activate by releasing extra modifiers (backslide)
@Test
func multipleModifiers_noBackslideActivation() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option, .command]),
steps: [
// Press with extra modifier (doesn't match)
ScenarioStep(time: 0.0, key: nil, modifiers: [.option, .command, .shift], expectedOutput: nil, expectedIsMatched: false),
// Release Shift - now matches hotkey exactly, but should NOT activate (backslide)
ScenarioStep(time: 0.1, key: nil, modifiers: [.option, .command], expectedOutput: nil, expectedIsMatched: false),
// Full release
ScenarioStep(time: 0.2, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// NOW pressing hotkey should work
ScenarioStep(time: 0.3, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
}
struct ScenarioStep {
/// The time offset (in seconds) relative to the scenario start.
let time: TimeInterval
/// Which key (if any) is pressed in this chord
let key: Key?
/// Which modifiers are held in this chord
let modifiers: Modifiers
/// The expected output from `processor.process(...)` at this step,
/// or `nil` if we expect no output.
let expectedOutput: HotKeyProcessor.Output?
/// Whether we expect `processor.isMatched` after this step, or `nil` if we don't care.
let expectedIsMatched: Bool?
/// If we want to check the processor's exact `state`.
/// This is optional; if `nil` we won't check it.
let expectedState: HotKeyProcessor.State?
init(
time: TimeInterval,
key: Key? = nil,
modifiers: Modifiers = [],
expectedOutput: HotKeyProcessor.Output? = nil,
expectedIsMatched: Bool? = nil,
expectedState: HotKeyProcessor.State? = nil
) {
self.time = time
self.key = key
self.modifiers = modifiers
self.expectedOutput = expectedOutput
self.expectedIsMatched = expectedIsMatched
self.expectedState = expectedState
}
}
func runScenario(
hotkey: HotKey,
useDoubleTapOnly: Bool = false,
doubleTapLockEnabled: Bool = true,
steps: [ScenarioStep]
) {
// Sort steps by time, just in case they're not in ascending order
let sortedSteps = steps.sorted { $0.time < $1.time }
// We'll keep track of the "current time" as we simulate
var currentTime: TimeInterval = 0
// Create the processor with an initial date
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: currentTime)
} operation: {
HotKeyProcessor(
hotkey: hotkey,
useDoubleTapOnly: useDoubleTapOnly,
doubleTapLockEnabled: doubleTapLockEnabled
)
}
// We'll step through each event
for step in sortedSteps {
// let delta = step.time - currentTime
currentTime = step.time
// Sleep or jump time
withDependencies {
$0.date.now = Date(timeIntervalSince1970: currentTime)
} operation: {
// Build a KeyEvent from step's chord
let keyEvent = KeyEvent(key: step.key, modifiers: step.modifiers)
// Process
let actualOutput = processor.process(keyEvent: keyEvent)
// If step.expectedOutput != nil, #expect that it matches actualOutput
if let expected = step.expectedOutput {
#expect(
actualOutput == expected,
"\(step.time)s: expected output \(expected), got \(String(describing: actualOutput))"
)
} else {
// We expect no output
#expect(
actualOutput == nil,
"\(step.time)s: expected no output, got \(String(describing: actualOutput))"
)
}
// If step.expectedIsMatched != nil, #expect that it matches processor.isMatched
if let expMatch = step.expectedIsMatched {
#expect(
processor.isMatched == expMatch,
"\(step.time)s: expected isMatched=\(expMatch), got \(processor.isMatched)"
)
}
// If we want to test the entire state:
if let expState = step.expectedState {
#expect(
processor.state == expState,
"\(step.time)s: expected state=\(expState), got \(processor.state)"
)
}
}
}
}
// MARK: - Recording Decision Tests
struct RecordingDecisionTests {
private func makeContext(
hotkey: HotKey,
minimumKeyTime: TimeInterval = 0.2,
duration: TimeInterval?
) -> RecordingDecisionEngine.Context {
let now = Date(timeIntervalSinceReferenceDate: 0)
let start = duration.map { now.addingTimeInterval(-$0) }
return RecordingDecisionEngine.Context(
hotkey: hotkey,
minimumKeyTime: minimumKeyTime,
recordingStartTime: start,
currentTime: now
)
}
@Test
func modifierOnlyShortPressIsDiscarded() {
let ctx = makeContext(hotkey: HotKey(key: nil, modifiers: [.command]), duration: 0.1)
#expect(RecordingDecisionEngine.decide(ctx) == .discardShortRecording)
}
@Test
func printableKeyShortPressStillProceeds() {
let ctx = makeContext(hotkey: HotKey(key: .quote, modifiers: [.command]), duration: 0.1)
#expect(RecordingDecisionEngine.decide(ctx) == .proceedToTranscription)
}
@Test
func longPressModifierOnlyProceeds() {
// Duration at modifierOnlyMinimumDuration threshold (0.3s)
let ctx = makeContext(hotkey: HotKey(key: nil, modifiers: [.option]), duration: 0.3)
#expect(RecordingDecisionEngine.decide(ctx) == .proceedToTranscription)
}
@Test
func missingStartTimeDefaultsToShort() {
let ctx = RecordingDecisionEngine.Context(
hotkey: HotKey(key: nil, modifiers: [.option]),
minimumKeyTime: 0.2,
recordingStartTime: nil,
currentTime: Date(timeIntervalSinceReferenceDate: 0)
)
#expect(RecordingDecisionEngine.decide(ctx) == .discardShortRecording)
}
// MARK: - Modifier-Only Minimum Duration Tests
@Test
func modifierOnly_enforcesMinimumDuration_0_3s() {
// User sets minimumKeyTime to 0.1s, but modifier-only enforces modifierOnlyMinimumDuration (0.3s)
let ctx = makeContext(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.1, duration: 0.25)
#expect(RecordingDecisionEngine.decide(ctx) == .discardShortRecording)
}
@Test
func modifierOnly_proceedsWhenAboveMinimumDuration() {
// User sets minimumKeyTime to 0.1s, recording is 0.35s (above modifierOnlyMinimumDuration)
let ctx = makeContext(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.1, duration: 0.35)
#expect(RecordingDecisionEngine.decide(ctx) == .proceedToTranscription)
}
@Test
func modifierOnly_respectsUserPreferenceWhenHigher() {
// User sets minimumKeyTime to 0.5s (higher than modifierOnlyMinimumDuration)
let ctx = makeContext(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.5, duration: 0.4)
#expect(RecordingDecisionEngine.decide(ctx) == .discardShortRecording)
}
@Test
func printableKey_doesNotEnforceModifierOnlyMinimum() {
// Printable key hotkeys use user's minimumKeyTime, not modifierOnlyMinimumDuration
let ctx = makeContext(hotkey: HotKey(key: .a, modifiers: [.command]), minimumKeyTime: 0.1, duration: 0.15)
#expect(RecordingDecisionEngine.decide(ctx) == .proceedToTranscription)
}
}
// MARK: - Mouse Click Tests
struct MouseClickTests {
@Test
func mouseClick_discardsQuickModifierOnlyRecording() throws {
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
HotKeyProcessor(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.15)
}
// Start recording with modifier-only hotkey
let startOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: [.option]))
}
#expect(startOutput == .startRecording)
// Mouse click 0.25s later (< 0.3s threshold for modifier-only) should discard silently
let clickOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.25)
} operation: {
processor.processMouseClick()
}
#expect(clickOutput == .discard)
}
@Test
func mouseClick_ignoredAfterThreshold() throws {
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
HotKeyProcessor(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.15)
}
// Start recording with modifier-only hotkey
let startOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: [.option]))
}
#expect(startOutput == .startRecording)
// Mouse click 0.35s later (> 0.3s threshold) should be ignored - only ESC cancels
let clickOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.35)
} operation: {
processor.processMouseClick()
}
#expect(clickOutput == nil)
}
@Test
func mouseClick_ignoredInDoubleTapLock() throws {
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
HotKeyProcessor(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.15)
}
// First tap
_ = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: [.option]))
}
_ = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.2)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: []))
}
// Second tap within threshold - should lock
_ = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.4)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: [.option]))
}
_ = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.5)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: []))
}
// Should be in double-tap lock now
#expect(processor.state == .doubleTapLock)
// Mouse click should be ignored - only ESC cancels locked recordings
let clickOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.6)
} operation: {
processor.processMouseClick()
}
#expect(clickOutput == nil)
}
@Test
func mouseClick_ignoresKeyPlusModifierHotkey() throws {
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
HotKeyProcessor(hotkey: HotKey(key: .a, modifiers: [.command]), minimumKeyTime: 0.15)
}
// Start recording with key+modifier hotkey
let startOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
processor.process(keyEvent: KeyEvent(key: .a, modifiers: [.command]))
}
#expect(startOutput == .startRecording)
// Mouse click should be ignored for key+modifier hotkeys
let clickOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.1)
} operation: {
processor.processMouseClick()
}
#expect(clickOutput == nil)
}
@Test
func mouseClick_respectsHigherUserPreference() throws {
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
HotKeyProcessor(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.5)
}
// Start recording with modifier-only hotkey
let startOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: [.option]))
}
#expect(startOutput == .startRecording)
// Mouse click 0.4s later (> 0.3s but < 0.5s user preference) should still discard
let clickOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.4)
} operation: {
processor.processMouseClick()
}
#expect(clickOutput == .discard)
}
}
@@ -0,0 +1,49 @@
import Testing
@testable import HexCore
struct TranscriptFormattingTests {
@Test
func lowercasesTranscript() {
let result = TranscriptFormattingApplier.apply(
"Hello WORLD!",
lowercase: true,
removePunctuation: false
)
#expect(result == "hello world!")
}
@Test
func removesUnicodePunctuation() {
let result = TranscriptFormattingApplier.apply(
"Hello, world! It’s well-known — really.",
lowercase: false,
removePunctuation: true
)
#expect(result == "Hello world Its wellknown really")
}
@Test
func appliesBothOptionsIndependently() {
let result = TranscriptFormattingApplier.apply(
"Hello, WORLD!",
lowercase: true,
removePunctuation: true
)
#expect(result == "hello world")
}
@Test
func leavesTranscriptUnchangedWhenDisabled() {
let input = "Hello, WORLD!"
let result = TranscriptFormattingApplier.apply(
input,
lowercase: false,
removePunctuation: false
)
#expect(result == input)
}
}
@@ -0,0 +1,105 @@
import Testing
@testable import HexCore
struct WordRemappingTests {
@Test
func basicRemapping() {
let remappings = [
WordRemapping(match: "comma", replacement: ",")
]
let result = WordRemappingApplier.apply("Hello comma world", remappings: remappings)
#expect(result == "Hello , world")
}
@Test
func newlineEscapeSequence() {
let remappings = [
WordRemapping(match: "new line", replacement: "\\n")
]
let result = WordRemappingApplier.apply("Hello new line world", remappings: remappings)
#expect(result == "Hello \n world")
}
@Test
func newParagraphEscapeSequence() {
let remappings = [
WordRemapping(match: "new paragraph", replacement: "\\n\\n")
]
let result = WordRemappingApplier.apply("Hello new paragraph world", remappings: remappings)
#expect(result == "Hello \n\n world")
}
@Test
func tabEscapeSequence() {
let remappings = [
WordRemapping(match: "tab", replacement: "\\t")
]
let result = WordRemappingApplier.apply("Hello tab world", remappings: remappings)
#expect(result == "Hello \t world")
}
@Test
func escapedBackslash() {
let remappings = [
WordRemapping(match: "backslash", replacement: "\\\\")
]
let result = WordRemappingApplier.apply("Hello backslash world", remappings: remappings)
#expect(result == "Hello \\ world")
}
@Test
func literalBackslashN() {
let remappings = [
WordRemapping(match: "code", replacement: "\\\\n")
]
let result = WordRemappingApplier.apply("Hello code world", remappings: remappings)
#expect(result == "Hello \\n world")
}
@Test
func literalDollarSign() {
let remappings = [
WordRemapping(match: "price", replacement: "$1")
]
let result = WordRemappingApplier.apply("It costs price today", remappings: remappings)
#expect(result == "It costs $1 today")
}
@Test
func caseInsensitive() {
let remappings = [
WordRemapping(match: "COMMA", replacement: ",")
]
let result = WordRemappingApplier.apply("Hello comma world", remappings: remappings)
#expect(result == "Hello , world")
}
@Test
func doesNotRemapInsideWords() {
let remappings = [
WordRemapping(match: "new", replacement: "\\n")
]
let result = WordRemappingApplier.apply("renewable energy", remappings: remappings)
#expect(result == "renewable energy")
}
@Test
func disabledRemappingIgnored() {
let remappings = [
WordRemapping(isEnabled: false, match: "comma", replacement: ",")
]
let result = WordRemappingApplier.apply("Hello comma world", remappings: remappings)
#expect(result == "Hello comma world")
}
@Test
func multipleRemappings() {
let remappings = [
WordRemapping(match: "comma", replacement: ","),
WordRemapping(match: "period", replacement: "."),
WordRemapping(match: "new line", replacement: "\\n")
]
let result = WordRemappingApplier.apply("Hello comma new line world period", remappings: remappings)
#expect(result == "Hello , \n world .")
}
}
@@ -0,0 +1,38 @@
import Testing
@testable import HexCore
struct WordRemovalTests {
@Test
func removesFillerWordsAndRepeats() {
let removals = [
WordRemoval(pattern: "uh+"),
WordRemoval(pattern: "um+"),
WordRemoval(pattern: "er+"),
WordRemoval(pattern: "hm+")
]
let result = WordRemovalApplier.apply("Umm uhhh er hmm", removals: removals)
#expect(result.isEmpty)
}
@Test
func cleansSpacesAndPunctuation() {
let removals = [
WordRemoval(pattern: "uh+"),
WordRemoval(pattern: "um+")
]
let result = WordRemovalApplier.apply("Well, um, that's uh fine", removals: removals)
#expect(result == "Well, that's fine")
}
@Test
func doesNotRemoveInsideWords() {
let result = WordRemovalApplier.apply("thumb", removals: [WordRemoval(pattern: "um+")])
#expect(result == "thumb")
}
@Test
func removesLeadingPunctuation() {
let result = WordRemovalApplier.apply("um, hello", removals: [WordRemoval(pattern: "um+")])
#expect(result == "hello")
}
}