diff --git a/Apps/Attendi/App/AttendiApp.swift b/Apps/Attendi/Sources/App/AttendiApp.swift similarity index 99% rename from Apps/Attendi/App/AttendiApp.swift rename to Apps/Attendi/Sources/App/AttendiApp.swift index c0cbc5f..0e2a135 100644 --- a/Apps/Attendi/App/AttendiApp.swift +++ b/Apps/Attendi/Sources/App/AttendiApp.swift @@ -17,5 +17,5 @@ struct AttendiApp: App { ContentView() } } - + } diff --git a/Apps/Attendi/View Models/ContentViewModel.swift b/Apps/Attendi/Sources/View Models/ContentViewModel.swift similarity index 75% rename from Apps/Attendi/View Models/ContentViewModel.swift rename to Apps/Attendi/Sources/View Models/ContentViewModel.swift index f79efec..e9cc6a4 100644 --- a/Apps/Attendi/View Models/ContentViewModel.swift +++ b/Apps/Attendi/Sources/View Models/ContentViewModel.swift @@ -30,9 +30,9 @@ extension ContentView { /// The locales the transcriber supports, sorted by their localized names, or empty while ``load()`` has not finished yet. private(set) var locales: [Locale] - /// The service that captures the audio from the device's microphone. + /// The service that captures the audio from a microphone. @ObservationIgnored - let capturer: AudioCapturing + let capturer: any Capturing /// The locale whose speech model assets are preinstalled or being preinstalled, or `nil` when none are. @ObservationIgnored @@ -44,7 +44,7 @@ extension ContentView { /// The service that preinstalls the speech model assets of a picked locale. @ObservationIgnored - private let preinstaller: AssetPreinstalling + private let preinstaller: any Preinstalling /// The task that listens to the events emitted by the preinstalling service, or `nil` until the first preinstallation starts. @ObservationIgnored @@ -54,21 +54,32 @@ extension ContentView { @ObservationIgnored private var taskPreinstall: Task? - /// The service that transcribes the recorded audio into text on device. + /// The service that transcribes the recorded audio into text. @ObservationIgnored - let transcriber: AudioTranscribing + let transcriber: any Transcribing // MARK: Initializers - /// Creates a model set to the user's current locale, with no supported locales loaded yet, attached to the on-device recording, - /// transcribing, and preinstalling services, and to a notifier for the download events. - init() { + /// Creates a model set to the user's current locale, with no supported locales loaded yet, attached to the given services + /// and notifier. + /// + /// - Parameters: + /// - capturer: The service that captures the audio from a microphone. Defaults to the on-device ``AudioCapturing``. + /// - preinstaller: The service that preinstalls the speech model assets of a picked locale. Defaults to ``AssetPreinstalling``. + /// - transcriber: The service that transcribes the recorded audio into text. Defaults to the on-device ``AudioTranscribing``. + /// - notifier: The notifier that owns the transient in-app notifications. Defaults to a notifier with its standard dismissal delay. + init( + capturer: any Capturing = AudioCapturing(), + preinstaller: any Preinstalling = AssetPreinstalling(), + transcriber: any Transcribing = AudioTranscribing(), + notifier: Notifier = .init(), + ) { self.locale = .current self.locales = [] - self.capturer = .init() - self.notifier = .init() - self.preinstaller = .init() - self.transcriber = .init() + self.capturer = capturer + self.notifier = notifier + self.preinstaller = preinstaller + self.transcriber = transcriber } // MARK: Methods @@ -77,19 +88,23 @@ extension ContentView { /// supported equivalent of its current value — falling back to the supported equivalent of a default locale when none exists — /// so the locale picker starts with a valid selection. func load() async { - locales = await preinstaller + locales = + await preinstaller .supportedLocales() .sorted { name(for: $0).localizedStandardCompare(name(for: $1)) == .orderedAscending } - locale = if let equivalent = await preinstaller.supportedLocale(equivalentTo: locale) { - equivalent - } else if let fallback = await preinstaller.supportedLocale(equivalentTo: .byDefault) { - fallback - } else { - .current - } + locale = + if let equivalent = await preinstaller.supportedLocale(equivalentTo: locale) { + equivalent + } + else if let fallback = await preinstaller.supportedLocale(equivalentTo: .byDefault) { + fallback + } + else { + .current + } preinstall() } @@ -125,7 +140,7 @@ extension ContentView { .localizedString( forIdentifier: locale.identifier )?.localizedCapitalized - ?? locale.identifier + ?? locale.identifier } /// Handles the transcription of a processed recording, presenting it in the modal sheet. @@ -154,7 +169,7 @@ private extension ContentView.Model { /// Starts listening to the events emitted by the preinstalling service, unless already listening: every started, cancelled, and /// failed download is posted to the ``ContentView/Model/notifier``, and a cancellation or failure clears the preinstalled locale - /// so picking it again retries. + /// — only when it is still the affected one, so a newer preinstallation is never forgotten — letting a re-pick retry. func listenToEvents() { guard taskEvents == nil else { return @@ -167,23 +182,27 @@ private extension ContentView.Model { } switch event { - case let .cancelled(locale): - localePreinstalling = nil + case .cancelled(let locale): + if localePreinstalling == locale { + localePreinstalling = nil + } notifier.post( .warning, message: String(localized: .viewRecordingNotificationDownloadCancelled(name(for: locale))), symbol: Constant.Symbol.cancelled ) - case let .failed(locale): - localePreinstalling = nil + case .failed(let locale): + if localePreinstalling == locale { + localePreinstalling = nil + } notifier.post( .error, message: String(localized: .viewRecordingNotificationDownloadFailed(name(for: locale))), symbol: Constant.Symbol.failed ) - case let .started(locale): + case .started(let locale): notifier.post( .info, message: String(localized: .viewRecordingNotificationDownloadStarted(name(for: locale))), diff --git a/Apps/Attendi/Views/ContentView.swift b/Apps/Attendi/Sources/Views/ContentView.swift similarity index 75% rename from Apps/Attendi/Views/ContentView.swift rename to Apps/Attendi/Sources/Views/ContentView.swift index a6b4b0a..a966888 100644 --- a/Apps/Attendi/Views/ContentView.swift +++ b/Apps/Attendi/Sources/Views/ContentView.swift @@ -19,7 +19,19 @@ struct ContentView: View { /// The model that owns the attached services, the transcription presented in the modal sheet, and the notifier of the in-app /// notifications. - @State private var model = Model() + @State + private var model: Model + + // MARK: Initializers + + /// Creates a content view driven by the given model. + /// + /// - Parameter model: The model that drives the view. Defaults to a model attached to the on-device services. + init( + model: Model = .init() + ) { + self._model = State(initialValue: model) + } // MARK: Body @@ -43,9 +55,9 @@ struct ContentView: View { maxWidth: .infinity, maxHeight: .infinity ) - .navigationTitle("view.recording.navigation.title") + .navigationTitle(.viewRecordingNavigationTitle) #if !os(macOS) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .primaryAction) { @@ -85,7 +97,7 @@ private extension ContentView { var menuLocale: some View { Menu { Picker( - "view.recording.picker.locale.title", + .viewRecordingPickerLocaleTitle, selection: $model.locale ) { ForEach( @@ -99,12 +111,12 @@ private extension ContentView { .pickerStyle(.inline) } label: { Label( - "view.recording.picker.locale.title", + .viewRecordingPickerLocaleTitle, systemImage: "globe" ) } .disabled(model.locales.isEmpty) - .accessibilityHint(Text("view.recording.picker.locale.hint")) + .accessibilityHint(Text(.viewRecordingPickerLocaleHint)) } /// The stack of the notifier's in-app notifications, each one sliding in from the leading edge when posted and out again when @@ -123,17 +135,21 @@ private extension ContentView { Image(systemName: notification.symbol) .accessibilityHidden(true) } - .labelStyle(.notification( - kind: notification.kind - )) + .labelStyle( + .notification( + kind: notification.kind + ) + ) .accessibilityElement( children: .combine ) - .transition(.move( - edge: .leading - ).combined( - with: .opacity - )) + .transition( + .move( + edge: .leading + ).combined( + with: .opacity + ) + ) } } .padding(.horizontal) @@ -157,11 +173,12 @@ private extension ContentView { Group { if transcription.isEmpty { ContentUnavailableView( - "view.transcription.unavailable.title", + .viewTranscriptionUnavailableTitle, systemImage: "text.page.slash", - description: Text("view.transcription.unavailable.description") + description: Text(.viewTranscriptionUnavailableDescription) ) - } else { + } + else { ScrollView { Text(transcription.text) .font(.body) @@ -175,9 +192,9 @@ private extension ContentView { } } } - .navigationTitle("view.transcription.navigation.title") + .navigationTitle(.viewTranscriptionNavigationTitle) #if !os(macOS) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) #endif .toolbar { Button(role: .close) { @@ -203,8 +220,41 @@ private enum Constant { // MARK: - Previews +/// The preinstalling service used by the previews, which resolves every locale as supported and downloads nothing. +private struct PreviewPreinstalling: Preinstalling { + + /// The stream of events the service emits, which finishes immediately. + let events: AsyncStream = .init { continuation in + continuation.finish() + } + + func preinstall( + for locale: Locale + ) async { + // The previews preinstall no speech model assets. + } + + func supportedLocale( + equivalentTo locale: Locale + ) async -> Locale? { + locale + } + + func supportedLocales() async -> [Locale] { + [ + Locale(identifier: "en-US"), + Locale(identifier: "nl-NL"), + ] + } + +} + #Preview( "Content view" ) { - ContentView() + ContentView( + model: .init( + preinstaller: PreviewPreinstalling() + ) + ) } diff --git a/Apps/Attendi/Tests/Mocks/PreinstallingMock.swift b/Apps/Attendi/Tests/Mocks/PreinstallingMock.swift new file mode 100644 index 0000000..a5da06f --- /dev/null +++ b/Apps/Attendi/Tests/Mocks/PreinstallingMock.swift @@ -0,0 +1,53 @@ +import Foundation +import Recording + +/// A preinstalling service that records its requests, resolves the supported locales from configurable tables, and emits events +/// on demand. +@MainActor +final class PreinstallingMock: Preinstalling { + + /// The stream of events the service emits while preinstalling. + let events: AsyncStream + + /// The supported equivalents the service resolves, keyed by the requested locale. + var equivalents: [Locale: Locale] = [:] + + /// The locales the service reports as supported. + var localesSupported: [Locale] = [] + + /// The locales a preinstallation was requested for, in request order. + private(set) var localesPreinstalled: [Locale] = [] + + /// The continuation that feeds ``events``. + private let continuation: AsyncStream.Continuation + + init() { + (events, continuation) = AsyncStream.makeStream(of: PreinstallingEvent.self) + } + + /// Emits the given event through ``events``. + /// + /// - Parameter event: The event to emit. + func emit( + _ event: PreinstallingEvent + ) { + continuation.yield(event) + } + + func preinstall( + for locale: Locale + ) async { + localesPreinstalled.append(locale) + } + + func supportedLocale( + equivalentTo locale: Locale + ) async -> Locale? { + equivalents[locale] + } + + func supportedLocales() async -> [Locale] { + localesSupported + } + +} diff --git a/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift b/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift new file mode 100644 index 0000000..7d089ad --- /dev/null +++ b/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Notifying +import Recording +import Testing + +@testable import Attendi + +@Suite("Content view model") +struct ContentViewModelTests { + + typealias Model = ContentView.Model + + // MARK: Load + + @MainActor + @Suite("Load") + struct Load { + + @Test + func `load fills the supported locales and aligns the picked locale`() async { + let preinstaller = PreinstallingMock() + + preinstaller.localesSupported = [.dutch, .english] + preinstaller.equivalents = [.current: .english] + + let model = Model(preinstaller: preinstaller) + + await model.load() + + #expect(model.locale == .english) + #expect(model.locales.count == 2) + #expect(model.locales.contains(.dutch)) + #expect(model.locales.contains(.english)) + } + + @Test + func `load preinstalls the aligned locale`() async throws { + let preinstaller = PreinstallingMock() + + preinstaller.localesSupported = [.dutch, .english] + preinstaller.equivalents = [.current: .english] + + let model = Model(preinstaller: preinstaller) + + await model.load() + + try await Task.sleep(for: .seconds(0.1)) + + #expect(preinstaller.localesPreinstalled == [.english]) + } + + } + + // MARK: Preinstall + + @MainActor + @Suite("Preinstall") + struct Preinstall { + + @Test + func `preinstalling the same locale twice runs once`() async throws { + let preinstaller = PreinstallingMock() + let model = Model(preinstaller: preinstaller) + + model.locale = .dutch + + model.preinstall() + model.preinstall() + + try await Task.sleep(for: .seconds(0.1)) + + #expect(preinstaller.localesPreinstalled == [.dutch]) + } + + @Test + func `preinstalling a newly picked locale runs again`() async throws { + let preinstaller = PreinstallingMock() + let model = Model(preinstaller: preinstaller) + + model.locale = .dutch + model.preinstall() + + model.locale = .english + model.preinstall() + + try await Task.sleep(for: .seconds(0.1)) + + #expect(preinstaller.localesPreinstalled == [.dutch, .english]) + } + + } + + // MARK: Events + + @MainActor + @Suite("Events") + struct Events { + + @Test + func `a started event posts an info notification`() async throws { + let preinstaller = PreinstallingMock() + let model = Model(preinstaller: preinstaller) + + model.locale = .dutch + model.preinstall() + + try await Task.sleep(for: .seconds(0.1)) + + preinstaller.emit(.started(.dutch)) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(model.notifier.notifications.count == 1) + #expect(model.notifier.notifications.first?.kind == .info) + #expect(model.notifier.notifications.first?.symbol == "arrow.down.circle") + #expect(model.notifier.notifications.first?.message == String(localized: .viewRecordingNotificationDownloadStarted(model.name(for: .dutch)))) + } + + @Test + func `a cancelled event posts a warning notification`() async throws { + let preinstaller = PreinstallingMock() + let model = Model(preinstaller: preinstaller) + + model.locale = .dutch + model.preinstall() + + try await Task.sleep(for: .seconds(0.1)) + + preinstaller.emit(.cancelled(.dutch)) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(model.notifier.notifications.first?.kind == .warning) + #expect(model.notifier.notifications.first?.symbol == "xmark.circle") + #expect(model.notifier.notifications.first?.message == String(localized: .viewRecordingNotificationDownloadCancelled(model.name(for: .dutch)))) + } + + @Test + func `a failed event posts an error notification`() async throws { + let preinstaller = PreinstallingMock() + let model = Model(preinstaller: preinstaller) + + model.locale = .dutch + model.preinstall() + + try await Task.sleep(for: .seconds(0.1)) + + preinstaller.emit(.failed(.dutch)) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(model.notifier.notifications.first?.kind == .error) + #expect(model.notifier.notifications.first?.symbol == "exclamationmark.triangle") + #expect(model.notifier.notifications.first?.message == String(localized: .viewRecordingNotificationDownloadFailed(model.name(for: .dutch)))) + } + + @Test + func `a cancellation of the current locale lets a re-pick retry`() async throws { + let preinstaller = PreinstallingMock() + let model = Model(preinstaller: preinstaller) + + model.locale = .dutch + model.preinstall() + + try await Task.sleep(for: .seconds(0.1)) + + preinstaller.emit(.cancelled(.dutch)) + + try await Task.sleep(for: .seconds(0.1)) + + model.preinstall() + + try await Task.sleep(for: .seconds(0.1)) + + #expect(preinstaller.localesPreinstalled == [.dutch, .dutch]) + } + + @Test + func `a cancellation of another locale keeps the current preinstallation`() async throws { + let preinstaller = PreinstallingMock() + let model = Model(preinstaller: preinstaller) + + model.locale = .english + model.preinstall() + + try await Task.sleep(for: .seconds(0.1)) + + preinstaller.emit(.cancelled(.dutch)) + + try await Task.sleep(for: .seconds(0.1)) + + model.preinstall() + + try await Task.sleep(for: .seconds(0.1)) + + #expect(preinstaller.localesPreinstalled == [.english]) + } + + } + +} + +// MARK: - Constants + +private extension Locale { + /// A supported locale to preinstall in the tests. + static let dutch = Locale(identifier: "nl-NL") + /// Another supported locale to preinstall in the tests. + static let english = Locale(identifier: "en-US") +} diff --git a/Attendi.xcodeproj/project.pbxproj b/Attendi.xcodeproj/project.pbxproj index ad00b7e..61c81aa 100644 --- a/Attendi.xcodeproj/project.pbxproj +++ b/Attendi.xcodeproj/project.pbxproj @@ -11,24 +11,43 @@ 02870A292FF7FF530079EA3A /* Features in Frameworks */ = {isa = PBXBuildFile; productRef = 02870A282FF7FF530079EA3A /* Features */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 0296F79E2FFA954E00D2C5FC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 028709F22FF7E8E40079EA3A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 02870A0C2FF7EB610079EA3A; + remoteInfo = Attendi; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 02870A0D2FF7EB610079EA3A /* Attendi.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Attendi.app; sourceTree = BUILT_PRODUCTS_DIR; }; 02870A262FF7F2990079EA3A /* README.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = README.pdf; sourceTree = ""; }; 02870A322FF831CD0079EA3A /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 0296F79A2FFA954E00D2C5FC /* AttendiTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AttendiTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 02870A1F2FF7EB680079EA3A /* Exceptions for "Apps" folder in "Attendi" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Attendi/App/AttendiApp.swift, Attendi/Catalogs/Assets.xcassets, Attendi/Catalogs/Localizable.xcstrings, - "Attendi/View Models/ContentViewModel.swift", - Attendi/Views/ContentView.swift, + Attendi/Sources/App/AttendiApp.swift, + "Attendi/Sources/View Models/ContentViewModel.swift", + Attendi/Sources/Views/ContentView.swift, ); target = 02870A0C2FF7EB610079EA3A /* Attendi */; }; + 0296F7A52FFA95DB00D2C5FC /* Exceptions for "Apps" folder in "AttendiTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Attendi/Tests/Mocks/PreinstallingMock.swift, + "Attendi/Tests/View Models/ContentViewModelTests.swift", + ); + target = 0296F7992FFA954E00D2C5FC /* AttendiTests */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -36,6 +55,7 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 02870A1F2FF7EB680079EA3A /* Exceptions for "Apps" folder in "Attendi" target */, + 0296F7A52FFA95DB00D2C5FC /* Exceptions for "Apps" folder in "AttendiTests" target */, ); path = Apps; sourceTree = ""; @@ -55,6 +75,11 @@ 02870A292FF7FF530079EA3A /* Features in Frameworks */, ); }; + 0296F7972FFA954E00D2C5FC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + files = ( + ); + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -74,6 +99,7 @@ isa = PBXGroup; children = ( 02870A0D2FF7EB610079EA3A /* Attendi.app */, + 0296F79A2FFA954E00D2C5FC /* AttendiTests.xctest */, ); name = Products; sourceTree = ""; @@ -107,6 +133,24 @@ productReference = 02870A0D2FF7EB610079EA3A /* Attendi.app */; productType = "com.apple.product-type.application"; }; + 0296F7992FFA954E00D2C5FC /* AttendiTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0296F7A02FFA954E00D2C5FC /* Build configuration list for PBXNativeTarget "AttendiTests" */; + buildPhases = ( + 0296F7962FFA954E00D2C5FC /* Sources */, + 0296F7972FFA954E00D2C5FC /* Frameworks */, + 0296F7982FFA954E00D2C5FC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 0296F79F2FFA954E00D2C5FC /* PBXTargetDependency */, + ); + name = AttendiTests; + productName = AttendiTests; + productReference = 0296F79A2FFA954E00D2C5FC /* AttendiTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -121,6 +165,10 @@ 02870A0C2FF7EB610079EA3A = { CreatedOnToolsVersion = 26.6; }; + 0296F7992FFA954E00D2C5FC = { + CreatedOnToolsVersion = 26.6; + TestTargetID = 02870A0C2FF7EB610079EA3A; + }; }; }; buildConfigurationList = 028709F52FF7E8E40079EA3A /* Build configuration list for PBXProject "Attendi" */; @@ -141,6 +189,7 @@ projectRoot = ""; targets = ( 02870A0C2FF7EB610079EA3A /* Attendi */, + 0296F7992FFA954E00D2C5FC /* AttendiTests */, ); }; /* End PBXProject section */ @@ -151,6 +200,11 @@ files = ( ); }; + 0296F7982FFA954E00D2C5FC /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -159,8 +213,21 @@ files = ( ); }; + 0296F7962FFA954E00D2C5FC /* Sources */ = { + isa = PBXSourcesBuildPhase; + files = ( + ); + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 0296F79F2FFA954E00D2C5FC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 02870A0C2FF7EB610079EA3A /* Attendi */; + targetProxy = 0296F79E2FFA954E00D2C5FC /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 028709F62FF7E8E40079EA3A /* Debug configuration for PBXProject "Attendi" */ = { isa = XCBuildConfiguration; @@ -402,6 +469,160 @@ }; name = Release; }; + 0296F7A12FFA954E00D2C5FC /* Debug configuration for PBXNativeTarget "AttendiTests" */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 7FMNM89WKG; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.app.AttendiTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Attendi.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Attendi"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + 0296F7A22FFA954E00D2C5FC /* Release configuration for PBXNativeTarget "AttendiTests" */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 7FMNM89WKG; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.app.AttendiTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Attendi.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Attendi"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -421,6 +642,14 @@ ); defaultConfigurationName = Release; }; + 0296F7A02FFA954E00D2C5FC /* Build configuration list for PBXNativeTarget "AttendiTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0296F7A12FFA954E00D2C5FC /* Debug configuration for PBXNativeTarget "AttendiTests" */, + 0296F7A22FFA954E00D2C5FC /* Release configuration for PBXNativeTarget "AttendiTests" */, + ); + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/Packages/Features/Tests/Recording/Mocks/PreinstallingMock.swift b/Packages/Features/Tests/Recording/Mocks/PreinstallingMock.swift new file mode 100644 index 0000000..a5da06f --- /dev/null +++ b/Packages/Features/Tests/Recording/Mocks/PreinstallingMock.swift @@ -0,0 +1,53 @@ +import Foundation +import Recording + +/// A preinstalling service that records its requests, resolves the supported locales from configurable tables, and emits events +/// on demand. +@MainActor +final class PreinstallingMock: Preinstalling { + + /// The stream of events the service emits while preinstalling. + let events: AsyncStream + + /// The supported equivalents the service resolves, keyed by the requested locale. + var equivalents: [Locale: Locale] = [:] + + /// The locales the service reports as supported. + var localesSupported: [Locale] = [] + + /// The locales a preinstallation was requested for, in request order. + private(set) var localesPreinstalled: [Locale] = [] + + /// The continuation that feeds ``events``. + private let continuation: AsyncStream.Continuation + + init() { + (events, continuation) = AsyncStream.makeStream(of: PreinstallingEvent.self) + } + + /// Emits the given event through ``events``. + /// + /// - Parameter event: The event to emit. + func emit( + _ event: PreinstallingEvent + ) { + continuation.yield(event) + } + + func preinstall( + for locale: Locale + ) async { + localesPreinstalled.append(locale) + } + + func supportedLocale( + equivalentTo locale: Locale + ) async -> Locale? { + equivalents[locale] + } + + func supportedLocales() async -> [Locale] { + localesSupported + } + +}