diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 302d8eb9..d6d086cb 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 000EBFCBA8CE49537690613B /* SymSpellCorrectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C850141146422A132B2B3516 /* SymSpellCorrectorTests.swift */; }; + 0160F9D9929465E6B6A3385F /* OnboardingTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B7EB84781C0ED57844585E /* OnboardingTemplateTests.swift */; }; 0187EAA1D37B92DD5B264016 /* PermissionDragSourceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D00A031C0D9CF2A7A2330D9 /* PermissionDragSourceView.swift */; }; 021197C88AF19DEA715C849B /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */; }; 029B10EE4D659BAAF2C48FBE /* EmojiPopularity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27B962C66727776D00069DE /* EmojiPopularity.swift */; }; @@ -29,6 +30,7 @@ 087EAFD68591401E870EFEC3 /* OnboardingTemplateFeatureList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F613F0E2F7046E6532A09C /* OnboardingTemplateFeatureList.swift */; }; 08A161D7775D09D116E48F6D /* SuggestionEngineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBE3E6CC585C1683787C877 /* SuggestionEngineModels.swift */; }; 08A76C831813F9D5CEC578E0 /* frequency_dictionary_en_82_765.txt in Resources */ = {isa = PBXBuildFile; fileRef = 99FBB636008490B66CF26772 /* frequency_dictionary_en_82_765.txt */; }; + 0933A62F9B9AECEEA95BBA29 /* SuggestionCoordinatorInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E25C90F99356EB5E249225 /* SuggestionCoordinatorInputTests.swift */; }; 094DE1CA4A821D722F173F9D /* SuggestionDebugLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003594B09C83EF2DF35577D5 /* SuggestionDebugLogger.swift */; }; 09FD133AFEFD8C08E7A9969D /* SymSpell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3B1232C4BE8072A5183F9C /* SymSpell.swift */; }; 0A08A88B773845EF9A27DF0F /* SymSpellCorrector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E1E72C09460390583D98D1 /* SymSpellCorrector.swift */; }; @@ -68,6 +70,7 @@ 175C4FA56C29DEE58C2D4D7E /* SuggestionSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */; }; 1899BC5A35DC96B4D04B18A5 /* es.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0B6816DF5D33863F966240B4 /* es.txt */; }; 19386985A3A91D0843092086 /* AboutPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */; }; + 19CA1BF8B508E0E219EF4485 /* SuggestionEngineModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470A7DAE3D6A2C873B395AE3 /* SuggestionEngineModelsTests.swift */; }; 19CB55B62977376E9AE8D428 /* VisualContextStartCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F01FAC4F57EB08471521196 /* VisualContextStartCoalescer.swift */; }; 1A4A2FCA8F08258A8A70A057 /* LGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */; }; 1AEA5D46AFB9095716406788 /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352AF5B2834FEE1F597394E4 /* ApplicationBundleMetadata.swift */; }; @@ -76,6 +79,7 @@ 1BDEC75125ADFCD67F3C406D /* SpellingLanguageResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0348A7053E5683C68879A71A /* SpellingLanguageResolver.swift */; }; 1C164EC9452EFB38173D227E /* TokenCountEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA30E71C21C77BB6EA4C166 /* TokenCountEstimator.swift */; }; 1C267B67EA61527B74C9D051 /* KeyCodeLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAAE6B395FAB604DF059280A /* KeyCodeLabels.swift */; }; + 1C46642846D8FD1475AA5CCF /* RequestIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1909DF39C47A113382BB53B6 /* RequestIDTests.swift */; }; 1D1C6FF0B8F50AC14A1000F4 /* SentenceBoundaryClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7360A6D4261989A66658ED /* SentenceBoundaryClassifierTests.swift */; }; 1D424A103EF7BFE45518F45E /* GhostFontMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E41890930AA80910E461EF /* GhostFontMetricsTests.swift */; }; 1D5389E9562AF6315BFCDCE1 /* NOTICE.md in Resources */ = {isa = PBXBuildFile; fileRef = 66CF2A70D4699421AC9BD849 /* NOTICE.md */; }; @@ -147,6 +151,7 @@ 344B9BF352C97CFA830853D6 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */; }; 35F6F62A299713660CFB4797 /* SettingsPaneScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */; }; 36312821AEE03E3E62845958 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */; }; + 36651BFF4917A1E80C667B64 /* SuggestionCoordinatorPredictionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 361D34F219C46FF21AC09B62 /* SuggestionCoordinatorPredictionTests.swift */; }; 3682DBB9DCF6C011F382A1B0 /* SuggestionWorkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */; }; 37625DC44E2228CC897222B7 /* ru-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6CF1FBAABEF545B620AF8D78 /* ru-100k.txt */; }; 378EE9C111040353A6335454 /* CurrentWordSpellChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733BF6287BDE599B02A12271 /* CurrentWordSpellChecker.swift */; }; @@ -182,12 +187,15 @@ 41DD807E251E1DC653540EFD /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033A468451259A3214EECBE5 /* InlinePreviewView.swift */; }; 429CE592897D8A952F2916C3 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BD71ECC2AE4821B643E0935 /* ConfidenceSuppressionPolicy.swift */; }; 42D40F37086294D0E58200C5 /* GhostFontSizeStabilizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9458F0820B3161FE9CF1DDAF /* GhostFontSizeStabilizer.swift */; }; + 43DED8ABEFF9894ED54097A9 /* DeviceInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49F67B3EEB2F2A577A54085 /* DeviceInfoTests.swift */; }; + 449218D0646AB3745B7E4F30 /* SuggestionEngineRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDA36955CCCFA87C1F67268 /* SuggestionEngineRouterTests.swift */; }; 4531645066A73971EB2A5FA1 /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC3BF78835C8F2C315932F1 /* EmojiCatalog.swift */; }; 45CE438CC67179356224AFD9 /* FocusTrackingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D42CD456B4B3C988B148A6 /* FocusTrackingModel.swift */; }; 467E2EF8E7B1EC83F60F6A35 /* EmojiRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E72A3972E15749337539C2D /* EmojiRecents.swift */; }; 469626C2EFEDFD6C188941F5 /* de.txt in Resources */ = {isa = PBXBuildFile; fileRef = C648EBB10D7F8E0B904DEC91 /* de.txt */; }; 46F341472191BC451B6BF6B5 /* SuggestionRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */; }; 47364583344BEA8FDC7178D8 /* DownloadFileRescuer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B4A2E2DD6733658EC05BD8 /* DownloadFileRescuer.swift */; }; + 475FB7450EEC3C1B16E66CC4 /* LLMIOFileHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9030FAAB468119A0236284A6 /* LLMIOFileHandlerTests.swift */; }; 47654BDCFD2DE6D4DE85D7FE /* LanguageTagsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */; }; 4767E0C7B4997069EA7ADBD7 /* GhostFontSizeStabilizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9458F0820B3161FE9CF1DDAF /* GhostFontSizeStabilizer.swift */; }; 47EBA122ABE99932326D9E4A /* CompletionRenderMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A03E565A11581FD2150B142 /* CompletionRenderMode.swift */; }; @@ -230,6 +238,7 @@ 54E515A0E75B3902E6497A71 /* EmojiPopularity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27B962C66727776D00069DE /* EmojiPopularity.swift */; }; 5560B54FBBEC80F13BCD2054 /* EmojiPickerPanelLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */; }; 55D4E6FB63E3475749E61EB3 /* CustomRulesCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */; }; + 55E841977534CBFD8B80E95F /* AXTreeDumpWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44745635AF702C96B4225A2 /* AXTreeDumpWriterTests.swift */; }; 55EDBFF489D4C31276E2A67F /* PermissionHostApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6ACCB12E4DB32D2F2BEA567 /* PermissionHostApp.swift */; }; 5614E22EAA5F5C37A9E4F7B6 /* LlamaRuntimeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52D0B550E00EF173A5D157E /* LlamaRuntimeManager.swift */; }; 56611BA0087710277140E9E6 /* DisplayCoordinateConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C5DE0F3FF63545000E2453 /* DisplayCoordinateConverterTests.swift */; }; @@ -238,6 +247,7 @@ 58AC3193D846FDE88513377D /* BundledRuntimeLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18D990E515E1AE4F312F4E95 /* BundledRuntimeLocatorTests.swift */; }; 59CFE4B314842EDD6EDAC5C9 /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033A468451259A3214EECBE5 /* InlinePreviewView.swift */; }; 5A441797D71A880A7482077D /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC24FD54860CE6737E65EF65 /* TextDirectionDetectorTests.swift */; }; + 5A6C6B59C3FC821813A7C3E9 /* SuggestionCoordinatorLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C2F4A55D7EC8C29D47B45C4 /* SuggestionCoordinatorLifecycleTests.swift */; }; 5ADAA8600CE1A5ED570F889E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81DD30EB657368AACE9625A /* InputMonitor.swift */; }; 5B404450B412A6102F514250 /* SuggestionCoordinatorAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C375227649689775275AA4B3 /* SuggestionCoordinatorAcceptanceTests.swift */; }; 5BA36B2042EB7D9ECFE3B92F /* SuggestionSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960F3FDBF283347594F30494 /* SuggestionSettingsStore.swift */; }; @@ -271,6 +281,7 @@ 64DA031AEAC20AC6C852A24A /* OnboardingTemplateFeatureList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F613F0E2F7046E6532A09C /* OnboardingTemplateFeatureList.swift */; }; 6503E1585D0CDE8CD852144B /* MacroTriggerStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C201A65A6B040F90C528A3B /* MacroTriggerStateMachine.swift */; }; 65478B0DABF5460C32D4C458 /* ModelFileValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A829F28F01FAE76CA7244BBC /* ModelFileValidatorTests.swift */; }; + 663D37E35292F38666D807A7 /* HuggingFaceModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AF9479EF020071CA64CCC1 /* HuggingFaceModelsTests.swift */; }; 664A5D62A723EB204ADEF2F9 /* DeepGeometryWalkThrottleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F20A19A24D20E16D25ADCDA /* DeepGeometryWalkThrottleTests.swift */; }; 66C23A7C2FCDE0266FF425F8 /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352AF5B2834FEE1F597394E4 /* ApplicationBundleMetadata.swift */; }; 66D0D9F605AF462F569A5CFD /* SpellingLanguageResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0871985CB1F877EC422E18C /* SpellingLanguageResolverTests.swift */; }; @@ -281,6 +292,7 @@ 6A8454A989104AE150308BCF /* it-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2D8AA55C2B730110E8598F91 /* it-100k.txt */; }; 6AE0B46FB52D189D94E1F79A /* WordCountFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0513E3B23937B099A3CFF2 /* WordCountFormatterTests.swift */; }; 6BE0C8F9D054A2C0D9018001 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BD71ECC2AE4821B643E0935 /* ConfidenceSuppressionPolicy.swift */; }; + 6CBEF02FCDFCF406E378C27C /* SuggestionInteractionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB1D4F2681FAF59014AE115 /* SuggestionInteractionStateTests.swift */; }; 6D0E79CF3C1A8CE53046FCE5 /* AXTextGeometryResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C046CB4F3CB4BFE9391DB5DE /* AXTextGeometryResolverTests.swift */; }; 6D57E3CDF56127422311C065 /* TerminalAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4C4A7EAF886E0CC945BFEF /* TerminalAppDetector.swift */; }; 6DD1E22151571E1A22FF22F4 /* FoundationModelSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5664E34B23FBDF69292FEF43 /* FoundationModelSuggestionEngine.swift */; }; @@ -294,9 +306,11 @@ 74422BB837D6A319D12BF981 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 746BC62993602F78147FF8B0 /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */; }; + 74E0082BA8D7E80C2E038EAA /* EmojiPickerModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7F3EB7B874C836BF75F15D /* EmojiPickerModelsTests.swift */; }; 753DC144B9394A35A3F395DA /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */; }; 76DFC829F2417FB048463285 /* GhostTextPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */; }; 76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */; }; + 773808D3D88440F0836D0072 /* FileLogHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE98A6C28731BF5C8D434543 /* FileLogHandlerTests.swift */; }; 77B57484667F71F7EFA380D1 /* fr-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6DB982BF30B3601F57277776 /* fr-100k.txt */; }; 783BEC91DBC86AF75CEDB269 /* MenuBarPresentationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */; }; 78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */; }; @@ -305,9 +319,12 @@ 799691376DD3EE52E5F0F7FB /* FocusCapabilityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */; }; 79AA66B111C059B342338443 /* BrowserAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = B997EC69E1C65B1E18234221 /* BrowserAppDetector.swift */; }; 79B0AEA0D2FC6A865E9303F9 /* DecodeStopPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B09064903B760D6DF2DF7D /* DecodeStopPolicyTests.swift */; }; + 7A5DBBC32ABEF9E7ED147577 /* HardwareCapabilityProbeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23CFCE3EB3F41DAC0202E9D0 /* HardwareCapabilityProbeTests.swift */; }; + 7AEF46950EF5E2EBCFE4BBD3 /* SuggestionTextColorCodecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C309CD6A454C415D8BEEC7 /* SuggestionTextColorCodecTests.swift */; }; 7B33F811711B26161212EAEA /* PermissionGuidanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F2C764D29C8D50D0C854FF8 /* PermissionGuidanceController.swift */; }; 7B6A63F5DCC2C163CDFD2A5C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; }; 7C36DBA762E19C8C31676D44 /* MidWordContinuationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1274F897631B1B3A835D157F /* MidWordContinuationPolicyTests.swift */; }; + 7C6D42EAD04C8144538B132A /* SuggestionSettingsModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BB95A341A8B5F4A1725640 /* SuggestionSettingsModelTests.swift */; }; 7C72A2D76E8BA38ADD523CF6 /* CaretGeometrySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */; }; 7C94725B4837DEC9ECF1BC54 /* CompletionRenderMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A03E565A11581FD2150B142 /* CompletionRenderMode.swift */; }; 7D6BB9AF72F7076A4E5EE96F /* DownloadableModelCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB5C2AE9A7E55495D26AD074 /* DownloadableModelCatalogView.swift */; }; @@ -324,11 +341,13 @@ 82D4ADEAF05337ABDE4C586C /* RuntimeBootstrapModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60629DFE309C1A4BD8A7FB3B /* RuntimeBootstrapModel.swift */; }; 82DAFCA8CC3AE3E2FBFDBD76 /* RequestID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC693E00430F46E41CB56E6 /* RequestID.swift */; }; 831B8DF92AAC8D0B99C5A262 /* AcceptanceModePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */; }; + 8369356B8E7E7E61787E828D /* AXHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B53214C3842F78B202D498 /* AXHelperTests.swift */; }; 83B25F54B74EE35787D5DBD4 /* de-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4B8665A5495891F9E3DDA48B /* de-100k.txt */; }; 83EC3543DC45B1601F119BF9 /* InsertionSafetyGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D627C4A55359EAF4676FF7 /* InsertionSafetyGateTests.swift */; }; 8429B116328C392DCA018D95 /* MacroEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8083D44ABCDCFA68A4CD497 /* MacroEngineTests.swift */; }; 8441299082E6B68F7F88911B /* ShortcutConflictTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0850B07CCDBA67C756C6EC59 /* ShortcutConflictTests.swift */; }; 84A4CA05AF6885AE4FA4C13A /* SettingsAttentionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */; }; + 856082F4732206A3761816DC /* SystemMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38931C165873B50B405CC602 /* SystemMetricsStoreTests.swift */; }; 862146ABDADC022A3BE74E00 /* CurrencyEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0537986794554F5FABE6EFF3 /* CurrencyEvaluator.swift */; }; 865C569A9BC95B08F440D199 /* SystemResourceSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A5D63F390E9B7A7FE343FE /* SystemResourceSampler.swift */; }; 86AC625B4DD14EF807002FA2 /* WebContentFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A35FAA742408D002B75920 /* WebContentFieldDetector.swift */; }; @@ -336,6 +355,7 @@ 8865B95FE84198D70390DF80 /* ClipboardContentDistillerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD89799BB82AF7A92559AEB /* ClipboardContentDistillerTests.swift */; }; 88BCD795A14E1C9308F7BB31 /* SuggestionAvailabilityEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05B0439348261163B37C508 /* SuggestionAvailabilityEvaluatorTests.swift */; }; 89329024F050602EFBC7CC6B /* FocusTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FDF029F7828CAF3FE8850 /* FocusTracker.swift */; }; + 8B26F7B26358438D6EB88C2E /* PerformanceMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0FCC5CCF6AE528E3C4DDA7 /* PerformanceMetricsStoreTests.swift */; }; 8B2DFC860803C0A7C4D34A36 /* ContextBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EF3C7F5D9D6F3FA50FD51C /* ContextBuffer.swift */; }; 8D0F66DD1E6C988368A4545D /* DownloadFileRescuer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B4A2E2DD6733658EC05BD8 /* DownloadFileRescuer.swift */; }; 8DA36F1521B6A59D8C20AC59 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 5A60D1467BBFECB3DFEB39C2 /* Logging */; }; @@ -369,6 +389,7 @@ 97EF76E6B7A1AFB3FA4879D1 /* LGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */; }; 982BA68A53CEE0F45D41F3D3 /* PromptSectionBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */; }; 98E2E14A069384C1088CDB44 /* PromptContextSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B45B91D4DEAC979C3113E /* PromptContextSanitizer.swift */; }; + 9938DE59D9E05BC51A5BA5B8 /* SuggestionDebugLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD60968AEA8A5843F4E24618 /* SuggestionDebugLoggerTests.swift */; }; 9973763A9F4EA4D8B4AE59EB /* SuggestionEngineRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */; }; 9A419D0704C95920CB71D3B1 /* RandomMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3EC87078D3A4C21DB3252C /* RandomMacroEvaluator.swift */; }; 9A82FB431A61719F275623B8 /* PermissionOverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C6EB9FDA48ADF425A116A9 /* PermissionOverlayWindowController.swift */; }; @@ -412,7 +433,9 @@ ACC1D8C50007AA193271F977 /* PerformancePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */; }; AD0FE3F0F75A40B827109589 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */; }; AD361AA6F90A5E5F6F5005BF /* SpellingDictionaryCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D9DF8723AF32C058BFACDE /* SpellingDictionaryCatalog.swift */; }; + AD39F3B11BC4ADE6C6E0A828 /* FocusModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C10BCF95451954641C602E4 /* FocusModelsTests.swift */; }; AD635E15149E7266BC309F34 /* WordCountFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 815F2ABAF6AB75DA3AFBBCEF /* WordCountFormatter.swift */; }; + AD6E005ABE34AB7EBD92A30D /* RuntimeBootstrapModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6D9CCC0AA3674FEE57AE0 /* RuntimeBootstrapModelTests.swift */; }; ADBCB725707ED11B19C7F08D /* InsertionStrategySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D2FEEA4304C86324BAADAB /* InsertionStrategySelector.swift */; }; ADFBCB4099CF919F3EC5BE7B /* SecureFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1827565F4FAD3E4E61CA65C3 /* SecureFieldDetector.swift */; }; AECC7289DA796B071B4FE3C0 /* MenuBarStatusLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD42C7E2852F59BEF7972663 /* MenuBarStatusLabelView.swift */; }; @@ -422,6 +445,7 @@ B00FDD3DEE0B73FF5136C91C /* FocusTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FDF029F7828CAF3FE8850 /* FocusTracker.swift */; }; B02F46E94F74BAEBB90E165A /* PerformanceMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979A7867966180A545BB44C4 /* PerformanceMetricsStore.swift */; }; B0B115C6EBAC37FF6115B4BE /* SuggestionCoordinator+Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */; }; + B2BDCFF0824EE41FC1C0451A /* FocusSnapshotResolverLiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67D57F248880978A09DD28A6 /* FocusSnapshotResolverLiveTests.swift */; }; B2F7589B8D32ACF97BB642AB /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */; }; B335B04A3EB50E51FF9C8C0F /* PerDomainDisableSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25C3087D4A9F4DC52FD5A69 /* PerDomainDisableSettings.swift */; }; B422256933F7BEAEF2FC4176 /* es-100l.txt in Resources */ = {isa = PBXBuildFile; fileRef = 620D393D3B7E687A08FA9446 /* es-100l.txt */; }; @@ -439,6 +463,7 @@ B709B362B786AA6ED548C673 /* TypoCaseTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CE63B8725EBD71A4C024E1 /* TypoCaseTransfer.swift */; }; B782EC08B7516791BDB21172 /* FieldStyleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7FBF2B766E728F25899B64E /* FieldStyleCache.swift */; }; B7A98BC225304E4DFED9E622 /* OnboardingTemplateRecommender.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */; }; + B816C6191738AB616F2E8D2D /* SuggestionCoordinatorTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C174D8294858BF9DF3D361D /* SuggestionCoordinatorTestSupport.swift */; }; B93AB7E845086F6FBB068369 /* SuggestionRequestFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */; }; B9623395B31459D9D45B1320 /* CurrentWordExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247561C626843957CFB4B632 /* CurrentWordExtractor.swift */; }; B9F400BCC20757DA5DB0B5F9 /* FoundationModelSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5664E34B23FBDF69292FEF43 /* FoundationModelSuggestionEngine.swift */; }; @@ -469,6 +494,7 @@ C607A624A0FB697486C56B8E /* PowerSourceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB235F0DEA53295DAF8B4FA0 /* PowerSourceMonitor.swift */; }; C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */; }; C63F95C324C29940FAC6B973 /* de-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4B8665A5495891F9E3DDA48B /* de-100k.txt */; }; + C6A112B51525F988EA46F725 /* SystemResourceSamplerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9255CBCDE66253F521EE0F08 /* SystemResourceSamplerTests.swift */; }; C6A91AD96F52DB72947830C0 /* DownloadableModelCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB5C2AE9A7E55495D26AD074 /* DownloadableModelCatalogView.swift */; }; C71B594433F3B411CAE5DE7E /* FocusCapabilityResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F6D5F94B238F7B4BE7C247 /* FocusCapabilityResolverTests.swift */; }; C7849B86C5F2EE7D0A81AEF7 /* HuggingFaceAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110CB0B53016644EF7840301 /* HuggingFaceAPIClient.swift */; }; @@ -489,6 +515,7 @@ CF4205B85D881B8176590D25 /* FocusSnapshotResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E25414C307A20B6F9F20EC /* FocusSnapshotResolver.swift */; }; D0D4C0E28F5CD99669A49414 /* FoundationModelAvailabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8724ECA8FABBC82B0A2B943B /* FoundationModelAvailabilityService.swift */; }; D1D7D50E5C620042CEA3A77E /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E0AA9F90B83B823132880E6F /* LaunchAtLogin */; }; + D21EBD25BCB37E69B633BC00 /* CotabbyDebugOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93AF1246C1C2E296A1162E63 /* CotabbyDebugOptionsTests.swift */; }; D22C324C4FDB813E54AAD113 /* ClipboardContentDistiller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96495E4147D828C0B1B22765 /* ClipboardContentDistiller.swift */; }; D2B271E0FAE4F65FC2287930 /* SymSpellCorrector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E1E72C09460390583D98D1 /* SymSpellCorrector.swift */; }; D2BB9B47E0E1619021931B49 /* WritingPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */; }; @@ -557,11 +584,14 @@ F08C139B246C1EC7BB435455 /* MenuBarPresentationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */; }; F28FB178EC507C3D42A6F893 /* SuggestionInteractionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA942A53B7C09D1F4EC57239 /* SuggestionInteractionState.swift */; }; F31B343F9C935A5421A526DE /* AXTreeDumpWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27492B04B627DA53BDAD938 /* AXTreeDumpWriter.swift */; }; + F41AB06FD117487D7136E896 /* FocusTrackingModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DA0B2D4FE343E321A95C22 /* FocusTrackingModelTests.swift */; }; F496D63FD0A163D222D8C76F /* EmojiPickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A3B81B92E0743C6152ED8DD /* EmojiPickerController.swift */; }; F4A01E4F12F0183449BCCBB9 /* BaseCompletionPromptRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9966D09D8D80F54BF2734E00 /* BaseCompletionPromptRendererTests.swift */; }; + F4BE7822C56F10CAC623B0C2 /* FoundationModelAvailabilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00D226C0B54B3B375EC2682D /* FoundationModelAvailabilityServiceTests.swift */; }; F4CFF323CDA02530ED0EBE57 /* MPL-2.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 4E283DF8948B10268B46811F /* MPL-2.0.txt */; }; F4EEE6291095B0BF2D3FBA21 /* GhostTextColorPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E44E393DD58B978B1EAB6CF /* GhostTextColorPreset.swift */; }; F596D7438459A7A4246A39CE /* GhostTextPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */; }; + F66F0D982EBAF5A3E99C5342 /* KeyCodeLabelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2930EC34057319130393696B /* KeyCodeLabelsTests.swift */; }; F71FD79FAC8B59C1CBD9E2E0 /* SettingsIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A2FFC2055C52FB837DEEB8F /* SettingsIndexTests.swift */; }; F7237FDB0665465F1C7EDCDE /* CustomRulesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F7F7355967725162DF2D1B /* CustomRulesEditor.swift */; }; F77DB394E9D6C6C482131BF9 /* VisualContextModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE97A8169438D593C6C23412 /* VisualContextModels.swift */; }; @@ -580,6 +610,7 @@ FDBD858EDABA08FBBE0C7ED3 /* InlineCommandCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D77C99769239E4B33D6B2C9 /* InlineCommandCoordinator.swift */; }; FE3AE9F51E1F616D843E7BFA /* EmojiUsageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE35C7770405ED368AA02448 /* EmojiUsageStore.swift */; }; FE4191A7C84E547DCD4F8B44 /* OnboardingTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */; }; + FE424DD09040640CC2400FBE /* EmojiCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 700275BECBBDA98354ABBDF9 /* EmojiCatalogTests.swift */; }; FEC24B9C23274B9FA1F0072E /* PromptContextSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B45B91D4DEAC979C3113E /* PromptContextSanitizer.swift */; }; FEF2CF888D8709D1FB0D2B20 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 6F27073D2818C0218C3F4370 /* Logging */; }; FF3F7B74B561EF0807D28FD8 /* SystemMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807148A920E003DEF8BA6092 /* SystemMetricsStore.swift */; }; @@ -598,6 +629,8 @@ /* Begin PBXFileReference section */ 003594B09C83EF2DF35577D5 /* SuggestionDebugLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionDebugLogger.swift; sourceTree = ""; }; 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPresentationObserver.swift; sourceTree = ""; }; + 00BB95A341A8B5F4A1725640 /* SuggestionSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelTests.swift; sourceTree = ""; }; + 00D226C0B54B3B375EC2682D /* FoundationModelAvailabilityServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityServiceTests.swift; sourceTree = ""; }; 01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateRecommenderTests.swift; sourceTree = ""; }; 01F583E92B0A78212B330E6E /* InputSuppressionControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSuppressionControllerTests.swift; sourceTree = ""; }; 023144451BB30F981D1F9EE6 /* EmojiPopularityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPopularityTests.swift; sourceTree = ""; }; @@ -619,6 +652,7 @@ 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInserter.swift; sourceTree = ""; }; 0AC3BF78835C8F2C315932F1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; 0B6816DF5D33863F966240B4 /* es.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = es.txt; sourceTree = ""; }; + 0C10BCF95451954641C602E4 /* FocusModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusModelsTests.swift; sourceTree = ""; }; 0C383AE85B971A9605787358 /* FocusModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusModels.swift; sourceTree = ""; }; 0CA88BB29BC8727878C99E95 /* LlamaPromptCacheHintTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaPromptCacheHintTrackerTests.swift; sourceTree = ""; }; 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizerTests.swift; sourceTree = ""; }; @@ -630,6 +664,7 @@ 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMatcher.swift; sourceTree = ""; }; 1827565F4FAD3E4E61CA65C3 /* SecureFieldDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureFieldDetector.swift; sourceTree = ""; }; 18D990E515E1AE4F312F4E95 /* BundledRuntimeLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocatorTests.swift; sourceTree = ""; }; + 1909DF39C47A113382BB53B6 /* RequestIDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestIDTests.swift; sourceTree = ""; }; 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGateTests.swift; sourceTree = ""; }; 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPaneScaffold.swift; sourceTree = ""; }; 19DB9558F4D3AFB108D71649 /* SuggestionStateHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStateHelperTests.swift; sourceTree = ""; }; @@ -650,13 +685,16 @@ 22544F4B756E3E4144497D17 /* SuggestionCoordinator+Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Input.swift"; sourceTree = ""; }; 22707E26E2106DF0E826D32D /* ControlTokenMarkersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlTokenMarkersTests.swift; sourceTree = ""; }; 22BE47D1DBF6C23151458836 /* MacroTriggerStateMachineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroTriggerStateMachineTests.swift; sourceTree = ""; }; + 23CFCE3EB3F41DAC0202E9D0 /* HardwareCapabilityProbeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareCapabilityProbeTests.swift; sourceTree = ""; }; 247561C626843957CFB4B632 /* CurrentWordExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWordExtractor.swift; sourceTree = ""; }; 24F613F0E2F7046E6532A09C /* OnboardingTemplateFeatureList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateFeatureList.swift; sourceTree = ""; }; 262BE2F1E97389FE8D7A5FB9 /* Cotabby.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cotabby.app; sourceTree = BUILT_PRODUCTS_DIR; }; 264CA64B2AB1611F82E5B760 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPollBackoffTests.swift; sourceTree = ""; }; 27A5D63F390E9B7A7FE343FE /* SystemResourceSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemResourceSampler.swift; sourceTree = ""; }; + 28B7EB84781C0ED57844585E /* OnboardingTemplateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateTests.swift; sourceTree = ""; }; 292DC9D4D9D5D26AE882E39B /* EmojiCatalogMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalogMatcherTests.swift; sourceTree = ""; }; + 2930EC34057319130393696B /* KeyCodeLabelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeLabelsTests.swift; sourceTree = ""; }; 2960080A726E51198225147A /* InsertionStrategySelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionStrategySelectorTests.swift; sourceTree = ""; }; 29ED42C4BDD0C521101AF95E /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAttentionEvaluator.swift; sourceTree = ""; }; @@ -676,8 +714,12 @@ 352AF5B2834FEE1F597394E4 /* ApplicationBundleMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBundleMetadata.swift; sourceTree = ""; }; 357C18383B047F24A531BDCD /* MidWordContinuationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MidWordContinuationPolicy.swift; sourceTree = ""; }; 3609CC88A5280B3AA40414DF /* SuggestionAvailabilityEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluator.swift; sourceTree = ""; }; + 361D34F219C46FF21AC09B62 /* SuggestionCoordinatorPredictionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorPredictionTests.swift; sourceTree = ""; }; + 37DA0B2D4FE343E321A95C22 /* FocusTrackingModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusTrackingModelTests.swift; sourceTree = ""; }; 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineRouter.swift; sourceTree = ""; }; 386C98FFCF76EC1C8C7E82BB /* SuggestionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionModels.swift; sourceTree = ""; }; + 38931C165873B50B405CC602 /* SystemMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMetricsStoreTests.swift; sourceTree = ""; }; + 3BDA36955CCCFA87C1F67268 /* SuggestionEngineRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineRouterTests.swift; sourceTree = ""; }; 3DE1975F3B5F4A70478DBF41 /* DownloadOutcomeClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifier.swift; sourceTree = ""; }; 41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareCapabilityProbe.swift; sourceTree = ""; }; 421FD16E18622824E038DFB4 /* CaretRunPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretRunPlacementTests.swift; sourceTree = ""; }; @@ -688,10 +730,12 @@ 45A896811745673061AF3612 /* SuggestionFocusFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionFocusFreshnessTests.swift; sourceTree = ""; }; 4638C74239D1DE2DC4D87975 /* MacroController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroController.swift; sourceTree = ""; }; 4696A84D17890B154533A08F /* PromptPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptPolicyTests.swift; sourceTree = ""; }; + 470A7DAE3D6A2C873B395AE3 /* SuggestionEngineModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineModelsTests.swift; sourceTree = ""; }; 474560E524C1D74BAB1570DA /* SecureFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureFieldDetectorTests.swift; sourceTree = ""; }; 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageSupportTests.swift; sourceTree = ""; }; 4B8665A5495891F9E3DDA48B /* de-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "de-100k.txt"; sourceTree = ""; }; 4BC92317837813ACA5051177 /* Cotabby Dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Cotabby Dev.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 4C174D8294858BF9DF3D361D /* SuggestionCoordinatorTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorTestSupport.swift; sourceTree = ""; }; 4E283DF8948B10268B46811F /* MPL-2.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MPL-2.0.txt"; sourceTree = ""; }; 4F283E5B403F9FEBFB4BA04A /* CompositionInputModeClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionInputModeClassifier.swift; sourceTree = ""; }; 51020F8CD58338BD643FBF63 /* ModelDownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelDownloadManager.swift; sourceTree = ""; }; @@ -724,9 +768,11 @@ 62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayout.swift; sourceTree = ""; }; 64442042F5B57CB0A701DA85 /* MacroReferenceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroReferenceSheet.swift; sourceTree = ""; }; 656F58E56FE9BC087B6F1D33 /* PermissionReminderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionReminderView.swift; sourceTree = ""; }; + 66B53214C3842F78B202D498 /* AXHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXHelperTests.swift; sourceTree = ""; }; 66CF2A70D4699421AC9BD849 /* NOTICE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = NOTICE.md; sourceTree = ""; }; 671689F289D45A124639C9C6 /* EmojiRecentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRecentsTests.swift; sourceTree = ""; }; 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TickMarkSlider.swift; sourceTree = ""; }; + 67D57F248880978A09DD28A6 /* FocusSnapshotResolverLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolverLiveTests.swift; sourceTree = ""; }; 684737E62EE6495A71344923 /* DeepGeometryWalkThrottle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepGeometryWalkThrottle.swift; sourceTree = ""; }; 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGate.swift; sourceTree = ""; }; 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWorkController.swift; sourceTree = ""; }; @@ -736,6 +782,7 @@ 6E3B1232C4BE8072A5183F9C /* SymSpell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymSpell.swift; sourceTree = ""; }; 6E3EC87078D3A4C21DB3252C /* RandomMacroEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomMacroEvaluator.swift; sourceTree = ""; }; 6F0EE728C0B1A7AD6B19CD0C /* AGPL-3.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "AGPL-3.0.txt"; sourceTree = ""; }; + 700275BECBBDA98354ABBDF9 /* EmojiCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalogTests.swift; sourceTree = ""; }; 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityResolver.swift; sourceTree = ""; }; 711293EA57808B9428C7B908 /* CotabbyAppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyAppEnvironment.swift; sourceTree = ""; }; 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsPaneView.swift; sourceTree = ""; }; @@ -750,6 +797,7 @@ 78AFA4586C82E92D7FBF381B /* ArithmeticEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluatorTests.swift; sourceTree = ""; }; 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = ""; }; 78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelBrowserView.swift; sourceTree = ""; }; + 7C0FCC5CCF6AE528E3C4DDA7 /* PerformanceMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetricsStoreTests.swift; sourceTree = ""; }; 7C9BB65FA5FC42B89766B037 /* he-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "he-100k.txt"; sourceTree = ""; }; 7D472F9F396672E57873303B /* InsertionSafetyGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGate.swift; sourceTree = ""; }; 7E44E393DD58B978B1EAB6CF /* GhostTextColorPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostTextColorPreset.swift; sourceTree = ""; }; @@ -763,30 +811,37 @@ 85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCompletionPromptRenderer.swift; sourceTree = ""; }; 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModel.swift; sourceTree = ""; }; 8724ECA8FABBC82B0A2B943B /* FoundationModelAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityService.swift; sourceTree = ""; }; + 87C309CD6A454C415D8BEEC7 /* SuggestionTextColorCodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextColorCodecTests.swift; sourceTree = ""; }; 8896D976C7F116EBA0F3969F /* ChromiumAccessibilityEnabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromiumAccessibilityEnabler.swift; sourceTree = ""; }; 89497C35D1825BAE9625EE06 /* ContextPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPaneView.swift; sourceTree = ""; }; 8BF8DC1860CCF0DFA3A3DFD7 /* TextLayoutCaretEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLayoutCaretEstimator.swift; sourceTree = ""; }; + 8CB1D4F2681FAF59014AE115 /* SuggestionInteractionStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInteractionStateTests.swift; sourceTree = ""; }; 8D610FCA3A97249DCCE7D0B8 /* LLMIOFileHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMIOFileHandler.swift; sourceTree = ""; }; 8EA827D6A2A54DF4BAD56405 /* CaretLinePositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretLinePositionTests.swift; sourceTree = ""; }; 8F20A19A24D20E16D25ADCDA /* DeepGeometryWalkThrottleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepGeometryWalkThrottleTests.swift; sourceTree = ""; }; 8F426127917FCB1096134732 /* HuggingFaceSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceSearchService.swift; sourceTree = ""; }; 8F961F5DF2A392F6F5F94F8A /* SuggestionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinator.swift; sourceTree = ""; }; + 9030FAAB468119A0236284A6 /* LLMIOFileHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMIOFileHandlerTests.swift; sourceTree = ""; }; 907549CB913B40C28B953A5D /* SettingsRowLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRowLabel.swift; sourceTree = ""; }; 90B0D133AB77A2503FB08827 /* ClipboardRelevanceFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardRelevanceFilterTests.swift; sourceTree = ""; }; + 9255CBCDE66253F521EE0F08 /* SystemResourceSamplerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemResourceSamplerTests.swift; sourceTree = ""; }; 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFeatureShowcase.swift; sourceTree = ""; }; 92C6EB9FDA48ADF425A116A9 /* PermissionOverlayWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayWindowController.swift; sourceTree = ""; }; + 93AF1246C1C2E296A1162E63 /* CotabbyDebugOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyDebugOptionsTests.swift; sourceTree = ""; }; 944065A858D9BC936CB12B23 /* LlamaRuntimeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaRuntimeCore.swift; sourceTree = ""; }; 9458F0820B3161FE9CF1DDAF /* GhostFontSizeStabilizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostFontSizeStabilizer.swift; sourceTree = ""; }; 960F3FDBF283347594F30494 /* SuggestionSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsStore.swift; sourceTree = ""; }; 96495E4147D828C0B1B22765 /* ClipboardContentDistiller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistiller.swift; sourceTree = ""; }; 974A8708D2006767BD76862A /* MacroEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroEngine.swift; sourceTree = ""; }; 979A7867966180A545BB44C4 /* PerformanceMetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetricsStore.swift; sourceTree = ""; }; + 98E25C90F99356EB5E249225 /* SuggestionCoordinatorInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorInputTests.swift; sourceTree = ""; }; 9966D09D8D80F54BF2734E00 /* BaseCompletionPromptRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCompletionPromptRendererTests.swift; sourceTree = ""; }; 99FBB636008490B66CF26772 /* frequency_dictionary_en_82_765.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = frequency_dictionary_en_82_765.txt; sourceTree = ""; }; 9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageTagsEditor.swift; sourceTree = ""; }; 9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizerTests.swift; sourceTree = ""; }; 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotContextGenerator.swift; sourceTree = ""; }; + 9C2F4A55D7EC8C29D47B45C4 /* SuggestionCoordinatorLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorLifecycleTests.swift; sourceTree = ""; }; 9C8F07AC52C7A482F5FE34C5 /* SuggestionSessionReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionReconcilerTests.swift; sourceTree = ""; }; 9CC2D6472ACD377FD73A5801 /* ControlTokenMarkers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlTokenMarkers.swift; sourceTree = ""; }; 9CF4FB0EC6C1BEB4EA74910A /* ClipboardContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContextProvider.swift; sourceTree = ""; }; @@ -816,9 +871,11 @@ AD8025E4A296845FC53E660D /* BrowserDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDomain.swift; sourceTree = ""; }; AD9573F3504CAE6891DF9B7D /* AppUpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateManager.swift; sourceTree = ""; }; ADBE3E6CC585C1683787C877 /* SuggestionEngineModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineModels.swift; sourceTree = ""; }; + AE98A6C28731BF5C8D434543 /* FileLogHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogHandlerTests.swift; sourceTree = ""; }; AF1E065C7FFB697FCEB2FA5C /* CotabbyTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyTestFixtures.swift; sourceTree = ""; }; AFBE491B3CA04FE9069B7B0F /* AppearancePaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePaneView.swift; sourceTree = ""; }; AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSectionBudget.swift; sourceTree = ""; }; + B1E6D9CCC0AA3674FEE57AE0 /* RuntimeBootstrapModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeBootstrapModelTests.swift; sourceTree = ""; }; B22FDEB3B1DCC9ADE906CC73 /* OCRTextHygiene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRTextHygiene.swift; sourceTree = ""; }; B25C3087D4A9F4DC52FD5A69 /* PerDomainDisableSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerDomainDisableSettings.swift; sourceTree = ""; }; B27492B04B627DA53BDAD938 /* AXTreeDumpWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXTreeDumpWriter.swift; sourceTree = ""; }; @@ -848,12 +905,14 @@ BEF60972B2D88E4EC4841AB0 /* GPL-3.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "GPL-3.0.txt"; sourceTree = ""; }; BF474064973F4752F79BB041 /* EmojiSynonymCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiSynonymCatalogTests.swift; sourceTree = ""; }; BF4BB93056F291FD24EFAD22 /* LanguageCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageCatalog.swift; sourceTree = ""; }; + BF7F3EB7B874C836BF75F15D /* EmojiPickerModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerModelsTests.swift; sourceTree = ""; }; C046CB4F3CB4BFE9391DB5DE /* AXTextGeometryResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXTextGeometryResolverTests.swift; sourceTree = ""; }; C05B0439348261163B37C508 /* SuggestionAvailabilityEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluatorTests.swift; sourceTree = ""; }; C1C5DE0F3FF63545000E2453 /* DisplayCoordinateConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverterTests.swift; sourceTree = ""; }; C375227649689775275AA4B3 /* SuggestionCoordinatorAcceptanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorAcceptanceTests.swift; sourceTree = ""; }; C379D77029D6E88C8C1B9AF7 /* emoji.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = emoji.json; sourceTree = ""; }; C3A35FAA742408D002B75920 /* WebContentFieldDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebContentFieldDetector.swift; sourceTree = ""; }; + C49F67B3EEB2F2A577A54085 /* DeviceInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoTests.swift; sourceTree = ""; }; C648EBB10D7F8E0B904DEC91 /* de.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = de.txt; sourceTree = ""; }; C71031E8DB171047318B92FC /* SyntheticReplacePlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticReplacePlannerTests.swift; sourceTree = ""; }; C727BF6FF8ACAAED30B0329F /* TypoGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoGateTests.swift; sourceTree = ""; }; @@ -867,12 +926,14 @@ CDB25ABC4FFB0E63477CDCB0 /* SuggestionOverlayStabilityGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayStabilityGateTests.swift; sourceTree = ""; }; CE0AA0503128B0FC3951D700 /* SuggestionSessionReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionReconciler.swift; sourceTree = ""; }; CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogHandler.swift; sourceTree = ""; }; + D0AF9479EF020071CA64CCC1 /* HuggingFaceModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelsTests.swift; sourceTree = ""; }; D1123AB515110BD0CBA39490 /* HomePaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePaneView.swift; sourceTree = ""; }; D12ABBCE23A946C22894945B /* DecodeStopPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeStopPolicy.swift; sourceTree = ""; }; D2D0FE44138BCA8B2EE05AFE /* TypoCaseTransferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoCaseTransferTests.swift; sourceTree = ""; }; D2F46767D9D1F0D44E239CA8 /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = ""; }; D3A2AC525DC664DB540D4F19 /* ClipboardRelevanceFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardRelevanceFilter.swift; sourceTree = ""; }; D408D647412C59F3E692C42B /* TrailingDuplicationFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingDuplicationFilter.swift; sourceTree = ""; }; + D44745635AF702C96B4225A2 /* AXTreeDumpWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXTreeDumpWriterTests.swift; sourceTree = ""; }; D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritingPaneView.swift; sourceTree = ""; }; D49F3B597374208594861B9B /* FoundationModelDriftEvalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelDriftEvalTests.swift; sourceTree = ""; }; D4B56C250DDEF3E81F9DCBD7 /* SentenceBoundaryClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBoundaryClassifier.swift; sourceTree = ""; }; @@ -929,6 +990,7 @@ FC48B188C6E6E263B876621D /* EmojiUsageModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageModels.swift; sourceTree = ""; }; FC83D14A7557BC0196E59007 /* MirrorOverlayLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirrorOverlayLayoutTests.swift; sourceTree = ""; }; FC9ECD5408B0F5708149B5C0 /* EngineAndModelPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngineAndModelPaneView.swift; sourceTree = ""; }; + FD60968AEA8A5843F4E24618 /* SuggestionDebugLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionDebugLoggerTests.swift; sourceTree = ""; }; FE35C7770405ED368AA02448 /* EmojiUsageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStore.swift; sourceTree = ""; }; FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelPromptRenderer.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1226,7 +1288,9 @@ children = ( A168A7B6A7AD11559B60C56B /* ApplicationBundleMetadataTests.swift */, 78AFA4586C82E92D7FBF381B /* ArithmeticEvaluatorTests.swift */, + 66B53214C3842F78B202D498 /* AXHelperTests.swift */, C046CB4F3CB4BFE9391DB5DE /* AXTextGeometryResolverTests.swift */, + D44745635AF702C96B4225A2 /* AXTreeDumpWriterTests.swift */, 9966D09D8D80F54BF2734E00 /* BaseCompletionPromptRendererTests.swift */, 04D853218B0A77B0CE090828 /* BrowserAppDetectorTests.swift */, 1F761083EA5465023D82B5F4 /* BrowserDomainTests.swift */, @@ -1239,17 +1303,21 @@ EC4A3C4BC38793EB11F484F1 /* CompositionInputModeClassifierTests.swift */, 06FF2B0A3094A952A8EBA9B5 /* ConfidenceSuppressionPolicyTests.swift */, 22707E26E2106DF0E826D32D /* ControlTokenMarkersTests.swift */, + 93AF1246C1C2E296A1162E63 /* CotabbyDebugOptionsTests.swift */, AF1E065C7FFB697FCEB2FA5C /* CotabbyTestFixtures.swift */, 1C4751DFE9DA372FBC40BA30 /* CurrentWordExtractorTests.swift */, AD752451330486FE270018B0 /* CustomRulesTests.swift */, 313EDBA60565836F32CEEC10 /* DateMacroEvaluatorTests.swift */, B3B09064903B760D6DF2DF7D /* DecodeStopPolicyTests.swift */, 8F20A19A24D20E16D25ADCDA /* DeepGeometryWalkThrottleTests.swift */, + C49F67B3EEB2F2A577A54085 /* DeviceInfoTests.swift */, C1C5DE0F3FF63545000E2453 /* DisplayCoordinateConverterTests.swift */, D2F46767D9D1F0D44E239CA8 /* DownloadFileRescuerTests.swift */, E217A66717D78E1E49350EC8 /* DownloadOutcomeClassifierTests.swift */, 292DC9D4D9D5D26AE882E39B /* EmojiCatalogMatcherTests.swift */, + 700275BECBBDA98354ABBDF9 /* EmojiCatalogTests.swift */, 62BD2ADED33249F5BA53D0AD /* EmojiPickerControllerTests.swift */, + BF7F3EB7B874C836BF75F15D /* EmojiPickerModelsTests.swift */, B7B185BA246A526CBA85E581 /* EmojiPickerPanelLayoutTests.swift */, 023144451BB30F981D1F9EE6 /* EmojiPopularityTests.swift */, 75396860978E81EFAA506CD4 /* EmojiQueryRunTests.swift */, @@ -1259,21 +1327,30 @@ 224438039A86E5619294EAF7 /* EmojiUsageStoreTests.swift */, EE8BB19D8EC9A75CD3458A6B /* EmojiVariantResolverTests.swift */, 54BC85605541E913EE57B258 /* ExtendedContextTests.swift */, + AE98A6C28731BF5C8D434543 /* FileLogHandlerTests.swift */, 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */, D4F6D5F94B238F7B4BE7C247 /* FocusCapabilityResolverTests.swift */, + 0C10BCF95451954641C602E4 /* FocusModelsTests.swift */, 273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */, + 67D57F248880978A09DD28A6 /* FocusSnapshotResolverLiveTests.swift */, BA705EDFE1C41294F0E381F1 /* FocusSnapshotResolverSelectionTests.swift */, + 37DA0B2D4FE343E321A95C22 /* FocusTrackingModelTests.swift */, + 00D226C0B54B3B375EC2682D /* FoundationModelAvailabilityServiceTests.swift */, D49F3B597374208594861B9B /* FoundationModelDriftEvalTests.swift */, 53E41890930AA80910E461EF /* GhostFontMetricsTests.swift */, 335BF59EE80F3A0143B79740 /* GhostFontSizeStabilizerTests.swift */, 5AD3F4F9FBE82007E4E15F58 /* GhostSuggestionLayoutTests.swift */, + 23CFCE3EB3F41DAC0202E9D0 /* HardwareCapabilityProbeTests.swift */, + D0AF9479EF020071CA64CCC1 /* HuggingFaceModelsTests.swift */, BAC01317B0B68E3C4125E421 /* InputMonitorTests.swift */, 01F583E92B0A78212B330E6E /* InputSuppressionControllerTests.swift */, 43D627C4A55359EAF4676FF7 /* InsertionSafetyGateTests.swift */, 2960080A726E51198225147A /* InsertionStrategySelectorTests.swift */, + 2930EC34057319130393696B /* KeyCodeLabelsTests.swift */, 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */, 0CA88BB29BC8727878C99E95 /* LlamaPromptCacheHintTrackerTests.swift */, AABCC3FD99B1824A81E665F3 /* LlamaSuggestionEngineCancellationTests.swift */, + 9030FAAB468119A0236284A6 /* LLMIOFileHandlerTests.swift */, D8083D44ABCDCFA68A4CD497 /* MacroEngineTests.swift */, 22BE47D1DBF6C23151458836 /* MacroTriggerStateMachineTests.swift */, 52BAFA2F989C3C4F7FB892B5 /* MarkerSelectionSynthesizerTests.swift */, @@ -1284,12 +1361,16 @@ 5EED3CD2BC7B48DF35DEE562 /* OCRTextHygieneTests.swift */, D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */, 01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */, + 28B7EB84781C0ED57844585E /* OnboardingTemplateTests.swift */, EC582636750B78D497119845 /* PerDomainDisableSettingsTests.swift */, + 7C0FCC5CCF6AE528E3C4DDA7 /* PerformanceMetricsStoreTests.swift */, E7F42112F14026E6253BB865 /* PermissionAndContextModelTests.swift */, 12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */, 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */, 4696A84D17890B154533A08F /* PromptPolicyTests.swift */, E260C4D08C786CDBD527B329 /* PromptSectionBudgetTests.swift */, + 1909DF39C47A113382BB53B6 /* RequestIDTests.swift */, + B1E6D9CCC0AA3674FEE57AE0 /* RuntimeBootstrapModelTests.swift */, B2BFD19A159680A495EE02FD /* ScreenshotContextGeneratorTests.swift */, 474560E524C1D74BAB1570DA /* SecureFieldDetectorTests.swift */, D5A5591BEB9EE7B6E9064412 /* SelfCaptureGateTests.swift */, @@ -1302,16 +1383,28 @@ C05B0439348261163B37C508 /* SuggestionAvailabilityEvaluatorTests.swift */, EC04832FBD5311352F35241B /* SuggestionCaretLayoutRepairTests.swift */, C375227649689775275AA4B3 /* SuggestionCoordinatorAcceptanceTests.swift */, + 98E25C90F99356EB5E249225 /* SuggestionCoordinatorInputTests.swift */, + 9C2F4A55D7EC8C29D47B45C4 /* SuggestionCoordinatorLifecycleTests.swift */, + 361D34F219C46FF21AC09B62 /* SuggestionCoordinatorPredictionTests.swift */, + 4C174D8294858BF9DF3D361D /* SuggestionCoordinatorTestSupport.swift */, + FD60968AEA8A5843F4E24618 /* SuggestionDebugLoggerTests.swift */, + 470A7DAE3D6A2C873B395AE3 /* SuggestionEngineModelsTests.swift */, + 3BDA36955CCCFA87C1F67268 /* SuggestionEngineRouterTests.swift */, 45A896811745673061AF3612 /* SuggestionFocusFreshnessTests.swift */, + 8CB1D4F2681FAF59014AE115 /* SuggestionInteractionStateTests.swift */, CDB25ABC4FFB0E63477CDCB0 /* SuggestionOverlayStabilityGateTests.swift */, EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */, 9C8F07AC52C7A482F5FE34C5 /* SuggestionSessionReconcilerTests.swift */, + 00BB95A341A8B5F4A1725640 /* SuggestionSettingsModelTests.swift */, F050CE655081B840E361899E /* SuggestionSettingsStoreTests.swift */, 19DB9558F4D3AFB108D71649 /* SuggestionStateHelperTests.swift */, + 87C309CD6A454C415D8BEEC7 /* SuggestionTextColorCodecTests.swift */, 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */, C850141146422A132B2B3516 /* SymSpellCorrectorTests.swift */, B32482EABE9EA979C40C8A8F /* SymSpellTests.swift */, C71031E8DB171047318B92FC /* SyntheticReplacePlannerTests.swift */, + 38931C165873B50B405CC602 /* SystemMetricsStoreTests.swift */, + 9255CBCDE66253F521EE0F08 /* SystemResourceSamplerTests.swift */, 43E37A7E835D3BDE6265843C /* TerminalAppDetectorTests.swift */, FC24FD54860CE6737E65EF65 /* TextDirectionDetectorTests.swift */, F36111592745117D04C42405 /* TextLayoutCaretEstimatorTests.swift */, @@ -2157,7 +2250,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8369356B8E7E7E61787E828D /* AXHelperTests.swift in Sources */, 6D0E79CF3C1A8CE53046FCE5 /* AXTextGeometryResolverTests.swift in Sources */, + 55E841977534CBFD8B80E95F /* AXTreeDumpWriterTests.swift in Sources */, A36481222BB5B2A67349D389 /* ApplicationBundleMetadataTests.swift in Sources */, 4D583CB3DA253FB795EE54F9 /* ArithmeticEvaluatorTests.swift in Sources */, F4A01E4F12F0183449BCCBB9 /* BaseCompletionPromptRendererTests.swift in Sources */, @@ -2172,17 +2267,21 @@ 2A53558D66C96E963B23CA11 /* CompositionInputModeClassifierTests.swift in Sources */, 91D8189EFCD1BA992EA6F038 /* ConfidenceSuppressionPolicyTests.swift in Sources */, 5009AF59DE8D40A45C0A5C2F /* ControlTokenMarkersTests.swift in Sources */, + D21EBD25BCB37E69B633BC00 /* CotabbyDebugOptionsTests.swift in Sources */, 5E10EFC426217CB7218A5847 /* CotabbyTestFixtures.swift in Sources */, 64599CD334AAD79266224689 /* CurrentWordExtractorTests.swift in Sources */, 91D1F16B8C5DA281D4B7F699 /* CustomRulesTests.swift in Sources */, 4CCF29A7EA1B7D37841C135D /* DateMacroEvaluatorTests.swift in Sources */, 79B0AEA0D2FC6A865E9303F9 /* DecodeStopPolicyTests.swift in Sources */, 664A5D62A723EB204ADEF2F9 /* DeepGeometryWalkThrottleTests.swift in Sources */, + 43DED8ABEFF9894ED54097A9 /* DeviceInfoTests.swift in Sources */, 56611BA0087710277140E9E6 /* DisplayCoordinateConverterTests.swift in Sources */, E994FE418A961FB234D9057A /* DownloadFileRescuerTests.swift in Sources */, FAC7FFC78BEBF62D5B7A2EFB /* DownloadOutcomeClassifierTests.swift in Sources */, 96498E097A5899AFC9F0C853 /* EmojiCatalogMatcherTests.swift in Sources */, + FE424DD09040640CC2400FBE /* EmojiCatalogTests.swift in Sources */, 0B6E28D1CBDF657F71548A3C /* EmojiPickerControllerTests.swift in Sources */, + 74E0082BA8D7E80C2E038EAA /* EmojiPickerModelsTests.swift in Sources */, D46A0DB70B07F487431F48F6 /* EmojiPickerPanelLayoutTests.swift in Sources */, 14C55DC5096F003BD3D2917D /* EmojiPopularityTests.swift in Sources */, 0D15CBF45EB1DB725B9F1A6A /* EmojiQueryRunTests.swift in Sources */, @@ -2192,18 +2291,27 @@ 26C5604C3CEF43FC755FD24E /* EmojiUsageStoreTests.swift in Sources */, C9B815652CED38966C53A5E8 /* EmojiVariantResolverTests.swift in Sources */, 63054CBDCA87560130BF5ADC /* ExtendedContextTests.swift in Sources */, + 773808D3D88440F0836D0072 /* FileLogHandlerTests.swift in Sources */, 78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */, C71B594433F3B411CAE5DE7E /* FocusCapabilityResolverTests.swift in Sources */, + AD39F3B11BC4ADE6C6E0A828 /* FocusModelsTests.swift in Sources */, A147C5EC3F2214A670F7556E /* FocusPollBackoffTests.swift in Sources */, + B2BDCFF0824EE41FC1C0451A /* FocusSnapshotResolverLiveTests.swift in Sources */, 156E6AB3D24134EEC29FDB93 /* FocusSnapshotResolverSelectionTests.swift in Sources */, + F41AB06FD117487D7136E896 /* FocusTrackingModelTests.swift in Sources */, + F4BE7822C56F10CAC623B0C2 /* FoundationModelAvailabilityServiceTests.swift in Sources */, 31DCCE980B6708401256D4D1 /* FoundationModelDriftEvalTests.swift in Sources */, 1D424A103EF7BFE45518F45E /* GhostFontMetricsTests.swift in Sources */, 2EE05B312C990104BE934772 /* GhostFontSizeStabilizerTests.swift in Sources */, A0BB87E3665EF6C209034798 /* GhostSuggestionLayoutTests.swift in Sources */, + 7A5DBBC32ABEF9E7ED147577 /* HardwareCapabilityProbeTests.swift in Sources */, + 663D37E35292F38666D807A7 /* HuggingFaceModelsTests.swift in Sources */, 07D046D406411ED85AC5758A /* InputMonitorTests.swift in Sources */, 0FCBF2250722780E46A92EE6 /* InputSuppressionControllerTests.swift in Sources */, 83EC3543DC45B1601F119BF9 /* InsertionSafetyGateTests.swift in Sources */, FC255241A3B34A5717F09B36 /* InsertionStrategySelectorTests.swift in Sources */, + F66F0D982EBAF5A3E99C5342 /* KeyCodeLabelsTests.swift in Sources */, + 475FB7450EEC3C1B16E66CC4 /* LLMIOFileHandlerTests.swift in Sources */, E912D4617AE1376061DF1F00 /* LanguageSupportTests.swift in Sources */, E38801433B99E65BD7E45A0E /* LlamaPromptCacheHintTrackerTests.swift in Sources */, BE3CB85508055D159C35020A /* LlamaSuggestionEngineCancellationTests.swift in Sources */, @@ -2217,12 +2325,16 @@ 3F5630CFB7BA40B900E832A1 /* OCRTextHygieneTests.swift in Sources */, DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */, D648DD70AD847F67B77CE052 /* OnboardingTemplateRecommenderTests.swift in Sources */, + 0160F9D9929465E6B6A3385F /* OnboardingTemplateTests.swift in Sources */, C9426ADFCA2EC1D11D841A2A /* PerDomainDisableSettingsTests.swift in Sources */, + 8B26F7B26358438D6EB88C2E /* PerformanceMetricsStoreTests.swift in Sources */, 15FA56CEF6FB5FF54C2FBA6F /* PermissionAndContextModelTests.swift in Sources */, 4F38CE1C2602CF4F41323032 /* PermissionOverlayTrackerTests.swift in Sources */, 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */, 3CF1A4E39F24917DF0470A7D /* PromptPolicyTests.swift in Sources */, 7EB20783E0D36715D1230A5C /* PromptSectionBudgetTests.swift in Sources */, + 1C46642846D8FD1475AA5CCF /* RequestIDTests.swift in Sources */, + AD6E005ABE34AB7EBD92A30D /* RuntimeBootstrapModelTests.swift in Sources */, 1B3FFCB9A979F49BF86EAAD4 /* ScreenshotContextGeneratorTests.swift in Sources */, 4FC52FB28AFC013F000D8FF9 /* SecureFieldDetectorTests.swift in Sources */, AF26E77871200BB1FAAEBE79 /* SelfCaptureGateTests.swift in Sources */, @@ -2235,16 +2347,28 @@ 88BCD795A14E1C9308F7BB31 /* SuggestionAvailabilityEvaluatorTests.swift in Sources */, EB9B5E5F7326AB72E0E44C70 /* SuggestionCaretLayoutRepairTests.swift in Sources */, 5B404450B412A6102F514250 /* SuggestionCoordinatorAcceptanceTests.swift in Sources */, + 0933A62F9B9AECEEA95BBA29 /* SuggestionCoordinatorInputTests.swift in Sources */, + 5A6C6B59C3FC821813A7C3E9 /* SuggestionCoordinatorLifecycleTests.swift in Sources */, + 36651BFF4917A1E80C667B64 /* SuggestionCoordinatorPredictionTests.swift in Sources */, + B816C6191738AB616F2E8D2D /* SuggestionCoordinatorTestSupport.swift in Sources */, + 9938DE59D9E05BC51A5BA5B8 /* SuggestionDebugLoggerTests.swift in Sources */, + 19CA1BF8B508E0E219EF4485 /* SuggestionEngineModelsTests.swift in Sources */, + 449218D0646AB3745B7E4F30 /* SuggestionEngineRouterTests.swift in Sources */, 5CED06E89FBEF557DCD6C684 /* SuggestionFocusFreshnessTests.swift in Sources */, + 6CBEF02FCDFCF406E378C27C /* SuggestionInteractionStateTests.swift in Sources */, 4C6D8ED0A7B45D2EADF06DA5 /* SuggestionOverlayStabilityGateTests.swift in Sources */, B93AB7E845086F6FBB068369 /* SuggestionRequestFactoryTests.swift in Sources */, 7E9413CE7C999C4612348248 /* SuggestionSessionReconcilerTests.swift in Sources */, + 7C6D42EAD04C8144538B132A /* SuggestionSettingsModelTests.swift in Sources */, C7E1F0365602914CAC03F342 /* SuggestionSettingsStoreTests.swift in Sources */, CB65A79F164269991FABC32E /* SuggestionStateHelperTests.swift in Sources */, + 7AEF46950EF5E2EBCFE4BBD3 /* SuggestionTextColorCodecTests.swift in Sources */, 0C98ECB5BCEBA72C693AC1C9 /* SuggestionTextNormalizerTests.swift in Sources */, 000EBFCBA8CE49537690613B /* SymSpellCorrectorTests.swift in Sources */, A3AFE27EDE956ADC04C91C94 /* SymSpellTests.swift in Sources */, EF5BAB96DDADABB86F9E02D9 /* SyntheticReplacePlannerTests.swift in Sources */, + 856082F4732206A3761816DC /* SystemMetricsStoreTests.swift in Sources */, + C6A112B51525F988EA46F725 /* SystemResourceSamplerTests.swift in Sources */, DE236C9285635C686D66A2F6 /* TerminalAppDetectorTests.swift in Sources */, 5A441797D71A880A7482077D /* TextDirectionDetectorTests.swift in Sources */, 2B1F6B9BD4DEBD7DA4DD9855 /* TextLayoutCaretEstimatorTests.swift in Sources */, diff --git a/Cotabby/Services/Focus/DeepGeometryWalkThrottle.swift b/Cotabby/Services/Focus/DeepGeometryWalkThrottle.swift index 8ad6a53f..9674b9c3 100644 --- a/Cotabby/Services/Focus/DeepGeometryWalkThrottle.swift +++ b/Cotabby/Services/Focus/DeepGeometryWalkThrottle.swift @@ -40,4 +40,9 @@ final class DeepGeometryWalkThrottle { cachedResult = result return result } + + // Mirrors FieldStyleCache: keep deallocation off the back-deployment main-actor executor + // shim, whose StopLookupScope double-frees on macOS 26. Only test-scoped resolvers ever + // deallocate this type. + nonisolated deinit {} } diff --git a/Cotabby/Services/Focus/FieldStyleCache.swift b/Cotabby/Services/Focus/FieldStyleCache.swift index 6b16955d..2895db1b 100644 --- a/Cotabby/Services/Focus/FieldStyleCache.swift +++ b/Cotabby/Services/Focus/FieldStyleCache.swift @@ -29,4 +29,10 @@ final class FieldStyleCache { style = resolved return resolved } + + // Stored state is plain value types, safe to release anywhere. The nonisolated deinit keeps + // deallocation off the back-deployment main-actor executor shim, whose StopLookupScope + // double-frees on macOS 26 (see InputSuppressionController). Production's single long-lived + // instance never deallocates; test-scoped resolvers do. + nonisolated deinit {} } diff --git a/Cotabby/Services/Suggestion/SuggestionDebugLogger.swift b/Cotabby/Services/Suggestion/SuggestionDebugLogger.swift index 3dddd37e..44c1a1eb 100644 --- a/Cotabby/Services/Suggestion/SuggestionDebugLogger.swift +++ b/Cotabby/Services/Suggestion/SuggestionDebugLogger.swift @@ -53,6 +53,11 @@ final class SuggestionDebugLogger { self.colorizedOutput = colorizedOutput ?? shouldUseColor } + // All stored state is thread-safe to release (a Bool and an optional String). The nonisolated + // deinit prevents Swift from scheduling the teardown through the back-deployment main-actor + // executor shim, which double-frees in app-hosted tests (see InputSuppressionController). + nonisolated deinit {} + /// Emits only the model-boundary artifacts that are useful for debugging suggestion quality. /// /// Lifecycle stages such as debounce, acceptance, and visual-context session dedup still update diff --git a/Cotabby/Support/FileLogHandler.swift b/Cotabby/Support/FileLogHandler.swift index 647f05d3..caa58db4 100644 --- a/Cotabby/Support/FileLogHandler.swift +++ b/Cotabby/Support/FileLogHandler.swift @@ -29,12 +29,21 @@ final class FileLogWriter: @unchecked Sendable { private var handle: FileHandle? private var currentByteOffset: UInt64 = 0 - init(sizeCapBytes: UInt64? = nil) { + /// `fileURL` overrides the default `~/Library/Logs//cotabby.jsonl` destination. Tests + /// inject a temp-directory URL so rotation and write behavior can be exercised against a real + /// file handle without touching the user's live logs. + init(sizeCapBytes: UInt64? = nil, fileURL: URL? = nil) { self.sizeCapBytesOverride = sizeCapBytes - self.logFileURL = Self.makeLogFileURL() + self.logFileURL = fileURL ?? Self.makeLogFileURL() openHandle() } + // The target's default MainActor isolation applies to this unannotated class, so without this + // a deallocation routes through the back-deployment main-actor executor shim, which + // double-frees in its StopLookupScope on macOS 26 (see InputSuppressionController). The shared + // singleton never deallocates in production; tests deallocate per-case writers constantly. + nonisolated deinit {} + private let sizeCapBytesOverride: UInt64? private var effectiveCap: UInt64 { sizeCapBytesOverride ?? sizeCapBytes } diff --git a/Cotabby/Support/LLMIOFileHandler.swift b/Cotabby/Support/LLMIOFileHandler.swift index 422d9c72..dcb4f17e 100644 --- a/Cotabby/Support/LLMIOFileHandler.swift +++ b/Cotabby/Support/LLMIOFileHandler.swift @@ -25,12 +25,20 @@ final class LLMIOFileWriter: @unchecked Sendable { private var handle: FileHandle? private var currentByteOffset: UInt64 = 0 - init(sizeCapBytes: UInt64? = nil) { + /// `fileURL` overrides the default `~/Library/Logs//llm-io.jsonl` destination. Tests + /// inject a temp-directory URL so rotation and write behavior can be exercised against a real + /// file handle without touching the user's live logs. + init(sizeCapBytes: UInt64? = nil, fileURL: URL? = nil) { self.sizeCapBytesOverride = sizeCapBytes - self.logFileURL = Self.makeLogFileURL() + self.logFileURL = fileURL ?? Self.makeLogFileURL() openHandle() } + // Mirrors FileLogWriter: the target's default MainActor isolation would otherwise route + // deallocation through the back-deployment executor shim, which double-frees in its + // StopLookupScope on macOS 26. Production only ever uses the never-deallocated singleton. + nonisolated deinit {} + private let sizeCapBytesOverride: UInt64? private var effectiveCap: UInt64 { sizeCapBytesOverride ?? sizeCapBytes } diff --git a/CotabbyTests/AXHelperTests.swift b/CotabbyTests/AXHelperTests.swift new file mode 100644 index 00000000..d2a2c4f8 --- /dev/null +++ b/CotabbyTests/AXHelperTests.swift @@ -0,0 +1,350 @@ +import AppKit +import ApplicationServices +import XCTest +@testable import Cotabby + +/// Exercises the AX bridging layer against the test host's own process. +/// +/// App-hosted tests run inside the Cotabby app, so the suite can build a real window with a real +/// text view, then read it back through the same `AXUIElement` APIs production uses against other +/// apps. That validates the CF bridging, type guards, and traversal helpers against a live AX +/// implementation (AppKit's) rather than mocks. Environments where self-process AX is unavailable +/// (an untrusted headless CI runner) skip the live cases via `requireFieldElement`; the pure +/// helpers below run everywhere. +@MainActor +final class AXHelperTests: XCTestCase { + private static var window: NSWindow? + private static var textView: NSTextView? + private static var fieldElement: AXUIElement? + + /// Builds the host window once for the whole suite; tearing it down per-test would re-pay AX + /// tree registration and make later tests race the window server. + private func requireFieldElement() throws -> AXUIElement { + if let element = Self.fieldElement { + return element + } + + if Self.window == nil { + let window = NSWindow( + contentRect: NSRect(x: 120, y: 120, width: 420, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.title = "Cotabby AXHelper test host" + let textView = NSTextView(frame: NSRect(x: 10, y: 10, width: 400, height: 180)) + textView.string = "The quick brown fox jumps over the lazy dog" + textView.font = NSFont(name: "Helvetica", size: 13) ?? NSFont.systemFont(ofSize: 13) + textView.textColor = .black + textView.setAccessibilityIdentifier("cotabby-axhelper-test-field") + window.contentView?.addSubview(textView) + window.orderFrontRegardless() + window.makeFirstResponder(textView) + Self.window = window + Self.textView = textView + } + + // Locate the text area through the app's own AX tree, exactly as an assistive client + // would. A miss here means this environment does not serve self-process AX. + let appElement = AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier) + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + if let element = Self.findTextArea(under: appElement, depth: 0) { + Self.fieldElement = element + return element + } + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + } + throw XCTSkip("Self-process AX is unavailable in this environment") + } + + private static func findTextArea(under element: AXUIElement, depth: Int) -> AXUIElement? { + guard depth <= 8 else { return nil } + if AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) == (kAXTextAreaRole as String), + AXHelper.accessibilityIdentifier(of: element) == "cotabby-axhelper-test-field" { + return element + } + for child in AXHelper.childElements(of: element) { + if let found = findTextArea(under: child, depth: depth + 1) { + return found + } + } + return nil + } + + // MARK: - Live AX reads against the host's own field + + func test_typedAttributeReaders_readTheRealFieldBack() throws { + let element = try requireFieldElement() + + XCTAssertEqual( + AXHelper.stringValue(for: kAXValueAttribute as CFString, on: element), + "The quick brown fox jumps over the lazy dog" + ) + XCTAssertEqual( + AXHelper.intValue(for: kAXNumberOfCharactersAttribute as CFString, on: element), + 43 + ) + XCTAssertNotNil(AXHelper.rangeValue(for: kAXSelectedTextRangeAttribute as CFString, on: element)) + let frame = AXHelper.rectValue(for: "AXFrame" as CFString, on: element) + XCTAssertEqual(frame?.isEmpty, false) + XCTAssertFalse(AXHelper.attributeNames(on: element).isEmpty) + XCTAssertTrue( + AXHelper.parameterizedAttributeNames(on: element) + .contains(kAXBoundsForRangeParameterizedAttribute as String) + ) + } + + func test_typedAttributeReaders_returnNilOnTypeMismatches() throws { + let element = try requireFieldElement() + + // Each reader must reject a present-but-differently-typed value instead of bridging junk. + XCTAssertNil(AXHelper.rangeValue(for: kAXRoleAttribute as CFString, on: element)) + XCTAssertNil(AXHelper.rectValue(for: kAXRoleAttribute as CFString, on: element)) + XCTAssertNil(AXHelper.intValue(for: kAXRoleAttribute as CFString, on: element)) + XCTAssertNil(AXHelper.boolValue(for: kAXRoleAttribute as CFString, on: element)) + XCTAssertNil(AXHelper.stringValue(for: "AXNoSuchAttribute" as CFString, on: element)) + XCTAssertNil(AXHelper.stringArrayValue(for: "AXDOMClassList" as CFString, on: element)) + } + + func test_parameterizedReaders_resolveRangesOnTheRealLayout() throws { + let element = try requireFieldElement() + + XCTAssertEqual( + AXHelper.parameterizedStringValue( + for: kAXStringForRangeParameterizedAttribute as CFString, + range: NSRange(location: 4, length: 5), + on: element + ), + "quick" + ) + + let bounds = AXHelper.parameterizedRectValue( + for: kAXBoundsForRangeParameterizedAttribute as CFString, + range: NSRange(location: 0, length: 3), + on: element + ) + XCTAssertEqual(bounds?.isEmpty, false) + + let attributed = AXHelper.parameterizedAttributedStringValue( + for: "AXAttributedStringForRange" as CFString, + range: NSRange(location: 0, length: 1), + on: element + ) + XCTAssertEqual(attributed?.length, 1) + } + + func test_resolveFieldStyle_readsFontAndColorFromTheHost() throws { + let element = try requireFieldElement() + + let style = AXHelper.resolveFieldStyle(for: element, caretLocation: 5, textLength: 43) + XCTAssertNotNil(style) + XCTAssertEqual(style?.fontPointSize, 13) + XCTAssertNotNil(style?.fontName) + + // Empty fields can have no style source at all; the helper must refuse, not crash. + XCTAssertNil(AXHelper.resolveFieldStyle(for: element, caretLocation: 0, textLength: 0)) + } + + func test_treeTraversal_walksParentsChildrenAndIdentity() throws { + let element = try requireFieldElement() + + let parent = AXHelper.parentElement(of: element) + XCTAssertNotNil(parent) + if let parent { + XCTAssertFalse(AXHelper.childElements(of: parent).isEmpty) + } + + let identity = AXHelper.elementIdentity(for: element) + XCTAssertTrue(identity.hasPrefix("\(ProcessInfo.processInfo.processIdentifier)-")) + XCTAssertEqual( + AXHelper.elementIdentifier(for: element, bundleIdentifier: "com.example.test"), + "com.example.test-\(identity)" + ) + + let owner = AXHelper.owningApplication(of: element) + XCTAssertEqual(owner?.processIdentifier, ProcessInfo.processInfo.processIdentifier) + } + + func test_nearestEditable_returnsTheEditableItselfAndClimbsFromLeaves() throws { + let element = try requireFieldElement() + + // An already-editable element is returned as-is. + let fromEditable = AXHelper.nearestEditable(from: element) + XCTAssertEqual(AXHelper.elementIdentity(for: fromEditable), AXHelper.elementIdentity(for: element)) + + // Climbing from a non-editable ancestor gives up after maxClimb and returns the original. + if let parent = AXHelper.parentElement(of: element) { + let fromParent = AXHelper.nearestEditable(from: parent, maxClimb: 0) + XCTAssertEqual( + AXHelper.elementIdentity(for: fromParent), + AXHelper.elementIdentity(for: parent) + ) + } + } + + func test_markerAPIs_degradeToNilOnNativeElements() throws { + let element = try requireFieldElement() + + // Native AppKit text views have no Chromium/WebKit text-marker surface; both helpers must + // miss cleanly because production calls them on arbitrary focused elements. + XCTAssertNil(AXHelper.textMarkerCaretRect(on: element)) + XCTAssertNil(AXHelper.synthesizeMarkerSelection(on: element, parameterizedAttributes: [])) + XCTAssertNil( + AXHelper.synthesizeMarkerSelection( + on: element, + parameterizedAttributes: [ + "AXStartTextMarkerForTextMarkerRange", + "AXEndTextMarkerForTextMarkerRange", + "AXTextMarkerRangeForUnorderedTextMarkers", + "AXStringForTextMarkerRange" + ] + ) + ) + } + + func test_webURL_missesCleanlyOnNonBrowserTrees() throws { + let element = try requireFieldElement() + XCTAssertNil(AXHelper.webURL(near: element, maxClimb: 2)) + } + + func test_focusQueries_resolveTheHostsOwnFocus() throws { + let element = try requireFieldElement() + Self.window?.makeFirstResponder(Self.textView) + + // The app-scoped focused-element query should land on our text area (or at least not + // crash and return a typed element) while the field is first responder. + let focused = AXHelper.focusedElement( + forApplicationPID: ProcessInfo.processInfo.processIdentifier + ) + if let focused { + XCTAssertNotNil(AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: focused)) + } + _ = AXHelper.isFocused(element) + // System-wide focus belongs to whatever app is active; the call just must not crash. + _ = AXHelper.focusedElement() + } + + func test_pasteMenuItem_findsCmdVInARealMenuBarWhenTrusted() throws { + _ = try requireFieldElement() + // Finder always runs and always has Edit > Paste bound to plain Cmd-V. Reading another + // process's menu bar requires AX trust, so a nil result in an untrusted environment is + // tolerated; a non-nil result must actually be a Cmd-V menu item carrier. + guard let finder = NSRunningApplication + .runningApplications(withBundleIdentifier: "com.apple.finder").first else { + throw XCTSkip("Finder is not running") + } + let item = AXHelper.pasteMenuItem(forApplicationPID: finder.processIdentifier) + if let item { + let cmdChar = AXHelper.stringValue(for: kAXMenuItemCmdCharAttribute as CFString, on: item) + XCTAssertEqual(cmdChar?.uppercased(), "V") + } + } + + // MARK: - Invalid-pid guards (no AX needed) + + func test_invalidPIDGuards_failFastWithoutTouchingAX() { + XCTAssertNil(AXHelper.focusedElement(forApplicationPID: -1)) + XCTAssertNil(AXHelper.pasteMenuItem(forApplicationPID: 0)) + XCTAssertEqual(AXHelper.setManualAccessibility(true, forApplicationPID: -1), .failure) + } + + // MARK: - Editability heuristics (pure) + + func test_editabilityHeuristics_scoreRolesAndExplicitFlags() { + XCTAssertTrue(AXHelper.isKnownEditableRole(kAXTextFieldRole as String)) + XCTAssertTrue(AXHelper.isKnownEditableRole(kAXTextAreaRole as String)) + XCTAssertTrue(AXHelper.isKnownEditableRole("AXSearchField")) + XCTAssertFalse(AXHelper.isKnownEditableRole(kAXStaticTextRole as String)) + + XCTAssertTrue(AXHelper.isKnownReadOnlyRole(kAXStaticTextRole as String)) + XCTAssertTrue(AXHelper.isKnownReadOnlyRole(kAXButtonRole as String)) + XCTAssertFalse(AXHelper.isKnownReadOnlyRole(kAXTextFieldRole as String)) + + XCTAssertEqual(AXHelper.editabilityHintScore(role: kAXTextFieldRole as String, explicitEditableFlag: true), 11) + XCTAssertEqual(AXHelper.editabilityHintScore(role: kAXTextFieldRole as String, explicitEditableFlag: nil), 1) + XCTAssertEqual(AXHelper.editabilityHintScore(role: "AXGroup", explicitEditableFlag: false), 0) + + XCTAssertTrue(AXHelper.hasStrongEditabilitySignal(role: "AXGroup", explicitEditableFlag: true)) + XCTAssertTrue(AXHelper.hasStrongEditabilitySignal(role: kAXComboBoxRole as String, explicitEditableFlag: nil)) + XCTAssertFalse(AXHelper.hasStrongEditabilitySignal(role: "AXGroup", explicitEditableFlag: nil)) + } + + // MARK: - Coordinate conversion (pure over live screen geometry) + + func test_rectHasFiniteComponents_rejectsNaNAndInfinity() { + XCTAssertTrue(AXHelper.rectHasFiniteComponents(CGRect(x: 1, y: 2, width: 3, height: 4))) + XCTAssertFalse(AXHelper.rectHasFiniteComponents(CGRect(x: CGFloat.nan, y: 2, width: 3, height: 4))) + XCTAssertFalse(AXHelper.rectHasFiniteComponents(CGRect(x: 1, y: CGFloat.infinity, width: 3, height: 4))) + XCTAssertFalse(AXHelper.rectHasFiniteComponents(CGRect(x: 1, y: 2, width: -CGFloat.infinity, height: 4))) + XCTAssertFalse(AXHelper.rectHasFiniteComponents(CGRect(x: 1, y: 2, width: 3, height: CGFloat.nan))) + } + + func test_cocoaRect_zeroAndNonFiniteRectsNeverReachGeometryMath() { + XCTAssertEqual(AXHelper.cocoaRect(fromAccessibilityRect: .zero), .zero) + XCTAssertEqual( + AXHelper.cocoaRect(fromAccessibilityRect: CGRect(x: CGFloat.nan, y: 0, width: 10, height: 10)), + .zero + ) + } + + func test_cocoaRect_preservesSizeAndFlipsWithinThePrimaryDisplay() throws { + guard let primary = NSScreen.screens.first else { + throw XCTSkip("No display attached") + } + // A rect 100pt below the AX top-left origin must come back 100pt below the Cocoa top. + let axRect = CGRect(x: 50, y: 100, width: 200, height: 20) + let converted = AXHelper.cocoaRect(fromAccessibilityRect: axRect) + XCTAssertEqual(converted.width, 200) + XCTAssertEqual(converted.height, 20) + XCTAssertEqual(converted.minX, 50) + XCTAssertEqual(converted.maxY, primary.frame.maxY - 100, accuracy: 0.5) + } + + func test_validatedCocoaTextRect_anchorsDecideBetweenCandidates() throws { + guard let primary = NSScreen.screens.first else { + throw XCTSkip("No display attached") + } + + XCTAssertEqual( + AXHelper.validatedCocoaTextRect( + fromAccessibilityRect: CGRect(x: CGFloat.infinity, y: 0, width: 1, height: 1), + anchorFrame: nil + ), + .zero + ) + XCTAssertEqual( + AXHelper.validatedCocoaTextRect(fromAccessibilityRect: .zero, anchorFrame: nil), + .zero + ) + + // No anchor: the plain Y-flip candidate wins. + let axRect = CGRect(x: 50, y: 100, width: 10, height: 20) + let unanchored = AXHelper.validatedCocoaTextRect(fromAccessibilityRect: axRect, anchorFrame: nil) + XCTAssertEqual(unanchored.maxY, primary.frame.maxY - 100, accuracy: 0.5) + + // An anchor surrounding the flipped candidate keeps it. + let goodAnchor = unanchored.insetBy(dx: -40, dy: -40) + XCTAssertEqual( + AXHelper.validatedCocoaTextRect(fromAccessibilityRect: axRect, anchorFrame: goodAnchor), + unanchored + ) + + // An anchor nowhere near either candidate falls back to the flipped rect rather than + // inventing geometry. + let farAnchor = CGRect(x: 5_000, y: 5_000, width: 10, height: 10) + XCTAssertEqual( + AXHelper.validatedCocoaTextRect(fromAccessibilityRect: axRect, anchorFrame: farAnchor), + unanchored + ) + } + + // MARK: - System-wide element + + func test_systemWideElement_isCreatedWithTheShortMessagingTimeout() { + // The timeout itself is not readable back, but creation must succeed and be callable. + let element = AXHelper.systemWideElement() + XCTAssertEqual(CFGetTypeID(element), AXUIElementGetTypeID()) + } +} diff --git a/CotabbyTests/AXTreeDumpWriterTests.swift b/CotabbyTests/AXTreeDumpWriterTests.swift new file mode 100644 index 00000000..592432ea --- /dev/null +++ b/CotabbyTests/AXTreeDumpWriterTests.swift @@ -0,0 +1,38 @@ +import ApplicationServices +import XCTest +@testable import Cotabby + +/// Tests for `AXTreeDumpWriter`'s gating: the dump is a Chrome-only developer diagnostic, so for +/// any other bundle the call must return without touching the element or the disk. +/// +/// Only the gate is exercised. The rendering and write path requires a live Chrome accessibility +/// tree and overwrites `~/Desktop/cotabby-ax-dump.txt`, neither of which a unit test may touch, so +/// that path stays covered by manual `-cotabby-debug` runs against Chrome. +@MainActor +final class AXTreeDumpWriterTests: XCTestCase { + func test_dumpIfEnabled_isNoOpForNonConfiguredBundles() async { + // An application element for our own process: structurally valid, but any traversal of it + // would be observable as latency or AX errors. The gate must reject on the bundle check + // (or earlier on the debug flag when tests run without -cotabby-debug) before any of that. + let element = AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier) + + AXTreeDumpWriter.dumpIfEnabled( + focusedElement: element, + applicationName: "TestApp", + bundleIdentifier: "com.example.not-chrome", + focusedElementIdentifier: "field-1" + ) + // A second call with a fresh identifier must take the same early-out: the identity debounce + // only applies to the configured bundle. + AXTreeDumpWriter.dumpIfEnabled( + focusedElement: element, + applicationName: "TestApp", + bundleIdentifier: "com.example.not-chrome", + focusedElementIdentifier: "field-2" + ) + + var pid: pid_t = 0 + XCTAssertEqual(AXUIElementGetPid(element, &pid), .success) + XCTAssertEqual(pid, ProcessInfo.processInfo.processIdentifier, "The element must pass through untouched") + } +} diff --git a/CotabbyTests/ApplicationBundleMetadataTests.swift b/CotabbyTests/ApplicationBundleMetadataTests.swift index dc56f883..1bb23662 100644 --- a/CotabbyTests/ApplicationBundleMetadataTests.swift +++ b/CotabbyTests/ApplicationBundleMetadataTests.swift @@ -3,9 +3,10 @@ import XCTest /// Tests for the picker-to-rule resolution that backs "Add App" in Settings. /// -/// These exercise the pure initializer — the one that does not touch disk — so the display-name -/// fallback order and the bundle-identifier requirement are locked independently of whatever apps -/// happen to be installed on the machine running the suite. +/// The pure initializer locks the display-name fallback order and the bundle-identifier +/// requirement independently of whatever apps happen to be installed on the machine running the +/// suite. The disk-reading initializer is then exercised against minimal `.app` directories built +/// in a temp folder, so its plist extraction is covered without machine-specific fixtures. final class ApplicationBundleMetadataTests: XCTestCase { func test_init_returnsNilWhenBundleIdentifierIsMissing() { XCTAssertNil( @@ -85,4 +86,82 @@ final class ApplicationBundleMetadataTests: XCTestCase { XCTAssertEqual(metadata?.bundleIdentifier, "com.example.app") XCTAssertEqual(metadata?.displayName, "Example App") } + + // MARK: - init(appURL:) against a real on-disk bundle + + // These build a minimal `.app` directory (Contents/Info.plist) in a temp folder so the + // disk-reading initializer is exercised without depending on whatever apps the machine has. + + private var temporaryDirectories: [URL] = [] + + override func tearDown() { + for url in temporaryDirectories { + try? FileManager.default.removeItem(at: url) + } + temporaryDirectories.removeAll() + super.tearDown() + } + + func test_initAppURL_readsIdentifierAndDisplayNameFromInfoPlist() throws { + let appURL = try makeAppBundle( + named: "Probe.app", + info: [ + "CFBundleIdentifier": "com.example.probe", + "CFBundleDisplayName": "Probe Display", + "CFBundleName": "ProbeName" + ] + ) + + let metadata = ApplicationBundleMetadata(appURL: appURL) + + XCTAssertEqual(metadata?.bundleIdentifier, "com.example.probe") + XCTAssertEqual(metadata?.displayName, "Probe Display") + } + + func test_initAppURL_fallsBackToBundleNameThenFileName() throws { + let bundleNameOnly = try makeAppBundle( + named: "NameOnly.app", + info: ["CFBundleIdentifier": "com.example.nameonly", "CFBundleName": "Name Only"] + ) + XCTAssertEqual(ApplicationBundleMetadata(appURL: bundleNameOnly)?.displayName, "Name Only") + + let identifierOnly = try makeAppBundle( + named: "My Tool.app", + info: ["CFBundleIdentifier": "com.example.mytool"] + ) + // No Info.plist names at all: the file name minus `.app` is the closest thing to what the + // user clicked in the open panel. + XCTAssertEqual(ApplicationBundleMetadata(appURL: identifierOnly)?.displayName, "My Tool") + } + + func test_initAppURL_returnsNilWhenBundleHasNoIdentifier() throws { + let appURL = try makeAppBundle( + named: "NoIdentifier.app", + info: ["CFBundleDisplayName": "No Identifier"] + ) + + XCTAssertNil( + ApplicationBundleMetadata(appURL: appURL), + "A rule without a bundle identifier can never match a focused app" + ) + } + + func test_initAppURL_returnsNilWhenURLDoesNotExist() { + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("cotabby-missing-\(UUID().uuidString).app", isDirectory: true) + + XCTAssertNil(ApplicationBundleMetadata(appURL: missing)) + } + + private func makeAppBundle(named name: String, info: [String: Any]) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cotabby-bundle-test-\(UUID().uuidString)", isDirectory: true) + temporaryDirectories.append(root) + let appURL = root.appendingPathComponent(name, isDirectory: true) + let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contentsURL, withIntermediateDirectories: true) + let plistData = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try plistData.write(to: contentsURL.appendingPathComponent("Info.plist", isDirectory: false)) + return appURL + } } diff --git a/CotabbyTests/BundledRuntimeLocatorTests.swift b/CotabbyTests/BundledRuntimeLocatorTests.swift index 851f8f65..31b36bef 100644 --- a/CotabbyTests/BundledRuntimeLocatorTests.swift +++ b/CotabbyTests/BundledRuntimeLocatorTests.swift @@ -264,8 +264,99 @@ final class BundledRuntimeLocatorTests: XCTestCase { XCTAssertNil(BundledRuntimeLocator.enabledLMStudioModelsDirectory()) } + // MARK: - User runtime directory resolution + + func test_userRuntimeDirectoryURL_usesBundleNameFromInfoPlist() throws { + let bundle = try makeBundle(withInfo: ["CFBundleName": "LocatorProbe"]) + + let url = BundledRuntimeLocator.userRuntimeDirectoryURL(bundle: bundle) + + XCTAssertEqual(url.lastPathComponent, BundledRuntimeLocator.runtimeFolderName) + XCTAssertEqual(url.deletingLastPathComponent().lastPathComponent, "LocatorProbe") + XCTAssertEqual( + url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent, + "Application Support" + ) + } + + func test_userRuntimeDirectoryURL_fallsBackToCotabbyFolderWhenBundleNameMissing() throws { + // A bare directory bundle carries no Info.plist, so CFBundleName resolves to nil and the + // app-folder name must fall back to "Cotabby" rather than producing a nameless path. + let bundle = try makeBundle(withInfo: nil) + + let url = BundledRuntimeLocator.userRuntimeDirectoryURL(bundle: bundle) + + XCTAssertEqual(url.lastPathComponent, BundledRuntimeLocator.runtimeFolderName) + XCTAssertEqual(url.deletingLastPathComponent().lastPathComponent, "Cotabby") + } + + // MARK: - Default candidate enumeration (nil runtimeDirectoryPath) + + func test_resolve_defaultCandidates_throwsLocatorErrorForUnknownSelectedModel() { + // No explicit runtime directory: the locator walks its default candidates (the user-managed + // Application Support directory, plus LM Studio when enabled). The selected filename is + // unique, so resolution must fail with a locator error on every machine regardless of which + // models happen to be installed. This is read-only against the real directories. + let config = LlamaRuntimeConfiguration( + runtimeDirectoryPath: nil, + preferredModelNames: [], + contextWindowTokens: 2048, + batchSize: 512, + gpuLayerCount: -1 + ) + let locator = BundledRuntimeLocator() + + XCTAssertThrowsError( + try locator.resolve( + configuration: config, + selectedModelFilename: "cotabby-test-missing-\(UUID().uuidString).gguf" + ) + ) { error in + XCTAssertTrue(error is BundledRuntimeLocatorError, "Expected a locator error, got \(error)") + } + } + + func test_runtimeSearchDirectories_putsUserManagedDirectoryFirst() { + let directories = BundledRuntimeLocator.runtimeSearchDirectories(bundle: .main) + + // Cotabby's own directory is authoritative (and the download target), so it must always be + // scanned first; the LM Studio library may or may not be appended depending on the toggle. + XCTAssertEqual(directories.first, BundledRuntimeLocator.userRuntimeDirectoryURL(bundle: .main)) + XCTAssertFalse(directories.isEmpty) + } + + func test_lmStudioModelsDirectoryIfAvailable_returnsWellKnownPathOnlyWhenPresent() { + // Machine-state dependent by design, so assert the contract that holds either way: nil when + // ~/.lmstudio/models does not exist, otherwise exactly that directory. + let url = BundledRuntimeLocator.lmStudioModelsDirectoryIfAvailable() + + if let url { + XCTAssertTrue(url.path.hasSuffix("/.lmstudio/models")) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } else { + let expected = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".lmstudio/models") + XCTAssertFalse(FileManager.default.fileExists(atPath: expected.path)) + } + } + // MARK: - Helpers + /// Builds a throwaway on-disk bundle. With `info`, a minimal `.app` layout carrying that + /// Info.plist; with nil, a bare directory whose Bundle has no info dictionary values. + private func makeBundle(withInfo info: [String: Any]?) throws -> Bundle { + let root = try makeTemporaryDirectory() + guard let info else { + return try XCTUnwrap(Bundle(url: root)) + } + let appURL = root.appendingPathComponent("Probe.app", isDirectory: true) + let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contentsURL, withIntermediateDirectories: true) + let plistData = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try plistData.write(to: contentsURL.appendingPathComponent("Info.plist", isDirectory: false)) + return try XCTUnwrap(Bundle(url: appURL)) + } + private func makeTemporaryDirectory() throws -> URL { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("Cotabby-locator-test-\(UUID().uuidString)", isDirectory: true) diff --git a/CotabbyTests/CompletionRenderModePolicyTests.swift b/CotabbyTests/CompletionRenderModePolicyTests.swift index 2c517701..a70a911c 100644 --- a/CotabbyTests/CompletionRenderModePolicyTests.swift +++ b/CotabbyTests/CompletionRenderModePolicyTests.swift @@ -256,4 +256,20 @@ final class CompletionRenderModePolicyTests: XCTestCase { .mirror(reason: .caretMidLine) ) } + + // MARK: - User-facing preference metadata + + func test_mirrorPreference_displayLabelsUseProductVocabulary() { + // The policy is the single source of truth for the Settings copy: "mirror" is internal + // naming, the user-facing word is "Popup". + XCTAssertEqual(MirrorPreference.auto.displayLabel, "Auto") + XCTAssertEqual(MirrorPreference.alwaysInline.displayLabel, "Inline") + XCTAssertEqual(MirrorPreference.alwaysMirror.displayLabel, "Popup") + } + + func test_mirrorPreference_identifiableIdIsTheRawValue() { + for preference in MirrorPreference.allCases { + XCTAssertEqual(preference.id, preference.rawValue) + } + } } diff --git a/CotabbyTests/CotabbyDebugOptionsTests.swift b/CotabbyTests/CotabbyDebugOptionsTests.swift new file mode 100644 index 00000000..1d572ef3 --- /dev/null +++ b/CotabbyTests/CotabbyDebugOptionsTests.swift @@ -0,0 +1,156 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Locks the developer-diagnostics gate and the logging verbosity floor: the launch-argument +/// contract, the documented `COTABBY_LOG_LEVEL` precedence, and the swift-log-to-OSLog bridge. +/// +/// Environment-variable tests mutate the process environment through `setenv`/`unsetenv` and +/// restore the prior value before returning; XCTest runs the methods in this bundle serially, so +/// no other test can observe the temporary value. +final class CotabbyDebugOptionsTests: XCTestCase { + private static let levelKey = "COTABBY_LOG_LEVEL" + + /// Runs `body` with the environment variable forced to `value` (or removed when nil), then + /// restores whatever was there before, even if `body` throws or skips. + private func withEnvironmentValue( + _ key: String, + _ value: String?, + perform body: () throws -> Void + ) rethrows { + let previous = ProcessInfo.processInfo.environment[key] + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + defer { + if let previous { + setenv(key, previous, 1) + } else { + unsetenv(key) + } + } + try body() + } + + // MARK: - Debug gate + + func test_launchArgument_matchesDocumentedFlag() { + XCTAssertEqual(CotabbyDebugOptions.launchArgument, "-cotabby-debug") + } + + func test_isEnabled_mirrorsProcessLaunchArguments() { + XCTAssertEqual( + CotabbyDebugOptions.isEnabled, + ProcessInfo.processInfo.arguments.contains(CotabbyDebugOptions.launchArgument), + "The debug gate must key off the launch argument and nothing else" + ) + } + + func test_log_staysQuietWhenDebugModeIsOff() throws { + try XCTSkipIf(CotabbyDebugOptions.isEnabled, "Runner was launched with -cotabby-debug") + + // The guard is the privacy gate: without the explicit launch argument this must be a + // no-op rather than writing diagnostics anywhere. + CotabbyDebugOptions.log("coverage probe, should never be emitted") + } + + // MARK: - Verbosity floor precedence + + func test_minimumLogLevel_honorsExplicitEnvironmentOverride() { + withEnvironmentValue(Self.levelKey, "warning") { + XCTAssertEqual(CotabbyDebugOptions.minimumLogLevel.rawValue, "warning") + } + withEnvironmentValue(Self.levelKey, "error") { + XCTAssertEqual(CotabbyDebugOptions.minimumLogLevel.rawValue, "error") + } + } + + func test_minimumLogLevel_ignoresUnrecognizedOverride() { + withEnvironmentValue(Self.levelKey, "chatty") { + let expected = CotabbyDebugOptions.isEnabled ? "trace" : "info" + XCTAssertEqual( + CotabbyDebugOptions.minimumLogLevel.rawValue, + expected, + "A bogus override must fall back to the launch-argument default, not crash or stick" + ) + } + } + + func test_minimumLogLevel_normalizesOverrideCasing() { + withEnvironmentValue(Self.levelKey, "WARNING") { + XCTAssertEqual(CotabbyDebugOptions.minimumLogLevel.rawValue, "warning") + } + } + + func test_minimumLogLevel_defaultsToInfo() throws { + try withEnvironmentValue(Self.levelKey, nil) { + try XCTSkipIf(CotabbyDebugOptions.isEnabled, "Runner was launched with -cotabby-debug") + XCTAssertEqual(CotabbyDebugOptions.minimumLogLevel.rawValue, "info") + } + } + + // MARK: - Logger plumbing + + func test_llmIOLabel_isTheReservedRoutingContract() { + // FileLogHandler routing and the jq-based debugging workflow both key off this label. + XCTAssertEqual(CotabbyLogger.llmIOLabel, "com.cotabby.llm-io") + } + + func test_bootstrap_isIdempotent() { + // The host app already bootstrapped logging at launch; repeated calls must be safe no-ops + // (LoggingSystem traps on a second real bootstrap, so this locks the once-only guard). + CotabbyLogger.bootstrap() + CotabbyLogger.bootstrap() + } + + // MARK: - OSLogHandler + + func test_osLogHandler_defaultFloorTracksGlobalConfiguration() { + let handler = OSLogHandler(label: "com.cotabby.test-floor") + + XCTAssertEqual(handler.logLevel.rawValue, CotabbyDebugOptions.minimumLogLevel.rawValue) + } + + func test_osLogHandler_acceptsExplicitFloor() { + let handler = OSLogHandler(label: "com.cotabby.test-floor", logLevel: .critical) + + XCTAssertEqual(handler.logLevel.rawValue, "critical") + } + + func test_osLogHandler_metadataSubscriptReadsAndWrites() { + var handler = OSLogHandler(label: "com.cotabby.test-metadata") + + XCTAssertNil(handler[metadataKey: "request_id"]) + + handler[metadataKey: "request_id"] = .string("req_test1234") + XCTAssertEqual(handler[metadataKey: "request_id"], .string("req_test1234")) + + handler[metadataKey: "request_id"] = nil + XCTAssertNil(handler[metadataKey: "request_id"]) + } + + func test_loggerCopy_canLowerItsOwnFloorWithoutLeakingGlobally() { + // Logger is a value type: a call site may locally drop the floor to trace and emit at + // every level (exercising the full OSLog bridge switch) without mutating the shared + // logger's configuration. + var logger = CotabbyLogger.debug + logger.logLevel = .trace + + logger.trace("bridge probe: trace") + logger.debug("bridge probe: debug") + logger.info("bridge probe: info") + logger.notice("bridge probe: notice") + logger.warning("bridge probe: warning") + logger.error("bridge probe: error") + logger.critical("bridge probe: critical") + + XCTAssertEqual(logger.logLevel.rawValue, "trace") + XCTAssertEqual( + CotabbyLogger.debug.logLevel.rawValue, + CotabbyDebugOptions.minimumLogLevel.rawValue, + "Mutating a copied logger must not change the shared instance's floor" + ) + } +} diff --git a/CotabbyTests/DateMacroEvaluatorTests.swift b/CotabbyTests/DateMacroEvaluatorTests.swift index 09c9848a..894d8826 100644 --- a/CotabbyTests/DateMacroEvaluatorTests.swift +++ b/CotabbyTests/DateMacroEvaluatorTests.swift @@ -72,4 +72,45 @@ final class DateMacroEvaluatorTests: XCTestCase { XCTAssertEqual(sut.evaluate("+1week")?.insertionText, "Jun 11, 2026") XCTAssertEqual(sut.evaluate("+2days")?.insertionText, "Jun 6, 2026") } + + func test_noonAndMidnight_with24HourArgument() { + let sut = makeEvaluator() + XCTAssertEqual(sut.evaluate("noon(24h)")?.insertionText, "12:00") + XCTAssertEqual(sut.evaluate("midnight(24h)")?.insertionText, "00:00") + } + + func test_noon_defaultsToTwelveHourClock() { + // en_US short time style renders an AM/PM marker. The separator between the digits and the + // marker varies across ICU versions (regular vs narrow no-break space), so only the stable + // leading digits are pinned here. + let result = makeEvaluator().evaluate("noon")?.insertionText + XCTAssertNotNil(result) + XCTAssertTrue(result?.hasPrefix("12:00") ?? false, "Expected 12-hour rendering, got \(result ?? "nil")") + XCTAssertNotEqual(result, "12:00", "Without (24h) the locale's AM/PM marker must be present") + } + + func test_middayAlias_resolvesToNoon() { + let sut = makeEvaluator() + XCTAssertEqual(sut.evaluate("midday(24h)")?.insertionText, sut.evaluate("noon(24h)")?.insertionText) + } + + func test_datetime_combinesMediumDateWithShortTime() { + // The date/time joiner ("at") and the AM/PM separator are ICU details, so assert the two + // halves rather than the full literal string. + let result = makeEvaluator().evaluate("datetime")?.insertionText + XCTAssertNotNil(result) + XCTAssertTrue(result?.contains("Jun 4, 2026") ?? false, "Expected medium date in \(result ?? "nil")") + XCTAssertTrue(result?.contains("12:00") ?? false, "Expected short time in \(result ?? "nil")") + } + + func test_dtAlias_resolvesToDatetime() { + let sut = makeEvaluator() + XCTAssertEqual(sut.evaluate("dt")?.insertionText, sut.evaluate("datetime")?.insertionText) + } + + func test_unknownWeekdayPrefix_returnsNil() { + // "blah-fri" carries a valid weekday token but no recognized this/next/last prefix, so the + // weekday resolver must decline instead of guessing a direction. + XCTAssertNil(makeEvaluator().evaluate("blah-fri")) + } } diff --git a/CotabbyTests/DeviceInfoTests.swift b/CotabbyTests/DeviceInfoTests.swift new file mode 100644 index 00000000..cb97071c --- /dev/null +++ b/CotabbyTests/DeviceInfoTests.swift @@ -0,0 +1,142 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Covers both halves of `DeviceInfo`: the host snapshot (sanity-bounded, since values come from +/// the real machine) and the pure query-item serialization that feedback links rely on. +final class DeviceInfoTests: XCTestCase { + // MARK: - Host snapshot + + func test_snapshot_reportsShortMacOSVersionString() { + let snapshot = DeviceInfo.snapshot() + + // Same composition rule as production: the point is locking the short "14.6" format + // (no "Version" prefix, no build number, patch omitted when zero). + let version = ProcessInfo.processInfo.operatingSystemVersion + let expected: String + if version.patchVersion == 0 { + expected = "\(version.majorVersion).\(version.minorVersion)" + } else { + expected = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + } + XCTAssertEqual(snapshot.macosVersion, expected) + } + + func test_snapshot_reportsWholeGigabyteMemory() { + let snapshot = DeviceInfo.snapshot() + + let expected = Int((Double(ProcessInfo.processInfo.physicalMemory) / 1_073_741_824.0).rounded()) + XCTAssertEqual(snapshot.memoryGB, expected) + XCTAssertGreaterThan(snapshot.memoryGB ?? 0, 0) + } + + func test_snapshot_reportsTrimmedHardwareIdentifiers() throws { + let snapshot = DeviceInfo.snapshot() + + let model = try XCTUnwrap(snapshot.model, "hw.model should exist on every Mac") + XCTAssertFalse(model.isEmpty) + XCTAssertEqual(model, model.trimmingCharacters(in: .whitespacesAndNewlines)) + + let chip = try XCTUnwrap(snapshot.chip, "machdep.cpu.brand_string should exist on macOS 14+") + XCTAssertFalse(chip.isEmpty) + XCTAssertEqual(chip, chip.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + func test_snapshot_reportsAppVersionFromHostBundle() throws { + let snapshot = DeviceInfo.snapshot() + + // Hosted tests run inside the Cotabby app bundle, which always carries a version string. + let appVersion = try XCTUnwrap(snapshot.appVersion) + XCTAssertFalse(appVersion.isEmpty) + } + + // MARK: - Query-item serialization + + private let base = URL(string: "https://cotabby.app/feedback")! + + private func queryItems(of url: URL) -> [URLQueryItem] { + URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + } + + func test_appending_addsAllFieldsInStableOrder() { + let snapshot = DeviceInfo.Snapshot( + appVersion: "1.2", + macosVersion: "14.6", + model: "Mac15,6", + chip: "Apple M3 Pro", + memoryGB: 36 + ) + + let url = snapshot.appending(to: base) + + let items = queryItems(of: url) + XCTAssertEqual(items.map(\.name), ["appVersion", "macosVersion", "model", "chip", "memoryGB"]) + XCTAssertEqual(items.map(\.value), ["1.2", "14.6", "Mac15,6", "Apple M3 Pro", "36"]) + } + + func test_appending_preservesExistingQueryItems() { + let snapshot = DeviceInfo.Snapshot( + appVersion: "2.0", + macosVersion: nil, + model: nil, + chip: nil, + memoryGB: nil + ) + let baseWithQuery = URL(string: "https://cotabby.app/feedback?source=menu")! + + let url = snapshot.appending(to: baseWithQuery) + + let items = queryItems(of: url) + XCTAssertEqual(items.map(\.name), ["source", "appVersion"]) + XCTAssertEqual(items.map(\.value), ["menu", "2.0"]) + } + + func test_appending_omitsNilAndEmptyFields() { + // Empty strings and nils must both vanish so the landing page never receives blank values. + let snapshot = DeviceInfo.Snapshot( + appVersion: "", + macosVersion: "15.0", + model: nil, + chip: "", + memoryGB: 16 + ) + + let url = snapshot.appending(to: base) + + let items = queryItems(of: url) + XCTAssertEqual(items.map(\.name), ["macosVersion", "memoryGB"]) + XCTAssertEqual(items.map(\.value), ["15.0", "16"]) + } + + func test_appending_returnsBaseUnchangedWhenSnapshotIsEmpty() { + let snapshot = DeviceInfo.Snapshot( + appVersion: nil, + macosVersion: nil, + model: nil, + chip: nil, + memoryGB: nil + ) + + let url = snapshot.appending(to: base) + + XCTAssertEqual(url, base) + XCTAssertNil(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + } + + func test_appending_percentEncodesValuesLosslessly() { + let snapshot = DeviceInfo.Snapshot( + appVersion: nil, + macosVersion: nil, + model: nil, + chip: "Intel(R) Core(TM) i9 & friends", + memoryGB: nil + ) + + let url = snapshot.appending(to: base) + + // The raw absolute string must not contain unescaped spaces, and decoding must round-trip + // the original value exactly. + XCTAssertFalse(url.absoluteString.contains(" ")) + XCTAssertEqual(queryItems(of: url).first?.value, "Intel(R) Core(TM) i9 & friends") + } +} diff --git a/CotabbyTests/DisplayCoordinateConverterTests.swift b/CotabbyTests/DisplayCoordinateConverterTests.swift index 4b1a6438..346ba7a4 100644 --- a/CotabbyTests/DisplayCoordinateConverterTests.swift +++ b/CotabbyTests/DisplayCoordinateConverterTests.swift @@ -68,4 +68,73 @@ final class DisplayCoordinateConverterTests: XCTestCase { XCTAssertEqual(rects, [CGRect(x: 1540, y: 902, width: 40, height: 20)]) } + + func test_appKitRect_returnsNilWhenNoDisplayOwnsTheRect() { + let primary = DisplayGeometry( + appKitFrame: CGRect(x: 0, y: 0, width: 1440, height: 900), + visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 875), + coreGraphicsBounds: CGRect(x: 0, y: 0, width: 1440, height: 900), + backingScaleFactor: 2 + ) + + // A rect far outside every display (a stale AX value after a monitor was unplugged) must + // map to nil rather than to a flipped guess against the wrong display. + XCTAssertNil(DisplayCoordinateConverter.appKitRect( + fromCoreGraphicsRect: CGRect(x: 5000, y: 5000, width: 10, height: 10), + displays: [primary] + )) + XCTAssertNil(DisplayCoordinateConverter.appKitRect( + fromCoreGraphicsRect: CGRect(x: 100, y: 100, width: 10, height: 10), + displays: [] + )) + } + + func test_appKitRect_fallsBackToLargestIntersectionWhenMidpointOutsideEveryDisplay() { + let left = DisplayGeometry( + appKitFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + visibleFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + coreGraphicsBounds: CGRect(x: 0, y: 0, width: 100, height: 100), + backingScaleFactor: 1 + ) + let right = DisplayGeometry( + appKitFrame: CGRect(x: 100, y: 0, width: 100, height: 100), + visibleFrame: CGRect(x: 100, y: 0, width: 100, height: 100), + coreGraphicsBounds: CGRect(x: 100, y: 0, width: 100, height: 100), + backingScaleFactor: 1 + ) + + // The rect hangs below both displays so its midpoint (90, 110) is inside neither, but it + // overlaps the left display by 300 square points and the right by only 100. The conversion + // must flip inside the display with the larger overlap. + let rect = DisplayCoordinateConverter.appKitRect( + fromCoreGraphicsRect: CGRect(x: 70, y: 90, width: 40, height: 40), + displays: [left, right] + ) + + XCTAssertEqual(rect, CGRect(x: 70, y: -30, width: 40, height: 40)) + } + + func test_appKitRectsFromPixelRect_skipsDisplaysWithZeroScaleFactor() { + // A zero backing scale (a display mid-reconfiguration) would divide by zero; the converter + // must skip that display and still convert against the healthy one. + let broken = DisplayGeometry( + appKitFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + visibleFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + coreGraphicsBounds: CGRect(x: 0, y: 0, width: 100, height: 100), + backingScaleFactor: 0 + ) + let working = DisplayGeometry( + appKitFrame: CGRect(x: 0, y: 0, width: 1440, height: 900), + visibleFrame: CGRect(x: 0, y: 0, width: 1440, height: 875), + coreGraphicsBounds: CGRect(x: 0, y: 0, width: 1440, height: 900), + backingScaleFactor: 2 + ) + + let rects = DisplayCoordinateConverter.appKitRectsFromPixelRect( + CGRect(x: 100, y: 100, width: 80, height: 40), + displays: [broken, working] + ) + + XCTAssertEqual(rects, [CGRect(x: 50, y: 830, width: 40, height: 20)]) + } } diff --git a/CotabbyTests/EmojiCatalogTests.swift b/CotabbyTests/EmojiCatalogTests.swift new file mode 100644 index 00000000..da34f34d --- /dev/null +++ b/CotabbyTests/EmojiCatalogTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import Cotabby + +/// Tests for `EmojiCatalog`'s impure loading entry point and its failure modes. +/// +/// `bundled(in:)` must degrade to an empty catalog on a packaging mistake (missing or undecodable +/// resource) instead of taking down the app, so each failure branch is pinned against a throwaway +/// directory bundle. The matcher-facing index behavior lives in `EmojiCatalogMatcherTests`. +final class EmojiCatalogTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDown() { + for url in temporaryDirectories { + try? FileManager.default.removeItem(at: url) + } + temporaryDirectories.removeAll() + super.tearDown() + } + + func test_bundled_loadsEntriesFromResourceJSON() throws { + let json = """ + [ + {"glyph": "😀", "name": "grinning face", "aliases": ["grinning"], + "keywords": ["smile", "happy"], "group": "Smileys & Emotion", "unicodeVersion": "6.1"}, + {"glyph": "👍", "name": "thumbs up", "aliases": ["+1", "thumbsup"], + "keywords": ["approve"], "group": "People & Body", "unicodeVersion": "6.0"} + ] + """ + let bundle = try makeResourceBundle(emojiJSON: json) + + let catalog = EmojiCatalog.bundled(in: bundle) + + XCTAssertEqual(catalog.count, 2) + XCTAssertFalse(catalog.isEmpty) + // The loaded catalog must resolve stored aliases case-insensitively, as recents/popularity + // lookups rely on. + XCTAssertEqual(catalog.entry(forAlias: "Grinning")?.glyph, "😀") + XCTAssertEqual(catalog.entry(forAlias: "+1")?.glyph, "👍") + } + + func test_bundled_returnsEmptyCatalogWhenResourceIsMissing() throws { + let bundle = try makeResourceBundle(emojiJSON: nil) + + let catalog = EmojiCatalog.bundled(in: bundle) + + XCTAssertTrue(catalog.isEmpty, "A missing resource must disable the picker, not crash") + XCTAssertEqual(catalog.count, 0) + } + + func test_bundled_returnsEmptyCatalogWhenJSONIsMalformed() throws { + let bundle = try makeResourceBundle(emojiJSON: "this is not json") + + let catalog = EmojiCatalog.bundled(in: bundle) + + XCTAssertTrue(catalog.isEmpty, "An undecodable resource must disable the picker, not crash") + } + + func test_count_reportsNumberOfIndexedEntries() { + let entries = [ + EmojiEntry( + glyph: "🐱", + name: "cat face", + aliases: ["cat"], + keywords: ["pet"], + group: "Animals & Nature", + unicodeVersion: "6.0" + ) + ] + + XCTAssertEqual(EmojiCatalog(entries: entries).count, 1) + XCTAssertEqual(EmojiCatalog(entries: []).count, 0) + } + + /// Builds a flat directory bundle. Foundation treats a plain directory as an unbundled layout + /// whose resources live at the root, which is exactly how `bundled(in:)` probes for emoji.json. + private func makeResourceBundle(emojiJSON: String?) throws -> Bundle { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cotabby-emoji-catalog-test-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + temporaryDirectories.append(dir) + if let emojiJSON { + try emojiJSON.write( + to: dir.appendingPathComponent("emoji.json", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + } + return try XCTUnwrap(Bundle(url: dir)) + } +} diff --git a/CotabbyTests/EmojiPickerModelsTests.swift b/CotabbyTests/EmojiPickerModelsTests.swift new file mode 100644 index 00000000..66bc65d6 --- /dev/null +++ b/CotabbyTests/EmojiPickerModelsTests.swift @@ -0,0 +1,75 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Tests for the emoji picker value models: match identity under variant overrides, alias +/// fallbacks, and the skin-tone / gender settings copy and sample glyphs. +final class EmojiPickerModelsTests: XCTestCase { + func test_emojiMatch_defaultsDisplayGlyphToEntryGlyphAndUsesItAsIdentity() { + let match = EmojiMatch(entry: makeEntry()) + + XCTAssertEqual(match.displayGlyph, "\u{1F44B}") + XCTAssertEqual(match.glyph, "\u{1F44B}") + XCTAssertEqual(match.id, "\u{1F44B}") + XCTAssertEqual(match.primaryAlias, "wave") + } + + func test_emojiMatch_variantOverrideChangesIdentityButKeepsSourceEntry() { + let base = makeEntry() + let toned = EmojiMatch(entry: base, displayGlyph: "\u{1F44B}\u{1F3FD}") + + // A neutral row and its skin-toned sibling share the entry but must stay distinct + // SwiftUI list rows, so identity follows the displayed glyph. + XCTAssertEqual(toned.id, "\u{1F44B}\u{1F3FD}") + XCTAssertEqual(toned.glyph, "\u{1F44B}\u{1F3FD}") + XCTAssertEqual(toned.entry, base) + XCTAssertEqual(toned.primaryAlias, "wave") + } + + func test_emojiMatch_primaryAliasFallsBackToNameWhenEntryHasNoAliases() { + let match = EmojiMatch(entry: makeEntry(aliases: [])) + XCTAssertEqual(match.primaryAlias, "waving hand") + } + + func test_emojiSkinTone_displayNamesArePinnedSettingsCopy() { + XCTAssertEqual(EmojiSkinTone.neutral.displayName, "Default") + XCTAssertEqual(EmojiSkinTone.light.displayName, "Light") + XCTAssertEqual(EmojiSkinTone.mediumLight.displayName, "Medium Light") + XCTAssertEqual(EmojiSkinTone.medium.displayName, "Medium") + XCTAssertEqual(EmojiSkinTone.mediumDark.displayName, "Medium Dark") + XCTAssertEqual(EmojiSkinTone.dark.displayName, "Dark") + } + + func test_emojiSkinTone_sampleGlyphAppendsModifierAndNeutralKeepsVariationSelector() throws { + // Without U+FE0F the neutral victory hand can render as the plain text symbol. + XCTAssertEqual(EmojiSkinTone.neutral.sampleGlyph, "\u{270C}\u{FE0F}") + + for tone in EmojiSkinTone.allCases where tone != .neutral { + let modifier = try XCTUnwrap(tone.modifier, "\(tone) should carry a Fitzpatrick modifier") + XCTAssertEqual(tone.sampleGlyph, "\u{270C}" + modifier) + } + } + + func test_emojiGender_displayNamesArePinnedSettingsCopy() { + XCTAssertEqual(EmojiGender.neutral.displayName, "Person") + XCTAssertEqual(EmojiGender.male.displayName, "Man") + XCTAssertEqual(EmojiGender.female.displayName, "Woman") + } + + func test_emojiGender_sampleGlyphsAreTheBasePersonManWomanScalars() { + XCTAssertEqual(EmojiGender.neutral.sampleGlyph, "\u{1F9D1}") + XCTAssertEqual(EmojiGender.male.sampleGlyph, "\u{1F468}") + XCTAssertEqual(EmojiGender.female.sampleGlyph, "\u{1F469}") + } + + private func makeEntry(aliases: [String] = ["wave"]) -> EmojiEntry { + EmojiEntry( + glyph: "\u{1F44B}", + name: "waving hand", + aliases: aliases, + keywords: ["hello"], + group: "People & Body", + unicodeVersion: "6.0" + ) + } +} diff --git a/CotabbyTests/EmojiTriggerStateMachineTests.swift b/CotabbyTests/EmojiTriggerStateMachineTests.swift index 44e24ccc..c9f644a7 100644 --- a/CotabbyTests/EmojiTriggerStateMachineTests.swift +++ b/CotabbyTests/EmojiTriggerStateMachineTests.swift @@ -234,4 +234,27 @@ final class EmojiTriggerStateMachineTests: XCTestCase { XCTAssertEqual(output.actions, [.cancel]) XCTAssertFalse(sut.isCapturing) } + + func test_nonCharacterInputsWhileIdle_areIgnoredAndEraseBoundaryKnowledge() { + let inputs: [EmojiTriggerInput] = [ + .backspace, .navigate(.down), .commitKey, .escape, .focusChanged, .dismissExternally + ] + for input in inputs { + var sut = EmojiTriggerStateMachine() + type("a", into: &sut) + + let output = sut.reduce(input, selectableMatchCount: 3) + + XCTAssertEqual(output, .ignored, "input \(input) should be ignored while idle") + XCTAssertFalse(sut.isCapturing) + // The preceding "a" is forgotten, so the next ":" is evaluated like the start of the + // field and opens a capture even though a letter was the last typed character. + let reopened = sut.reduce(.character(":"), selectableMatchCount: 0) + XCTAssertEqual( + reopened.actions, + [.open(query: "")], + "input \(input) should erase the preceding-character memory" + ) + } + } } diff --git a/CotabbyTests/FileLogHandlerTests.swift b/CotabbyTests/FileLogHandlerTests.swift new file mode 100644 index 00000000..25a540a1 --- /dev/null +++ b/CotabbyTests/FileLogHandlerTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Logging +import XCTest +@testable import Cotabby + +/// Locks the JSONL debug sink: one valid JSON object per line with metadata flattened to +/// top-level keys (the `jq` contract the debugging docs promise), and one-step size rotation +/// that preserves the previous file as `.jsonl.1` instead of truncating recent history. +final class FileLogHandlerTests: XCTestCase { + private var directory: URL! + + override func setUp() { + super.setUp() + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + directory = nil + super.tearDown() + } + + private func makeWriter(cap: UInt64? = nil) -> FileLogWriter { + FileLogWriter(sizeCapBytes: cap, fileURL: directory.appendingPathComponent("cotabby.jsonl")) + } + + private func lines(of url: URL) -> [String] { + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return [] } + return content.split(separator: "\n").map(String.init) + } + + func test_log_emitsOneValidJSONObjectPerLineWithFlattenedMetadata() throws { + let writer = makeWriter() + var handler = FileLogHandler(label: "com.cotabby.suggestion", writer: writer, logLevel: .trace) + handler[metadataKey: "handler_key"] = .string("handler_value") + XCTAssertEqual(handler[metadataKey: "handler_key"], .string("handler_value")) + handler.log(event: LogEvent( + level: .info, + message: "Suggestion ready", + metadata: [ + "request_id": .string("req_test1234"), + "latency_ms": .stringConvertible(42), + "nested": .dictionary(["inner": .string("x")]), + "list": .array([.string("a"), .string("b")]) + ], + source: "CotabbyTests", + file: #filePath, + function: #function, + line: #line + )) + + let url = try XCTUnwrap(writer.fileURL) + let written = lines(of: url) + XCTAssertEqual(written.count, 1) + let record = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(written[0].utf8)) as? [String: Any] + ) + XCTAssertEqual(record["category"] as? String, "suggestion") + XCTAssertEqual(record["level"] as? String, "info") + XCTAssertEqual(record["message"] as? String, "Suggestion ready") + XCTAssertEqual(record["request_id"] as? String, "req_test1234") + XCTAssertEqual(record["latency_ms"] as? String, "42") + XCTAssertEqual((record["nested"] as? [String: Any])?["inner"] as? String, "x") + XCTAssertEqual(record["list"] as? [String], ["a", "b"]) + XCTAssertEqual(record["handler_key"] as? String, "handler_value") + XCTAssertNotNil(record["timestamp"]) + } + + func test_log_eventMetadataWinsOverHandlerMetadataOnCollision() throws { + let writer = makeWriter() + var handler = FileLogHandler(label: "short-label", writer: writer, logLevel: .trace) + handler[metadataKey: "shared"] = .string("from_handler") + handler.log(event: LogEvent( + level: .warning, + message: "collide", + metadata: ["shared": .string("from_event")], + source: "CotabbyTests", + file: #filePath, + function: #function, + line: #line + )) + + let url = try XCTUnwrap(writer.fileURL) + let record = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(lines(of: url)[0].utf8)) as? [String: Any] + ) + XCTAssertEqual(record["shared"] as? String, "from_event") + // A label without the reverse-DNS shape passes through unchanged. + XCTAssertEqual(record["category"] as? String, "short-label") + } + + func test_writer_rotatesPastTheCapKeepingPreviousHistory() throws { + let writer = makeWriter(cap: 64) + let line = String(repeating: "a", count: 40) + "\n" + + writer.write(line) + writer.write(line) + // The third write finds the offset past the cap: the existing file must move to .jsonl.1 + // and the new line start a fresh file, so the most recent history survives the cap. + writer.write("fresh\n") + + let url = try XCTUnwrap(writer.fileURL) + let rotatedURL = url.deletingPathExtension().appendingPathExtension("jsonl.1") + XCTAssertEqual(lines(of: url), ["fresh"]) + XCTAssertEqual(lines(of: rotatedURL).count, 2) + XCTAssertTrue(lines(of: rotatedURL).allSatisfy { $0 == String(repeating: "a", count: 40) }) + } + + func test_writer_appendsAcrossInstancesLikeARelaunch() throws { + let url = directory.appendingPathComponent("cotabby.jsonl") + FileLogWriter(sizeCapBytes: nil, fileURL: url).write("first\n") + + // A second writer (a relaunch) must append after the existing bytes, not truncate. + FileLogWriter(sizeCapBytes: nil, fileURL: url).write("second\n") + + XCTAssertEqual(lines(of: url), ["first", "second"]) + } +} diff --git a/CotabbyTests/FocusModelsTests.swift b/CotabbyTests/FocusModelsTests.swift new file mode 100644 index 00000000..d55826d6 --- /dev/null +++ b/CotabbyTests/FocusModelsTests.swift @@ -0,0 +1,63 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Tests for the pure focus value models: resolved field style emptiness, menu-facing capability +/// summaries, and the polling-event change label. +final class FocusModelsTests: XCTestCase { + func test_resolvedFieldStyle_isEmptyWhenNoRenderableAttributeIsPresent() { + let empty = ResolvedFieldStyle(fontName: nil, fontPointSize: nil, colorHex: nil) + XCTAssertTrue(empty.isEmpty) + + let fontOnly = ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: nil, colorHex: nil) + XCTAssertFalse(fontOnly.isEmpty) + + let colorOnly = ResolvedFieldStyle(fontName: nil, fontPointSize: nil, colorHex: "336699") + XCTAssertFalse(colorOnly.isEmpty) + } + + func test_resolvedFieldStyle_pointSizeAloneIsNotARenderableStyle() { + // A bare point size cannot style ghost text without a font or color, so it must still + // count as empty and let the overlay fall back to defaults. + let sizeOnly = ResolvedFieldStyle(fontName: nil, fontPointSize: 13, colorHex: nil) + XCTAssertTrue(sizeOnly.isEmpty) + } + + func test_focusSnapshot_capabilitySummaryForwardsTheCapabilityReason() { + XCTAssertEqual(FocusSnapshot.inactive.capabilitySummary, "No focused text input") + + let supported = FocusSnapshot( + applicationName: "Notes", + bundleIdentifier: "com.apple.Notes", + capability: .supported, + context: nil, + inspection: nil + ) + XCTAssertEqual(supported.capabilitySummary, "Supported") + + let blocked = FocusSnapshot( + applicationName: "Safari", + bundleIdentifier: "com.apple.Safari", + capability: .blocked("Secure text field"), + context: nil, + inspection: nil + ) + XCTAssertEqual(blocked.capabilitySummary, "Secure text field") + } + + func test_focusPollingEvent_changeSummaryLabelsReflectFocusChange() { + XCTAssertEqual(makePollingEvent(didChange: true).changeSummary, "changed") + XCTAssertEqual(makePollingEvent(didChange: false).changeSummary, "unchanged") + } + + private func makePollingEvent(didChange: Bool) -> FocusPollingEvent { + FocusPollingEvent( + sequence: 1, + focusChangeSequence: 2, + didChangeFocusedInput: didChange, + applicationName: "Notes", + capabilitySummary: "Supported", + occurredAt: Date(timeIntervalSinceReferenceDate: 0) + ) + } +} diff --git a/CotabbyTests/FocusSnapshotResolverLiveTests.swift b/CotabbyTests/FocusSnapshotResolverLiveTests.swift new file mode 100644 index 00000000..1a8bd3b9 --- /dev/null +++ b/CotabbyTests/FocusSnapshotResolverLiveTests.swift @@ -0,0 +1,203 @@ +import AppKit +import ApplicationServices +import XCTest +@testable import Cotabby + +/// End-to-end resolver coverage against a real AX implementation: the suite builds windows with +/// real AppKit text fields inside the test host, then runs `FocusSnapshotResolver.resolveSnapshot` +/// on their live `AXUIElement`s. This validates candidate search, capability gating, text-window +/// slicing, caret-geometry resolution, and snapshot assembly against AppKit's actual AX surface +/// instead of mocks. Skips (rather than fails) where self-process AX is unavailable, e.g. an +/// untrusted headless CI runner. +@MainActor +final class FocusSnapshotResolverLiveTests: XCTestCase { + private static var window: NSWindow? + private static var textView: NSTextView? + private static var secureField: NSSecureTextField? + + private static let bodyText = "Pack my box with five dozen liquor jugs" + + private func requireElements() throws -> (text: AXUIElement, secure: AXUIElement) { + if let text = Self.textElement, let secure = Self.secureElement { + return (text, secure) + } + + if Self.window == nil { + let window = NSWindow( + contentRect: NSRect(x: 160, y: 160, width: 460, height: 240), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.title = "Cotabby resolver test host" + let textView = NSTextView(frame: NSRect(x: 10, y: 60, width: 440, height: 170)) + textView.string = Self.bodyText + textView.font = NSFont(name: "Helvetica", size: 13) ?? NSFont.systemFont(ofSize: 13) + textView.setAccessibilityIdentifier("cotabby-resolver-test-field") + let secureField = NSSecureTextField(frame: NSRect(x: 10, y: 16, width: 200, height: 24)) + secureField.stringValue = "hunter2" + window.contentView?.addSubview(textView) + window.contentView?.addSubview(secureField) + window.orderFrontRegardless() + window.makeFirstResponder(textView) + Self.window = window + Self.textView = textView + Self.secureField = secureField + } + + let appElement = AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier) + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + if let text = Self.textElement, let secure = Self.secureElement { + return (text, secure) + } + _ = Self.cacheElements(under: appElement, depth: 0) + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + } + throw XCTSkip("Self-process AX is unavailable in this environment") + } + + private static var textElement: AXUIElement? + private static var secureElement: AXUIElement? + + private static func cacheElements(under element: AXUIElement, depth: Int) -> Bool { + guard depth <= 8 else { return false } + let role = AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) + if role == (kAXTextAreaRole as String), + AXHelper.accessibilityIdentifier(of: element) == "cotabby-resolver-test-field" { + textElement = element + } + if role == (kAXTextFieldRole as String), + AXHelper.stringValue(for: kAXSubroleAttribute as CFString, on: element) == "AXSecureTextField" { + secureElement = element + } + if textElement != nil, secureElement != nil { + return true + } + for child in AXHelper.childElements(of: element) where cacheElements(under: child, depth: depth + 1) { + return true + } + return false + } + + private var resolver: FocusSnapshotResolver { + FocusSnapshotResolver() + } + + func test_resolveSnapshot_supportedFieldCarriesTextSelectionAndGeometry() throws { + let elements = try requireElements() + Self.textView?.setSelectedRange(NSRange(location: 8, length: 0)) + + let snapshot = resolver.resolveSnapshot( + focusedElement: elements.text, + application: NSRunningApplication.current, + focusChangeSequence: 42 + ) + + guard case .supported = snapshot.capability else { + return XCTFail("Expected supported, got \(snapshot.capability.summary)") + } + guard let context = snapshot.context else { + return XCTFail("Supported snapshot must carry a context") + } + XCTAssertEqual(context.precedingText, "Pack my ") + XCTAssertEqual(context.trailingText, "box with five dozen liquor jugs") + XCTAssertEqual(context.selection.length, 0) + XCTAssertEqual(context.focusChangeSequence, 42) + XCTAssertFalse(context.isSecure) + XCTAssertFalse(context.caretRect.isEmpty, "A real caret rect must resolve from live AX") + XCTAssertNotEqual(context.caretQuality, .estimated, "AppKit serves real text geometry") + XCTAssertEqual(context.inputFrameRect?.isEmpty, false) + XCTAssertEqual(context.processIdentifier, ProcessInfo.processInfo.processIdentifier) + XCTAssertEqual(context.resolvedFieldStyle?.fontPointSize, 13) + } + + func test_resolveSnapshot_selectionRangeBlocksAssistance() throws { + let elements = try requireElements() + Self.textView?.setSelectedRange(NSRange(location: 0, length: 4)) + defer { Self.textView?.setSelectedRange(NSRange(location: 8, length: 0)) } + + let snapshot = resolver.resolveSnapshot( + focusedElement: elements.text, + application: NSRunningApplication.current + ) + + guard case let .blocked(reason) = snapshot.capability else { + return XCTFail("Expected blocked, got \(snapshot.capability.summary)") + } + XCTAssertTrue(reason.contains("selected")) + XCTAssertNotNil(snapshot.context, "Blocked snapshots still carry context for diagnostics") + } + + func test_resolveSnapshot_secureFieldIsBlockedNotUnsupported() throws { + let elements = try requireElements() + + let snapshot = resolver.resolveSnapshot( + focusedElement: elements.secure, + application: NSRunningApplication.current + ) + + guard case let .blocked(reason) = snapshot.capability else { + return XCTFail("Expected blocked, got \(snapshot.capability.summary)") + } + XCTAssertTrue(reason.contains("Secure"), "Got: \(reason)") + XCTAssertEqual(snapshot.context?.isSecure, true) + } + + func test_resolveSnapshot_nonEditableElementIsUnsupportedWithInspection() throws { + _ = try requireElements() + + let appElement = AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier) + var windowValue: CFTypeRef? + guard AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowValue) == .success, + let windows = windowValue as? [AnyObject], + let first = windows.first, + CFGetTypeID(first) == AXUIElementGetTypeID() else { + throw XCTSkip("Window AX element unavailable") + } + let windowElement = unsafeBitCast(first, to: AXUIElement.self) + + let snapshot = resolver.resolveSnapshot( + focusedElement: windowElement, + application: NSRunningApplication.current + ) + + // Whichever editable the candidate walk happens to find (the window owns two), the result + // must be deterministic in shape: either a supported editable resolved from descendants, + // or a structured unsupported reason; never a crash or an empty-context "supported". + switch snapshot.capability { + case .supported: + XCTAssertNotNil(snapshot.context) + case .blocked, .unsupported: + XCTAssertNotNil(snapshot.inspection) + } + } + + func test_resolveSnapshot_caretWindowBoundsLargeDocuments() throws { + let elements = try requireElements() + let hugePrefix = String(repeating: "a", count: 6_000) + Self.textView?.string = hugePrefix + "tail" + Self.textView?.setSelectedRange(NSRange(location: 6_000, length: 0)) + defer { + Self.textView?.string = Self.bodyText + Self.textView?.setSelectedRange(NSRange(location: 8, length: 0)) + } + + let snapshot = resolver.resolveSnapshot( + focusedElement: elements.text, + application: NSRunningApplication.current + ) + + guard let context = snapshot.context else { + return XCTFail("Expected a context for the large document") + } + XCTAssertLessThanOrEqual( + context.precedingText.utf16.count, + FocusSnapshotResolver.focusedTextContextWindowUTF16, + "The caret window must cap what flows into equality checks and signatures" + ) + XCTAssertTrue(context.precedingText.allSatisfy { $0 == "a" }) + XCTAssertEqual(context.trailingText, "tail") + } +} diff --git a/CotabbyTests/FocusSnapshotResolverSelectionTests.swift b/CotabbyTests/FocusSnapshotResolverSelectionTests.swift index 64e21ae3..b0d11cfe 100644 --- a/CotabbyTests/FocusSnapshotResolverSelectionTests.swift +++ b/CotabbyTests/FocusSnapshotResolverSelectionTests.swift @@ -88,4 +88,43 @@ final class FocusSnapshotResolverSelectionTests: XCTestCase { XCTAssertEqual(selected.quality, .estimated) XCTAssertEqual(selected.source, "estimated primary-fallback") } + + func testSelectReturnsNilWhenNeitherSourceProducedARect() { + XCTAssertNil(CaretGeometrySelector.select( + primaryRect: nil, + primaryQuality: nil, + primaryObservedCharWidth: nil, + deepResult: nil + )) + } + + func testPrimarySourceDetailIsAppendedToTheSourceLabel() throws { + // The resolver-supplied mapping detail must surface in the debug badge label so logs show + // not just which branch won but how the caret mapped. + let selected = try XCTUnwrap(CaretGeometrySelector.select( + primaryRect: primaryRect, + primaryQuality: .exact, + primaryObservedCharWidth: nil, + primarySourceDetail: "marker-run", + deepResult: nil + )) + + XCTAssertEqual(selected.source, "exact primary (marker-run)") + XCTAssertEqual(selected.quality, .exact) + } + + func testUnknownPrimaryQualityFallsBackToEstimatedWithUnknownLabel() throws { + // A rect with no quality signal at all still ships (better than nothing), but it must be + // labeled "unknown" and demoted to `.estimated` so downstream policy treats it as weak. + let selected = try XCTUnwrap(CaretGeometrySelector.select( + primaryRect: primaryRect, + primaryQuality: nil, + primaryObservedCharWidth: nil, + deepResult: nil + )) + + XCTAssertEqual(selected.rect, primaryRect) + XCTAssertEqual(selected.quality, .estimated) + XCTAssertEqual(selected.source, "unknown primary-fallback") + } } diff --git a/CotabbyTests/FocusTrackingModelTests.swift b/CotabbyTests/FocusTrackingModelTests.swift new file mode 100644 index 00000000..5e55c583 --- /dev/null +++ b/CotabbyTests/FocusTrackingModelTests.swift @@ -0,0 +1,158 @@ +import XCTest +@testable import Cotabby + +/// Drives `FocusTrackingModel` through its observable lifecycle with a permission provider that +/// always answers `false`. That keeps every capture on the deterministic "permission missing" +/// path: no Accessibility reads, no dependence on whatever window happens to be focused on the +/// machine running the tests, while still exercising the model's publishing and polling plumbing. +/// +/// `FocusTrackingModel` is `@MainActor` with stored properties and no `nonisolated deinit`, so +/// instances are quarantined in a process-lifetime retain list (the `InputMonitorTests` pattern) +/// and every interaction runs through `runOnMainActor`. +final class FocusTrackingModelTests: XCTestCase { + @MainActor private static var retainedModels: [FocusTrackingModel] = [] + + /// The exact snapshot the tracker publishes when Accessibility permission is missing. + /// Asserted verbatim because the menu bar UI renders these strings. + private static let blockedApplicationName = "Accessibility permission missing" + + @MainActor + private func makeModel(publishesPollingEvents: Bool = false) -> FocusTrackingModel { + let model = FocusTrackingModel( + permissionProvider: { false }, + ignoredBundleIdentifier: nil, + publishesPollingEvents: publishesPollingEvents + ) + Self.retainedModels.append(model) + // The retain list keeps instances alive for the whole run, so make sure no poll timer + // outlives its test even when an assertion fails first. + addTeardownBlock { + runOnMainActor { model.stop() } + } + return model + } + + func test_init_startsInactiveWithoutEventsOrExternalApp() { + runOnMainActor { + let model = makeModel() + + XCTAssertEqual(model.snapshot, .inactive) + XCTAssertNil(model.latestExternalApplication) + XCTAssertNil(model.latestPollEvent) + } + } + + func test_start_publishesPermissionBlockedSnapshot() { + runOnMainActor { + let model = makeModel() + + model.start() + + XCTAssertEqual(model.snapshot.applicationName, Self.blockedApplicationName) + XCTAssertEqual(model.snapshot.capability, .blocked("Accessibility permission is required.")) + XCTAssertNil(model.snapshot.bundleIdentifier) + // A nil bundle identifier can never become the "Enable in X" target. + XCTAssertNil(model.latestExternalApplication) + } + } + + func test_start_emitsPollingEventsWhenEnabled() { + runOnMainActor { + let model = makeModel(publishesPollingEvents: true) + + model.start() + + let event = model.latestPollEvent + XCTAssertEqual(event?.sequence, 1, "start() must capture immediately, not wait for a tick") + XCTAssertEqual(event?.applicationName, Self.blockedApplicationName) + XCTAssertEqual(event?.capabilitySummary, "Blocked") + XCTAssertEqual(event?.didChangeFocusedInput, false) + } + } + + func test_start_whileStarted_actsAsImmediateRefresh() { + runOnMainActor { + let model = makeModel(publishesPollingEvents: true) + model.start() + let snapshotAfterFirstStart = model.snapshot + + model.start() + + XCTAssertEqual(model.latestPollEvent?.sequence, 2, "Second start() should re-poll, not re-arm") + XCTAssertEqual(model.snapshot, snapshotAfterFirstStart, "A stable capture must not churn the snapshot") + } + } + + func test_start_withoutPollingPublication_keepsLatestPollEventNil() { + runOnMainActor { + let model = makeModel(publishesPollingEvents: false) + + model.start() + + XCTAssertEqual(model.snapshot.applicationName, Self.blockedApplicationName) + XCTAssertNil(model.latestPollEvent, "Debug polling events are opt-in") + } + } + + func test_refreshNow_capturesOnDemand() { + runOnMainActor { + let model = makeModel(publishesPollingEvents: true) + model.start() + + model.refreshNow() + + XCTAssertEqual(model.latestPollEvent?.sequence, 2) + } + } + + func test_stop_leavesLastSnapshotAvailableAndAllowsManualRefresh() { + runOnMainActor { + let model = makeModel(publishesPollingEvents: true) + model.start() + + model.stop() + + XCTAssertEqual(model.snapshot.applicationName, Self.blockedApplicationName) + + // Manual refreshes still work while observation is stopped; only the timer is gone. + model.refreshNow() + XCTAssertEqual(model.latestPollEvent?.sequence, 2) + } + } + + func test_updatePollInterval_isNoOpForUnchangedInterval() { + runOnMainActor { + let model = makeModel(publishesPollingEvents: true) + model.start() + + // 80 ms is the tracker's default cadence: re-applying it must not restart polling. + model.updatePollInterval(milliseconds: 80) + + XCTAssertEqual(model.latestPollEvent?.sequence, 1) + } + } + + func test_updatePollInterval_restartsActivePollingOnChange() { + runOnMainActor { + let model = makeModel(publishesPollingEvents: true) + model.start() + + model.updatePollInterval(milliseconds: 133) + + // The restart performs an immediate capture, which is observable as a new poll event. + XCTAssertEqual(model.latestPollEvent?.sequence, 2) + } + } +} + +private func runOnMainActor( + _ body: @MainActor () throws -> Result +) rethrows -> Result { + if Thread.isMainThread { + return try MainActor.assumeIsolated(body) + } + + return try DispatchQueue.main.sync { + try MainActor.assumeIsolated(body) + } +} diff --git a/CotabbyTests/FoundationModelAvailabilityServiceTests.swift b/CotabbyTests/FoundationModelAvailabilityServiceTests.swift new file mode 100644 index 00000000..c946c2f4 --- /dev/null +++ b/CotabbyTests/FoundationModelAvailabilityServiceTests.swift @@ -0,0 +1,89 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Locks the availability facade around Apple Intelligence: the binary-decision vocabulary the +/// rest of the app consumes, the refresh transition logging branch, and the observation wiring +/// that pushes provider changes into the published state. The real `SystemLanguageModel` provider +/// is OS-state-dependent and deliberately untested here; the protocol seam exists precisely so +/// this contract can be locked deterministically. +@MainActor +final class FoundationModelAvailabilityServiceTests: XCTestCase { + /// Production @MainActor classes deallocate through the buggy back-deploy executor shim in + /// this app-hosted runner; quarantine instances for the process lifetime. + private static var retained: [AnyObject] = [] + + @MainActor + private final class FakeProvider: FoundationModelAvailabilityProviding { + var currentState: FoundationModelAvailabilityState + var refreshResult: FoundationModelAvailabilityState + private(set) var onChange: (@MainActor (FoundationModelAvailabilityState) -> Void)? + + init(initial: FoundationModelAvailabilityState) { + currentState = initial + refreshResult = initial + } + + func refresh() -> FoundationModelAvailabilityState { + refreshResult + } + + func observe( + onChange: @escaping @MainActor (FoundationModelAvailabilityState) -> Void + ) -> Task? { + self.onChange = onChange + return nil + } + } + + private func makeService( + initial: FoundationModelAvailabilityState + ) -> (service: FoundationModelAvailabilityService, provider: FakeProvider) { + let provider = FakeProvider(initial: initial) + let service = FoundationModelAvailabilityService(provider: provider) + Self.retained.append(service) + return (service, provider) + } + + func test_state_vocabularyExposesBinaryDecisionPlusExplanation() { + XCTAssertTrue(FoundationModelAvailabilityState.available.isAvailable) + XCTAssertEqual( + FoundationModelAvailabilityState.available.summary, + "Apple Intelligence is available." + ) + let unavailable = FoundationModelAvailabilityState.unavailable("Model still downloading.") + XCTAssertFalse(unavailable.isAvailable) + XCTAssertEqual(unavailable.summary, "Model still downloading.") + } + + func test_init_publishesTheProvidersCurrentStateAndStartsObserving() { + let (service, provider) = makeService(initial: .unavailable("Turned off.")) + + XCTAssertFalse(service.isAvailable) + XCTAssertEqual(service.userVisibleMessage, "Turned off.") + XCTAssertNotNil(provider.onChange, "The service must subscribe to provider changes at init") + } + + func test_refresh_adoptsTheProvidersNewStateAcrossTheChangeBoundary() { + let (service, provider) = makeService(initial: .unavailable("Downloading.")) + + // No-change refresh keeps the state (and skips the transition log branch). + service.refresh() + XCTAssertEqual(service.userVisibleMessage, "Downloading.") + + // A real transition is adopted and exposed through both conveniences. + provider.refreshResult = .available + service.refresh() + XCTAssertTrue(service.isAvailable) + XCTAssertEqual(service.userVisibleMessage, "Apple Intelligence is available.") + } + + func test_observation_pushesProviderChangesIntoThePublishedState() { + let (service, provider) = makeService(initial: .available) + + provider.onChange?(.unavailable("User disabled Apple Intelligence.")) + + XCTAssertFalse(service.isAvailable) + XCTAssertEqual(service.userVisibleMessage, "User disabled Apple Intelligence.") + } +} diff --git a/CotabbyTests/GhostSuggestionLayoutTests.swift b/CotabbyTests/GhostSuggestionLayoutTests.swift index 34568b3b..c01e7ca2 100644 --- a/CotabbyTests/GhostSuggestionLayoutTests.swift +++ b/CotabbyTests/GhostSuggestionLayoutTests.swift @@ -313,6 +313,219 @@ final class GhostSuggestionLayoutTests: XCTestCase { ) } + // MARK: - Line identity + + func test_make_lineIdsMatchLineIndices() { + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 10, y: 80, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 70, width: 400, height: 30), + observedCharWidth: 7 + ) + + let layout = GhostSuggestionLayout.make( + text: "hello\nworld", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 500, height: 300) + ) + + XCTAssertEqual(layout.lines.map(\.id), layout.lines.map(\.index)) + XCTAssertEqual(layout.lines.map(\.id), [0, 1]) + } + + // MARK: - Explicit newlines + + func test_make_explicitNewlineForcesLineBreakAtThatPoint() { + // usable frame: minX = max(0 + 8, 0 + 16) = 16; caret anchor = 12 + 6 = 18, so the first + // line is indented 2pt from the panel origin and the wrapped line starts at the origin. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 10, y: 80, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 70, width: 400, height: 30), + observedCharWidth: 7 + ) + + let layout = GhostSuggestionLayout.make( + text: "hello\nworld", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 500, height: 300) + ) + + XCTAssertEqual(layout.lines.map(\.text), ["hello", "world"]) + XCTAssertEqual(layout.lines[0].leadingIndent, 2) + XCTAssertEqual(layout.lines[1].leadingIndent, 0) + XCTAssertEqual(layout.topLineCenterOffsetFromCaret, 0) + XCTAssertEqual(layout.panelOriginX, 16) + } + + func test_make_leadingNewlineYieldsOnlyTheTextAfterIt() { + // The empty segment before a leading newline is skipped, so the visible line is the text + // after the break, still anchored at the caret. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 10, y: 80, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 70, width: 400, height: 30), + observedCharWidth: 7 + ) + + let layout = GhostSuggestionLayout.make( + text: "\nworld", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 500, height: 300) + ) + + XCTAssertEqual(layout.lines.map(\.text), ["world"]) + XCTAssertEqual(layout.lines[0].leadingIndent, 2) + XCTAssertEqual(layout.topLineCenterOffsetFromCaret, 0) + } + + func test_make_newlineOnlyTextProducesOnePlaceholderLineBelowCaret() { + // A suggestion that is just a line break has no splittable content: the layout falls back + // to a single raw line and renders it below the caret instead of beside it. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 10, y: 80, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 70, width: 400, height: 30), + observedCharWidth: 7 + ) + + let layout = GhostSuggestionLayout.make( + text: "\n", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 500, height: 300) + ) + + XCTAssertEqual(layout.lines.map(\.text), ["\n"]) + XCTAssertEqual(layout.lines[0].leadingIndent, 0) + XCTAssertEqual(layout.lineHeight, 18, "ceil(14 * 1.25)") + XCTAssertEqual(layout.topLineCenterOffsetFromCaret, -layout.lineHeight) + XCTAssertEqual(layout.panelOriginX, 16) + } + + func test_make_overwideSegmentBeforeNewlineWidthWrapsAndCarriesRemainder() { + // usable: minX 16, maxX 492; first-line budget = 492 - 18 - 36 (keycap) = 438; at 10pt per + // char the 60-char segment splits after 43 chars, and the leftover 17 chars must carry + // forward together with the post-newline text as separate lines. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 10, y: 80, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 70, width: 500, height: 30), + observedCharWidth: 10 + ) + + let layout = GhostSuggestionLayout.make( + text: String(repeating: "a", count: 60) + "\nrest", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 600) + ) + + XCTAssertEqual( + layout.lines.map(\.text), + [String(repeating: "a", count: 43), String(repeating: "a", count: 17), "rest"] + ) + XCTAssertEqual(layout.lines[0].leadingIndent, 2) + XCTAssertEqual(layout.topLineCenterOffsetFromCaret, 0) + XCTAssertEqual(layout.lines.last?.showsKeycap, true) + } + + func test_make_overwideSingleCharacterSegmentStillEmitsItBeforeTheNewlineText() { + // A single glyph wider than the whole budget cannot be split further: it must ship as its + // own line (never an empty line) and the post-newline text follows, one glyph per line. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 10, y: 80, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 70, width: 500, height: 30), + observedCharWidth: 500 + ) + + let layout = GhostSuggestionLayout.make( + text: "W\nnext", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 600) + ) + + XCTAssertEqual(layout.lines.map(\.text), ["W", "n", "e", "x", "t"]) + XCTAssertEqual(layout.lines[0].leadingIndent, 2) + } + + func test_make_trailingNewlineAfterOverwideSegmentKeepsWidthWrappedRemainder() { + // Same overwide segment, but nothing follows the newline: the width-wrapped leftover is + // the entire remainder and the trailing break adds no extra line. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 10, y: 80, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 70, width: 500, height: 30), + observedCharWidth: 10 + ) + + let layout = GhostSuggestionLayout.make( + text: String(repeating: "a", count: 60) + "\n", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 1000, height: 600) + ) + + XCTAssertEqual( + layout.lines.map(\.text), + [String(repeating: "a", count: 43), String(repeating: "a", count: 17)] + ) + } + + // MARK: - RTL fallback frame (no input frame) + + func test_make_rtlWithoutInputFrameUsesAreaLeftOfCaret() { + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 300, y: 80, width: 2, height: 18), + inputFrameRect: nil, + observedCharWidth: 7, + isRightToLeft: true + ) + + let layout = GhostSuggestionLayout.make( + text: "مرحبا", + geometry: geometry, + fontSize: 14, + visibleFrame: CGRect(x: 0, y: 0, width: 500, height: 300) + ) + + // The fallback text frame runs from the screen margin to the caret gap, so the RTL anchor + // is exactly caret.minX - 6. + XCTAssertEqual(layout.lines.count, 1) + XCTAssertEqual(layout.panelOriginX, 294) + XCTAssertEqual(layout.topLineCenterOffsetFromCaret, 0) + XCTAssertTrue(layout.isRightToLeft) + } + + // MARK: - Width measurement without an observed char width + + func test_make_measuresWithFontWhenNoObservedCharWidth() { + // No AX-observed average width: the layout must measure the rendered string with a real + // font. The same 19-char text fits one line at the system fallback size but must wrap once + // the host's (much wider) monospace font is supplied, proving the host font drives wrap. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 10, y: 80, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 70, width: 400, height: 30) + ) + let visibleFrame = CGRect(x: 0, y: 0, width: 500, height: 300) + let text = " brief reply coming" + + let systemMeasured = GhostSuggestionLayout.make( + text: text, + geometry: geometry, + fontSize: 14, + visibleFrame: visibleFrame + ) + let hostMeasured = GhostSuggestionLayout.make( + text: text, + geometry: geometry, + fontSize: 14, + visibleFrame: visibleFrame, + font: NSFont.monospacedSystemFont(ofSize: 40, weight: .regular) + ) + + XCTAssertEqual(systemMeasured.lines.count, 1) + XCTAssertGreaterThan(hostMeasured.lines.count, 1) + } + // MARK: - renderedWidth (exact-advance measurement) func test_renderedWidth_emptyAndWhitespaceOnlyAreZero() { diff --git a/CotabbyTests/HardwareCapabilityProbeTests.swift b/CotabbyTests/HardwareCapabilityProbeTests.swift new file mode 100644 index 00000000..f335a4d8 --- /dev/null +++ b/CotabbyTests/HardwareCapabilityProbeTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import Cotabby + +/// The probe is the seam between real host hardware and the pure +/// `OnboardingTemplateRecommender`, so these tests pin its two outputs to the +/// authoritative sources they must mirror. +final class HardwareCapabilityProbeTests: XCTestCase { + func test_current_reportsHostPhysicalMemoryExactly() { + let capability = HardwareCapabilityProbe.current() + + XCTAssertEqual(capability.physicalMemoryBytes, ProcessInfo.processInfo.physicalMemory) + XCTAssertGreaterThan(capability.physicalMemoryBytes, 0) + } + + func test_current_derivedGigabytesMatchInstalledMemoryScale() { + let capability = HardwareCapabilityProbe.current() + + // Sanity bounds, not exact values: any supported Mac has at least 4 GiB and the binary + // GiB conversion must stay consistent with the raw byte count. + XCTAssertGreaterThanOrEqual(capability.physicalMemoryGigabytes, 4) + XCTAssertEqual( + capability.physicalMemoryGigabytes, + Double(capability.physicalMemoryBytes) / 1_073_741_824, + accuracy: 0.0001 + ) + } + + func test_current_reportsCompileTimeArchitecture() { + let capability = HardwareCapabilityProbe.current() + + // The probe intentionally answers from compile-time architecture; the test target builds + // for the same architecture, so the same condition is the ground truth here. + #if arch(arm64) + XCTAssertTrue(capability.isAppleSilicon) + #else + XCTAssertFalse(capability.isAppleSilicon) + #endif + } +} diff --git a/CotabbyTests/HuggingFaceModelsTests.swift b/CotabbyTests/HuggingFaceModelsTests.swift new file mode 100644 index 00000000..f68c7ee9 --- /dev/null +++ b/CotabbyTests/HuggingFaceModelsTests.swift @@ -0,0 +1,73 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Tests for the HuggingFace REST response models: schema decoding, GGUF detection, size labels, +/// and direct-download URL construction. +final class HuggingFaceModelsTests: XCTestCase { + func test_hfModelSearchResult_decodesTheSearchAPIShape() throws { + let json = Data(""" + { + "id": "org/model-GGUF", + "modelId": "org/model-GGUF", + "downloads": 1200, + "likes": 7, + "tags": ["gguf", "text-generation"] + } + """.utf8) + + let result = try JSONDecoder().decode(HFModelSearchResult.self, from: json) + + XCTAssertEqual(result.id, "org/model-GGUF") + XCTAssertEqual(result.modelId, "org/model-GGUF") + XCTAssertEqual(result.downloads, 1200) + XCTAssertEqual(result.likes, 7) + XCTAssertEqual(result.tags, ["gguf", "text-generation"]) + } + + func test_hfRepoFile_idIsThePathWithinTheRepo() { + let file = makeFile(path: "weights/model.gguf") + XCTAssertEqual(file.id, "weights/model.gguf") + } + + func test_hfRepoFile_isGGUFMatchesExtensionCaseInsensitively() { + XCTAssertTrue(makeFile(path: "weights/model.gguf").isGGUF) + XCTAssertTrue(makeFile(path: "Model.GGUF").isGGUF) + XCTAssertFalse(makeFile(path: "model.bin").isGGUF) + // The extension must be a real suffix with the dot; a name merely containing "gguf" is not one. + XCTAssertFalse(makeFile(path: "ggufmodel").isGGUF) + } + + func test_hfRepoFile_sizeInGigabytesUsesBinaryGigabytes() { + XCTAssertEqual(makeFile(size: 1_073_741_824).sizeInGigabytes, 1.0) + XCTAssertEqual(makeFile(size: 536_870_912).sizeInGigabytes, 0.5) + } + + func test_hfRepoFile_sizeLabelSwitchesToMegabytesBelowOneGigabyte() { + XCTAssertEqual(makeFile(size: 1_610_612_736).sizeLabel, "1.5 GB") + XCTAssertEqual(makeFile(size: 524_288_000).sizeLabel, "500 MB") + // One byte under a binary gigabyte stays on the MB branch. + XCTAssertEqual(makeFile(size: 1_073_741_823).sizeLabel, "1024 MB") + } + + func test_hfRepoFile_downloadURLBuildsResolveMainEndpointWithDownloadFlag() { + let url = makeFile(path: "subdir/model.gguf").downloadURL(repoId: "TheOrg/TheRepo") + + XCTAssertEqual( + url?.absoluteString, + "https://huggingface.co/TheOrg/TheRepo/resolve/main/subdir/model.gguf?download=true" + ) + } + + func test_hfRepoFile_downloadURLEncodesPathsThatNeedEscaping() throws { + let url = try XCTUnwrap(makeFile(path: "my model.gguf").downloadURL(repoId: "org/repo")) + + XCTAssertEqual(url.host, "huggingface.co") + XCTAssertFalse(url.absoluteString.contains(" ")) + XCTAssertTrue(url.absoluteString.hasSuffix("?download=true")) + } + + private func makeFile(path: String = "model.gguf", size: Int64 = 1_073_741_824) -> HFRepoFile { + HFRepoFile(path: path, size: size, type: "file") + } +} diff --git a/CotabbyTests/KeyCodeLabelsTests.swift b/CotabbyTests/KeyCodeLabelsTests.swift new file mode 100644 index 00000000..7b133d98 --- /dev/null +++ b/CotabbyTests/KeyCodeLabelsTests.swift @@ -0,0 +1,78 @@ +import ApplicationServices +import XCTest +@testable import Cotabby + +/// Locks the human-readable shortcut labels rendered in the settings keycap and the ghost-text +/// hint pill. These strings are user-facing UI contracts, so each mapping is asserted exactly. +final class KeyCodeLabelsTests: XCTestCase { + // MARK: - Special key names + + func test_label_mapsEditingKeysByKeyCode() { + XCTAssertEqual(KeyCodeLabels.label(for: 48, fallback: nil), "Tab") + XCTAssertEqual(KeyCodeLabels.label(for: 49, fallback: nil), "Space") + XCTAssertEqual(KeyCodeLabels.label(for: 51, fallback: nil), "Delete") + XCTAssertEqual(KeyCodeLabels.label(for: 53, fallback: nil), "Escape") + XCTAssertEqual(KeyCodeLabels.label(for: 117, fallback: nil), "Forward Delete") + XCTAssertEqual(KeyCodeLabels.label(for: 36, fallback: nil), "Return") + XCTAssertEqual(KeyCodeLabels.label(for: 76, fallback: nil), "Enter") + } + + func test_label_mapsArrowAndFunctionKeys() { + XCTAssertEqual(KeyCodeLabels.label(for: 123, fallback: nil), "Left Arrow") + XCTAssertEqual(KeyCodeLabels.label(for: 124, fallback: nil), "Right Arrow") + XCTAssertEqual(KeyCodeLabels.label(for: 125, fallback: nil), "Down Arrow") + XCTAssertEqual(KeyCodeLabels.label(for: 126, fallback: nil), "Up Arrow") + XCTAssertEqual(KeyCodeLabels.label(for: 122, fallback: nil), "F1") + XCTAssertEqual(KeyCodeLabels.label(for: 100, fallback: nil), "F8") + XCTAssertEqual(KeyCodeLabels.label(for: 111, fallback: nil), "F12") + } + + func test_label_prefersSpecialNameOverFallbackCharacters() { + // Tab must never render as a literal tab character even if the event carried one. + XCTAssertEqual(KeyCodeLabels.label(for: 48, fallback: "\t"), "Tab") + XCTAssertEqual(KeyCodeLabels.label(for: 49, fallback: " "), "Space") + } + + // MARK: - Fallback characters + + func test_label_uppercasesAndTrimsFallbackCharacters() { + XCTAssertEqual(KeyCodeLabels.label(for: 0, fallback: "a"), "A") + XCTAssertEqual(KeyCodeLabels.label(for: 6, fallback: " z "), "Z") + XCTAssertEqual(KeyCodeLabels.label(for: 18, fallback: "1"), "1") + } + + func test_label_describesPhysicalKeysWhenFallbackIsUnhelpful() { + // ISO/JIS layout keys that produce no glyph: the fallback is empty or whitespace, so the + // user gets a positional description instead of a blank keycap. + XCTAssertEqual(KeyCodeLabels.label(for: 10, fallback: ""), "Key above Tab") + XCTAssertEqual(KeyCodeLabels.label(for: 50, fallback: " "), "Key above Tab") + XCTAssertEqual(KeyCodeLabels.label(for: 93, fallback: nil), "Key beside Right Shift") + } + + func test_label_fallsBackToNumericDescriptionForUnknownKeys() { + XCTAssertEqual(KeyCodeLabels.label(for: 7, fallback: nil), "Key 7") + XCTAssertEqual(KeyCodeLabels.label(for: 7, fallback: " "), "Key 7") + } + + // MARK: - Modifier glyphs + + func test_modifierGlyphs_followMacOSConventionOrdering() { + // Control, Option, Shift, Command: the order macOS renders in menus, regardless of the + // order the caller assembled the mask in. + XCTAssertEqual(KeyCodeLabels.modifierGlyphs([]), "") + XCTAssertEqual(KeyCodeLabels.modifierGlyphs([.command]), "⌘") + XCTAssertEqual(KeyCodeLabels.modifierGlyphs([.control]), "⌃") + XCTAssertEqual(KeyCodeLabels.modifierGlyphs([.shift, .command]), "⇧⌘") + XCTAssertEqual(KeyCodeLabels.modifierGlyphs([.command, .shift, .option, .control]), "⌃⌥⇧⌘") + } + + func test_combinedLabel_joinsGlyphsAndKeyNameWithSingleSpace() { + XCTAssertEqual(KeyCodeLabels.label(for: 48, modifiers: [.option], fallback: nil), "⌥ Tab") + XCTAssertEqual(KeyCodeLabels.label(for: 49, modifiers: [.shift, .command], fallback: nil), "⇧⌘ Space") + XCTAssertEqual(KeyCodeLabels.label(for: 0, modifiers: [.control], fallback: "a"), "⌃ A") + } + + func test_combinedLabel_omitsGlyphsWhenNoModifiersAreBound() { + XCTAssertEqual(KeyCodeLabels.label(for: 48, modifiers: [], fallback: nil), "Tab") + } +} diff --git a/CotabbyTests/LLMIOFileHandlerTests.swift b/CotabbyTests/LLMIOFileHandlerTests.swift new file mode 100644 index 00000000..404efa3c --- /dev/null +++ b/CotabbyTests/LLMIOFileHandlerTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Logging +import XCTest +@testable import Cotabby + +/// Locks the dedicated LLM I/O JSONL sink: full prompts and completions land as one valid JSON +/// record per generation under the fixed `llm-io` category (the `request_id` join contract with +/// the main log), and the writer rotates exactly like the main sink. +final class LLMIOFileHandlerTests: XCTestCase { + private var directory: URL! + + override func setUp() { + super.setUp() + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + directory = nil + super.tearDown() + } + + private func makeWriter(cap: UInt64? = nil) -> LLMIOFileWriter { + LLMIOFileWriter(sizeCapBytes: cap, fileURL: directory.appendingPathComponent("llm-io.jsonl")) + } + + private func lines(of url: URL) -> [String] { + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return [] } + return content.split(separator: "\n").map(String.init) + } + + func test_log_emitsLLMIORecordWithPromptAndCompletionMetadata() throws { + let writer = makeWriter() + var handler = LLMIOFileHandler(label: "com.cotabby.llm-io", writer: writer) + handler[metadataKey: "engine"] = .string("llama") + XCTAssertEqual(handler[metadataKey: "engine"], .string("llama")) + handler.log(event: LogEvent( + level: .info, + message: "generation", + metadata: [ + "request_id": .string("req_join42"), + "prompt": .string("The quick brown"), + "completion": .string(" fox jumps"), + "token_counts": .dictionary(["prompt": .stringConvertible(3)]), + "stops": .array([.string("\n")]) + ], + source: "CotabbyTests", + file: #filePath, + function: #function, + line: #line + )) + + let url = try XCTUnwrap(writer.fileURL) + let written = lines(of: url) + XCTAssertEqual(written.count, 1) + let record = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(written[0].utf8)) as? [String: Any] + ) + XCTAssertEqual(record["category"] as? String, "llm-io") + XCTAssertEqual(record["request_id"] as? String, "req_join42") + XCTAssertEqual(record["prompt"] as? String, "The quick brown") + XCTAssertEqual(record["completion"] as? String, " fox jumps") + XCTAssertEqual((record["token_counts"] as? [String: Any])?["prompt"] as? String, "3") + XCTAssertEqual(record["stops"] as? [String], ["\n"]) + XCTAssertEqual(record["engine"] as? String, "llama") + } + + func test_writer_rotatesPastTheCapKeepingPreviousHistory() throws { + let writer = makeWriter(cap: 32) + writer.write(String(repeating: "p", count: 40) + "\n") + writer.write("after-cap\n") + + let url = try XCTUnwrap(writer.fileURL) + let rotatedURL = url.deletingPathExtension().appendingPathExtension("jsonl.1") + XCTAssertEqual(lines(of: url), ["after-cap"]) + XCTAssertEqual(lines(of: rotatedURL), [String(repeating: "p", count: 40)]) + } +} diff --git a/CotabbyTests/LlamaSuggestionEngineCancellationTests.swift b/CotabbyTests/LlamaSuggestionEngineCancellationTests.swift index 47c69a1a..336c6ed0 100644 --- a/CotabbyTests/LlamaSuggestionEngineCancellationTests.swift +++ b/CotabbyTests/LlamaSuggestionEngineCancellationTests.swift @@ -62,6 +62,42 @@ final class LlamaSuggestionEngineCancellationTests: XCTestCase { XCTAssertEqual(runtime.resetCount, 0) } + func test_suggestionClientError_resetsCache_andRethrowsSameError() async { + // A `SuggestionClientError` crossing the runtime boundary is a genuine failure, so it must + // reset the cache but keep its original case and message for the coordinator's diagnostics. + let runtime = FakeLlamaRuntime() + runtime.generateResult = .failure(SuggestionClientError.unavailable("model not loaded")) + let engine = LlamaSuggestionEngine(runtimeManager: runtime) + + do { + _ = try await engine.generateSuggestion(for: makeRequest(prompt: "hello")) + XCTFail("Expected a thrown error") + } catch SuggestionClientError.unavailable(let message) { + XCTAssertEqual(message, "model not loaded") + } catch { + XCTFail("Expected SuggestionClientError.unavailable to pass through unchanged, got \(error)") + } + XCTAssertEqual(runtime.resetCount, 1, "A client error should reset the KV cache exactly once") + } + + func test_unexpectedError_resetsCache_andWrapsAsGenerationFailed() async { + // Errors outside the engine's known vocabulary fall into the catch-all: reset the cache and + // surface a `generationFailed` carrying the underlying description. + let runtime = FakeLlamaRuntime() + runtime.generateResult = .failure(UnexpectedRuntimeBoom()) + let engine = LlamaSuggestionEngine(runtimeManager: runtime) + + do { + _ = try await engine.generateSuggestion(for: makeRequest(prompt: "hello")) + XCTFail("Expected a thrown error") + } catch SuggestionClientError.generationFailed(let message) { + XCTAssertEqual(message, "UNEXPECTED_BOOM") + } catch { + XCTFail("Expected SuggestionClientError.generationFailed, got \(error)") + } + XCTAssertEqual(runtime.resetCount, 1, "An unexpected error should reset the KV cache exactly once") + } + // MARK: - Helpers private func assertThrowsCancelled( @@ -123,6 +159,11 @@ final class LlamaSuggestionEngineCancellationTests: XCTestCase { } } +/// An error type the engine has no dedicated handling for, used to drive the catch-all wrap path. +private struct UnexpectedRuntimeBoom: LocalizedError { + var errorDescription: String? { "UNEXPECTED_BOOM" } +} + /// Minimal `LlamaRuntimeGenerating` fake that returns a staged result and counts cache resets, /// so the engine's failure routing can be exercised without loading a real model. @MainActor diff --git a/CotabbyTests/MacroTriggerStateMachineTests.swift b/CotabbyTests/MacroTriggerStateMachineTests.swift index 44463956..a4a0b0e0 100644 --- a/CotabbyTests/MacroTriggerStateMachineTests.swift +++ b/CotabbyTests/MacroTriggerStateMachineTests.swift @@ -115,4 +115,48 @@ final class MacroTriggerStateMachineTests: XCTestCase { let output = sut.reduce(.backspace, hasInsertableResult: false) XCTAssertEqual(output.actions, [.updateQuery("a")]) } + + func test_reset_returnsToIdleAndRestoresBoundary() { + var sut = MacroTriggerStateMachine() + openCapture(&sut) + _ = sut.reduce(.character("5"), hasInsertableResult: false) + + sut.reset() + + XCTAssertFalse(sut.isCapturing) + XCTAssertEqual(sut.state, .idle(previousCharacter: nil)) + // A cleared machine has no boundary memory, so the next `/` opens as at the start of a field. + let output = sut.reduce(.character("/"), hasInsertableResult: false) + XCTAssertEqual(output.actions, [.open]) + } + + func test_nonCharacterInputsWhileIdle_areIgnoredAndClearBoundaryMemory() { + let inputs: [MacroTriggerInput] = [.backspace, .commitKey, .escape, .navigate, .focusChanged, .dismissExternally] + for input in inputs { + var sut = MacroTriggerStateMachine() + _ = sut.reduce(.character("x"), hasInsertableResult: false) + + let output = sut.reduce(input, hasInsertableResult: true) + + XCTAssertEqual(output, .ignored, "input \(input) should be ignored while idle") + // The machine forgot the preceding "x", so the next `/` is evaluated as a fresh boundary. + let reopened = sut.reduce(.character("/"), hasInsertableResult: false) + XCTAssertEqual(reopened.actions, [.open], "input \(input) should clear boundary memory") + } + } + + func test_navigationAndFocusEventsWhileCapturing_cancelWithoutConsuming() { + let inputs: [MacroTriggerInput] = [.navigate, .focusChanged, .dismissExternally] + for input in inputs { + var sut = MacroTriggerStateMachine() + openCapture(&sut) + _ = sut.reduce(.character("5"), hasInsertableResult: false) + + let output = sut.reduce(input, hasInsertableResult: true) + + XCTAssertEqual(output.actions, [.cancel], "input \(input) should cancel capture") + XCTAssertFalse(output.consumesKey, "input \(input) must reach the focused app") + XCTAssertFalse(sut.isCapturing) + } + } } diff --git a/CotabbyTests/MirrorOverlayLayoutTests.swift b/CotabbyTests/MirrorOverlayLayoutTests.swift index 614b6746..aa1eb01b 100644 --- a/CotabbyTests/MirrorOverlayLayoutTests.swift +++ b/CotabbyTests/MirrorOverlayLayoutTests.swift @@ -297,6 +297,72 @@ final class MirrorOverlayLayoutTests: XCTestCase { XCTAssertTrue(layout.isRightToLeft) } + // MARK: - Degenerate caret rect (empty rect at the origin) + + func test_make_emptyCaretRect_anchorsToInputFrameForUserPreference() { + // A zero caret rect is the degenerate shape some hosts publish right after focus. With a + // trustworthy reason the caret anchor is preferred, but an empty rect forces the safety-net + // anchor: just below the field's bottom edge, centered on the field. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: .zero, + inputFrameRect: CGRect(x: 100, y: 100, width: 200, height: 40) + ) + + let layout = MirrorOverlayLayout.make( + suggestion: "hi", + geometry: geometry, + visibleFrame: screen, + showsAcceptanceHint: true, + reason: .userPreference + ) + + // Card width: 120pt text floor + 36pt keycap + 2 * 10pt padding. Height: ceil(13 * 1.6) + 12. + // Anchor top: field minY (100) - 8pt gap = 92; center: field midX (200). + XCTAssertEqual(layout.panelFrame, CGRect(x: 112, y: 59, width: 176, height: 33)) + } + + func test_make_emptyCaretRectAndMissingInputFrame_clampsToScreenMargin() { + // With no usable anchor at all, the fixed caret fallback lands off-screen and the clamp + // must pull the card back to the visible frame's margin instead of dropping it off-screen. + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: .zero, + inputFrameRect: nil + ) + + let layout = MirrorOverlayLayout.make( + suggestion: "hi", + geometry: geometry, + visibleFrame: screen, + showsAcceptanceHint: true, + reason: .userPreference + ) + + XCTAssertEqual(layout.panelFrame, CGRect(x: 12, y: 12, width: 176, height: 33)) + } + + // MARK: - Visible frame smaller than the card + + func test_make_pinsCardToMarginWhenVisibleFrameIsSmallerThanCard() { + // When the visible frame cannot contain the card at all (tiny screen or extreme zoom), the + // min/max clamp inverts; the layout must pin to the leading margin on both axes rather + // than producing a frame outside the screen. + let tinyScreen = CGRect(x: 0, y: 0, width: 150, height: 28) + let geometry = CotabbyTestFixtures.overlayGeometry( + caretRect: CGRect(x: 60, y: 200, width: 2, height: 18), + inputFrameRect: nil + ) + + let layout = MirrorOverlayLayout.make( + suggestion: "hi", + geometry: geometry, + visibleFrame: tinyScreen, + showsAcceptanceHint: true, + reason: .userPreference + ) + + XCTAssertEqual(layout.panelFrame, CGRect(x: 12, y: 12, width: 176, height: 33)) + } + // MARK: - Acceptance-hint reservation func test_make_widerCardWhenAcceptanceHintEnabled() { diff --git a/CotabbyTests/ModelAndPresentationValueTests.swift b/CotabbyTests/ModelAndPresentationValueTests.swift index 3c9528bf..2ca33ee0 100644 --- a/CotabbyTests/ModelAndPresentationValueTests.swift +++ b/CotabbyTests/ModelAndPresentationValueTests.swift @@ -158,6 +158,120 @@ final class SuggestionModelValueTests: XCTestCase { XCTAssertEqual(SuggestionDebugState.failed("Runtime failed").detail, "Runtime failed") XCTAssertEqual(SuggestionDebugState.ready(text: "hello", latency: 0.2).shortLabel, "Ready") } + + func test_suggestionWordRange_labelsRenderLowAndHighBounds() { + let range = SuggestionWordRange(lowWords: 5, highWords: 15) + + XCTAssertEqual(range.displayLabel, "5-15 words") + XCTAssertEqual(range.compactLabel, "5-15 w") + } + + func test_wordCountPreset_idsAndCompactLabelsStayInSyncWithRawValues() { + XCTAssertEqual( + SuggestionWordCountPreset.allCases.map(\.id), + ["2-4", "4-7", "7-12", "12-20"] + ) + XCTAssertEqual( + SuggestionWordCountPreset.allCases.map(\.compactLabel), + ["2-4 w", "4-7 w", "7-12 w", "12-20 w"] + ) + } + + func test_focusedInputContext_identityPairsElementWithFocusSequence() { + let context = CotabbyTestFixtures.focusedInputContext( + elementIdentifier: "field-a", + focusChangeSequence: 7 + ) + + XCTAssertEqual( + context.identity, + FocusedInputIdentity(elementIdentifier: "field-a", focusChangeSequence: 7) + ) + } + + func test_focusedInputContext_contentSignatureMirrorsSnapshotAndTagsSecureFields() { + let context = CotabbyTestFixtures.focusedInputContext( + elementIdentifier: "field-a", + precedingText: "Hello", + trailingText: " tail", + focusChangeSequence: 7 + ) + let snapshot = CotabbyTestFixtures.focusedInputSnapshot( + elementIdentifier: "field-a", + precedingText: "Hello", + trailingText: " tail", + focusChangeSequence: 7 + ) + + XCTAssertEqual(context.contentSignature, "5::0::Hello:: tail::plain") + // The context's fingerprint must stay interchangeable with the snapshot's so staleness + // checks can compare across the debounce boundary. + XCTAssertEqual(context.contentSignature, snapshot.contentSignature) + + let secure = CotabbyTestFixtures.focusedInputContext(isSecure: true) + XCTAssertTrue(secure.contentSignature.hasSuffix("::secure")) + } + + func test_suggestionKind_isCorrectionOnlyForCorrections() { + XCTAssertTrue(SuggestionKind.correction(typoWord: "teh").isCorrection) + XCTAssertFalse(SuggestionKind.continuation.isCorrection) + } + + func test_overlayGeometry_withCaretRectReplacesOnlyTheCaretRect() { + let style = ResolvedFieldStyle(fontName: "Helvetica", fontPointSize: 13, colorHex: "336699") + let original = SuggestionOverlayGeometry( + caretRect: CGRect(x: 10, y: 20, width: 2, height: 18), + inputFrameRect: CGRect(x: 0, y: 0, width: 240, height: 32), + caretQuality: .derived, + isCaretAtEndOfLine: false, + observedCharWidth: 7, + isRightToLeft: true, + focusChangeSequence: 9, + focusedInputIdentityKey: 77, + resolvedFieldStyle: style + ) + + let advanced = original.withCaretRect(CGRect(x: 52, y: 20, width: 2, height: 18)) + + XCTAssertEqual(advanced.caretRect, CGRect(x: 52, y: 20, width: 2, height: 18)) + XCTAssertEqual(advanced.inputFrameRect, original.inputFrameRect) + XCTAssertEqual(advanced.caretQuality, .derived) + XCTAssertFalse(advanced.isCaretAtEndOfLine) + XCTAssertEqual(advanced.observedCharWidth, 7) + XCTAssertTrue(advanced.isRightToLeft) + XCTAssertEqual(advanced.focusChangeSequence, 9) + XCTAssertEqual(advanced.focusedInputIdentityKey, 77) + XCTAssertEqual(advanced.resolvedFieldStyle, style) + } + + func test_overlayState_hiddenExposesNoVisibleTextOrMode() { + let hidden = OverlayState.hidden(reason: "No suggestion buffered") + + XCTAssertEqual(hidden.shortLabel, "Hidden") + XCTAssertEqual(hidden.detail, "No suggestion buffered") + XCTAssertFalse(hidden.isVisible) + XCTAssertNil(hidden.visibleText) + XCTAssertNil(hidden.visibleMode) + } + + func test_suggestionClientError_errorDescriptionSurfacesTheUnderlyingMessage() { + XCTAssertEqual( + SuggestionClientError.unavailable("Engine offline").errorDescription, + "Engine offline" + ) + XCTAssertEqual( + SuggestionClientError.unsupportedLanguageOrLocale("Locale unsupported").errorDescription, + "Locale unsupported" + ) + XCTAssertEqual( + SuggestionClientError.generationFailed("Decode failed").errorDescription, + "Decode failed" + ) + XCTAssertEqual( + SuggestionClientError.cancelled.errorDescription, + "Generation was cancelled." + ) + } } final class RuntimeAndInputModelValueTests: XCTestCase { @@ -225,6 +339,83 @@ final class RuntimeAndInputModelValueTests: XCTestCase { XCTAssertFalse(CotabbyTestFixtures.inputEvent(kind: .fullAcceptance).shouldSchedulePrediction) XCTAssertFalse(CotabbyTestFixtures.inputEvent(kind: .other).shouldClearSuggestion) } + + func test_runtimeBootstrapState_summaryShowsDetailForEveryNonIdleState() { + XCTAssertEqual(RuntimeBootstrapState.idle.summary, "Idle") + XCTAssertEqual(RuntimeBootstrapState.starting("Locating runtime").summary, "Locating runtime") + XCTAssertEqual(RuntimeBootstrapState.loading("Loading model").summary, "Loading model") + XCTAssertEqual(RuntimeBootstrapState.ready("tabby-2-base ready").summary, "tabby-2-base ready") + XCTAssertEqual(RuntimeBootstrapState.failed("Missing model file").summary, "Missing model file") + } + + func test_runtimeBootstrapState_failureDetailIsNonNilOnlyWhenFailed() { + XCTAssertEqual(RuntimeBootstrapState.failed("Missing model file").failureDetail, "Missing model file") + XCTAssertNil(RuntimeBootstrapState.idle.failureDetail) + XCTAssertNil(RuntimeBootstrapState.starting("Locating runtime").failureDetail) + XCTAssertNil(RuntimeBootstrapState.loading("Loading model").failureDetail) + XCTAssertNil(RuntimeBootstrapState.ready("tabby-2-base ready").failureDetail) + } + + func test_runtimeModelOption_keepsRawFilenameAsIdentityButAliasesDisplayName() { + let option = RuntimeModelOption( + filename: "Qwen3.5-0.8B-Base.i1-Q6_K.gguf", + url: URL(fileURLWithPath: "/tmp/models/Qwen3.5-0.8B-Base.i1-Q6_K.gguf") + ) + + XCTAssertEqual(option.id, "Qwen3.5-0.8B-Base.i1-Q6_K.gguf") + XCTAssertEqual(option.actualModelName, "Qwen3.5-0.8B-Base.i1-Q6_K.gguf") + XCTAssertEqual(option.displayName, "tabby-2-nano") + } + + func test_downloadableRuntimeModel_defaultsLeaveValidationMetadataEmpty() throws { + let url = try XCTUnwrap(URL(string: "https://example.com/custom.gguf")) + let model = DownloadableRuntimeModel( + filename: "custom.gguf", + displayName: "Custom", + downloadURL: url, + approximateSizeInGigabytes: 1.4 + ) + + XCTAssertEqual(model.id, "custom.gguf") + XCTAssertEqual(model.actualModelName, "custom.gguf") + XCTAssertNil(model.expectedSizeBytes) + XCTAssertNil(model.sha256) + XCTAssertEqual(model.allKnownFilenames, ["custom.gguf"]) + XCTAssertEqual(model.approximateSizeLabel, "~1.4 GB") + } + + func test_downloadableModelCatalog_entriesAreUniqueHuggingFaceGGUFDownloads() { + let models = RuntimeModelCatalog.downloadableModels + + XCTAssertEqual(models.count, 4) + XCTAssertEqual(Set(models.map(\.id)).count, models.count) + for model in models { + XCTAssertTrue(model.filename.hasSuffix(".gguf"), "\(model.filename) should be a GGUF") + XCTAssertEqual(model.downloadURL.host, "huggingface.co") + XCTAssertTrue(model.downloadURL.absoluteString.hasSuffix("?download=true")) + XCTAssertEqual(model.displayName, RuntimeModelCatalog.displayName(for: model.filename)) + } + } + + func test_llamaGenerationOptions_defaultsKeepMaskingAndSuppressionOff() { + // Omitting the trailing parameters must reproduce the conservative production defaults: + // no line masking, no forced word continuation, suppression disabled, two-token stop floor. + let options = LlamaGenerationOptions( + maxPredictionTokens: 8, + temperature: 0.1, + topK: 20, + topP: 0.7, + minP: 0.08, + repetitionPenalty: 1.05, + seed: nil + ) + + XCTAssertNil(options.seed) + XCTAssertFalse(options.singleLine) + XCTAssertFalse(options.forceWordContinuation) + XCTAssertEqual(options.confidenceFloor, -.infinity) + XCTAssertEqual(options.sentenceStopMinimumTokens, 2) + } } final class GhostTextColorPresetTests: XCTestCase { diff --git a/CotabbyTests/OnboardingTemplateTests.swift b/CotabbyTests/OnboardingTemplateTests.swift new file mode 100644 index 00000000..78f05482 --- /dev/null +++ b/CotabbyTests/OnboardingTemplateTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import Cotabby + +/// Tests for the onboarding template card metadata. The recommender and feature-list rules have +/// their own suites; this one pins the per-tier identity, copy, and icons the cards render. +final class OnboardingTemplateTests: XCTestCase { + func test_id_matchesRawValueForEveryTemplate() { + for template in OnboardingTemplate.allCases { + XCTAssertEqual(template.id, template.rawValue) + } + } + + func test_curatedTiers_excludeCustomAndKeepDisplayOrder() { + // Custom is applied by the "Set up later" button, never shown as a card. + XCTAssertEqual(OnboardingTemplate.curatedTiers, [.quick, .everyday, .powerful]) + XCTAssertFalse(OnboardingTemplate.curatedTiers.contains(.custom)) + } + + func test_title_isThePinnedCardNamePerTier() { + XCTAssertEqual(OnboardingTemplate.quick.title, "Quick") + XCTAssertEqual(OnboardingTemplate.everyday.title, "Everyday") + XCTAssertEqual(OnboardingTemplate.powerful.title, "Powerful") + XCTAssertEqual(OnboardingTemplate.custom.title, "Custom") + } + + func test_systemImageName_isThePinnedCardIconPerTier() { + XCTAssertEqual(OnboardingTemplate.quick.systemImageName, "hare.fill") + XCTAssertEqual(OnboardingTemplate.everyday.systemImageName, "sparkles") + XCTAssertEqual(OnboardingTemplate.powerful.systemImageName, "bolt.fill") + XCTAssertEqual(OnboardingTemplate.custom.systemImageName, "slider.horizontal.3") + } + + func test_tagline_isUniqueAndNonEmptyPerTier() { + let taglines = OnboardingTemplate.allCases.map(\.tagline) + + XCTAssertEqual(Set(taglines).count, taglines.count) + for tagline in taglines { + XCTAssertFalse(tagline.isEmpty) + } + XCTAssertEqual(OnboardingTemplate.quick.tagline, "Fast and lightweight") + XCTAssertEqual(OnboardingTemplate.powerful.tagline, "Highest quality") + } + + func test_detail_isUniquePerTierAndCustomExplainsBothUserPopulations() { + let details = OnboardingTemplate.allCases.map(\.detail) + + XCTAssertEqual(Set(details).count, details.count) + for detail in details { + XCTAssertFalse(detail.isEmpty) + } + // Custom's copy must reassure returning users their tuned settings survive and point new + // users at Settings for fine-tuning. + XCTAssertTrue(OnboardingTemplate.custom.detail.contains("keep every setting")) + XCTAssertTrue(OnboardingTemplate.custom.detail.contains("Settings")) + } +} diff --git a/CotabbyTests/PerformanceMetricsStoreTests.swift b/CotabbyTests/PerformanceMetricsStoreTests.swift new file mode 100644 index 00000000..e2901c72 --- /dev/null +++ b/CotabbyTests/PerformanceMetricsStoreTests.swift @@ -0,0 +1,207 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Persistence and ring-buffer behavior of the latency metrics behind the Performance pane. +/// +/// `PerformanceMetricsStore` is `@MainActor` with stored properties and no `nonisolated deinit`, +/// so deallocating an instance inside the app-hosted runner risks the isolated-deinit double-free. +/// Instances are quarantined in a process-lifetime retain list (the same pattern as +/// `InputMonitorTests`), and each test gets its own UserDefaults suite so nothing touches +/// process-global state. +final class PerformanceMetricsStoreTests: XCTestCase { + @MainActor private static var retainedStores: [PerformanceMetricsStore] = [] + + /// Persisted-blob key, mirrored from the production constant: it is a persistence contract, + /// so a silent rename should fail a test. + private static let entriesKey = "cotabbyPerformanceMetricEntries" + + private var userDefaultsSuites: [(suiteName: String, userDefaults: UserDefaults)] = [] + + override func tearDown() { + for suite in userDefaultsSuites { + suite.userDefaults.removePersistentDomain(forName: suite.suiteName) + } + userDefaultsSuites.removeAll() + super.tearDown() + } + + private func makeUserDefaults() -> UserDefaults { + let suiteName = "io.cotabby.tests.PerformanceMetricsStoreTests-\(UUID().uuidString)" + guard let userDefaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Expected an isolated UserDefaults suite") + return .standard + } + userDefaults.removePersistentDomain(forName: suiteName) + userDefaultsSuites.append((suiteName: suiteName, userDefaults: userDefaults)) + return userDefaults + } + + @MainActor + private func makeStore(userDefaults: UserDefaults) -> PerformanceMetricsStore { + let store = PerformanceMetricsStore(userDefaults: userDefaults) + Self.retainedStores.append(store) + return store + } + + /// Integer-second dates encode to exact JSON doubles, keeping Codable round-trip equality + /// deterministic (fractional timestamps can lose ULPs through the JSON text form). + private func exactDate(offset: Int = 0) -> Date { + Date(timeIntervalSince1970: TimeInterval(1_750_000_000 + offset)) + } + + private func decodePersistedEntries(from userDefaults: UserDefaults) -> [PerformanceMetricEntry]? { + guard let data = userDefaults.data(forKey: Self.entriesKey) else { return nil } + return try? JSONDecoder().decode([PerformanceMetricEntry].self, from: data) + } + + // MARK: - Recording + + func test_record_appendsEntryAndPersistsBlob() { + let userDefaults = makeUserDefaults() + + runOnMainActor { + let store = makeStore(userDefaults: userDefaults) + store.record(modelName: "tabby-2-nano", latencyMs: 230, timestamp: exactDate()) + + XCTAssertEqual(store.entries.count, 1) + XCTAssertEqual(store.entries.first?.modelName, "tabby-2-nano") + XCTAssertEqual(store.entries.first?.latencyMs, 230) + XCTAssertEqual(store.entries.first?.timestamp, exactDate()) + + XCTAssertEqual(decodePersistedEntries(from: userDefaults), store.entries) + } + } + + func test_record_capsRetainedEntriesDroppingOldest() { + let userDefaults = makeUserDefaults() + let cap = runOnMainActor { PerformanceMetricsStore.maximumEntries } + + runOnMainActor { + let store = makeStore(userDefaults: userDefaults) + for index in 0..<(cap + 5) { + store.record(modelName: "model", latencyMs: index, timestamp: exactDate(offset: index)) + } + + XCTAssertEqual(store.entries.count, cap) + XCTAssertEqual(store.entries.first?.latencyMs, 5, "Oldest five entries should have fallen off") + XCTAssertEqual(store.entries.last?.latencyMs, cap + 4) + XCTAssertEqual(decodePersistedEntries(from: userDefaults), store.entries, "Persisted blob mirrors the cap") + } + } + + // MARK: - Loading + + func test_init_restoresPersistedEntries() throws { + let userDefaults = makeUserDefaults() + let seeded = [ + PerformanceMetricEntry(timestamp: exactDate(), modelName: "alpha", latencyMs: 100), + PerformanceMetricEntry(timestamp: exactDate(offset: 1), modelName: "beta", latencyMs: 200) + ] + userDefaults.set(try JSONEncoder().encode(seeded), forKey: Self.entriesKey) + + runOnMainActor { + let store = makeStore(userDefaults: userDefaults) + XCTAssertEqual(store.entries, seeded) + } + } + + func test_init_truncatesOversizedPersistedBlobKeepingNewest() throws { + let userDefaults = makeUserDefaults() + let cap = runOnMainActor { PerformanceMetricsStore.maximumEntries } + let seeded = (0..<(cap + 5)).map { index in + PerformanceMetricEntry(timestamp: exactDate(offset: index), modelName: "model", latencyMs: index) + } + userDefaults.set(try JSONEncoder().encode(seeded), forKey: Self.entriesKey) + + runOnMainActor { + let store = makeStore(userDefaults: userDefaults) + XCTAssertEqual(store.entries.count, cap) + XCTAssertEqual(store.entries, Array(seeded.suffix(cap)), "Truncation keeps the newest tail") + } + } + + func test_init_startsEmptyWhenPersistedBlobIsCorrupt() { + let userDefaults = makeUserDefaults() + userDefaults.set(Data("not json".utf8), forKey: Self.entriesKey) + + runOnMainActor { + let store = makeStore(userDefaults: userDefaults) + XCTAssertTrue(store.entries.isEmpty) + } + } + + // MARK: - Clearing + + func test_clear_removesEntriesAndPersistedBlob() { + let userDefaults = makeUserDefaults() + + runOnMainActor { + let store = makeStore(userDefaults: userDefaults) + store.record(modelName: "model", latencyMs: 50, timestamp: exactDate()) + + store.clear() + + XCTAssertTrue(store.entries.isEmpty) + XCTAssertNil(userDefaults.data(forKey: Self.entriesKey)) + } + } + + func test_clear_whenAlreadyEmpty_skipsTheDefaultsWrite() { + let userDefaults = makeUserDefaults() + // Corrupt seed data loads as "no entries" but stays on disk; an empty clear must take the + // guard path and not touch the key. + let corrupt = Data("not json".utf8) + userDefaults.set(corrupt, forKey: Self.entriesKey) + + runOnMainActor { + let store = makeStore(userDefaults: userDefaults) + XCTAssertTrue(store.entries.isEmpty) + + store.clear() + + XCTAssertEqual(userDefaults.data(forKey: Self.entriesKey), corrupt) + } + } + + // MARK: - Entry value semantics + + func test_metricEntry_defaultsProvideUniqueIdentityAndCurrentTimestamp() { + let before = Date() + let first = PerformanceMetricEntry(modelName: "model", latencyMs: 10) + let second = PerformanceMetricEntry(modelName: "model", latencyMs: 10) + let after = Date() + + XCTAssertNotEqual(first.id, second.id) + XCTAssertGreaterThanOrEqual(first.timestamp, before) + XCTAssertLessThanOrEqual(first.timestamp, after) + } + + func test_metricEntry_roundTripsThroughJSON() throws { + // The entry's synthesized Codable/Equatable inherit the app module's default MainActor + // isolation, so the round trip runs through the main-actor hop helper. + try runOnMainActor { + let entry = PerformanceMetricEntry(timestamp: exactDate(), modelName: "tabby-2-mini", latencyMs: 412) + + let decoded = try JSONDecoder().decode( + PerformanceMetricEntry.self, + from: JSONEncoder().encode(entry) + ) + + XCTAssertEqual(decoded, entry) + XCTAssertEqual(decoded.hashValue, entry.hashValue) + } + } +} + +private func runOnMainActor( + _ body: @MainActor () throws -> Result +) rethrows -> Result { + if Thread.isMainThread { + return try MainActor.assumeIsolated(body) + } + + return try DispatchQueue.main.sync { + try MainActor.assumeIsolated(body) + } +} diff --git a/CotabbyTests/PermissionAndContextModelTests.swift b/CotabbyTests/PermissionAndContextModelTests.swift index bd2a8019..c6e0c740 100644 --- a/CotabbyTests/PermissionAndContextModelTests.swift +++ b/CotabbyTests/PermissionAndContextModelTests.swift @@ -75,6 +75,20 @@ final class CotabbyPermissionKindTests: XCTestCase { ) } } + + func test_id_isTheCaseItself() { + for kind in CotabbyPermissionKind.allCases { + XCTAssertEqual(kind.id, kind) + } + } + + func test_compactRowTitle_appendsOptionalQualifierOnlyForEnhancements() { + // Compact rows reuse the required rows' styling, so this suffix is the only thing that + // marks Screen Recording as optional there. + XCTAssertEqual(CotabbyPermissionKind.accessibility.compactRowTitle, "Accessibility") + XCTAssertEqual(CotabbyPermissionKind.inputMonitoring.compactRowTitle, "Input Monitoring") + XCTAssertEqual(CotabbyPermissionKind.screenRecording.compactRowTitle, "Screen Recording (Optional)") + } } final class VisualContextModelTests: XCTestCase { diff --git a/CotabbyTests/PromptContextSanitizerTests.swift b/CotabbyTests/PromptContextSanitizerTests.swift index 4e4d417d..e47cdd18 100644 --- a/CotabbyTests/PromptContextSanitizerTests.swift +++ b/CotabbyTests/PromptContextSanitizerTests.swift @@ -174,6 +174,36 @@ final class PromptContextSanitizerTests: XCTestCase { XCTAssertFalse(result.contains("54tbdbDX")) } + func test_sanitizeOCR_dropsLineOfOnlyWeakShortWords() { + // Preserved short words survive token scoring but are never strong signal on their own, so + // a line made entirely of them is UI chrome ("we", "go", "to") and must be dropped whole. + XCTAssertEqual(PromptContextSanitizer.sanitizeOCR("we go to it"), "") + } + + func test_sanitizeOCR_dropsRepeatedGlyphRuns() { + // "aaaa" is the repeated-glyph hallucination shape; the real words around it must survive. + XCTAssertEqual(PromptContextSanitizer.sanitizeOCR("meeting notes aaaa"), "meeting notes") + } + + func test_sanitizeOCR_returnsEmptyWhenBaseSanitizationLeavesNothing() { + // Symbols-only input sanitizes to an empty base string, which becomes one empty line; the + // OCR line filter must treat that as no tokens, not crash or emit whitespace. + XCTAssertEqual(PromptContextSanitizer.sanitizeOCR("*** ---"), "") + XCTAssertEqual(PromptContextSanitizer.sanitizeOCR(""), "") + } + + func test_sanitizeOCR_dropsLetterlessDottedToken() { + // "12.34" splits like a domain but carries no letters, is not all-digits (the dot), and has + // no word signal, so it scores as numeric UI chrome and is dropped. + XCTAssertEqual(PromptContextSanitizer.sanitizeOCR("meeting notes 12.34"), "meeting notes") + } + + func test_sanitizeOCR_dropsLowercaseLedTokenWithInteriorCapital() { + // "abeW" has vowels, so only the mixed-case rule can reject it: a non-leading capital in a + // short token without a known technical word is OCR garbage, unlike "Safari"-style prose. + XCTAssertEqual(PromptContextSanitizer.sanitizeOCR("meeting notes abeW"), "meeting notes") + } + // MARK: - containsAlphanumericSignal func test_containsAlphanumericSignal_returnsTrueForMixedInput() { diff --git a/CotabbyTests/RequestIDTests.swift b/CotabbyTests/RequestIDTests.swift new file mode 100644 index 00000000..38cc682d --- /dev/null +++ b/CotabbyTests/RequestIDTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import Cotabby + +/// Locks the correlation-ID format that the structured-logging workflow depends on: `jq` filters, +/// the symptom-to-category debugging map, and cross-file joins between `cotabby.jsonl` and +/// `llm-io.jsonl` all assume a stable `req_` + 8 base32 shape. +final class RequestIDTests: XCTestCase { + /// Crockford-style alphabet copied from the production contract: no `i`, `l`, `o`, or `u`, + /// so IDs stay unambiguous when read back from a log line. + private static let crockfordAlphabet = Set("0123456789abcdefghjkmnpqrstvwxyz") + + func test_generate_producesPrefixedTwelveCharacterID() { + let id = RequestID.generate() + + XCTAssertTrue(id.hasPrefix("req_"), "Expected req_ prefix, got \(id)") + XCTAssertEqual(id.count, 12, "Expected req_ plus exactly 8 base32 characters, got \(id)") + } + + func test_generate_usesOnlyCrockfordBase32Characters() { + for _ in 0..<64 { + let suffix = RequestID.generate().dropFirst(4) + + XCTAssertEqual(suffix.count, 8) + for character in suffix { + XCTAssertTrue( + Self.crockfordAlphabet.contains(character), + "Character \(character) is outside the Crockford base32 alphabet" + ) + } + } + } + + func test_generate_doesNotCollideAcrossManyDraws() { + // 1,000 draws from a 40-bit space: a duplicate here means the encoder is reusing entropy, + // not bad luck (the birthday-bound collision chance is below one in a million). + let ids = Set((0..<1_000).map { _ in RequestID.generate() }) + + XCTAssertEqual(ids.count, 1_000) + } + + func test_metadataRequestID_buildsTheSingleStampedField() { + // OSLogHandler's metadata property gives us a typed `Logger.Metadata` context without the + // test target needing its own swift-log dependency. + var handler = OSLogHandler(label: "com.cotabby.test-request-id") + handler.metadata = .requestID("req_a3f9k2lq") + + XCTAssertEqual(handler.metadata.count, 1) + XCTAssertEqual(handler.metadata["request_id"], .string("req_a3f9k2lq")) + } +} diff --git a/CotabbyTests/RuntimeBootstrapModelTests.swift b/CotabbyTests/RuntimeBootstrapModelTests.swift new file mode 100644 index 00000000..ae7065a0 --- /dev/null +++ b/CotabbyTests/RuntimeBootstrapModelTests.swift @@ -0,0 +1,377 @@ +import Combine +import Foundation +import XCTest +@testable import Cotabby + +/// Exercises `RuntimeBootstrapModel`'s selection persistence, reconciliation, and lifecycle +/// forwarding against a real `LlamaRuntimeManager` pointed at a throwaway model directory. +/// +/// No test ever lets a model load reach native llama.cpp: the planted `.gguf` files are deleted +/// before any prepare/select runs, so resolution fails inside `BundledRuntimeLocator` (pure file +/// checks) and the error paths complete deterministically in milliseconds. +/// +/// Both `RuntimeBootstrapModel` and `LlamaRuntimeManager` are `@MainActor` with stored properties +/// and no `nonisolated deinit`, so instances are quarantined in a process-lifetime retain list +/// (the `InputMonitorTests` pattern) and all interactions run through `runOnMainActor`. +final class RuntimeBootstrapModelTests: XCTestCase { + @MainActor private static var retainedModels: [RuntimeBootstrapModel] = [] + + /// Persisted-selection key, mirrored from the production constant: it is a persistence + /// contract across launches, so a silent rename should fail a test. + private static let selectionKey = "cotabbySelectedModelFilename" + + private var temporaryDirectories: [URL] = [] + private var userDefaultsSuites: [(suiteName: String, userDefaults: UserDefaults)] = [] + + override func tearDown() { + for url in temporaryDirectories { + try? FileManager.default.removeItem(at: url) + } + temporaryDirectories.removeAll() + for suite in userDefaultsSuites { + suite.userDefaults.removePersistentDomain(forName: suite.suiteName) + } + userDefaultsSuites.removeAll() + super.tearDown() + } + + // MARK: - Harness + + private func makeModelDirectory(filenames: [String]) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RuntimeBootstrapModelTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + for filename in filenames { + try Data("not a real model".utf8).write(to: directory.appendingPathComponent(filename)) + } + return directory + } + + private func removeModelFile(_ filename: String, in directory: URL) throws { + try FileManager.default.removeItem(at: directory.appendingPathComponent(filename)) + } + + private func makeUserDefaults() -> UserDefaults { + let suiteName = "io.cotabby.tests.RuntimeBootstrapModelTests-\(UUID().uuidString)" + guard let userDefaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Expected an isolated UserDefaults suite") + return .standard + } + userDefaults.removePersistentDomain(forName: suiteName) + userDefaultsSuites.append((suiteName: suiteName, userDefaults: userDefaults)) + return userDefaults + } + + @MainActor + private func makeModel(modelDirectory: URL, userDefaults: UserDefaults) -> RuntimeBootstrapModel { + // An empty preferred list keeps discovery ordering purely alphabetical, so tests can + // reason about "the first available model" without referencing the shipping catalog. + let configuration = LlamaRuntimeConfiguration( + runtimeDirectoryPath: modelDirectory.path, + preferredModelNames: [], + contextWindowTokens: 512, + batchSize: 128, + gpuLayerCount: 0 + ) + let manager = LlamaRuntimeManager( + configuration: configuration, + runtimeLocator: BundledRuntimeLocator() + ) + let model = RuntimeBootstrapModel(runtimeManager: manager, userDefaults: userDefaults) + Self.retainedModels.append(model) + return model + } + + /// Subscribes to the model's state and fulfills once it first reports failure. Used to wait + /// out the internal startup `Task` without sleeping. + private func expectFailureState(of model: RuntimeBootstrapModel) -> (XCTestExpectation, AnyCancellable) { + let failed = expectation(description: "runtime state reports failure") + let cancellable = runOnMainActor { + model.$state + .compactMap { $0.failureDetail } + .first() + .sink { _ in failed.fulfill() } + } + return (failed, cancellable) + } + + // MARK: - Initial selection + + func test_init_withoutModels_clearsSelectionAndStalePersistedValue() throws { + let directory = try makeModelDirectory(filenames: []) + let userDefaults = makeUserDefaults() + userDefaults.set("stale.gguf", forKey: Self.selectionKey) + + runOnMainActor { + let model = makeModel(modelDirectory: directory, userDefaults: userDefaults) + + XCTAssertTrue(model.availableModels.isEmpty) + XCTAssertNil(model.selectedModelFilename) + XCTAssertNil(userDefaults.string(forKey: Self.selectionKey), "Stale persisted choice must be cleared") + XCTAssertEqual(model.state, .idle) + } + } + + func test_init_prefersPersistedSelectionWhenStillAvailable() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf", "beta.gguf"]) + let userDefaults = makeUserDefaults() + userDefaults.set("beta.gguf", forKey: Self.selectionKey) + + runOnMainActor { + let model = makeModel(modelDirectory: directory, userDefaults: userDefaults) + + XCTAssertEqual(model.availableModels.map(\.filename), ["alpha.gguf", "beta.gguf"]) + XCTAssertEqual(model.selectedModelFilename, "beta.gguf") + XCTAssertEqual(userDefaults.string(forKey: Self.selectionKey), "beta.gguf") + } + } + + func test_init_fallsBackToFirstModelWhenPersistedSelectionIsGone() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf", "beta.gguf"]) + let userDefaults = makeUserDefaults() + userDefaults.set("ghost.gguf", forKey: Self.selectionKey) + + runOnMainActor { + let model = makeModel(modelDirectory: directory, userDefaults: userDefaults) + + XCTAssertEqual(model.selectedModelFilename, "alpha.gguf") + XCTAssertEqual( + userDefaults.string(forKey: Self.selectionKey), + "alpha.gguf", + "The repaired selection must be re-persisted" + ) + } + } + + // MARK: - startIfNeeded + + func test_startIfNeeded_withoutModels_staysIdle() throws { + let directory = try makeModelDirectory(filenames: []) + let userDefaults = makeUserDefaults() + + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + runOnMainActor { model.startIfNeeded() } + + // Give any (incorrectly) spawned startup task a chance to run: a regression here would + // surface as a .failed state because the empty directory cannot resolve a model. + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + runOnMainActor { + XCTAssertEqual(model.state, .idle) + } + } + + func test_startIfNeeded_reportsFailureWhenSelectedModelDisappears() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf"]) + let userDefaults = makeUserDefaults() + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + try removeModelFile("alpha.gguf", in: directory) + + let (failed, cancellable) = expectFailureState(of: model) + runOnMainActor { + model.startIfNeeded() + // The duplicate call must be swallowed while the first startup task is in flight. + model.startIfNeeded() + } + wait(for: [failed], timeout: 10) + cancellable.cancel() + + runOnMainActor { + XCTAssertNotNil(model.state.failureDetail) + XCTAssertNotNil(model.diagnostics.lastError, "Diagnostics must surface the resolution failure") + } + } + + // MARK: - selectModel + + func test_selectModel_ignoresUnknownFilename() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf", "beta.gguf"]) + let userDefaults = makeUserDefaults() + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + let reloads = ReloadCounter() + runOnMainActor { model.onWillReloadModel = { reloads.increment() } } + + let done = expectation(description: "selectModel returned") + Task { @MainActor in + await model.selectModel("ghost.gguf") + done.fulfill() + } + wait(for: [done], timeout: 10) + + runOnMainActor { + XCTAssertEqual(model.selectedModelFilename, "alpha.gguf") + XCTAssertEqual(userDefaults.string(forKey: Self.selectionKey), "alpha.gguf") + XCTAssertEqual(reloads.count, 0, "An ignored selection must not reset suggestion state") + XCTAssertEqual(model.state, .idle) + } + } + + func test_selectModel_switchesPersistsAndSignalsReload() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf", "beta.gguf"]) + let userDefaults = makeUserDefaults() + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + let reloads = ReloadCounter() + runOnMainActor { model.onWillReloadModel = { reloads.increment() } } + + // Deleting the files after discovery makes the subsequent load fail inside the locator, + // keeping the test off the native llama.cpp path while the full selection flow still runs. + try removeModelFile("alpha.gguf", in: directory) + try removeModelFile("beta.gguf", in: directory) + + let done = expectation(description: "selectModel returned") + Task { @MainActor in + await model.selectModel("beta.gguf") + done.fulfill() + } + wait(for: [done], timeout: 10) + + runOnMainActor { + XCTAssertEqual(model.selectedModelFilename, "beta.gguf") + XCTAssertEqual(userDefaults.string(forKey: Self.selectionKey), "beta.gguf") + XCTAssertEqual(reloads.count, 1, "Suggestion state must be told before the runtime reloads") + XCTAssertNotNil(model.state.failureDetail, "The vanished file should surface as a failed load") + } + } + + // MARK: - Available-model reconciliation + + func test_refreshAvailableModels_discoversNewModelsAndKeepsValidSelection() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf"]) + let userDefaults = makeUserDefaults() + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + + try Data("not a real model".utf8).write(to: directory.appendingPathComponent("beta.gguf")) + runOnMainActor { model.refreshAvailableModels() } + + runOnMainActor { + XCTAssertEqual(model.availableModels.map(\.filename), ["alpha.gguf", "beta.gguf"]) + XCTAssertEqual(model.selectedModelFilename, "alpha.gguf", "A still-valid selection must not churn") + XCTAssertEqual(userDefaults.string(forKey: Self.selectionKey), "alpha.gguf") + } + } + + func test_refreshAvailableModels_fallsBackToPersistedSelectionWhenCurrentDisappears() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf", "bravo.gguf", "charlie.gguf"]) + let userDefaults = makeUserDefaults() + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + runOnMainActor { + XCTAssertEqual(model.selectedModelFilename, "alpha.gguf") + } + + // The user's persisted intent (charlie) must beat "first remaining" (bravo) once the + // current selection vanishes from disk. + try removeModelFile("alpha.gguf", in: directory) + userDefaults.set("charlie.gguf", forKey: Self.selectionKey) + runOnMainActor { model.refreshAvailableModels() } + + runOnMainActor { + XCTAssertEqual(model.availableModels.map(\.filename), ["bravo.gguf", "charlie.gguf"]) + XCTAssertEqual(model.selectedModelFilename, "charlie.gguf") + XCTAssertEqual(userDefaults.string(forKey: Self.selectionKey), "charlie.gguf") + } + } + + func test_refreshAvailableModels_fallsBackToFirstRemainingModel() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf", "beta.gguf"]) + let userDefaults = makeUserDefaults() + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + + // Both the current selection and the persisted value point at the vanished alpha. + try removeModelFile("alpha.gguf", in: directory) + runOnMainActor { model.refreshAvailableModels() } + + runOnMainActor { + XCTAssertEqual(model.selectedModelFilename, "beta.gguf") + XCTAssertEqual(userDefaults.string(forKey: Self.selectionKey), "beta.gguf") + } + } + + func test_refreshAvailableModels_clearsSelectionWhenAllModelsDisappear() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf"]) + let userDefaults = makeUserDefaults() + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + + try removeModelFile("alpha.gguf", in: directory) + runOnMainActor { model.refreshAvailableModels() } + + runOnMainActor { + XCTAssertTrue(model.availableModels.isEmpty) + XCTAssertNil(model.selectedModelFilename) + XCTAssertNil(userDefaults.string(forKey: Self.selectionKey)) + } + } + + // MARK: - Shutdown forwarding + + func test_stop_returnsRuntimeToIdleAfterFailure() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf"]) + let userDefaults = makeUserDefaults() + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + try removeModelFile("alpha.gguf", in: directory) + + let (failed, cancellable) = expectFailureState(of: model) + runOnMainActor { model.startIfNeeded() } + wait(for: [failed], timeout: 10) + cancellable.cancel() + + runOnMainActor { + model.stop() + + XCTAssertEqual(model.state, .idle) + XCTAssertEqual(model.diagnostics.lastLoadStatus, "Stopped") + } + } + + func test_stopAndWait_completesWhenNothingWasLoaded() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf"]) + let userDefaults = makeUserDefaults() + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + + let done = expectation(description: "stopAndWait returned") + Task { @MainActor in + await model.stopAndWait() + done.fulfill() + } + wait(for: [done], timeout: 10) + + runOnMainActor { + XCTAssertEqual(model.state, .idle) + XCTAssertEqual(model.diagnostics.lastLoadStatus, "Stopped") + } + } + + func test_shutdownSync_returnsPromptlyWhenRuntimeNeverLoaded() throws { + let directory = try makeModelDirectory(filenames: ["alpha.gguf"]) + let userDefaults = makeUserDefaults() + let model = runOnMainActor { makeModel(modelDirectory: directory, userDefaults: userDefaults) } + + runOnMainActor { + model.shutdownSync(timeoutSeconds: 2.0) + + XCTAssertEqual(model.state, .idle) + XCTAssertEqual(model.diagnostics.lastLoadStatus, "Stopped") + } + } +} + +/// Reference-typed counter so `onWillReloadModel` invocations stay observable from outside the +/// closure without capturing a mutable local in an escaping context. +private final class ReloadCounter { + private(set) var count = 0 + + func increment() { + count += 1 + } +} + +private func runOnMainActor( + _ body: @MainActor () throws -> Result +) rethrows -> Result { + if Thread.isMainThread { + return try MainActor.assumeIsolated(body) + } + + return try DispatchQueue.main.sync { + try MainActor.assumeIsolated(body) + } +} diff --git a/CotabbyTests/ScreenshotContextGeneratorTests.swift b/CotabbyTests/ScreenshotContextGeneratorTests.swift index 1fdc6faf..eb946a58 100644 --- a/CotabbyTests/ScreenshotContextGeneratorTests.swift +++ b/CotabbyTests/ScreenshotContextGeneratorTests.swift @@ -104,6 +104,86 @@ final class ScreenshotContextGeneratorTests: XCTestCase { XCTAssertTrue(excerpt.text.contains("budget spreadsheet")) } + // MARK: - OCR-empty fallback to the window title + + private func makeGenerator( + extractionError: Error, + windowTitle: String? + ) -> ScreenshotContextGenerator { + ScreenshotContextGenerator( + screenshotService: StubScreenshotCapture( + screenshot: CapturedWindowScreenshot(image: makeImage(), windowTitle: windowTitle) + ), + textExtractor: StubTextExtractor(result: .failure(extractionError)), + configuration: .default + ) + } + + func test_generateContext_noRecognizedTextFallsBackToTheWindowTitle() async throws { + // A screenshot of an image-heavy window can OCR to nothing while its title still names + // the document; the title is the last usable signal before giving up. + let generator = makeGenerator( + extractionError: ScreenTextExtractionError.noRecognizedText, + windowTitle: "Quarterly budget review draft for the finance meeting" + ) + + let excerpt = try await generator.generateContext(for: makeSnapshot()) + + XCTAssertTrue(excerpt.text.contains("Quarterly budget review")) + } + + func test_generateContext_noRecognizedTextWithoutATitleIsUnavailable() async { + let generator = makeGenerator( + extractionError: ScreenTextExtractionError.noRecognizedText, + windowTitle: nil + ) + + do { + _ = try await generator.generateContext(for: makeSnapshot()) + XCTFail("Expected unavailable") + } catch let error as ScreenshotContextGenerationError { + XCTAssertTrue(error.localizedDescription.contains("not contain enough visible text")) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_generateContext_noRecognizedTextWithAJunkTitleIsUnavailable() async { + // A title with no meaningful signal (window chrome noise) must not be promoted to prompt + // context just because OCR came up empty. + let generator = makeGenerator( + extractionError: ScreenTextExtractionError.noRecognizedText, + windowTitle: "x1 9z" + ) + + do { + _ = try await generator.generateContext(for: makeSnapshot()) + XCTFail("Expected unavailable") + } catch is ScreenshotContextGenerationError { + // Expected: the junk title fails the meaningful-signal gate. + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_generateContext_unexpectedExtractionErrorSurfacesAsFailed() async { + struct VisionExploded: Error {} + let generator = makeGenerator(extractionError: VisionExploded(), windowTitle: nil) + + do { + _ = try await generator.generateContext(for: makeSnapshot()) + XCTFail("Expected failure") + } catch let error as ScreenshotContextGenerationError { + if case .failed = error { + // Non-extraction errors keep their distinct "failed" classification. + } else { + XCTFail("Expected .failed, got \(error)") + } + } catch { + XCTFail("Unexpected error: \(error)") + } + } + private func makeSnapshot() -> FocusedInputSnapshot { FocusedInputSnapshot( applicationName: "Xcode", diff --git a/CotabbyTests/SentenceBoundaryClassifierTests.swift b/CotabbyTests/SentenceBoundaryClassifierTests.swift index d0f368be..9b468b71 100644 --- a/CotabbyTests/SentenceBoundaryClassifierTests.swift +++ b/CotabbyTests/SentenceBoundaryClassifierTests.swift @@ -42,6 +42,14 @@ final class SentenceBoundaryClassifierTests: XCTestCase { XCTAssertFalse(SentenceBoundaryClassifier.isTerminalPeriod(in: text, at: lastPeriodIndex(in: text))) } + func test_leadingPeriodWithNothingBefore_isTerminal() { + // A period at the very start has no preceding word to qualify it, so it keeps the old + // unconditional behavior and counts as terminal. + let text = "." + XCTAssertTrue(SentenceBoundaryClassifier.isTerminalPeriod(in: text, at: text.startIndex)) + XCTAssertTrue(SentenceBoundaryClassifier.endsSentence(".")) + } + // MARK: - endsSentence func test_endsSentence_trueForTerminalPeriod() { diff --git a/CotabbyTests/SpellingDictionaryResourceTests.swift b/CotabbyTests/SpellingDictionaryResourceTests.swift index 8ea4da2b..6ff10e1d 100644 --- a/CotabbyTests/SpellingDictionaryResourceTests.swift +++ b/CotabbyTests/SpellingDictionaryResourceTests.swift @@ -21,3 +21,33 @@ final class SpellingDictionaryResourceTests: XCTestCase { } } } + +final class SpellingDictionaryLanguageMetadataTests: XCTestCase { + func test_id_matchesPersistedISOCodeInStableCatalogOrder() { + for language in SpellingDictionaryLanguage.allCases { + XCTAssertEqual(language.id, language.rawValue) + } + // `SpellingDictionaryCatalog.normalize` emits codes in `allCases` order, so this order is a + // persistence and rendering contract, not an implementation detail. + XCTAssertEqual( + SpellingDictionaryLanguage.allCases.map(\.rawValue), + ["en", "de", "es", "fr", "he", "it", "ru"] + ) + } + + func test_settingsLabel_includesEnglishNameForEveryLanguageAndStaysUnique() { + let labels = SpellingDictionaryLanguage.allCases.map(\.settingsLabel) + XCTAssertEqual(Set(labels).count, labels.count) + + for language in SpellingDictionaryLanguage.allCases { + XCTAssertTrue( + language.settingsLabel.contains(language.displayName), + "\(language.rawValue) settings label should include the English name" + ) + } + + XCTAssertEqual(SpellingDictionaryLanguage.english.settingsLabel, "English") + XCTAssertEqual(SpellingDictionaryLanguage.german.settingsLabel, "Deutsch (German)") + XCTAssertEqual(SpellingDictionaryLanguage.hebrew.settingsLabel, "עברית (Hebrew)") + } +} diff --git a/CotabbyTests/SpellingLanguageResolverTests.swift b/CotabbyTests/SpellingLanguageResolverTests.swift index 952108bb..bd0cb776 100644 --- a/CotabbyTests/SpellingLanguageResolverTests.swift +++ b/CotabbyTests/SpellingLanguageResolverTests.swift @@ -65,4 +65,28 @@ final class SpellingLanguageResolverTests: XCTestCase { ) ) } + + func test_emptyContextWithMultipleEnabledLanguagesReturnsNil() { + // With several dictionaries enabled and no text to sample, the resolver must not guess. + XCTAssertNil( + resolver.resolve( + precedingText: "", + currentWord: "", + enabledLanguages: [.english, .german] + ) + ) + } + + func test_currentWordNotAtEndOfContextStillResolvesFromFullContext() { + // The typo is not the suffix of the preceding text (mid-edit correction), so the resolver + // samples the whole preceding context instead of dropping a trailing word. + XCTAssertEqual( + resolver.resolve( + precedingText: "Das ist ein kurzer deutscher Satz mit einem", + currentWord: "Feler", + enabledLanguages: [.english, .german, .spanish] + ), + .german + ) + } } diff --git a/CotabbyTests/SuggestionCoordinatorInputTests.swift b/CotabbyTests/SuggestionCoordinatorInputTests.swift new file mode 100644 index 00000000..d7b2038d --- /dev/null +++ b/CotabbyTests/SuggestionCoordinatorInputTests.swift @@ -0,0 +1,322 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Locks the coordinator's keyboard and environment entry points: which keystrokes route into +/// acceptance, which tear the session down, which reschedule generation, and how focus and +/// permission changes start or stop the pipeline. These paths decide whether typing feels +/// instant or haunted, so every branch asserts the user-visible cleanup it leaves behind. +@MainActor +final class SuggestionCoordinatorInputTests: XCTestCase { + private var rigs: [CoordinatorRig] = [] + + override func tearDown() { + rigs.removeAll() + super.tearDown() + } + + private func retained(_ rig: CoordinatorRig) -> CoordinatorRig { + rigs.append(rig) + return rig + } + + /// Starts a live session with visible ghost text, the precondition for the with-session paths. + private func startSession(in rig: CoordinatorRig, fullText: String = " world") { + let context = FocusedInputContext(snapshot: rig.focusProvider.snapshot.context!, generation: 1) + let session = rig.interactionState.startSession(fullText: fullText, liveContext: context, latency: 0.05) + rig.overlayController.showSuggestion( + session.remainingText, + geometry: CotabbyTestFixtures.overlayGeometry() + ) + } + + // MARK: - Key routing + + func test_acceptanceEventRoutesIntoAcceptance() { + let rig = retained(makeCoordinatorRig()) + startSession(in: rig) + + let consumed = rig.coordinator.handleInputEvent(CotabbyTestFixtures.inputEvent(kind: .acceptance)) + + XCTAssertTrue(consumed, "Tab with a live session must be consumed") + XCTAssertEqual(rig.inserter.insertedChunks, [" world"]) + } + + func test_fullAcceptanceEventCommitsTheWholeSuggestion() { + let rig = retained(makeCoordinatorRig()) + startSession(in: rig, fullText: " world again") + + let consumed = rig.coordinator.handleInputEvent(CotabbyTestFixtures.inputEvent(kind: .fullAcceptance)) + + XCTAssertTrue(consumed) + XCTAssertEqual(rig.inserter.insertedChunks, [" world again"]) + } + + func test_disabledEnvironmentSwallowsNothingAndDisablesPipeline() { + let rig = retained(makeCoordinatorRig( + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot(isGloballyEnabled: false) + )) + + let consumed = rig.coordinator.handleInputEvent(CotabbyTestFixtures.inputEvent(kind: .textMutation, characters: "a")) + + XCTAssertFalse(consumed) + guard case .disabled = rig.coordinator.state else { + return XCTFail("Expected disabled, got \(rig.coordinator.state)") + } + } + + // MARK: - Emoji picker priority + + func test_emojiCaptureStandsTheSuggestionPipelineDown() { + let rig = retained(makeCoordinatorRig()) + startSession(in: rig) + rig.coordinator.emojiInputObserver = { _ in true } + + let consumed = rig.coordinator.handleInputEvent(CotabbyTestFixtures.inputEvent(kind: .textMutation, characters: ":")) + + XCTAssertFalse(consumed, "Consumption is the tap's job; the coordinator only stands down") + XCTAssertNil(rig.interactionState.activeSession) + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + XCTAssertTrue(rig.overlayController.hideReasons.contains { $0.contains("emoji picker") }) + } + + // MARK: - Typing against a live session + + func test_typingTheExpectedCharactersAdvancesTheSession() { + let rig = retained(makeCoordinatorRig()) + startSession(in: rig, fullText: " world") + + let consumed = rig.coordinator.handleInputEvent( + CotabbyTestFixtures.inputEvent(kind: .textMutation, characters: " "), + with: rig.interactionState.activeSession! + ) + + XCTAssertFalse(consumed) + XCTAssertNotNil(rig.interactionState.activeSession, "A matching keystroke advances, never kills") + guard case let .ready(text, _) = rig.coordinator.state else { + return XCTFail("Expected ready, got \(rig.coordinator.state)") + } + XCTAssertEqual(text, "world") + } + + func test_divergentTypingInvalidatesAndReschedules() async { + let rig = retained(makeCoordinatorRig()) + startSession(in: rig, fullText: " world") + + let consumed = rig.coordinator.handleInputEvent( + CotabbyTestFixtures.inputEvent(kind: .textMutation, characters: "x"), + with: rig.interactionState.activeSession! + ) + + XCTAssertFalse(consumed) + XCTAssertNil(rig.interactionState.activeSession) + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + + // The reschedule waits for the host to publish the keystroke; simulate the publish by + // changing the live preceding text so the poll's change gate fires. + let typedSnapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hellox") + rig.focusProvider.snapshot = FocusSnapshot( + applicationName: typedSnapshot.applicationName, + bundleIdentifier: typedSnapshot.bundleIdentifier, + capability: .supported, + context: typedSnapshot, + inspection: nil + ) + await waitUntil("Divergent typing never rescheduled generation") { + rig.coordinator.state == .debouncing || rig.engine.requests.count == 1 + } + } + + func test_navigationDismissesTheSessionWithoutRescheduling() { + let rig = retained(makeCoordinatorRig()) + startSession(in: rig) + + _ = rig.coordinator.handleInputEvent( + CotabbyTestFixtures.inputEvent(kind: .navigation), + with: rig.interactionState.activeSession! + ) + + XCTAssertNil(rig.interactionState.activeSession) + XCTAssertEqual(rig.coordinator.state, .idle) + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + } + + func test_shortcutMutationInvalidatesTheSession() { + let rig = retained(makeCoordinatorRig()) + startSession(in: rig) + + _ = rig.coordinator.handleInputEvent( + CotabbyTestFixtures.inputEvent(kind: .shortcutMutation, characters: "z"), + with: rig.interactionState.activeSession! + ) + + XCTAssertNil(rig.interactionState.activeSession) + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + } + + func test_otherEventsLeaveTheSessionAlone() { + let rig = retained(makeCoordinatorRig()) + startSession(in: rig) + + _ = rig.coordinator.handleInputEvent( + CotabbyTestFixtures.inputEvent(kind: .other), + with: rig.interactionState.activeSession! + ) + + XCTAssertNotNil(rig.interactionState.activeSession) + XCTAssertTrue(rig.coordinator.overlayState.isVisible) + } + + // MARK: - Typing with no session + + func test_typingWithoutASessionClearsStaleUIAndReschedules() async { + let rig = retained(makeCoordinatorRig()) + rig.overlayController.showSuggestion(" stale", geometry: CotabbyTestFixtures.overlayGeometry()) + + let consumed = rig.coordinator.handleInputEvent( + CotabbyTestFixtures.inputEvent(kind: .textMutation, characters: "a") + ) + + XCTAssertFalse(consumed) + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + + let typedSnapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Helloa") + rig.focusProvider.snapshot = FocusSnapshot( + applicationName: typedSnapshot.applicationName, + bundleIdentifier: typedSnapshot.bundleIdentifier, + capability: .supported, + context: typedSnapshot, + inspection: nil + ) + await waitUntil("Keystroke never rescheduled generation") { + rig.coordinator.state == .debouncing || !rig.engine.requests.isEmpty + } + } + + func test_dismissalWithoutASessionEndsIdleWithoutRescheduling() async { + let rig = retained(makeCoordinatorRig()) + rig.overlayController.showSuggestion(" stale", geometry: CotabbyTestFixtures.overlayGeometry()) + + _ = rig.coordinator.handleInputEvent(CotabbyTestFixtures.inputEvent(kind: .dismissal)) + + XCTAssertEqual(rig.coordinator.state, .idle) + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + // Escape must not trigger a fresh generation. + try? await Task.sleep(nanoseconds: 80_000_000) + XCTAssertTrue(rig.engine.requests.isEmpty) + } + + // MARK: - Focus snapshot changes + + func test_focusChangeToSupportedFieldStartsVisualContextCapture() { + let rig = retained(makeCoordinatorRig()) + + rig.coordinator.handleFocusSnapshotChange(rig.focusProvider.snapshot) + + XCTAssertEqual(rig.visualContext.startedSessions.count, 2, "Both gate sites start the OCR session") + } + + func test_focusChangeInFastModeSkipsVisualContextCapture() { + let rig = retained(makeCoordinatorRig( + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot(debounceMilliseconds: 1, isFastModeEnabled: true) + )) + + rig.coordinator.handleFocusSnapshotChange(rig.focusProvider.snapshot) + + XCTAssertTrue(rig.visualContext.startedSessions.isEmpty, "Fast mode skips screenshot/OCR work entirely") + } + + func test_focusChangeToDisabledAppPreservesVisualContextSession() { + let rig = retained(makeCoordinatorRig( + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + disabledAppBundleIdentifiers: ["com.example.TestApp"], + debounceMilliseconds: 1 + ) + )) + + rig.coordinator.handleFocusSnapshotChange(rig.focusProvider.snapshot) + + guard case .disabled = rig.coordinator.state else { + return XCTFail("Expected disabled, got \(rig.coordinator.state)") + } + XCTAssertTrue(rig.visualContext.cancelCalls.isEmpty, "Focus-level disables are transient; keep the OCR session") + } + + func test_handleSupportedSnapshot_recoversFromDisabledAndClearsOnFieldChange() async { + let rig = retained(makeCoordinatorRig()) + // Anchor the interaction state to the current app so a different pid below reads as a + // genuine field switch (a fresh state treats the first observation as unchanged). + _ = rig.interactionState.materializeContext(from: rig.focusProvider.snapshot.context!) + rig.coordinator.state = .disabled("old reason") + + let otherApp = CotabbyTestFixtures.focusedInputSnapshot( + processIdentifier: 456, + precedingText: "Hi" + ) + let otherFocus = FocusSnapshot( + applicationName: otherApp.applicationName, + bundleIdentifier: otherApp.bundleIdentifier, + capability: .supported, + context: otherApp, + inspection: nil + ) + rig.coordinator.handleSupportedSnapshot(otherFocus) + + XCTAssertEqual(rig.coordinator.state, .idle) + XCTAssertTrue(rig.overlayController.hideReasons.contains { $0.contains("focused field changed") }) + + // The field switch also prewarms the routed engine for the new surface, with the sentinel + // generation that can never trip the stale-result drop logic. + await waitUntil("Engine was never prewarmed for the new field") { + !rig.engine.prewarmedRequests.isEmpty + } + XCTAssertEqual(rig.engine.prewarmedRequests.first?.generation, 0) + } + + func test_handleSupportedSnapshot_withoutContextDisablesOutright() { + let rig = retained(makeCoordinatorRig()) + let bareSnapshot = FocusSnapshot( + applicationName: "TestApp", + bundleIdentifier: "com.example.TestApp", + capability: .unsupported("No focused text input"), + context: nil, + inspection: nil + ) + + rig.coordinator.handleSupportedSnapshot(bareSnapshot) + + guard case .disabled = rig.coordinator.state else { + return XCTFail("Expected disabled, got \(rig.coordinator.state)") + } + XCTAssertEqual(rig.visualContext.cancelCalls, [true]) + } + + func test_handleSupportedSnapshot_withActiveSessionReconcilesInsteadOfClearing() { + let rig = retained(makeCoordinatorRig()) + startSession(in: rig) + + rig.coordinator.handleSupportedSnapshot(rig.focusProvider.snapshot) + + XCTAssertNotNil(rig.interactionState.activeSession, "An unchanged field must keep the live session") + } + + // MARK: - Permission changes + + func test_permissionChange_revokedScreenRecordingCancelsVisualContext() { + let rig = retained(makeCoordinatorRig()) + rig.permissionProvider.screenRecordingGranted = false + + rig.coordinator.handlePermissionChange() + + XCTAssertEqual(rig.visualContext.cancelCalls, [true]) + } + + func test_suppressedSyntheticInput_logsWithoutMutatingState() { + let rig = retained(makeCoordinatorRig()) + let stateBefore = rig.coordinator.state + + rig.coordinator.handleSuppressedSyntheticInput() + + XCTAssertEqual(rig.coordinator.state, stateBefore) + } +} diff --git a/CotabbyTests/SuggestionCoordinatorLifecycleTests.swift b/CotabbyTests/SuggestionCoordinatorLifecycleTests.swift new file mode 100644 index 00000000..bc1d088f --- /dev/null +++ b/CotabbyTests/SuggestionCoordinatorLifecycleTests.swift @@ -0,0 +1,141 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Locks the coordinator's lifecycle commands and the settings-change reaction: what gets torn +/// down on stop and model switches, and which settings edits restart the pipeline. A regression +/// here leaks callbacks across shutdown or leaves stale suggestions alive across a model swap. +@MainActor +final class SuggestionCoordinatorLifecycleTests: XCTestCase { + private var rigs: [CoordinatorRig] = [] + + override func tearDown() { + rigs.removeAll() + super.tearDown() + } + + private func retained(_ rig: CoordinatorRig) -> CoordinatorRig { + rigs.append(rig) + return rig + } + + func test_start_reconcilesOutOfAStaleDisabledState() { + let rig = retained(makeCoordinatorRig()) + rig.coordinator.state = .disabled("stale launch state") + + rig.coordinator.start() + + XCTAssertEqual(rig.coordinator.state, .idle) + } + + func test_stop_detachesEveryLongLivedCallbackAndHidesTheOverlay() { + let rig = retained(makeCoordinatorRig()) + // The coordinator wires these at construction; overwriting them here would sever its own + // overlay-state mirror and fake the assertion below, so verify the wiring instead. + XCTAssertNotNil(rig.inputMonitor.onEvent, "Construction must install the event callback") + XCTAssertNotNil(rig.overlayController.onStateChange) + rig.overlayController.showSuggestion(" ghost", geometry: CotabbyTestFixtures.overlayGeometry()) + XCTAssertTrue(rig.coordinator.overlayState.isVisible) + + rig.coordinator.stop() + + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + XCTAssertEqual(rig.visualContext.cancelCalls, [true]) + XCTAssertNil(rig.inputMonitor.onEvent, "A leaked event callback outlives shutdown") + XCTAssertNil(rig.inputMonitor.onSuppressedSyntheticInput) + XCTAssertNil(rig.overlayController.onStateChange) + XCTAssertNil(rig.visualContext.onStateChange) + XCTAssertNil(rig.visualContext.onInjectedContextReady) + } + + func test_prepareForRuntimeModelSwitch_clearsTheActiveSessionAndOverlay() { + let rig = retained(makeCoordinatorRig()) + let context = FocusedInputContext(snapshot: rig.focusProvider.snapshot.context!, generation: 1) + _ = rig.interactionState.startSession(fullText: " world", liveContext: context, latency: 0.05) + rig.overlayController.showSuggestion(" world", geometry: CotabbyTestFixtures.overlayGeometry()) + + rig.coordinator.prepareForRuntimeModelSwitch() + + XCTAssertNil(rig.interactionState.activeSession, "A stale session must not survive a model swap") + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + XCTAssertEqual(rig.coordinator.state, .idle) + XCTAssertEqual(rig.visualContext.cancelCalls, [true]) + XCTAssertTrue(rig.coordinator.latestStageMessage.contains("model switching")) + } + + func test_settingsChange_identicalSnapshotIsANoOp() { + let rig = retained(makeCoordinatorRig()) + rig.overlayController.showSuggestion(" keep me", geometry: CotabbyTestFixtures.overlayGeometry()) + + rig.coordinator.handleSuggestionSettingsChange(rig.coordinator.settingsSnapshot) + + XCTAssertTrue(rig.coordinator.overlayState.isVisible, "An unchanged snapshot must not reset anything") + } + + func test_settingsChange_engineSwitchResetsStateAndRestartsThePipeline() { + let rig = retained(makeCoordinatorRig()) + rig.overlayController.showSuggestion(" stale", geometry: CotabbyTestFixtures.overlayGeometry()) + + let switched = CotabbyTestFixtures.settingsSnapshot( + selectedEngine: .appleIntelligence, + debounceMilliseconds: 1 + ) + rig.coordinator.handleSuggestionSettingsChange(switched) + + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + XCTAssertEqual(rig.coordinator.settingsSnapshot, switched) + // A supported focus environment restarts both visual context and prediction. + XCTAssertFalse(rig.visualContext.startedSessions.isEmpty) + XCTAssertEqual(rig.coordinator.state, .debouncing) + } + + func test_settingsChange_stageMessagesNameTheEngineOrLengthChange() { + // Use a focus environment that cannot schedule a prediction, so the settings-change + // message is not immediately overwritten by the "debouncing" stage log. + let rig = retained(makeCoordinatorRig(capability: .unsupported("No focused text input"))) + + rig.coordinator.handleSuggestionSettingsChange( + CotabbyTestFixtures.settingsSnapshot(selectedEngine: .appleIntelligence, debounceMilliseconds: 1) + ) + XCTAssertTrue( + rig.coordinator.latestStageMessage.contains(SuggestionEngineKind.appleIntelligence.displayLabel) + ) + + rig.coordinator.handleSuggestionSettingsChange( + CotabbyTestFixtures.settingsSnapshot( + selectedEngine: .appleIntelligence, + selectedWordCountPreset: .twelveToTwenty, + debounceMilliseconds: 1 + ) + ) + XCTAssertTrue( + rig.coordinator.latestStageMessage.contains(SuggestionWordCountPreset.twelveToTwenty.displayLabel) + ) + + // Any other field change gets the generic message. + rig.coordinator.handleSuggestionSettingsChange( + CotabbyTestFixtures.settingsSnapshot( + selectedEngine: .appleIntelligence, + selectedWordCountPreset: .twelveToTwenty, + isClipboardContextEnabled: false, + debounceMilliseconds: 1 + ) + ) + XCTAssertEqual(rig.coordinator.latestStageMessage, "Updated autocomplete settings.") + } + + func test_settingsChange_disablingGloballyDoesNotRestartThePipeline() { + let rig = retained(makeCoordinatorRig()) + + let disabled = CotabbyTestFixtures.settingsSnapshot( + isGloballyEnabled: false, + debounceMilliseconds: 1 + ) + rig.coordinator.handleSuggestionSettingsChange(disabled) + + XCTAssertNotEqual(rig.coordinator.state, .debouncing, "A disabling change must not schedule generation") + XCTAssertTrue(rig.visualContext.startedSessions.isEmpty, "No OCR session for a disabled subsystem") + // The obsolete visual context is still torn down. + XCTAssertEqual(rig.visualContext.cancelCalls, [true]) + } +} diff --git a/CotabbyTests/SuggestionCoordinatorPredictionTests.swift b/CotabbyTests/SuggestionCoordinatorPredictionTests.swift new file mode 100644 index 00000000..d3b32b3a --- /dev/null +++ b/CotabbyTests/SuggestionCoordinatorPredictionTests.swift @@ -0,0 +1,371 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Exercises the async half of the coordinator's state machine: debounce scheduling, request +/// build, engine dispatch, and every freshness gate in `apply`. These are the paths that decide +/// whether a model reply ever reaches the screen, so each gate gets a test that proves both the +/// drop and the user-visible cleanup (state + overlay) it must leave behind. +@MainActor +final class SuggestionCoordinatorPredictionTests: XCTestCase { + private var rigs: [CoordinatorRig] = [] + + override func tearDown() { + rigs.removeAll() + super.tearDown() + } + + private func retained(_ rig: CoordinatorRig) -> CoordinatorRig { + rigs.append(rig) + return rig + } + + // MARK: - Happy path + + func test_schedulePrediction_generatesAndPresentsTheSuggestion() async { + let rig = retained(makeCoordinatorRig()) + + rig.coordinator.schedulePrediction() + XCTAssertEqual(rig.coordinator.state, .debouncing) + + await waitUntil("Suggestion never became ready") { + if case .ready = rig.coordinator.state { return true } + return false + } + + guard case let .ready(text, _) = rig.coordinator.state else { + return XCTFail("Expected ready state") + } + XCTAssertEqual(text, " world") + XCTAssertEqual(rig.overlayController.shownTexts, [" world"]) + XCTAssertTrue(rig.coordinator.overlayState.isVisible) + XCTAssertEqual(rig.engine.requests.count, 1) + XCTAssertEqual(rig.engine.requests.first?.prefixText.isEmpty, false) + XCTAssertNotNil(rig.coordinator.latestRequestID) + XCTAssertNotNil(rig.interactionState.activeSession) + } + + // MARK: - Gates before generation + + func test_schedulePrediction_disabledAppGoesStraightToDisabledState() async { + let rig = retained(makeCoordinatorRig( + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + disabledAppBundleIdentifiers: ["com.example.TestApp"], + debounceMilliseconds: 1 + ) + )) + + rig.coordinator.schedulePrediction() + + guard case .disabled = rig.coordinator.state else { + return XCTFail("Expected disabled state, got \(rig.coordinator.state)") + } + XCTAssertTrue(rig.engine.requests.isEmpty) + // The hard-disable path tears down the field-scoped OCR session too. + XCTAssertEqual(rig.visualContext.cancelCalls, [true]) + } + + func test_generate_emptyFieldEndsIdleWithoutCallingTheEngine() async { + let rig = retained(makeCoordinatorRig( + snapshot: CotabbyTestFixtures.focusedInputSnapshot(precedingText: "") + )) + + rig.coordinator.schedulePrediction() + await waitUntil("Pipeline never settled to idle") { rig.coordinator.state == .idle } + + XCTAssertTrue(rig.engine.requests.isEmpty) + XCTAssertTrue(rig.overlayController.hideReasons.contains { + $0.contains("no typed text yet") + }) + } + + // MARK: - Freshness gates in apply + + func test_apply_emptyNormalizedResultEndsIdle() async { + let rig = retained(makeCoordinatorRig()) + rig.engine.resultProvider = { request in + SuggestionResult(generation: request.generation, rawText: " ", text: "", latency: 0.01) + } + + rig.coordinator.schedulePrediction() + await waitUntil("Pipeline never settled to idle") { rig.coordinator.state == .idle } + + XCTAssertNil(rig.interactionState.activeSession) + XCTAssertTrue(rig.overlayController.hideReasons.contains { + $0.contains("empty continuation") + }) + } + + func test_apply_staleGenerationIsDroppedWithoutASession() async { + let rig = retained(makeCoordinatorRig()) + rig.engine.resultProvider = { _ in + SuggestionResult(generation: 9_999, rawText: " world", text: " world", latency: 0.01) + } + + rig.coordinator.schedulePrediction() + await waitUntil("Stale result was never processed") { + rig.overlayController.hideReasons.contains { $0.contains("stale result") } + } + + XCTAssertNil(rig.interactionState.activeSession) + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + } + + func test_apply_selectedTextDropsTheSuggestion() async { + let rig = retained(makeCoordinatorRig( + snapshot: CotabbyTestFixtures.focusedInputSnapshot( + precedingText: "Hello", + selection: NSRange(location: 2, length: 3) + ) + )) + + rig.coordinator.schedulePrediction() + await waitUntil("Pipeline never settled to idle") { rig.coordinator.state == .idle } + + XCTAssertNil(rig.interactionState.activeSession) + XCTAssertTrue(rig.overlayController.hideReasons.contains { + $0.contains("text is selected") + }) + } + + func test_apply_staleAcceptanceEchoIsDroppedBeforeHostPublishesTheInsert() async { + let rig = retained(makeCoordinatorRig()) + // The regeneration after a final-chunk accept re-proposes the accepted tail while the + // field still shows the pre-acceptance text: the signature of an unpublished insert. + rig.coordinator.lastAcceptedTail = AcceptedSuggestionTail(text: " world", precedingText: "Hello") + + rig.coordinator.schedulePrediction() + await waitUntil("Echo was never dropped") { + rig.overlayController.hideReasons.contains { $0.contains("echoed the just-accepted") } + } + + XCTAssertEqual(rig.coordinator.state, .idle) + XCTAssertNil(rig.interactionState.activeSession) + XCTAssertNil(rig.coordinator.lastAcceptedTail, "The recorded tail gets exactly one shot") + } + + // MARK: - Engine failure modes + + func test_engineFailure_surfacesAsFailedState() async { + struct EngineExploded: Error {} + let rig = retained(makeCoordinatorRig()) + rig.engine.resultProvider = { _ in throw EngineExploded() } + + rig.coordinator.schedulePrediction() + await waitUntil("Failure never surfaced") { + if case .failed = rig.coordinator.state { return true } + return false + } + + XCTAssertTrue(rig.overlayController.hideReasons.contains { + $0.contains("generation failed") + }) + } + + func test_engineCancellation_isSilentlySwallowed() async { + let rig = retained(makeCoordinatorRig()) + rig.engine.resultProvider = { _ in throw SuggestionClientError.cancelled } + + rig.coordinator.schedulePrediction() + await waitUntil("Engine was never called") { rig.engine.requests.count == 1 } + // Give the post-throw path a beat to (incorrectly) mutate state if it were going to. + try? await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertEqual(rig.coordinator.state, .generating, "Cancellation must not surface as failure") + XCTAssertTrue(rig.overlayController.hideReasons.isEmpty) + } + + // MARK: - Typo gate + + func test_typoGate_suppressesGenerationForAMisspelledCurrentWord() async { + let rig = retained(makeCoordinatorRig( + snapshot: CotabbyTestFixtures.focusedInputSnapshot(precedingText: "I typed qzxkvjw"), + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + debounceMilliseconds: 1, + suppressCompletionsOnTypo: true + ) + )) + + rig.coordinator.schedulePrediction() + await waitUntil("Typo gate never settled") { rig.coordinator.state == .idle } + + XCTAssertTrue(rig.engine.requests.isEmpty, "A misspelled current word must skip generation") + XCTAssertTrue(rig.overlayController.hideReasons.contains { + $0.contains("looks misspelled") + }) + } + + func test_typoGate_offersACorrectionSessionInsteadOfGenerating() async { + let rig = retained(makeCoordinatorRig( + snapshot: CotabbyTestFixtures.focusedInputSnapshot(precedingText: "I typed recieve"), + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + debounceMilliseconds: 1, + suppressCompletionsOnTypo: true, + offerTypoCorrections: true + ) + )) + + rig.coordinator.schedulePrediction() + await waitUntil("Correction was never offered") { + rig.interactionState.activeSession?.kind.isCorrection == true + } + + XCTAssertTrue(rig.engine.requests.isEmpty, "Corrections are native; no model generation runs") + guard case .ready = rig.coordinator.state else { + return XCTFail("A correction offer should present as ready, got \(rig.coordinator.state)") + } + XCTAssertTrue(rig.coordinator.overlayState.isVisible) + } + + func test_typoGate_automaticallyFixesACompletedWordAfterSpace() async { + let rig = retained(makeCoordinatorRig( + snapshot: CotabbyTestFixtures.focusedInputSnapshot(precedingText: "I typed recieve "), + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + debounceMilliseconds: 1, + suppressCompletionsOnTypo: true, + offerTypoCorrections: true, + automaticallyFixTypos: true + ) + )) + + rig.coordinator.schedulePrediction() + await waitUntil("Automatic correction never ran") { !rig.inserter.replacements.isEmpty } + + XCTAssertEqual(rig.inserter.replacements.count, 1) + XCTAssertEqual(rig.coordinator.state, .idle) + XCTAssertEqual(rig.coordinator.latestAcceptanceAction?.hasPrefix("Automatically corrected") ?? false, true) + XCTAssertTrue(rig.engine.requests.isEmpty) + } + + // MARK: - Environment reconciliation + + func test_reconcileWithCurrentEnvironment_reenablesOnceTheBlockerClears() { + let rig = retained(makeCoordinatorRig()) + rig.coordinator.disablePredictions(reason: "Test disable") + + rig.coordinator.reconcileWithCurrentEnvironment() + XCTAssertEqual(rig.coordinator.state, .idle) + + // With a real blocker present the same call must keep predictions disabled. + rig.coordinator.settingsSnapshot = CotabbyTestFixtures.settingsSnapshot(isGloballyEnabled: false) + rig.coordinator.reconcileWithCurrentEnvironment() + guard case .disabled = rig.coordinator.state else { + return XCTFail("Expected disabled, got \(rig.coordinator.state)") + } + } + + func test_disablePredictionsPreservingVisualContext_keepsTheOCRSessionAlive() { + let rig = retained(makeCoordinatorRig()) + + rig.coordinator.disablePredictionsPreservingVisualContext(reason: "Text is currently selected.") + + guard case .disabled = rig.coordinator.state else { + return XCTFail("Expected disabled, got \(rig.coordinator.state)") + } + XCTAssertTrue( + rig.visualContext.cancelCalls.isEmpty, + "Transient disables must not destroy the field-scoped visual-context session" + ) + } + + // MARK: - Session reconciliation + + func test_reconcileActiveSession_hidesAStaleOverlayWhenNoSessionExists() { + let rig = retained(makeCoordinatorRig()) + rig.overlayController.showSuggestion( + " stale", + geometry: CotabbyTestFixtures.overlayGeometry() + ) + + rig.coordinator.reconcileActiveSession(with: rig.focusProvider.snapshot) + + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + } + + func test_reconcileActiveSession_advancesWhenTheUserTypesThroughTheTail() { + let rig = retained(makeCoordinatorRig()) + let context = FocusedInputContext(snapshot: rig.focusProvider.snapshot.context!, generation: 1) + _ = rig.interactionState.startSession(fullText: " world", liveContext: context, latency: 0.05) + + // The user typed the next three expected characters; the session must advance, not die. + let typedSnapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello wo") + rig.focusProvider.snapshot = FocusSnapshot( + applicationName: typedSnapshot.applicationName, + bundleIdentifier: typedSnapshot.bundleIdentifier, + capability: .supported, + context: typedSnapshot, + inspection: nil + ) + rig.coordinator.reconcileActiveSession(with: rig.focusProvider.snapshot) + + guard case let .ready(text, _) = rig.coordinator.state else { + return XCTFail("Expected ready state, got \(rig.coordinator.state)") + } + XCTAssertEqual(text, "rld") + XCTAssertNotNil(rig.interactionState.activeSession) + } + + func test_reconcileActiveSession_correctionSurvivesUnchangedFieldAndDropsOnEdit() { + let rig = retained(makeCoordinatorRig( + snapshot: CotabbyTestFixtures.focusedInputSnapshot(precedingText: "I typed recieve") + )) + let context = FocusedInputContext(snapshot: rig.focusProvider.snapshot.context!, generation: 1) + _ = rig.interactionState.startSession( + fullText: "receive", + liveContext: context, + latency: 0, + kind: .correction(typoWord: "recieve") + ) + rig.overlayController.showSuggestion("receive", geometry: CotabbyTestFixtures.overlayGeometry()) + + // Unchanged field: the offer stays. + rig.coordinator.reconcileActiveSession(with: rig.focusProvider.snapshot) + XCTAssertNotNil(rig.interactionState.activeSession) + + // Any edit to the trailing word drops the offer; the next prediction re-runs the gate. + let editedSnapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "I typed recievex") + let editedFocus = FocusSnapshot( + applicationName: editedSnapshot.applicationName, + bundleIdentifier: editedSnapshot.bundleIdentifier, + capability: .supported, + context: editedSnapshot, + inspection: nil + ) + rig.coordinator.reconcileActiveSession(with: editedFocus) + XCTAssertNil(rig.interactionState.activeSession) + XCTAssertFalse(rig.coordinator.overlayState.isVisible) + } + + // MARK: - Cache reset barrier + + func test_resetCachedGenerationContext_barrierRunsTheEngineResetExactlyOnce() async { + let rig = retained(makeCoordinatorRig()) + + rig.coordinator.resetCachedGenerationContext() + await rig.coordinator.awaitCachedGenerationContextResetIfNeeded() + + XCTAssertEqual(rig.engine.resetCount, 1) + // A second await without a new reset must not re-run the engine reset. + await rig.coordinator.awaitCachedGenerationContextResetIfNeeded() + XCTAssertEqual(rig.engine.resetCount, 1) + } + + // MARK: - Visual-context-triggered rescheduling + + func test_visualContextReady_reschedulesOnlyForTheSameField() { + let rig = retained(makeCoordinatorRig()) + let identity = rig.focusProvider.snapshot.context!.identity + + rig.coordinator.schedulePredictionForCurrentFocusIfPossible(matching: identity) + XCTAssertEqual(rig.coordinator.state, .debouncing, "Same field: OCR readiness reschedules") + + rig.coordinator.cancelPredictionWork() + rig.coordinator.state = .idle + let otherIdentity = FocusedInputIdentity( + elementIdentifier: identity.elementIdentifier, + focusChangeSequence: identity.focusChangeSequence &+ 1 + ) + rig.coordinator.schedulePredictionForCurrentFocusIfPossible(matching: otherIdentity) + XCTAssertEqual(rig.coordinator.state, .idle, "A different field must not reschedule") + } +} diff --git a/CotabbyTests/SuggestionCoordinatorTestSupport.swift b/CotabbyTests/SuggestionCoordinatorTestSupport.swift new file mode 100644 index 00000000..37a13b57 --- /dev/null +++ b/CotabbyTests/SuggestionCoordinatorTestSupport.swift @@ -0,0 +1,285 @@ +import Combine +import Foundation +import XCTest +@testable import Cotabby + +/// Shared, recording test doubles for `SuggestionCoordinator` suites. +/// +/// `SuggestionCoordinatorAcceptanceTests` predates this file and keeps its own private stubs; +/// new coordinator suites (prediction, input, lifecycle) build on these so the protocol surface +/// is mocked once. Every double records what the coordinator asked of it, because most of the +/// pipeline's contracts are about *which* boundary was poked, not return values. +@MainActor +final class RigPermissionProvider: SuggestionPermissionProviding { + var inputMonitoringGranted = true + var screenRecordingGranted = true + + let inputSubject = PassthroughSubject() + let screenSubject = PassthroughSubject() + + var inputMonitoringGrantedPublisher: AnyPublisher { + inputSubject.eraseToAnyPublisher() + } + + var screenRecordingGrantedPublisher: AnyPublisher { + screenSubject.eraseToAnyPublisher() + } +} + +@MainActor +final class RigFocusProvider: SuggestionFocusProviding { + var snapshot: FocusSnapshot + private(set) var refreshCount = 0 + + let snapshotSubject = PassthroughSubject() + + var snapshotPublisher: AnyPublisher { + snapshotSubject.eraseToAnyPublisher() + } + + init(snapshot: FocusSnapshot) { + self.snapshot = snapshot + } + + func refreshNow() { + refreshCount += 1 + } +} + +@MainActor +final class RigInputMonitor: SuggestionInputMonitoring { + var onEvent: ((CapturedInputEvent) -> Bool)? + var onSuppressedSyntheticInput: (() -> Void)? + var shouldConsumeAcceptKeyProvider: @MainActor @Sendable () -> Bool = { false } + private(set) var acceptInterceptionRequests: [Bool] = [] + + func setAcceptInterceptionActive(_ active: Bool) { + acceptInterceptionRequests.append(active) + } +} + +@MainActor +final class RigOverlayController: SuggestionOverlayControlling { + var state: OverlayState + var onStateChange: ((OverlayState) -> Void)? + private(set) var shownTexts: [String] = [] + private(set) var hideReasons: [String] = [] + + init(state: OverlayState = .hidden(reason: "initial")) { + self.state = state + } + + func showSuggestion(_ text: String, geometry: SuggestionOverlayGeometry) { + shownTexts.append(text) + state = .visible(text: text, geometry: geometry, mode: .inline) + onStateChange?(state) + } + + func hide(reason: String) { + hideReasons.append(reason) + state = .hidden(reason: reason) + onStateChange?(state) + } +} + +@MainActor +final class RigInserter: SuggestionInserting { + var lastErrorMessage: String? + var insertedChunks: [String] = [] + var replacements: [(deleteCount: Int, text: String)] = [] + var shouldInsert = true + + func insert(_ suggestion: String) -> Bool { + insertedChunks.append(suggestion) + return shouldInsert + } + + func replace(deletingUTF16Count: Int, with text: String) -> Bool { + replacements.append((deletingUTF16Count, text)) + return shouldInsert + } +} + +@MainActor +final class RigSuggestionEngine: SuggestionGenerating { + /// Provides the result for each generation. The default echoes a fixed continuation with the + /// request's own generation, which is what a fresh (non-stale) engine reply looks like. + var resultProvider: (SuggestionRequest) async throws -> SuggestionResult = { request in + SuggestionResult(generation: request.generation, rawText: " world", text: " world", latency: 0.01) + } + private(set) var requests: [SuggestionRequest] = [] + private(set) var resetCount = 0 + private(set) var prewarmedRequests: [SuggestionRequest] = [] + + func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult { + requests.append(request) + return try await resultProvider(request) + } + + func resetCachedGenerationContext() async { + resetCount += 1 + } + + func prewarm(for request: SuggestionRequest) async { + prewarmedRequests.append(request) + } +} + +@MainActor +final class RigSettingsProvider: SuggestionSettingsProviding { + var snapshot: SuggestionSettingsSnapshot + + let snapshotSubject = PassthroughSubject() + + var snapshotPublisher: AnyPublisher { + snapshotSubject.eraseToAnyPublisher() + } + + init(snapshot: SuggestionSettingsSnapshot) { + self.snapshot = snapshot + } +} + +@MainActor +final class RigClipboardProvider: ClipboardContextProviding { + var currentChangeCount = 0 + var context: String? + + func currentContext() -> String? { + context + } +} + +@MainActor +final class RigClipboardFilter: ClipboardRelevanceFiltering { + var filtered: String? + + func filter( + clipboard: String?, + pasteboardChangeCount: Int, + precedingText: String + ) -> String? { + filtered + } +} + +@MainActor +final class RigVisualContextCoordinator: VisualContextCoordinating { + var status: VisualContextStatus = .idle + var latestExcerpt: String? + var onStateChange: ((VisualContextStatus, String?) -> Void)? + var onInjectedContextReady: ((FocusedInputIdentity) -> Void)? + private(set) var startedSessions: [FocusedInputSnapshot] = [] + private(set) var cancelCalls: [Bool] = [] + var excerptValue: String? + + func startSessionIfNeeded(for snapshotContext: FocusedInputSnapshot) { + startedSessions.append(snapshotContext) + } + + func cancel(resetState: Bool) { + cancelCalls.append(resetState) + } + + func excerpt(for context: FocusedInputContext) -> String? { + excerptValue + } +} + +/// One fully-stubbed coordinator plus handles to every double, so a test can both drive the +/// pipeline and assert which boundaries it touched. +@MainActor +struct CoordinatorRig { + let coordinator: SuggestionCoordinator + let permissionProvider: RigPermissionProvider + let focusProvider: RigFocusProvider + let inputMonitor: RigInputMonitor + let overlayController: RigOverlayController + let inserter: RigInserter + let engine: RigSuggestionEngine + let settingsProvider: RigSettingsProvider + let clipboardProvider: RigClipboardProvider + let clipboardFilter: RigClipboardFilter + let visualContext: RigVisualContextCoordinator + let interactionState: SuggestionInteractionState +} + +@MainActor +func makeCoordinatorRig( + snapshot: FocusedInputSnapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello"), + capability: FocusCapability = .supported, + overlayState: OverlayState = .hidden(reason: "initial"), + settingsSnapshot: SuggestionSettingsSnapshot = CotabbyTestFixtures.settingsSnapshot(debounceMilliseconds: 1) +) -> CoordinatorRig { + let focusSnapshot = FocusSnapshot( + applicationName: snapshot.applicationName, + bundleIdentifier: snapshot.bundleIdentifier, + capability: capability, + context: snapshot, + inspection: nil + ) + let permissionProvider = RigPermissionProvider() + let focusProvider = RigFocusProvider(snapshot: focusSnapshot) + let inputMonitor = RigInputMonitor() + let overlayController = RigOverlayController(state: overlayState) + let inserter = RigInserter() + let engine = RigSuggestionEngine() + let settingsProvider = RigSettingsProvider(snapshot: settingsSnapshot) + let clipboardProvider = RigClipboardProvider() + let clipboardFilter = RigClipboardFilter() + let visualContext = RigVisualContextCoordinator() + let interactionState = SuggestionInteractionState() + let coordinator = SuggestionCoordinator( + permissionManager: permissionProvider, + focusModel: focusProvider, + inputMonitor: inputMonitor, + overlayController: overlayController, + suggestionInserter: inserter, + suggestionEngine: engine, + suggestionSettings: settingsProvider, + clipboardContextProvider: clipboardProvider, + clipboardRelevanceFilter: clipboardFilter, + visualContextCoordinator: visualContext, + interactionState: interactionState, + workController: SuggestionWorkController(), + configuration: .standard, + spellChecker: CurrentWordSpellChecker(), + symSpellCorrector: SymSpellCorrector(preloadLanguage: nil), + userDefaults: UserDefaults(suiteName: "CotabbyTests.rig.\(UUID().uuidString)") ?? .standard + ) + return CoordinatorRig( + coordinator: coordinator, + permissionProvider: permissionProvider, + focusProvider: focusProvider, + inputMonitor: inputMonitor, + overlayController: overlayController, + inserter: inserter, + engine: engine, + settingsProvider: settingsProvider, + clipboardProvider: clipboardProvider, + clipboardFilter: clipboardFilter, + visualContext: visualContext, + interactionState: interactionState + ) +} + +/// Polls a main-actor condition until it holds or the timeout elapses, yielding to the run loop +/// between checks. The coordinator pipeline hops through Tasks and a debounce timer, so tests +/// await observable state instead of sleeping fixed amounts. +@MainActor +func waitUntil( + timeout: TimeInterval = 5, + _ message: @autoclosure () -> String = "Condition not met before timeout", + file: StaticString = #filePath, + line: UInt = #line, + condition: @MainActor () -> Bool +) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + guard Date() < deadline else { + XCTFail(message(), file: file, line: line) + return + } + try? await Task.sleep(nanoseconds: 5_000_000) + } +} diff --git a/CotabbyTests/SuggestionDebugLoggerTests.swift b/CotabbyTests/SuggestionDebugLoggerTests.swift new file mode 100644 index 00000000..ba9cbfb7 --- /dev/null +++ b/CotabbyTests/SuggestionDebugLoggerTests.swift @@ -0,0 +1,77 @@ +import XCTest +@testable import Cotabby + +/// Tests for `SuggestionDebugLogger`: the escaped single-line preview used by compact logs and +/// menu summaries, plus the instance-level console block formatting (safe to instantiate since +/// the type grew its `nonisolated deinit`). +@MainActor +final class SuggestionDebugLoggerTests: XCTestCase { + // MARK: - Instance logging paths + + /// The block formatter is a console-only sink (its output goes through + /// `CotabbyDebugOptions.log`), so these lock the routing decisions: which stage/payload + /// combinations emit which block kinds, and that the duplicate-line guard tolerates repeats. + func test_logStage_routesEveryPayloadShapeWithoutCrashing() { + let logger = SuggestionDebugLogger(colorizedOutput: true) + + logger.logStage("generating", workID: 1, generation: 2, message: "m", prompt: "PROMPT") + logger.logStage( + "ready", + workID: 1, + generation: 2, + message: "m", + rawOutput: "raw words", + normalizedOutput: "normalized words" + ) + logger.logStage("ready", workID: 1, generation: nil, message: "m", rawOutput: "raw only") + logger.logStage("failed", workID: 1, generation: nil, message: "engine exploded") + // Repeating the identical failure exercises the duplicate-line suppression. + logger.logStage("failed", workID: 1, generation: nil, message: "engine exploded") + // Stages with no model-boundary payload are deliberately not console-logged. + logger.logStage("debouncing", workID: 1, generation: 2, message: "m") + } + + func test_logStage_plainOutputPathHandlesUncoloredConsoles() { + let logger = SuggestionDebugLogger(colorizedOutput: false) + + logger.logStage("generating", workID: 9, generation: 1, message: "m", prompt: "P") + logger.logStage("failed", workID: 9, generation: 1, message: "boom") + } + + func test_debugPreview_emptyTextReturnsPlaceholder() async { + XCTAssertEqual(SuggestionDebugLogger.debugPreview(""), "") + } + + func test_debugPreview_shortTextReturnsQuotedEscapedDescription() async { + XCTAssertEqual(SuggestionDebugLogger.debugPreview("hello"), "\"hello\"") + } + + func test_debugPreview_escapesControlCharactersIntoOneLine() async { + let preview = SuggestionDebugLogger.debugPreview("line1\nline2\ttabbed") + + XCTAssertEqual(preview, "\"line1\\nline2\\ttabbed\"") + XCTAssertFalse(preview.contains("\n"), "A preview must never break the log line it is embedded in") + } + + func test_debugPreview_truncatesLongEscapedTextWithEllipsis() async { + let text = String(repeating: "a", count: 200) + + let preview = SuggestionDebugLogger.debugPreview(text) + + // The escaped form is 202 characters (two quotes), so the preview keeps the first 160 + // escaped characters and appends the ellipsis. + XCTAssertEqual(preview, "\"" + String(repeating: "a", count: 159) + "...") + XCTAssertEqual(preview.count, 163) + } + + func test_debugPreview_keepsTextWhoseEscapedFormFitsTheLimit() async { + // 158 characters plus the surrounding quotes is exactly 160 escaped characters: the + // boundary case must pass through untouched. + let text = String(repeating: "a", count: 158) + + let preview = SuggestionDebugLogger.debugPreview(text) + + XCTAssertEqual(preview, text.debugDescription) + XCTAssertFalse(preview.hasSuffix("...")) + } +} diff --git a/CotabbyTests/SuggestionEngineModelsTests.swift b/CotabbyTests/SuggestionEngineModelsTests.swift new file mode 100644 index 00000000..98e634cc --- /dev/null +++ b/CotabbyTests/SuggestionEngineModelsTests.swift @@ -0,0 +1,42 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Tests for the engine-choice domain models: the product-facing engine labels, the power-profile +/// bridge back to an engine kind, and the persisted app-blocklist entry. +final class SuggestionEngineModelsTests: XCTestCase { + func test_suggestionEngineKind_displayLabelsArePinnedProductCopy() { + XCTAssertEqual(SuggestionEngineKind.appleIntelligence.displayLabel, "Apple Intelligence") + XCTAssertEqual(SuggestionEngineKind.llamaOpenSource.displayLabel, "Open Source") + } + + func test_suggestionEngineKind_idMatchesRawValueForEveryCase() { + XCTAssertEqual(SuggestionEngineKind.allCases.count, 2) + for kind in SuggestionEngineKind.allCases { + XCTAssertEqual(kind.id, kind.rawValue) + } + } + + func test_suggestionEngineKind_onlyOpenSourceManagesLocalModels() { + // Apple Intelligence has no GGUF files to manage; the OS owns its model. + XCTAssertFalse(SuggestionEngineKind.appleIntelligence.supportsLocalModelManagement) + XCTAssertTrue(SuggestionEngineKind.llamaOpenSource.supportsLocalModelManagement) + } + + func test_powerProfile_engineBridgesEachProfileToItsEngineKind() { + XCTAssertEqual(PowerProfile.appleIntelligence.engine, .appleIntelligence) + XCTAssertEqual(PowerProfile.llama(filename: "tabby.gguf").engine, .llamaOpenSource) + } + + func test_disabledApplicationRule_identityIsBundleIdentifierAndSurvivesCodableRoundTrip() throws { + let rule = DisabledApplicationRule(bundleIdentifier: "com.example.app", displayName: "Example") + + XCTAssertEqual(rule.id, "com.example.app") + + let decoded = try JSONDecoder().decode( + DisabledApplicationRule.self, + from: JSONEncoder().encode(rule) + ) + XCTAssertEqual(decoded, rule) + } +} diff --git a/CotabbyTests/SuggestionEngineRouterTests.swift b/CotabbyTests/SuggestionEngineRouterTests.swift new file mode 100644 index 00000000..75e4f395 --- /dev/null +++ b/CotabbyTests/SuggestionEngineRouterTests.swift @@ -0,0 +1,199 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Locks the engine routing contract: which backend serves a request for each selected engine, +/// when the Apple Intelligence locale failure falls back to the local model, and when a finished +/// result lands in the performance ring buffer. A routing regression silently sends every request +/// to the wrong backend, so each path asserts which engine was actually asked. +@MainActor +final class SuggestionEngineRouterRoutingTests: XCTestCase { + /// Production classes built with the app target's default MainActor isolation crash the + /// app-hosted runner when deallocated (back-deploy executor shim); quarantine them for the + /// process lifetime instead. + private static var retained: [AnyObject] = [] + + private struct Rig { + let router: SuggestionEngineRouter + let settings: SuggestionSettingsModel + let foundation: ScriptedEngine + let llama: ScriptedEngine + let metrics: PerformanceMetricsStore + } + + @MainActor + private final class ScriptedEngine: SuggestionGenerating { + var script: (SuggestionRequest) async throws -> SuggestionResult + private(set) var requests: [SuggestionRequest] = [] + private(set) var prewarmCount = 0 + private(set) var resetCount = 0 + + init(latency: TimeInterval = 0.02) { + script = { request in + SuggestionResult(generation: request.generation, rawText: " ok", text: " ok", latency: latency) + } + } + + func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult { + requests.append(request) + return try await script(request) + } + + func resetCachedGenerationContext() async { + resetCount += 1 + } + + func prewarm(for request: SuggestionRequest) async { + prewarmCount += 1 + } + } + + private func makeRig( + engine: SuggestionEngineKind, + performanceTracking: Bool = true, + llamaModelName: String? = "test-model.gguf" + ) -> Rig { + let suiteName = "cotabby.test.router.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let settings = SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + settings.selectEngine(engine) + settings.setPerformanceTrackingEnabled(performanceTracking) + let metrics = PerformanceMetricsStore(userDefaults: defaults) + let foundation = ScriptedEngine() + let llama = ScriptedEngine() + let router = SuggestionEngineRouter( + suggestionSettings: settings, + foundationModelEngine: foundation, + llamaEngine: llama, + performanceMetricsStore: metrics, + llamaModelNameProvider: { llamaModelName } + ) + Self.retained.append(contentsOf: [router, settings, metrics] as [AnyObject]) + return Rig(router: router, settings: settings, foundation: foundation, llama: llama, metrics: metrics) + } + + func test_appleIntelligenceSelection_routesToFoundationEngineAndRecordsMetric() async throws { + let rig = makeRig(engine: .appleIntelligence) + + let result = try await rig.router.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(result.text, " ok") + XCTAssertEqual(rig.foundation.requests.count, 1) + XCTAssertTrue(rig.llama.requests.isEmpty) + XCTAssertEqual(rig.metrics.entries.first?.modelName, "Apple Intelligence") + XCTAssertEqual(rig.metrics.entries.first?.latencyMs, 20) + } + + func test_llamaSelection_routesToLlamaEngineAndRecordsTheModelName() async throws { + let rig = makeRig(engine: .llamaOpenSource) + + _ = try await rig.router.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(rig.llama.requests.count, 1) + XCTAssertTrue(rig.foundation.requests.isEmpty) + XCTAssertEqual(rig.metrics.entries.first?.modelName, "test-model.gguf") + } + + func test_llamaSelection_missingModelNameFallsBackToGenericLabel() async throws { + let rig = makeRig(engine: .llamaOpenSource, llamaModelName: nil) + + _ = try await rig.router.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(rig.metrics.entries.first?.modelName, "Llama") + } + + func test_performanceTrackingOff_recordsNothing() async throws { + let rig = makeRig(engine: .llamaOpenSource, performanceTracking: false) + + _ = try await rig.router.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertTrue(rig.metrics.entries.isEmpty, "The default user must never pay the metrics write cost") + } + + func test_unsupportedLocale_fallsBackToLlamaAndReturnsItsResult() async throws { + let rig = makeRig(engine: .appleIntelligence) + rig.foundation.script = { _ in + throw SuggestionClientError.unsupportedLanguageOrLocale("Locale not supported.") + } + + let result = try await rig.router.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(result.text, " ok") + XCTAssertEqual(rig.llama.requests.count, 1, "The locale failure must reach the local model") + XCTAssertEqual(rig.metrics.entries.first?.modelName, "test-model.gguf") + } + + func test_unsupportedLocale_fallbackFailureComposesBothMessages() async { + struct LlamaDown: Error {} + let rig = makeRig(engine: .appleIntelligence) + rig.foundation.script = { _ in + throw SuggestionClientError.unsupportedLanguageOrLocale("Locale not supported.") + } + rig.llama.script = { _ in throw LlamaDown() } + + do { + _ = try await rig.router.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + XCTFail("Expected the composed unavailable error") + } catch let SuggestionClientError.unavailable(message) { + XCTAssertTrue(message.contains("Locale not supported.")) + XCTAssertTrue(message.contains("fallback also failed")) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_unsupportedLocale_fallbackCancellationStaysCancellation() async { + let rig = makeRig(engine: .appleIntelligence) + rig.foundation.script = { _ in + throw SuggestionClientError.unsupportedLanguageOrLocale("Locale not supported.") + } + rig.llama.script = { _ in throw SuggestionClientError.cancelled } + + do { + _ = try await rig.router.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + XCTFail("Expected cancellation to propagate") + } catch SuggestionClientError.cancelled { + // Cancellation must never be rewrapped as unavailability: the coordinator treats it + // as silence, not as an error state. + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_prewarm_reachesOnlyTheSelectedEngine() async { + let appleRig = makeRig(engine: .appleIntelligence) + await appleRig.router.prewarm(for: CotabbyTestFixtures.suggestionRequest()) + XCTAssertEqual(appleRig.foundation.prewarmCount, 1) + XCTAssertEqual(appleRig.llama.prewarmCount, 0) + + let llamaRig = makeRig(engine: .llamaOpenSource) + await llamaRig.router.prewarm(for: CotabbyTestFixtures.suggestionRequest()) + XCTAssertEqual(llamaRig.foundation.prewarmCount, 0) + XCTAssertEqual(llamaRig.llama.prewarmCount, 1) + } + + func test_resetCachedGenerationContext_fansOutToBothEngines() async { + let rig = makeRig(engine: .appleIntelligence) + + await rig.router.resetCachedGenerationContext() + + XCTAssertEqual(rig.foundation.resetCount, 1) + XCTAssertEqual(rig.llama.resetCount, 1, "Switching engines must not leave stale state behind") + } + + func test_unavailableEngine_throwsItsConfiguredMessage() async { + let engine = UnavailableSuggestionEngine(message: "Needs macOS 26.") + Self.retained.append(engine) + + do { + _ = try await engine.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + XCTFail("Expected unavailable error") + } catch let SuggestionClientError.unavailable(message) { + XCTAssertEqual(message, "Needs macOS 26.") + } catch { + XCTFail("Unexpected error: \(error)") + } + await engine.resetCachedGenerationContext() + } +} diff --git a/CotabbyTests/SuggestionInteractionStateTests.swift b/CotabbyTests/SuggestionInteractionStateTests.swift new file mode 100644 index 00000000..b8a202fb --- /dev/null +++ b/CotabbyTests/SuggestionInteractionStateTests.swift @@ -0,0 +1,120 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Locks the acceptance-preparation guards in `SuggestionInteractionState` that the coordinator +/// suites cannot reach: each one is the difference between Tab inserting text and Tab leaking +/// through to the host as a focus-moving keystroke. +@MainActor +final class SuggestionInteractionStateAcceptanceGuardTests: XCTestCase { + /// Production @MainActor class instances are quarantined against the back-deploy deinit shim. + private static var retained: [AnyObject] = [] + + private func makeState() -> SuggestionInteractionState { + let state = SuggestionInteractionState() + Self.retained.append(state) + return state + } + + private func visibleOverlay(text: String, for snapshot: FocusedInputSnapshot) -> OverlayState { + .visible( + text: text, + geometry: CotabbyTestFixtures.overlayGeometry(caretRect: snapshot.caretRect), + mode: .inline + ) + } + + func test_prepareAcceptance_withoutASessionPassesTheKeyThrough() { + let state = makeState() + let snapshot = CotabbyTestFixtures.focusedInputSnapshot() + + let preparation = state.prepareAcceptance( + from: snapshot, + overlayState: visibleOverlay(text: " world", for: snapshot), + granularity: .word, + autoAcceptTrailingPunctuation: true + ) + + guard case let .invalid(reason) = preparation else { + return XCTFail("Expected invalid preparation") + } + XCTAssertTrue(reason.contains("no valid suggestion")) + } + + func test_prepareAcceptance_selectedTextPassesTheKeyThrough() { + let state = makeState() + let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello") + _ = state.startSession( + fullText: " world", + liveContext: FocusedInputContext(snapshot: snapshot, generation: 1), + latency: 0.05 + ) + + let selectedSnapshot = CotabbyTestFixtures.focusedInputSnapshot( + precedingText: "Hello", + selection: NSRange(location: 0, length: 3) + ) + let preparation = state.prepareAcceptance( + from: selectedSnapshot, + overlayState: visibleOverlay(text: " world", for: selectedSnapshot), + granularity: .word, + autoAcceptTrailingPunctuation: true + ) + + guard case let .invalid(reason) = preparation else { + return XCTFail("Expected invalid preparation") + } + XCTAssertTrue(reason.contains("selected")) + } + + func test_prepareAcceptance_processChangePassesTheKeyThrough() { + let state = makeState() + let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello") + _ = state.startSession( + fullText: " world", + liveContext: FocusedInputContext(snapshot: snapshot, generation: 1), + latency: 0.05 + ) + + // The same text in a different app must never be accepted into: the session belongs to + // the original process. + let otherApp = CotabbyTestFixtures.focusedInputSnapshot( + processIdentifier: 999, + precedingText: "Hello" + ) + let preparation = state.prepareFullAcceptance( + from: otherApp, + overlayState: visibleOverlay(text: " world", for: otherApp) + ) + + guard case let .invalid(reason) = preparation else { + return XCTFail("Expected invalid preparation") + } + XCTAssertTrue(reason.contains("focused field changed")) + } + + func test_prepareFullAcceptance_returnsTheEntireRemainingTail() { + let state = makeState() + let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello") + _ = state.startSession( + fullText: " world again", + liveContext: FocusedInputContext(snapshot: snapshot, generation: 1), + latency: 0.05 + ) + + let preparation = state.prepareFullAcceptance( + from: snapshot, + overlayState: visibleOverlay(text: " world again", for: snapshot) + ) + + guard case let .ready(_, _, chunk) = preparation else { + return XCTFail("Expected ready preparation") + } + XCTAssertEqual(chunk, " world again") + } + + func test_reconcileActiveSession_withoutASessionReturnsNil() { + let state = makeState() + XCTAssertNil(state.reconcileActiveSession(with: CotabbyTestFixtures.focusedInputSnapshot())) + } +} diff --git a/CotabbyTests/SuggestionSessionReconcilerTests.swift b/CotabbyTests/SuggestionSessionReconcilerTests.swift index 4f133967..9ba379c0 100644 --- a/CotabbyTests/SuggestionSessionReconcilerTests.swift +++ b/CotabbyTests/SuggestionSessionReconcilerTests.swift @@ -41,6 +41,14 @@ final class SuggestionSessionReconcilerTests: XCTestCase { XCTAssertNil(advanced) } + func test_advanceIfTypedCharactersMatch_returnsNilForEmptyInput() { + // An empty capture is not a text mutation; advancing by zero would silently re-validate a + // session that no key event actually confirmed. + let session = CotabbyTestFixtures.activeSession(fullText: " world again") + + XCTAssertNil(SuggestionSessionReconciler.advanceIfTypedCharactersMatch("", session: session)) + } + func test_nextAcceptanceChunk_includesLeadingWhitespaceAndNextVisibleToken() { XCTAssertEqual( SuggestionSessionReconciler.nextAcceptanceChunk(from: " world again"), @@ -327,6 +335,14 @@ final class SuggestionSessionReconcilerTests: XCTestCase { XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "。」「次の文"), "。」「") } + /// The katakana middle dot lives in the kana block, so it enters the ICU branch, but a run of + /// middle dots contains no segmentable word. The chunker must fall back to the whole + /// whitespace-bounded token rather than producing an empty chunk and stalling. + func test_nextAcceptanceChunk_kanaPunctuationRunWithoutWordsAcceptsWholeToken() { + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "・・・ あと"), "・・・") + XCTAssertEqual(SuggestionSessionReconciler.nextAcceptanceChunk(from: "・・・"), "・・・") + } + /// The trailing binding must stop before an opening bracket: the closer and full stop belong to /// the word, but the next quote's opener belongs to the next word. func test_nextAcceptanceChunk_trailingBindingStopsBeforeOpeningBracket() { @@ -449,6 +465,15 @@ final class SuggestionSessionReconcilerTests: XCTestCase { ) } + func test_nextAcceptancePhrase_chunkOfOnlyClosingPunctuationIsNotABoundary() { + // The closer walk-back can consume the entire accumulated chunk; with no character left + // underneath there is no terminator, so the phrase must keep accumulating. + XCTAssertEqual( + SuggestionSessionReconciler.nextAcceptancePhrase(from: "\"\" hello"), + "\"\" hello" + ) + } + func test_insertionChunk_dropsLeadingSpaceWhenPrecedingTextAlreadyEndsInWhitespace() { XCTAssertEqual( SuggestionSessionReconciler.insertionChunk(forAcceptedChunk: " you", precedingText: "How are "), @@ -660,6 +685,25 @@ final class SuggestionSessionReconcilerTests: XCTestCase { ) } + func test_overlayHideReason_acceptanceAndOtherEventsUseTheGenericReason() { + // Acceptance-driven hides are expected behavior, not invalidation, so they get the plain + // message; shortcut mutations read as typing. + for kind in [CapturedInputEvent.Kind.acceptance, .fullAcceptance, .other] { + XCTAssertEqual( + SuggestionSessionReconciler.overlayHideReason( + for: CotabbyTestFixtures.inputEvent(kind: kind) + ), + "Overlay hidden." + ) + } + XCTAssertEqual( + SuggestionSessionReconciler.overlayHideReason( + for: CotabbyTestFixtures.inputEvent(kind: .shortcutMutation) + ), + "Overlay hidden because typing invalidated the current suggestion." + ) + } + func test_reconcile_validWhenLiveContextStillMatchesBaseContext() { let session = CotabbyTestFixtures.activeSession( fullText: " world again", @@ -830,6 +874,105 @@ final class SuggestionSessionReconcilerTests: XCTestCase { XCTAssertNil(nextPending) } + func test_reconcile_invalidWhenSuggestionPartiallyUndoneOutsideInsertionSyncWindow() { + // The session has consumed " worl" (5 chars) but the live field only shows " wo": the user + // deleted part of the accepted text, so the session must die. + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + consumedCharacterCount: 5, + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello wo") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: nil + ) + + assertInvalid( + reconciliation, + reason: "Overlay hidden because the active suggestion was partially undone." + ) + } + + func test_reconcile_toleratesShorterConsumedSuffixRightAfterAcceptedInsertion() { + // Same field state as the undo case, but we just Tab-inserted up to 5 consumed characters + // (the sentinel matches): AX simply has not published the full insert yet, so the session + // must survive untouched for one more cycle. + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + consumedCharacterCount: 5, + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Hello wo") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: 5 + ) + + guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { + XCTFail("Expected post-insertion AX lag to be tolerated") + return + } + XCTAssertEqual(reconciledSession.acceptedText, session.acceptedText) + XCTAssertEqual(reconciledSession.remainingText, session.remainingText) + XCTAssertNil(advancement) + XCTAssertEqual(nextPending, 5) + } + + func test_reconcile_toleratesPrefixAnchorRaceRightAfterAcceptedInsertion() { + // Inverse Chromium race: trailing text already stable, but the prefix still reflects the + // pre-insertion snapshot. With the sentinel armed the session waits instead of dying. + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + consumedCharacterCount: 6, + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Goodbye") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: 6 + ) + + guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { + XCTFail("Expected prefix-anchor race to be tolerated during the insertion sync window") + return + } + XCTAssertEqual(reconciledSession.remainingText, session.remainingText) + XCTAssertNil(advancement) + XCTAssertEqual(nextPending, 6) + } + + func test_reconcile_toleratesConsumedSuffixDivergenceRightAfterAcceptedInsertion() { + // The preceding text grew with characters that do not match the suggestion: outside the + // sync window that is invalidating, but right after Tab it is just stale AX content. + let session = CotabbyTestFixtures.activeSession( + fullText: " world again", + consumedCharacterCount: 6, + basePrecedingText: "Hello" + ) + let liveContext = CotabbyTestFixtures.focusedInputContext(precedingText: "Helloxyz") + + let reconciliation = SuggestionSessionReconciler.reconcile( + session: session, + with: liveContext, + pendingInsertionConsumedCount: 6 + ) + + guard case let .valid(reconciledSession, advancement, nextPending) = reconciliation else { + XCTFail("Expected consumed-suffix divergence to be tolerated during the insertion sync window") + return + } + XCTAssertEqual(reconciledSession.remainingText, session.remainingText) + XCTAssertNil(advancement) + XCTAssertEqual(nextPending, 6) + } + func test_reconcile_clearsPendingInsertionSentinelWhenAXCatchesUp() { let session = CotabbyTestFixtures.activeSession( fullText: " world again", diff --git a/CotabbyTests/SuggestionSettingsModelTests.swift b/CotabbyTests/SuggestionSettingsModelTests.swift new file mode 100644 index 00000000..80d80c8c --- /dev/null +++ b/CotabbyTests/SuggestionSettingsModelTests.swift @@ -0,0 +1,455 @@ +import Combine +import CoreGraphics +import XCTest +@testable import Cotabby + +/// Locks the settings facade: every setter persists through the store (so a fresh model reload +/// sees the value), every guard makes repeat writes no-ops, and the cross-field policies +/// (keybinding conflicts, hint-label fallback, power-profile seeding) hold. The pure store is +/// tested in isolation in `SuggestionSettingsStoreTests`; these tests cover the facade wiring, +/// which is exactly the layer a renamed defaults key or a dropped save call would break. +@MainActor +final class SuggestionSettingsModelTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "cotabby.test.settingsModel.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + private func makeModel() -> SuggestionSettingsModel { + SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + } + + // MARK: - Setter persistence round-trip + + func test_setters_persistThroughStoreAndReloadInAFreshModel() { + let model = makeModel() + + // Each setter runs twice with the same value: the first call exercises the mutate+save + // path, the second the same-value guard. A silent guard regression (saving anyway) is + // invisible here, but a broken save or a load/save key mismatch fails the reload below. + for _ in 0..<2 { + model.setGloballyEnabled(false) + model.selectEngine(.appleIntelligence) + model.selectWordCountPreset(.twelveToTwenty) + model.setUsingCustomWordCountRange(true) + model.setCustomWordCountRange(low: 3, high: 9) + model.setClipboardContextEnabled(false) + model.setFastModeEnabled(true) + model.setSuppressCompletionsOnTypo(true) + model.setOfferTypoCorrections(true) + model.setAutomaticallyFixTypos(true) + model.setPerformanceTrackingEnabled(true) + model.setMenuBarWordCountVisible(false) + model.setMirrorPreference(.alwaysMirror) + model.setMultiLineEnabled(true) + model.setEmojiPickerEnabled(false) + model.setMacroExpansionEnabled(false) + model.setPreferredEmojiSkinTone(.mediumDark) + model.setPreferredEmojiGender(.female) + model.setAutoAcceptTrailingPunctuation(false) + model.setAcceptanceGranularity(.phrase) + model.setSuggestInIntegratedTerminals(true) + model.setShowIndicator(false) + model.setShowAcceptanceHint(false) + model.setUserName("Ada") + model.setExtendedContext("Glossary: cotabby means tea whisk") + model.setGhostTextOpacity(SuggestionSettingsModel.minimumGhostTextOpacity) + model.setGhostTextSizeMultiplier(SuggestionSettingsModel.maximumGhostTextSizeMultiplier) + model.setCustomSuggestionTextColorHex("#a1b2c3") + model.setPowerBasedModelSwitchingEnabled(true) + model.setBatteryEngine(.appleIntelligence) + model.setBatteryModelFilename("small.gguf") + model.setPluggedInEngine(.llamaOpenSource) + model.setPluggedInModelFilename("big.gguf") + } + + let reloaded = makeModel() + XCTAssertFalse(reloaded.isGloballyEnabled) + XCTAssertEqual(reloaded.selectedEngine, .appleIntelligence) + XCTAssertEqual(reloaded.selectedWordCountPreset, .twelveToTwenty) + XCTAssertTrue(reloaded.isUsingCustomWordCountRange) + XCTAssertEqual(reloaded.customWordCountLowWords, 3) + XCTAssertEqual(reloaded.customWordCountHighWords, 9) + XCTAssertFalse(reloaded.isClipboardContextEnabled) + XCTAssertTrue(reloaded.isFastModeEnabled) + XCTAssertTrue(reloaded.suppressCompletionsOnTypo) + XCTAssertTrue(reloaded.offerTypoCorrections) + XCTAssertTrue(reloaded.automaticallyFixTypos) + XCTAssertTrue(reloaded.isPerformanceTrackingEnabled) + XCTAssertFalse(reloaded.isMenuBarWordCountVisible) + XCTAssertEqual(reloaded.mirrorPreference, .alwaysMirror) + XCTAssertTrue(reloaded.isMultiLineEnabled) + XCTAssertFalse(reloaded.isEmojiPickerEnabled) + XCTAssertFalse(reloaded.isMacroExpansionEnabled) + XCTAssertEqual(reloaded.preferredEmojiSkinTone, .mediumDark) + XCTAssertEqual(reloaded.preferredEmojiGender, .female) + XCTAssertFalse(reloaded.autoAcceptTrailingPunctuation) + XCTAssertEqual(reloaded.acceptanceGranularity, .phrase) + XCTAssertTrue(reloaded.suggestInIntegratedTerminals) + XCTAssertFalse(reloaded.showIndicator) + XCTAssertFalse(reloaded.showCaretIndicator) + XCTAssertFalse(reloaded.showAcceptanceHint) + XCTAssertEqual(reloaded.userName, "Ada") + XCTAssertEqual(reloaded.extendedContext, "Glossary: cotabby means tea whisk") + XCTAssertEqual(reloaded.ghostTextOpacity, SuggestionSettingsModel.minimumGhostTextOpacity) + XCTAssertEqual(reloaded.ghostTextSizeMultiplier, SuggestionSettingsModel.maximumGhostTextSizeMultiplier) + XCTAssertEqual(reloaded.customSuggestionTextColorHex, "A1B2C3") + XCTAssertTrue(reloaded.isPowerBasedModelSwitchingEnabled) + XCTAssertEqual(reloaded.batteryEngine, .appleIntelligence) + XCTAssertEqual(reloaded.batteryModelFilename, "small.gguf") + XCTAssertEqual(reloaded.pluggedInEngine, .llamaOpenSource) + XCTAssertEqual(reloaded.pluggedInModelFilename, "big.gguf") + } + + // MARK: - Power profiles + + func test_powerProfiles_mapEngineAndFilenameIntoProfileValues() { + let model = makeModel() + model.setBatteryEngine(.appleIntelligence) + model.setPluggedInEngine(.llamaOpenSource) + model.setPluggedInModelFilename("qwen.gguf") + + XCTAssertEqual(model.batteryProfile, .appleIntelligence) + XCTAssertEqual(model.pluggedInProfile, .llama(filename: "qwen.gguf")) + } + + func test_setPowerProfiles_applyEngineAndLlamaFilename() { + let model = makeModel() + + model.setBatteryProfile(.llama(filename: "tiny.gguf")) + XCTAssertEqual(model.batteryEngine, .llamaOpenSource) + XCTAssertEqual(model.batteryModelFilename, "tiny.gguf") + + // Switching to Apple Intelligence keeps the stored llama filename so flipping back does + // not lose the previous model choice. + model.setPluggedInProfile(.llama(filename: "large.gguf")) + model.setPluggedInProfile(.appleIntelligence) + XCTAssertEqual(model.pluggedInEngine, .appleIntelligence) + XCTAssertEqual(model.pluggedInModelFilename, "large.gguf") + } + + func test_initializePowerProfiles_seedsOnlyPristineProfiles() { + let model = makeModel() + + // Battery profile made non-pristine by an explicit engine choice; plugged-in stays pristine. + model.setBatteryEngine(.appleIntelligence) + model.initializePowerProfiles(currentEngine: .llamaOpenSource, currentModelFilename: "active.gguf") + + XCTAssertEqual(model.batteryEngine, .appleIntelligence, "an explicit choice must never be reseeded") + XCTAssertEqual(model.batteryModelFilename, "") + XCTAssertEqual(model.pluggedInEngine, .llamaOpenSource) + XCTAssertEqual(model.pluggedInModelFilename, "active.gguf") + } + + func test_initializePowerProfiles_withNilFilenameSeedsEngineOnly() { + let model = makeModel() + model.initializePowerProfiles(currentEngine: .appleIntelligence, currentModelFilename: nil) + + XCTAssertEqual(model.batteryEngine, .appleIntelligence) + XCTAssertEqual(model.batteryModelFilename, "") + XCTAssertEqual(model.pluggedInEngine, .appleIntelligence) + XCTAssertEqual(model.pluggedInModelFilename, "") + } + + // MARK: - Spelling dictionaries + + func test_setSpellingDictionary_togglesMembershipAndPersists() { + let model = makeModel() + guard let language = SpellingDictionaryLanguage.allCases.first else { + return XCTFail("Catalog has no languages") + } + + model.setSpellingDictionary(language, enabled: false) + XCTAssertFalse(model.isSpellingDictionaryEnabled(language)) + + model.setSpellingDictionary(language, enabled: true) + XCTAssertTrue(model.isSpellingDictionaryEnabled(language)) + XCTAssertTrue(makeModel().isSpellingDictionaryEnabled(language)) + } + + // MARK: - Disabled application rules + + func test_disableApplication_addsNormalizedRuleAndDedupesByBundleIdentifier() { + let model = makeModel() + + model.disableApplication(bundleIdentifier: " com.example.app ", displayName: " ") + XCTAssertTrue(model.isApplicationDisabled(bundleIdentifier: "com.example.app")) + // A blank display name falls back to the bundle identifier. + XCTAssertEqual(model.disabledAppRules.first?.displayName, "com.example.app") + + // Re-disabling the same bundle replaces the rule instead of appending a duplicate. + model.disableApplication(bundleIdentifier: "com.example.app", displayName: "Example") + XCTAssertEqual(model.disabledAppRules.count, 1) + XCTAssertEqual(model.disabledAppRules.first?.displayName, "Example") + } + + func test_setApplicationDisabled_routesAddAndRemoveAndIgnoresInvalidBundles() { + let model = makeModel() + + model.setApplicationDisabled(bundleIdentifier: nil, displayName: "Ghost", disabled: true) + model.setApplicationDisabled(bundleIdentifier: " ", displayName: "Blank", disabled: true) + XCTAssertTrue(model.disabledAppRules.isEmpty) + + model.setApplicationDisabled(bundleIdentifier: "com.example.one", displayName: "One", disabled: true) + XCTAssertTrue(model.isApplicationDisabled(bundleIdentifier: "com.example.one")) + + model.setApplicationDisabled(bundleIdentifier: "com.example.one", displayName: "One", disabled: false) + XCTAssertFalse(model.isApplicationDisabled(bundleIdentifier: "com.example.one")) + + // Removing a bundle that was never disabled must not dirty the rule list. + model.removeDisabledApplication(bundleIdentifier: "com.example.never") + model.removeDisabledApplication(bundleIdentifier: nil) + XCTAssertTrue(model.disabledAppRules.isEmpty) + } + + func test_isApplicationDisabled_nilOrUnknownBundleIsNotDisabled() { + let model = makeModel() + XCTAssertFalse(model.isApplicationDisabled(bundleIdentifier: nil)) + XCTAssertFalse(model.isApplicationDisabled(bundleIdentifier: "com.example.unknown")) + } + + // MARK: - Acceptance hint label + + func test_acceptanceHintLabel_prefersWordKeyThenFullKeyThenNil() { + let model = makeModel() + + // Default state: word-accept key bound, hint shown. + XCTAssertEqual(model.acceptanceHintLabel, model.acceptanceKeyLabel) + + // Word key cleared: the hint falls back to the full-accept key so it still teaches a + // working gesture. + model.clearAcceptanceKey() + XCTAssertEqual(model.acceptanceHintLabel, model.fullAcceptanceKeyLabel) + XCTAssertNil(model.emojiPickerAcceptKeyLabel) + + // Both cleared: nothing to teach. + model.clearFullAcceptanceKey() + XCTAssertNil(model.acceptanceHintLabel) + } + + func test_acceptanceHintLabel_hiddenWhenHintDisabled() { + let model = makeModel() + model.setShowAcceptanceHint(false) + XCTAssertNil(model.acceptanceHintLabel) + // The emoji picker instruction is independent of the ghost-text hint toggle. + XCTAssertEqual(model.emojiPickerAcceptKeyLabel, model.acceptanceKeyLabel) + } + + // MARK: - Keybinding rules + + func test_setAcceptanceKey_stealingTheFullAcceptComboClearsFullAccept() { + let model = makeModel() + model.setFullAcceptanceKey(keyCode: 36, modifiers: [.command], label: "⌘Return") + + model.setAcceptanceKey(keyCode: 36, modifiers: [.command], label: "⌘Return") + + XCTAssertEqual(model.acceptanceKeyCode, 36) + XCTAssertEqual(model.fullAcceptanceKeyCode, SuggestionSettingsModel.disabledKeyCode) + XCTAssertEqual(model.fullAcceptanceKeyLabel, SuggestionSettingsModel.disabledKeyLabel) + } + + func test_setFullAcceptanceKey_stealingTheAcceptComboClearsAccept() { + let model = makeModel() + model.setAcceptanceKey(keyCode: 48, modifiers: [], label: "Tab") + + model.setFullAcceptanceKey(keyCode: 48, modifiers: [], label: "Tab") + + XCTAssertEqual(model.fullAcceptanceKeyCode, 48) + XCTAssertEqual(model.acceptanceKeyCode, SuggestionSettingsModel.disabledKeyCode) + } + + func test_setAcceptanceKey_sameKeyDifferentModifiersCoexists() { + // Tab and Shift-Tab are distinct bindings; only an exact (keyCode, modifiers) match is a + // conflict. + let model = makeModel() + model.setFullAcceptanceKey(keyCode: 48, modifiers: [.shift], label: "⇧Tab") + model.setAcceptanceKey(keyCode: 48, modifiers: [], label: "Tab") + + XCTAssertEqual(model.acceptanceKeyCode, 48) + XCTAssertEqual(model.fullAcceptanceKeyCode, 48) + XCTAssertEqual(model.fullAcceptanceKeyModifiers, [.shift]) + } + + func test_disabledKeyCode_normalizesModifiersToEmpty() { + let model = makeModel() + model.setAcceptanceKey( + keyCode: SuggestionSettingsModel.disabledKeyCode, + modifiers: [.command, .shift], + label: SuggestionSettingsModel.disabledKeyLabel + ) + XCTAssertEqual(model.acceptanceKeyModifiers, []) + + model.setGlobalToggleKey( + keyCode: SuggestionSettingsModel.disabledKeyCode, + modifiers: [.option], + label: SuggestionSettingsModel.disabledKeyLabel + ) + XCTAssertEqual(model.globalToggleKeyModifiers, []) + } + + func test_globalToggleKey_setAndClearPersist() { + let model = makeModel() + model.setGlobalToggleKey(keyCode: 11, modifiers: [.command, .option], label: "⌘⌥B") + + let reloaded = makeModel() + XCTAssertEqual(reloaded.globalToggleKeyCode, 11) + XCTAssertEqual(reloaded.globalToggleKeyModifiers, [.command, .option]) + XCTAssertEqual(reloaded.globalToggleKeyLabel, "⌘⌥B") + + model.clearGlobalToggleKey() + XCTAssertEqual(model.globalToggleKeyCode, SuggestionSettingsModel.disabledKeyCode) + XCTAssertEqual(model.globalToggleKeyLabel, SuggestionSettingsModel.disabledKeyLabel) + } + + func test_toggleGloballyEnabled_flipsAndPersists() { + let model = makeModel() + let initial = model.isGloballyEnabled + + model.toggleGloballyEnabled() + XCTAssertEqual(model.isGloballyEnabled, !initial) + XCTAssertEqual(makeModel().isGloballyEnabled, !initial) + + model.toggleGloballyEnabled() + XCTAssertEqual(model.isGloballyEnabled, initial) + } + + func test_shortcutActionDisplayNames_coverAllActions() { + XCTAssertEqual(ShortcutAction.acceptWord.displayName, "Accept Word") + XCTAssertEqual(ShortcutAction.acceptEntireSuggestion.displayName, "Accept Entire Suggestion") + XCTAssertEqual(ShortcutAction.toggleTabby.displayName, "Toggle Tabby") + } + + // MARK: - Normalization funnels + + func test_rules_addRemoveClearFunnelThroughNormalization() { + let model = makeModel() + + model.addRule(" Always answer briefly. ") + model.addRule("Always answer briefly.") + XCTAssertEqual(model.customRules, ["Always answer briefly."], "rules are trimmed and deduped") + + model.addRule("Prefer plain prose.") + model.removeRule("Always answer briefly.") + XCTAssertEqual(model.customRules, ["Prefer plain prose."]) + + model.clearRules() + XCTAssertEqual(model.customRules, CustomRulesCatalog.defaultRules) + } + + func test_languages_addRemoveClearFunnelThroughNormalization() { + let model = makeModel() + + model.addLanguage("French") + model.addLanguage("French") + XCTAssertEqual(model.responseLanguages.filter { $0 == "French" }.count, 1) + + model.removeLanguage("French") + XCTAssertFalse(model.responseLanguages.contains("French")) + + model.addLanguage("Japanese") + model.clearLanguages() + XCTAssertEqual(model.responseLanguages, LanguageCatalog.defaultLanguages) + } + + func test_setExtendedContext_capsLengthWithoutTrimmingInteriorWhitespace() { + let model = makeModel() + let oversized = String(repeating: "a", count: SuggestionSettingsModel.maximumExtendedContextCharacters + 500) + + model.setExtendedContext(oversized) + XCTAssertEqual(model.extendedContext.count, SuggestionSettingsModel.maximumExtendedContextCharacters) + + // Trailing whitespace survives: the editor writes back on every keystroke and a trim would + // make it impossible to type a space at the end of a word. + model.setExtendedContext("note ") + XCTAssertEqual(model.extendedContext, "note ") + } + + // MARK: - Clamps + + func test_setCustomWordCountRange_clampsAndOrders() { + let model = makeModel() + + model.setCustomWordCountRange(low: -10, high: 9_999) + XCTAssertEqual(model.customWordCountLowWords, SuggestionWordRange.minimumWord) + XCTAssertEqual(model.customWordCountHighWords, SuggestionWordRange.maximumWord) + + // An inverted pair snaps high up to low rather than crossing. + model.setCustomWordCountRange(low: 20, high: 5) + XCTAssertEqual(model.customWordCountLowWords, 20) + XCTAssertEqual(model.customWordCountHighWords, 20) + } + + func test_ghostTextAppearanceSetters_clampToDocumentedBounds() { + let model = makeModel() + + model.setGhostTextOpacity(5.0) + XCTAssertEqual(model.ghostTextOpacity, SuggestionSettingsModel.maximumGhostTextOpacity) + model.setGhostTextOpacity(-1) + XCTAssertEqual(model.ghostTextOpacity, SuggestionSettingsModel.minimumGhostTextOpacity) + + model.setGhostTextSizeMultiplier(100) + XCTAssertEqual(model.ghostTextSizeMultiplier, SuggestionSettingsModel.maximumGhostTextSizeMultiplier) + model.setGhostTextSizeMultiplier(0) + XCTAssertEqual(model.ghostTextSizeMultiplier, SuggestionSettingsModel.minimumGhostTextSizeMultiplier) + } + + func test_setCustomSuggestionTextColorHex_normalizesAndClears() { + let model = makeModel() + + model.setCustomSuggestionTextColorHex("#a1b2c3") + XCTAssertEqual(model.customSuggestionTextColorHex, "A1B2C3") + + model.setCustomSuggestionTextColorHex(nil) + XCTAssertNil(model.customSuggestionTextColorHex) + } + + // MARK: - Snapshot publisher + + func test_snapshotPublisher_emitsCurrentStateThenDistinctChangesOnly() { + let model = makeModel() + var snapshots: [SuggestionSettingsSnapshot] = [] + var cancellables = Set() + model.snapshotPublisher + .sink { snapshots.append($0) } + .store(in: &cancellables) + + XCTAssertEqual(snapshots.count, 1, "CombineLatest must emit the current state on subscribe") + XCTAssertEqual(snapshots.last?.isGloballyEnabled, model.isGloballyEnabled) + + let countBeforeNoOp = snapshots.count + model.setGloballyEnabled(model.isGloballyEnabled) + XCTAssertEqual(snapshots.count, countBeforeNoOp, "a same-value write must not re-emit") + + model.setGloballyEnabled(!model.isGloballyEnabled) + XCTAssertEqual(snapshots.count, countBeforeNoOp + 1) + XCTAssertEqual(snapshots.last?.isGloballyEnabled, model.isGloballyEnabled) + + // A custom-range edit flows into the snapshot pre-clamped. + model.setUsingCustomWordCountRange(true) + model.setCustomWordCountRange(low: 2, high: 200) + XCTAssertEqual(snapshots.last?.customWordCountRange, SuggestionWordRange(lowWords: 2, highWords: 50)) + XCTAssertEqual(snapshots.last?.isUsingCustomWordCountRange, true) + } + + func test_snapshot_reflectsDisabledBundlesAndExtendedContext() { + let model = makeModel() + model.disableApplication(bundleIdentifier: "com.example.app", displayName: "Example") + model.setExtendedContext("context body") + + let snapshot = model.snapshot + XCTAssertEqual(snapshot.disabledAppBundleIdentifiers, ["com.example.app"]) + XCTAssertEqual(snapshot.extendedContext, "context body") + } +} diff --git a/CotabbyTests/SuggestionSettingsStoreTests.swift b/CotabbyTests/SuggestionSettingsStoreTests.swift index 6eb4a8a6..42b2d169 100644 --- a/CotabbyTests/SuggestionSettingsStoreTests.swift +++ b/CotabbyTests/SuggestionSettingsStoreTests.swift @@ -300,14 +300,142 @@ final class SuggestionSettingsStoreTests: XCTestCase { XCTAssertEqual(data.pluggedInModelFilename, "big-model.gguf") } + // MARK: - Custom suggestion text color + + func test_load_normalizesPersistedColorHexAndWritesItBack() async { + let defaults = makeIsolatedDefaults() + // Hand-written value: leading #, lowercase, stray whitespace. The store must canonicalize it + // so the overlay and the picker agree on one representation. + defaults.set(" #a1b2c3 ", forKey: "cotabbyCustomSuggestionTextColorHex") + + let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + XCTAssertEqual(data.customSuggestionTextColorHex, "A1B2C3") + XCTAssertEqual(defaults.string(forKey: "cotabbyCustomSuggestionTextColorHex"), "A1B2C3") + } + + func test_load_discardsMalformedColorHex() async { + let defaults = makeIsolatedDefaults() + defaults.set("ZZZZZZ", forKey: "cotabbyCustomSuggestionTextColorHex") + + let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + XCTAssertNil(data.customSuggestionTextColorHex) + // The write-back must scrub the unusable value rather than re-persisting it. + XCTAssertNil(defaults.string(forKey: "cotabbyCustomSuggestionTextColorHex")) + } + + func test_saveCustomSuggestionTextColorHex_persistsValueAndNilRemoves() async { + let defaults = makeIsolatedDefaults() + let store = SuggestionSettingsStore(userDefaults: defaults) + + store.saveCustomSuggestionTextColorHex("AABBCC") + XCTAssertEqual(defaults.string(forKey: "cotabbyCustomSuggestionTextColorHex"), "AABBCC") + + store.saveCustomSuggestionTextColorHex(nil) + XCTAssertNil(defaults.object(forKey: "cotabbyCustomSuggestionTextColorHex")) + } + + func test_normalizedHexString_acceptsOnlySixHexDigits() async { + XCTAssertNil(SuggestionSettingsStore.normalizedHexString(nil)) + XCTAssertEqual(SuggestionSettingsStore.normalizedHexString("#ffcc00"), "FFCC00") + XCTAssertEqual(SuggestionSettingsStore.normalizedHexString(" 0011fF "), "0011FF") + XCTAssertNil(SuggestionSettingsStore.normalizedHexString("FFF"), "Three-digit shorthand is not supported") + XCTAssertNil(SuggestionSettingsStore.normalizedHexString("A1B2C3D"), "Seven digits is not a color") + XCTAssertNil(SuggestionSettingsStore.normalizedHexString("GGGGGG"), "Non-hex characters must be rejected") + } + + // MARK: - Clamp guards for non-finite values + + func test_clampedGhostTextOpacity_nonFiniteFallsBackToDefault() async { + XCTAssertEqual( + SuggestionSettingsStore.clampedGhostTextOpacity(.nan), + SuggestionSettingsStore.defaultGhostTextOpacity + ) + XCTAssertEqual( + SuggestionSettingsStore.clampedGhostTextOpacity(.infinity), + SuggestionSettingsStore.defaultGhostTextOpacity + ) + } + + func test_clampedGhostTextSizeMultiplier_nonFiniteFallsBackToDefault() async { + XCTAssertEqual( + SuggestionSettingsStore.clampedGhostTextSizeMultiplier(.nan), + SuggestionSettingsStore.defaultGhostTextSizeMultiplier + ) + XCTAssertEqual( + SuggestionSettingsStore.clampedGhostTextSizeMultiplier(-.infinity), + SuggestionSettingsStore.defaultGhostTextSizeMultiplier + ) + } + + // MARK: - Disabled-app rule sanitization + + func test_load_dropsDisabledAppRulesWithBlankBundleIdentifiers() async throws { + let defaults = makeIsolatedDefaults() + // A rule without a bundle identifier can never match a focused app, so it must be dropped + // on load instead of lingering as an unmatchable ghost row in Settings. + let persisted = [ + DisabledApplicationRule(bundleIdentifier: " ", displayName: "Ghost"), + DisabledApplicationRule(bundleIdentifier: " com.example.keep ", displayName: " ") + ] + defaults.set(try JSONEncoder().encode(persisted), forKey: "cotabbyDisabledAppRules") + + let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + XCTAssertEqual(data.disabledAppRules.map(\.bundleIdentifier), ["com.example.keep"]) + // A blank display name falls back to the bundle identifier so the row is never unlabeled. + XCTAssertEqual(data.disabledAppRules.first?.displayName, "com.example.keep") + } + + func test_sortedDisabledAppRules_tieBreaksEqualNamesByBundleIdentifier() async { + let sorted = SuggestionSettingsStore.sortedDisabledAppRules([ + DisabledApplicationRule(bundleIdentifier: "com.z.notes", displayName: "Notes"), + DisabledApplicationRule(bundleIdentifier: "com.a.notes", displayName: "notes") + ]) + + // Case-insensitively equal display names must order deterministically by identifier. + XCTAssertEqual(sorted.map(\.bundleIdentifier), ["com.a.notes", "com.z.notes"]) + } + + func test_normalizedBundleIdentifier_nilAndBlankCollapseToNil() async { + XCTAssertNil(SuggestionSettingsStore.normalizedBundleIdentifier(nil)) + XCTAssertNil(SuggestionSettingsStore.normalizedBundleIdentifier(" ")) + XCTAssertEqual(SuggestionSettingsStore.normalizedBundleIdentifier(" com.example.app "), "com.example.app") + } + + // MARK: - Corrupt persisted types degrade gracefully + + func test_load_recoversFromWrongValueTypesInDefaults() async { + let defaults = makeIsolatedDefaults() + // A hand-edited or corrupted plist can hold the wrong type under our keys. Load must treat + // each one as "present but unusable" and degrade to a safe value instead of crashing. + // Data is used for the user name because UserDefaults converts numbers to strings. + defaults.set(Data([0x01]), forKey: "cotabbyUserName") + defaults.set("not-an-array", forKey: "cotabbyCustomRules") + defaults.set("not-an-array", forKey: "cotabbyResponseLanguages") + defaults.set("not-an-array", forKey: "cotabbyEnabledSpellingDictionaryCodes") + + let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard) + + XCTAssertEqual(data.userName, "") + XCTAssertEqual(data.customRules, []) + XCTAssertEqual(data.responseLanguages, []) + XCTAssertEqual(data.enabledSpellingDictionaryCodes, []) + } + // MARK: - helpers /// Each store test gets its own isolated UserDefaults so state cannot leak between cases. - /// `removePersistentDomain` resets the in-memory suite to a clean slate before use. + /// `removePersistentDomain` resets the in-memory suite to a clean slate before use, and the + /// teardown block removes whatever the test persisted so suites do not accumulate on disk. private func makeIsolatedDefaults() -> UserDefaults { let suiteName = "cotabby.test.settingsStore.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { + defaults.removePersistentDomain(forName: suiteName) + } return defaults } } diff --git a/CotabbyTests/SuggestionTextColorCodecTests.swift b/CotabbyTests/SuggestionTextColorCodecTests.swift new file mode 100644 index 00000000..ea071ec8 --- /dev/null +++ b/CotabbyTests/SuggestionTextColorCodecTests.swift @@ -0,0 +1,65 @@ +import AppKit +import XCTest +@testable import Cotabby + +/// Tests for the hex <-> color conversions behind the ghost-text color setting. +/// +/// These lock the persistence contract: exactly six hex digits (no `#` prefix, unlike the settings +/// store's normalizer, which strips it before this codec ever sees the value), sRGB component math +/// in both directions, and a nil result for colors with no RGB representation. +final class SuggestionTextColorCodecConversionTests: XCTestCase { + func test_nsColor_parsesSixDigitHexIntoSRGBComponents() throws { + let color = try XCTUnwrap(SuggestionTextColorCodec.nsColor(fromHex: "3366FF")) + + XCTAssertEqual(color.redComponent, 0x33 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 0x66 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) + } + + func test_nsColor_trimsWhitespaceAndAcceptsLowercase() throws { + let padded = try XCTUnwrap(SuggestionTextColorCodec.nsColor(fromHex: " a1b2c3 \n")) + let canonical = try XCTUnwrap(SuggestionTextColorCodec.nsColor(fromHex: "A1B2C3")) + + XCTAssertEqual(padded.redComponent, canonical.redComponent, accuracy: 0.0001) + XCTAssertEqual(padded.greenComponent, canonical.greenComponent, accuracy: 0.0001) + XCTAssertEqual(padded.blueComponent, canonical.blueComponent, accuracy: 0.0001) + } + + func test_nsColor_rejectsNilAndMalformedHex() { + XCTAssertNil(SuggestionTextColorCodec.nsColor(fromHex: nil)) + XCTAssertNil(SuggestionTextColorCodec.nsColor(fromHex: "FFF"), "Shorthand hex is not supported") + XCTAssertNil(SuggestionTextColorCodec.nsColor(fromHex: "1234567"), "Seven digits is not a color") + XCTAssertNil(SuggestionTextColorCodec.nsColor(fromHex: "GGGGGG"), "Non-hex characters must be rejected") + XCTAssertNil( + SuggestionTextColorCodec.nsColor(fromHex: "#3366FF"), + "The codec expects the bare six digits; prefix stripping happens upstream" + ) + } + + func test_color_wrapsValidHexAndRejectsInvalid() { + XCTAssertNotNil(SuggestionTextColorCodec.color(fromHex: "FF0000")) + XCTAssertNil(SuggestionTextColorCodec.color(fromHex: "nope!!")) + XCTAssertNil(SuggestionTextColorCodec.color(fromHex: nil)) + } + + func test_hexString_roundTripsThroughNSColor() throws { + let color = try XCTUnwrap(SuggestionTextColorCodec.nsColor(fromHex: "1A2B3C")) + + XCTAssertEqual(SuggestionTextColorCodec.hexString(from: color), "1A2B3C") + } + + func test_hexString_convertsOtherColorSpacesToSRGB() { + // NSColor.white is calibrated grayscale; the codec must convert before reading components. + XCTAssertEqual(SuggestionTextColorCodec.hexString(from: .white), "FFFFFF") + XCTAssertEqual(SuggestionTextColorCodec.hexString(from: .black), "000000") + } + + func test_hexString_returnsNilWhenColorHasNoRGBRepresentation() { + // Pattern colors cannot be converted to sRGB, so persisting one must yield nil instead of + // garbage components. + let pattern = NSColor(patternImage: NSImage(size: NSSize(width: 1, height: 1))) + + XCTAssertNil(SuggestionTextColorCodec.hexString(from: pattern)) + } +} diff --git a/CotabbyTests/SuggestionTextNormalizerTests.swift b/CotabbyTests/SuggestionTextNormalizerTests.swift index ecb932a0..9fb33ee7 100644 --- a/CotabbyTests/SuggestionTextNormalizerTests.swift +++ b/CotabbyTests/SuggestionTextNormalizerTests.swift @@ -234,6 +234,79 @@ final class SuggestionTextNormalizerTests: XCTestCase { XCTAssertEqual(normalized, "first Task: review") } + // MARK: - Multi-line mode + + func test_normalize_multiLineKeepsLinesUpToBlankLineBoundary() { + // Multi-line mode keeps real line breaks but must stop at the first blank line, which is + // the runaway-paragraph signature. + let request = CotabbyTestFixtures.suggestionRequest( + precedingText: "Notes", + isMultiLineEnabled: true + ) + + let normalized = SuggestionTextNormalizer.normalize( + "first line\nsecond line\n\nrunaway paragraph", + for: request + ) + + XCTAssertEqual(normalized, "first line\nsecond line") + } + + func test_normalize_multiLineWithoutBlankLineKeepsEveryLine() { + let request = CotabbyTestFixtures.suggestionRequest( + precedingText: "Notes", + isMultiLineEnabled: true + ) + + let normalized = SuggestionTextNormalizer.normalize( + "first line\nsecond line", + for: request + ) + + XCTAssertEqual(normalized, "first line\nsecond line") + } + + // MARK: - Reasoning-block stripping + + func test_normalize_stripsCompleteThinkBlockBeforeContinuation() { + let request = CotabbyTestFixtures.suggestionRequest(precedingText: "Hello") + + let normalized = SuggestionTextNormalizer.normalize( + "the user is mid-sentencenext words", + for: request + ) + + XCTAssertEqual(normalized, "next words") + } + + func test_normalize_stripsCompleteAndDanglingThinkBlocks() { + // A completed block is removed in place; a second block cut off by the token limit has no + // closing tag, so everything from its open tag onward is dropped. + let request = CotabbyTestFixtures.suggestionRequest(precedingText: "Hello") + + let normalized = SuggestionTextNormalizer.normalize( + "firstrealsecond never closes", + for: request + ) + + XCTAssertEqual(normalized, "real") + } + + func test_normalizeDetailed_danglingThinkBlockOnlyReportsNormalizedToEmpty() { + // The model spent its whole budget reasoning: raw had content, but nothing printable + // survives the strip, which must be attributed as normalized-to-empty (not empty + // generation, and not a filter drop). + let request = CotabbyTestFixtures.suggestionRequest(precedingText: "Hello") + + let result = SuggestionTextNormalizer.normalizeDetailed( + "reasoning that never closes", + for: request + ) + + XCTAssertEqual(result.text, "") + XCTAssertEqual(result.suppression, .normalizedToEmpty) + } + // MARK: - Suppression-reason attribution (normalizeDetailed) func test_normalizeDetailed_successHasNoSuppressionReason() { diff --git a/CotabbyTests/SymSpellCorrectorTests.swift b/CotabbyTests/SymSpellCorrectorTests.swift index abf5a06b..b5689960 100644 --- a/CotabbyTests/SymSpellCorrectorTests.swift +++ b/CotabbyTests/SymSpellCorrectorTests.swift @@ -1,3 +1,4 @@ +import AppKit import XCTest @testable import Cotabby @@ -72,4 +73,121 @@ final class SymSpellCorrectorTests: XCTestCase { XCTAssertEqual(corrector.cachedLanguagesForTesting, [.english, .spanish]) } + + func test_missingDictionaryResourceFailsOpenAndCachesNothing() { + let loaderConsulted = expectation(description: "resource loader consulted for the missing language") + // The retry below may legitimately schedule a second load once the failed one has been + // forgotten; over-fulfillment is therefore expected behavior, not a test error. + loaderConsulted.assertForOverFulfill = false + let corrector = SymSpellCorrector( + preloadLanguage: nil, + resourceLoader: { language in + XCTAssertEqual(language, .italian) + loaderConsulted.fulfill() + return nil + } + ) + + // The first lookup schedules the background load; no index is ready yet. + XCTAssertNil(corrector.bestCorrection(for: "ciaoo", language: .italian)) + + wait(for: [loaderConsulted], timeout: 5.0) + + // A failed load publishes nothing: lookups keep failing open and the cache stays empty, + // so callers fall back to NSSpellChecker instead of crashing or blocking. + XCTAssertNil(corrector.bestCorrection(for: "ciaoo", language: .italian)) + XCTAssertEqual(corrector.cachedLanguagesForTesting, []) + } +} + +/// Behavioral contract tests for the `NSSpellChecker` wrapper used by the typo gate. +/// +/// `NSSpellChecker` verdicts depend on the machine's spelling configuration, enabled languages, and +/// learned words, so these tests deliberately avoid pinning concrete dictionary verdicts. They +/// instead assert the wrapper's documented contracts relative to the live spell server: whole-word +/// range interpretation for `isTypo`, faithful pass-through of ranked guesses for +/// `nativeCorrections`, and the single-word, case-transferred shape of `bestCorrection`. Those +/// relations hold on any machine because both sides of each assertion query the same engine. +@MainActor +final class CurrentWordSpellCheckerTests: XCTestCase { + /// Mix of well-formed words, misspellings, partially-flagged tokens, trailing punctuation, and + /// gibberish, so every range-interpretation branch is exercised on a typical machine while the + /// relative assertions stay true on any machine. + private let probeWords = ["hello", "helo", "nmae,", "ok nmae", "I'm", "Teh", "qqqqzzzzqq"] + + /// App-hosted tests have crashed deallocating short-lived `@MainActor` objects, so checkers are + /// retained for the process lifetime (mirrors `SuggestionStateHelperTests`). + private static var retainedCheckers: [CurrentWordSpellChecker] = [] + + private func makeChecker() -> CurrentWordSpellChecker { + let checker = CurrentWordSpellChecker() + Self.retainedCheckers.append(checker) + return checker + } + + func test_isTypo_emptyWordIsNeverATypo() async { + XCTAssertFalse(makeChecker().isTypo("")) + } + + func test_isTypo_mirrorsSpellServerWholeWordRange() async { + let checker = makeChecker() + let probeTag = NSSpellChecker.uniqueSpellDocumentTag() + for word in probeWords { + let flagged = NSSpellChecker.shared.checkSpelling( + of: word, + startingAt: 0, + language: nil, + wrap: false, + inSpellDocumentWithTag: probeTag, + wordCount: nil + ) + // The wrapper's whole job is range interpretation: a typo only when the flagged range + // starts at 0 and spans the entire token (so "I'm" and "nmae," do not misfire). + let expected = flagged.location == 0 && flagged.length == (word as NSString).length + XCTAssertEqual( + checker.isTypo(word), + expected, + "isTypo(\"\(word)\") must mirror the spell server range \(flagged)" + ) + } + } + + func test_nativeCorrections_passesRankedGuessesThroughNeverNil() async { + let checker = makeChecker() + let probeTag = NSSpellChecker.uniqueSpellDocumentTag() + for word in ["helo", "qqqqzzzzqq"] { + let fullRange = NSRange(location: 0, length: (word as NSString).length) + let rawGuesses = NSSpellChecker.shared.guesses( + forWordRange: fullRange, + in: word, + language: nil, + inSpellDocumentWithTag: probeTag + ) ?? [] + XCTAssertEqual(checker.nativeCorrections(for: word), rawGuesses) + } + } + + func test_bestCorrection_returnsNilOrADifferentSingleWord() async { + let checker = makeChecker() + for word in probeWords { + guard let correction = checker.bestCorrection(for: word) else { continue } + XCTAssertFalse(correction.isEmpty, "\"\(word)\" produced an empty correction") + // Single-word fixes only: a space would break the one-word-replace delete math. + XCTAssertFalse(correction.contains(" "), "\"\(word)\" produced a multi-word correction") + XCTAssertNotEqual(correction.lowercased(), word.lowercased()) + } + } + + func test_bestCorrection_transfersLeadingCapitalFromTypo() async { + guard let correction = makeChecker().bestCorrection(for: "Teh") else { + // This machine's dictionaries offered no usable guess; the case-transfer contract is + // vacuous here rather than failed. + return + } + XCTAssertEqual( + correction.first?.isUppercase, + true, + "a capitalized typo must yield a capitalized correction, got \(correction)" + ) + } } diff --git a/CotabbyTests/SystemMetricsStoreTests.swift b/CotabbyTests/SystemMetricsStoreTests.swift new file mode 100644 index 00000000..bd3e8176 --- /dev/null +++ b/CotabbyTests/SystemMetricsStoreTests.swift @@ -0,0 +1,251 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Behavior of the rolling CPU/RAM window behind the Performance pane graphs: reference-counted +/// sampling, the immediate first reading, the 60-sample cap, identity reset, and the weak-timer +/// teardown contract. The store declares `nonisolated deinit`, so instances may deallocate freely +/// inside the app-hosted runner; main-actor work still runs through `runOnMainActor` because the +/// test class itself must not be `@MainActor`. +final class SystemMetricsStoreTests: XCTestCase { + /// Deterministic sampler stand-in: each reading is derived from a call counter so tests can + /// tell exactly which capture produced which sample. + private final class SamplerProbe { + private(set) var sampleCount = 0 + + func next() -> SystemResourceSample { + sampleCount += 1 + return SystemResourceSample( + cpuPercent: Double(sampleCount), + footprintBytes: UInt64(sampleCount) * 100 + ) + } + } + + private func makeStore( + probe: SamplerProbe, + sampleInterval: TimeInterval = 600 + ) -> SystemMetricsStore { + // The default interval is deliberately huge so timer ticks can never interleave with + // assertions; timer-driven tests override it explicitly. + runOnMainActor { + SystemMetricsStore( + sampleInterval: sampleInterval, + physicalMemoryBytes: 8_589_934_592, + sampler: { probe.next() } + ) + } + } + + /// Pumps the main run loop until `condition` holds or `timeout` elapses. Returns whether the + /// condition was met, so callers fail with a real assertion instead of a hang. + private func pumpRunLoop(timeout: TimeInterval, until condition: () -> Bool) -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + while !condition() { + if Date() >= deadline { + return false + } + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + } + return true + } + + // MARK: - Lifecycle and reference counting + + func test_init_startsEmptyAndKeepsInjectedPhysicalMemory() { + let store = makeStore(probe: SamplerProbe()) + + runOnMainActor { + XCTAssertTrue(store.samples.isEmpty) + XCTAssertEqual(store.physicalMemoryBytes, 8_589_934_592) + } + } + + func test_beginSampling_capturesAnImmediateFirstReading() { + let probe = SamplerProbe() + let store = makeStore(probe: probe) + + runOnMainActor { + store.beginSampling() + + XCTAssertEqual(probe.sampleCount, 1, "First viewer should sample immediately, not wait an interval") + XCTAssertEqual(store.samples.count, 1) + XCTAssertEqual(store.samples.first?.id, 0) + XCTAssertEqual(store.samples.first?.cpuPercent, 1.0) + XCTAssertEqual(store.samples.first?.footprintBytes, 100) + + store.endSampling() + } + } + + func test_secondViewer_sharesTheRunningSession() { + let probe = SamplerProbe() + let store = makeStore(probe: probe) + + runOnMainActor { + store.beginSampling() + store.beginSampling() + + // The second viewer must not trigger a duplicate capture or a second timer. + XCTAssertEqual(probe.sampleCount, 1) + XCTAssertEqual(store.samples.count, 1) + + store.endSampling() + XCTAssertEqual(store.samples.count, 1, "Window survives while one viewer remains") + + store.endSampling() + XCTAssertTrue(store.samples.isEmpty, "Last viewer leaving drops the stale window") + } + } + + func test_unbalancedEndSampling_clampsAndStaysUsable() { + let probe = SamplerProbe() + let store = makeStore(probe: probe) + + runOnMainActor { + store.endSampling() + XCTAssertTrue(store.samples.isEmpty) + + // A later begin/end pair must behave exactly like a fresh session: the unbalanced + // call cannot leave the viewer count negative. + store.beginSampling() + XCTAssertEqual(store.samples.count, 1) + store.endSampling() + XCTAssertTrue(store.samples.isEmpty) + } + } + + func test_endSampling_resetsSampleIdentityForTheNextSession() { + let probe = SamplerProbe() + let store = makeStore(probe: probe) + + runOnMainActor { + store.beginSampling() + XCTAssertEqual(store.samples.last?.id, 0) + store.endSampling() + + store.beginSampling() + // A fresh session restarts the monotonic ID at zero instead of continuing the old + // timeline, which is what keeps SwiftUI Charts from stitching sessions together. + XCTAssertEqual(store.samples.last?.id, 0) + store.endSampling() + } + } + + func test_clear_dropsTheWindowWithoutStoppingSampling() { + let probe = SamplerProbe() + let store = makeStore(probe: probe) + + runOnMainActor { + store.beginSampling() + XCTAssertEqual(store.samples.count, 1) + + store.clear() + XCTAssertTrue(store.samples.isEmpty) + + store.endSampling() + } + } + + // MARK: - Timer-driven capture + + func test_timer_appendsContiguousSamplesWhileActive() { + let probe = SamplerProbe() + let store = makeStore(probe: probe, sampleInterval: 0.01) + + runOnMainActor { store.beginSampling() } + let reachedThree = pumpRunLoop(timeout: 10) { + runOnMainActor { store.samples.count >= 3 } + } + + XCTAssertTrue(reachedThree, "Timer never delivered follow-up samples") + runOnMainActor { + let ids = store.samples.map(\.id) + XCTAssertEqual(ids, Array(0..= target } + } + + XCTAssertTrue(overflowed, "Timer never produced enough samples to overflow the window") + // No further timer fires can land between the pump returning and these reads: the run + // loop is only pumped inside `pumpRunLoop`, so the window below is stable. + runOnMainActor { + let samples = store.samples + XCTAssertEqual(samples.count, SystemMetricsStore.maximumSamples) + if let first = samples.first, let last = samples.last { + XCTAssertEqual(first.id, last.id - UInt64(SystemMetricsStore.maximumSamples - 1)) + } + XCTAssertEqual( + samples.map(\.id), + samples.map(\.id).sorted(), + "Window must stay in capture order after dropping the oldest entries" + ) + store.endSampling() + } + } + + func test_orphanedTimer_selfInvalidatesAfterStoreIsReleased() { + let probe = SamplerProbe() + weak var weakStore: SystemMetricsStore? + + // The pool drains any autoreleased references before the deallocation assertion below. + autoreleasepool { + runOnMainActor { + let store = SystemMetricsStore( + sampleInterval: 0.01, + physicalMemoryBytes: 1_024, + sampler: { probe.next() } + ) + store.beginSampling() + weakStore = store + } + } + + // The timer captures the store weakly, so nothing should keep it alive after scope exit. + XCTAssertNil(weakStore, "Scheduled timer must not retain the store") + + // Let the orphaned timer fire once: it must invalidate itself without crashing. The pump + // is bounded and asserts nothing time-sensitive; it only gives the teardown path a chance + // to run under the test's watch. + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } + + // MARK: - Default configuration + + func test_defaultConfiguration_usesRealSamplerAndHostMemory() { + runOnMainActor { + let store = SystemMetricsStore() + + XCTAssertEqual(store.physicalMemoryBytes, ProcessInfo.processInfo.physicalMemory) + + store.beginSampling() + XCTAssertEqual(store.samples.count, 1) + // The default sampler is the real Mach-backed one, so the reading must be live. + XCTAssertGreaterThan(store.samples.first?.footprintBytes ?? 0, 0) + store.endSampling() + } + } +} + +private func runOnMainActor( + _ body: @MainActor () throws -> Result +) rethrows -> Result { + if Thread.isMainThread { + return try MainActor.assumeIsolated(body) + } + + return try DispatchQueue.main.sync { + try MainActor.assumeIsolated(body) + } +} diff --git a/CotabbyTests/SystemResourceSamplerTests.swift b/CotabbyTests/SystemResourceSamplerTests.swift new file mode 100644 index 00000000..fa28a120 --- /dev/null +++ b/CotabbyTests/SystemResourceSamplerTests.swift @@ -0,0 +1,63 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Exercises the Mach-kernel sampling path in-process. Exact values are nondeterministic by +/// nature, so every assertion is a sanity bound: the point is that the unsafe-pointer Mach calls +/// produce live, plausible readings rather than zeroed or corrupted structs. +final class SystemResourceSamplerTests: XCTestCase { + func test_sample_reportsPlausibleCPUPercent() { + let sample = SystemResourceSampler.sample() + + XCTAssertTrue(sample.cpuPercent.isFinite) + XCTAssertGreaterThanOrEqual(sample.cpuPercent, 0) + // The total can exceed 100 on multi-core machines (that is the documented contract), but + // it can never plausibly exceed every core running flat out with generous headroom. + let upperBound = Double(ProcessInfo.processInfo.activeProcessorCount) * 100.0 * 4.0 + XCTAssertLessThan(sample.cpuPercent, upperBound) + } + + func test_sample_reportsLiveProcessFootprint() { + let sample = SystemResourceSampler.sample() + + // The hosted test process (the full Cotabby app) always occupies well over 10 MB, and a + // footprint reading beyond several times installed RAM means the struct rebind went wrong. + XCTAssertGreaterThan(sample.footprintBytes, 10_000_000) + XCTAssertLessThan(sample.footprintBytes, ProcessInfo.processInfo.physicalMemory * 4) + } + + func test_sample_staysStableAcrossConsecutiveReads() { + let first = SystemResourceSampler.sample() + let second = SystemResourceSampler.sample() + + // Adjacent footprint readings of the same idle-ish process must land in the same order of + // magnitude; a 4x jump between back-to-back calls indicates a bogus read, not real growth. + XCTAssertGreaterThan(second.footprintBytes, first.footprintBytes / 4) + XCTAssertLessThan(second.footprintBytes, first.footprintBytes * 4) + XCTAssertGreaterThanOrEqual(second.cpuPercent, 0) + } + + func test_sampleValues_compareByBothFields() { + // The sample's synthesized Equatable inherits the app module's default MainActor + // isolation, so the comparisons run through the main-actor hop helper. + runOnMainActor { + let sample = SystemResourceSample(cpuPercent: 12.5, footprintBytes: 1_024) + + XCTAssertEqual(sample, SystemResourceSample(cpuPercent: 12.5, footprintBytes: 1_024)) + XCTAssertNotEqual(sample, SystemResourceSample(cpuPercent: 12.5, footprintBytes: 2_048)) + XCTAssertNotEqual(sample, SystemResourceSample(cpuPercent: 99.0, footprintBytes: 1_024)) + } + } +} + +private func runOnMainActor( + _ body: @MainActor () throws -> Result +) rethrows -> Result { + if Thread.isMainThread { + return try MainActor.assumeIsolated(body) + } + + return try DispatchQueue.main.sync { + try MainActor.assumeIsolated(body) + } +} diff --git a/CotabbyTests/TextDirectionDetectorTests.swift b/CotabbyTests/TextDirectionDetectorTests.swift index a0e3783a..7af8e0a0 100644 --- a/CotabbyTests/TextDirectionDetectorTests.swift +++ b/CotabbyTests/TextDirectionDetectorTests.swift @@ -51,4 +51,50 @@ final class TextDirectionDetectorTests: XCTestCase { // Numbers are weak — the last strong character is Arabic XCTAssertTrue(TextDirectionDetector.isRightToLeft("مرحبا 123")) } + + // MARK: - Presentation forms and directional marks + + func test_hebrewPresentationForm_isRTL() { + // U+FB2A (HEBREW LETTER SHIN WITH SHIN DOT) sits in the FB1D-FDFF presentation-form + // block, outside the main Hebrew range, and must still count as strong RTL. + XCTAssertTrue(TextDirectionDetector.isRightToLeft("\u{FB2A}")) + } + + func test_arabicPresentationFormB_isRTL() { + // U+FE8D (ARABIC LETTER ALEF ISOLATED FORM) is in Arabic Presentation Forms-B. + XCTAssertTrue(TextDirectionDetector.isRightToLeft("\u{FE8D}")) + } + + func test_rightToLeftMark_isRTL() { + // An explicit RLM is a strong RTL signal even though it renders as nothing. + XCTAssertTrue(TextDirectionDetector.isRightToLeft("\u{200F}")) + } + + func test_leftToRightMark_isLTR() { + // The LTR mark is the strong-LTR counterpart: it must terminate the scan as LTR. + XCTAssertFalse(TextDirectionDetector.isRightToLeft("مرحبا\u{200E}")) + } + + // MARK: - Strong LTR scripts beyond lowercase Basic Latin + + func test_uppercaseLatin_isLTR() { + XCTAssertFalse(TextDirectionDetector.isRightToLeft("WORLD")) + } + + func test_latinExtended_isLTR() { + // U+00E9 (e with acute) is in the Latin Extended range, not Basic Latin. + XCTAssertFalse(TextDirectionDetector.isRightToLeft("caf\u{00E9}")) + } + + func test_greek_isLTR() { + XCTAssertFalse(TextDirectionDetector.isRightToLeft("αβγ")) + } + + func test_cyrillic_isLTR() { + XCTAssertFalse(TextDirectionDetector.isRightToLeft("привет")) + } + + func test_cjkIdeographs_areTreatedAsLTR() { + XCTAssertFalse(TextDirectionDetector.isRightToLeft("中文")) + } } diff --git a/CotabbyTests/TextLayoutCaretEstimatorTests.swift b/CotabbyTests/TextLayoutCaretEstimatorTests.swift index 95dcee47..059a85ee 100644 --- a/CotabbyTests/TextLayoutCaretEstimatorTests.swift +++ b/CotabbyTests/TextLayoutCaretEstimatorTests.swift @@ -333,6 +333,61 @@ final class TextLayoutCaretEstimatorTests: XCTestCase { XCTAssertEqual(estimate.caretRect.maxY, frame.maxY - topInset, accuracy: 0.01) } + func test_estimate_observedCharWidthWithinTwoPercentDoesNotRescaleFont() throws { + // The width calibration has a 2% dead band: an observed average that close to the layout + // font's own average is measurement noise, and rescaling on it would jitter the font size + // every present. Mirrors the estimator's width-sample constant on purpose so this breaks + // loudly if the calibration sample changes. + let sample = "the quick brown fox jumps over the lazy dog, The Quick 0123456789. " as NSString + let menlo = try XCTUnwrap(NSFont(name: "Menlo-Regular", size: 16)) + let sampleAverage = sample.size(withAttributes: [.font: menlo]).width / CGFloat(sample.length) + + let estimate = try XCTUnwrap( + acceptedEstimate(for: makeInput( + prefix: "Hello", + frame: CGRect(x: 0, y: 0, width: 400, height: 24), + style: ResolvedFieldStyle(fontName: "Menlo-Regular", fontPointSize: 16, colorHex: nil), + observedCharWidth: sampleAverage * 1.015 + )) + ) + + // Without the dead band the layout font would become 16 * 1.015 = 16.24pt. + XCTAssertEqual(estimate.layoutFontPointSize, 16) + } + + func test_estimate_rightToLeftTrailingNewlineAnchorsCaretAtTrailingEdge() throws { + // After a hard line break in an RTL editor the insertion point sits at the line's leading + // edge, which is the field's right side; the empty new line must not snap the caret to the + // left edge the way LTR does. + let frame = CGRect(x: 100, y: 100, width: 300, height: 200) + let estimate = try XCTUnwrap( + acceptedEstimate(for: makeInput(prefix: "שלום\n", frame: frame, isRightToLeft: true)) + ) + + XCTAssertEqual(estimate.lineIndex, 1) + XCTAssertEqual(estimate.caretRect.minX, frame.maxX - horizontalInset, accuracy: 0.01) + } + + // MARK: - Memoization + + func test_estimate_repeatedIdenticalInputReturnsIdenticalOutcome() { + // Reconcile ticks re-present byte-identical inputs several times per second; the memo must + // return the exact same outcome for them (and the second call exercises the cached path). + let input = makeInput( + prefix: "memo probe text", + frame: CGRect(x: 0, y: 0, width: 300, height: 24) + ) + + let first = TextLayoutCaretEstimator.estimate(for: input) + let second = TextLayoutCaretEstimator.estimate(for: input) + + XCTAssertEqual(first, second) + guard case .estimate = first else { + XCTFail("Expected the probe input to produce an accepted estimate") + return + } + } + func test_estimate_measuredTopIgnoredWhenPrefixStartsWithLineBreak() throws { // The topmost run is the first *rendered* text; leading blank lines sit above it, so the // measured top edge would anchor the layout one line too high per blank. diff --git a/CotabbyTests/TrailingDuplicationFilterTests.swift b/CotabbyTests/TrailingDuplicationFilterTests.swift index 1388d97b..463669d6 100644 --- a/CotabbyTests/TrailingDuplicationFilterTests.swift +++ b/CotabbyTests/TrailingDuplicationFilterTests.swift @@ -47,4 +47,21 @@ final class TrailingDuplicationFilterTests: XCTestCase { TrailingDuplicationFilter.duplicatesTrailingText("ok", trailingText: "okay then") ) } + + func test_longLeadingRunAtHalfOfCompletion_isDuplicate() { + // Shape 3: neither side is a prefix of the other, but the shared leading run + // ("they we", folded to 6 alphanumerics) reaches half the completion's folded + // length (12 / 2 = 6), which is the "model re-emits the next few words" signature. + XCTAssertTrue( + TrailingDuplicationFilter.duplicatesTrailingText("they went home", trailingText: "they were here") + ) + } + + func test_shortSharedLeadingRunBelowHalf_isNotDuplicate() { + // The shared "the" run (3 folded characters) is well under half of the completion's + // 17 folded characters, so this is a coincidental stem match, not a duplication. + XCTAssertFalse( + TrailingDuplicationFilter.duplicatesTrailingText("the dog barks loudly", trailingText: "the cat") + ) + } }