diff --git a/docs/architecture/use-case-catalog.md b/docs/architecture/use-case-catalog.md index 000b2a41..2ac93c3e 100644 --- a/docs/architecture/use-case-catalog.md +++ b/docs/architecture/use-case-catalog.md @@ -553,6 +553,28 @@ scripts/check-boundaries.sh See that file for exit codes and per-path skip notes. +## Provincial Hansard adapters + +The application layer depends on `HansardRepository`. Runtime dispatch is owned +by `JurisdictionRoutedHansardRepository`, which maps each `Jurisdiction` to one +adapter. Provincial rows stay TODO until the province-specific implementation +issue lands. + +| Jurisdiction | Adapter file | Status | +|---|---|---| +| Federal | `ios/epac/Data/Repositories/SwiftDataHansardRepository.swift` | Registered in `JurisdictionRoutedHansardRepository` at app startup. | +| Alberta | `ios/epac/Data/Adapters/Hansard/AlbertaHansardAdapter.swift` | TODO — EPAC-613. | +| British Columbia | `ios/epac/Data/Adapters/Hansard/BritishColumbiaHansardAdapter.swift` | TODO — EPAC-680. | +| Manitoba | `ios/epac/Data/Adapters/Hansard/ManitobaHansardAdapter.swift` | TODO — EPAC-786. | +| Nova Scotia | `ios/epac/Data/Adapters/Hansard/NovaScotiaHansardAdapter.swift` | TODO — EPAC-793. | +| Quebec | `ios/epac/Data/Adapters/Hansard/QuebecHansardAdapter.swift` | TODO — EPAC-936. | +| Saskatchewan | `ios/epac/Data/Adapters/Hansard/SaskatchewanHansardAdapter.swift` | TODO — EPAC-874. | + +Boundary rule: files matching +`ios/epac/Data/Adapters/Hansard/**/*Adapter.swift` must not import `SwiftUI`, +`SwiftData`, or `UIKit`. If the check fails, move UI or persistence concerns to +a Repository or ViewModel layer. + ### What "inward" means for epac today The boundary between application policy and delivery adapters does not have fully isolated directories yet (those are being created by EPAC-1741, EPAC-1742, EPAC-1743). Until those tickets land, the boundary is conceptual and documented here: diff --git a/ios/epac/Data/Adapters/Hansard/HTMLHansardScaffold.swift b/ios/epac/Data/Adapters/Hansard/HTMLHansardScaffold.swift new file mode 100644 index 00000000..c6dcbc07 --- /dev/null +++ b/ios/epac/Data/Adapters/Hansard/HTMLHansardScaffold.swift @@ -0,0 +1,83 @@ +// +// HTMLHansardScaffold.swift +// epac +// + +import Foundation + +protocol HTMLHansardScaffold { + var jurisdiction: Jurisdiction { get } + var language: Locale { get } +} + +extension HTMLHansardScaffold { + var language: Locale { Locale(identifier: "en-CA") } + + func makeTranscript( + sittingDate: Date, + legislatureNumber: Int?, + sourceURL: URL, + subjects: [SubjectOfBusinessRecord] + ) -> HansardTranscript { + HansardTranscript( + jurisdiction: jurisdiction, + sittingDate: sittingDate, + parliamentNumber: nil, + sessionNumber: nil, + legislatureNumber: legislatureNumber, + sourceURL: sourceURL, + language: language, + subjects: subjects + ) + } + + func makeSubject( + id: String, + title: String, + speeches: [SpeechMessageRecord] + ) -> SubjectOfBusinessRecord { + SubjectOfBusinessRecord( + id: normalizedIdentifier(id), + title: normalizedText(title), + speeches: speeches + ) + } + + func makeSpeech( + interventionID: String, + speakerText: String, + speakerMemberID: String?, + text: String, + timestamp: Date? = nil + ) -> SpeechMessageRecord { + let speaker = HansardSpeakerParser.parse(speakerText) + let displayName = [speaker.firstName, speaker.lastName] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + return SpeechMessageRecord( + interventionID: normalizedIdentifier(interventionID), + speakerName: displayName.isEmpty ? normalizedText(speakerText) : displayName, + speakerMemberID: speakerMemberID, + text: normalizedText(strippingHTMLTags(from: text)), + timestamp: timestamp + ) + } + + func normalizedText(_ text: String) -> String { + text + .replacingOccurrences(of: "\u{00a0}", with: " ") + .split { $0.isWhitespace } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + func normalizedIdentifier(_ text: String) -> String { + let identifier = normalizedText(text) + return identifier.isEmpty ? "unknown" : identifier + } + + func strippingHTMLTags(from html: String) -> String { + html.replacing(/<[^>]+>/, with: " ") + } +} diff --git a/ios/epac/Data/Adapters/Hansard/JurisdictionRoutedHansardRepository.swift b/ios/epac/Data/Adapters/Hansard/JurisdictionRoutedHansardRepository.swift new file mode 100644 index 00000000..d94526c9 --- /dev/null +++ b/ios/epac/Data/Adapters/Hansard/JurisdictionRoutedHansardRepository.swift @@ -0,0 +1,45 @@ +// +// JurisdictionRoutedHansardRepository.swift +// epac +// + +import Foundation + +@MainActor +struct JurisdictionRoutedHansardRepository: HansardRepository { + private let adapters: [Jurisdiction: any HansardRepository] + + init(adapters: [Jurisdiction: any HansardRepository]) { + self.adapters = adapters + } + + func fetchTranscript(jurisdiction: Jurisdiction, sittingDate: Date) async throws -> HansardTranscript { + try await adapter(for: jurisdiction).fetchTranscript( + jurisdiction: jurisdiction, + sittingDate: sittingDate + ) + } + + func listSittingDates(jurisdiction: Jurisdiction, from startDate: Date, through endDate: Date) async throws -> [Date] { + try await adapter(for: jurisdiction).listSittingDates( + jurisdiction: jurisdiction, + from: startDate, + through: endDate + ) + } + + func storeTranscript(_ transcript: HansardTranscript) async throws { + try await adapter(for: transcript.jurisdiction).storeTranscript(transcript) + } + + private func adapter(for jurisdiction: Jurisdiction) throws -> any HansardRepository { + guard let adapter = adapters[jurisdiction] else { + throw HansardAdapterError.unsupportedJurisdiction(jurisdiction) + } + return adapter + } +} + +enum HansardAdapterError: Error, Equatable { + case unsupportedJurisdiction(Jurisdiction) +} diff --git a/ios/epac/Data/Adapters/Hansard/ProvincialHansardAdapter.md b/ios/epac/Data/Adapters/Hansard/ProvincialHansardAdapter.md new file mode 100644 index 00000000..8ad20e7d --- /dev/null +++ b/ios/epac/Data/Adapters/Hansard/ProvincialHansardAdapter.md @@ -0,0 +1,49 @@ +# Provincial Hansard Adapter Scaffold + +Each provincial Hansard adapter implements `HansardRepository` for exactly one +`Jurisdiction` and lives under: + +`ios/epac/Data/Adapters/Hansard/HansardAdapter.swift` + +The adapter is responsible for source-specific delivery details only. The +application layer calls `JurisdictionRoutedHansardRepository`, which dispatches +requests by jurisdiction. + +## Transport + +Fetch only from the province's authoritative Hansard source. Keep URL building, +HTTP status handling, retry policy, and source metadata inside the adapter or a +small province-local helper. If multiple provinces need the same HTTP client +shape after the first implementation lands, extract it then; do not add a shared +network abstraction before there are two real callers. + +## Parsing + +Return `HansardTranscript` with stable `SubjectOfBusinessRecord` and +`SpeechMessageRecord` values. Reuse `HTMLHansardScaffold` for HTML-only sources +such as Nova Scotia, Manitoba, and Saskatchewan when its default transcript, +subject, speech, and whitespace helpers fit. For PDF or XML sources, write the +smallest province-local parser that preserves source IDs and source text. + +## Speaker Resolution + +Use `HansardSpeakerParser` for common speaker prefixes before mapping a speaker +line to the province's member list. The Quebec adapter can rely on the parser's +French prefixes (`L'hon.`, `L’hon.`, `Mme`, `M.`), but member identity still +belongs to the province-specific adapter because each legislature publishes a +different member list and stable identifier shape. + +## Sitting Calendar Discovery + +Implement `listSittingDates(jurisdiction:from:through:)` from the province's +authoritative sitting calendar or Hansard index. The returned dates must be +filtered to the requested range and sorted before returning. If a province has +only per-day Hansard pages and no separate calendar endpoint, document that in +the adapter and derive the date list from the source index. + +## Boundary Rule + +Files matching `ios/epac/Data/Adapters/Hansard/**/*Adapter.swift` must not +import `SwiftUI`, `SwiftData`, or `UIKit`. If an adapter needs UI state or +persistence, move that concern to a repository, ViewModel, or another outer +layer and keep the adapter focused on transport and parsing. diff --git a/ios/epac/Util/HansardRepositoryEnvironment.swift b/ios/epac/Util/HansardRepositoryEnvironment.swift new file mode 100644 index 00000000..ab1ffe0f --- /dev/null +++ b/ios/epac/Util/HansardRepositoryEnvironment.swift @@ -0,0 +1,26 @@ +import SwiftUI + +private struct HansardRepositoryEnvironmentKey: EnvironmentKey { + static let defaultValue: any HansardRepository = UnsupportedHansardRepository() +} + +extension EnvironmentValues { + var hansardRepository: any HansardRepository { + get { self[HansardRepositoryEnvironmentKey.self] } + set { self[HansardRepositoryEnvironmentKey.self] = newValue } + } +} + +private struct UnsupportedHansardRepository: HansardRepository { + func fetchTranscript(jurisdiction: Jurisdiction, sittingDate: Date) async throws -> HansardTranscript { + throw HansardAdapterError.unsupportedJurisdiction(jurisdiction) + } + + func listSittingDates(jurisdiction: Jurisdiction, from startDate: Date, through endDate: Date) async throws -> [Date] { + throw HansardAdapterError.unsupportedJurisdiction(jurisdiction) + } + + func storeTranscript(_ transcript: HansardTranscript) async throws { + throw HansardAdapterError.unsupportedJurisdiction(transcript.jurisdiction) + } +} diff --git a/ios/epac/Util/HansardSpeakerParser.swift b/ios/epac/Util/HansardSpeakerParser.swift index c7aaf9e0..d83eada8 100644 --- a/ios/epac/Util/HansardSpeakerParser.swift +++ b/ios/epac/Util/HansardSpeakerParser.swift @@ -82,7 +82,23 @@ struct HansardSpeakerParser { return (firstName: cleanNames.dropLast().joined(separator: " "), lastName: cleanNames.last.map(String.init)) } - private static let speakerTitleWords = ["Hon.", "Rt.", "Mr.", "Ms.", "Mrs.", "Mme.", "Dr.", "The", "Hon", "Rt", "Right"] + private static let speakerTitleWords = [ + "Hon.", + "Rt.", + "Mr.", + "Ms.", + "Mrs.", + "Mme.", + "Mme", + "M.", + "L'hon.", + "L\u{2019}hon.", + "Dr.", + "The", + "Hon", + "Rt", + "Right" + ] } private extension String { diff --git a/ios/epac/Views/Calendar/SittingCalendarView.swift b/ios/epac/Views/Calendar/SittingCalendarView.swift index c506765f..ec608e71 100644 --- a/ios/epac/Views/Calendar/SittingCalendarView.swift +++ b/ios/epac/Views/Calendar/SittingCalendarView.swift @@ -44,6 +44,7 @@ struct SittingCalendarView: View { @Binding var selectedDate: DateComponents? @Environment(\.modelContext) private var modelContext + @Environment(\.hansardRepository) private var hansardRepository @Environment(\.isPresented) private var isPresented @Environment(\.font) private var font @StateObject private var calendarViewProxy = CalendarViewProxy() @@ -192,6 +193,7 @@ struct SittingCalendarView: View { .frame(maxWidth: .infinity) .task(id: viewModel.currentYear) { // id-based task cancels any in-flight fetch when year changes via the chevron picker. + viewModel.configure(browseHansardSitting: BrowseHansardSitting(repository: hansardRepository)) if viewModel.dates.isEmpty { await viewModel.fetchSittingCalendar(viewModel.currentYear, modelContext: modelContext, fetch: fetch) scrollToToday(animated: false) diff --git a/ios/epac/Views/Calendar/SittingLoaderView.swift b/ios/epac/Views/Calendar/SittingLoaderView.swift index a3842b63..97543f63 100644 --- a/ios/epac/Views/Calendar/SittingLoaderView.swift +++ b/ios/epac/Views/Calendar/SittingLoaderView.swift @@ -9,6 +9,7 @@ import SwiftUI struct SittingLoaderView: View { @Environment(\.modelContext) private var modelContext + @Environment(\.hansardRepository) private var hansardRepository @EnvironmentObject private var fetch: Fetch let date: Date @@ -57,7 +58,10 @@ struct SittingLoaderView: View { } do { - try await fetch.downloadHansard(date) + _ = try await LoadDailyHansard(repository: hansardRepository).execute( + jurisdiction: .federal, + sittingDate: date + ) guard let hansard = fetchHansard() else { loadState = .failed return diff --git a/ios/epac/Views/Chat/SpeechView.swift b/ios/epac/Views/Chat/SpeechView.swift index 670216e0..385c6b06 100644 --- a/ios/epac/Views/Chat/SpeechView.swift +++ b/ios/epac/Views/Chat/SpeechView.swift @@ -40,6 +40,7 @@ struct SpeechView: View { @Environment(\.modelContext) var modelContext @Environment(\.colorScheme) var colorScheme @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.hansardRepository) private var hansardRepository @Environment(NavigationRouter.self) var router @EnvironmentObject var fetch: Fetch @@ -222,7 +223,7 @@ struct SpeechView: View { guard viewModel.messages.isEmpty else { return } do { viewModel.configure(readHansardSpeech: ReadHansardSpeech( - repository: SwiftDataHansardRepository(modelContext: modelContext, fetch: fetch) + repository: hansardRepository )) try await viewModel.loadSpeech( jurisdiction: .federal, diff --git a/ios/epac/Views/ContentView.swift b/ios/epac/Views/ContentView.swift index af7d5a1a..57a4a8ec 100644 --- a/ios/epac/Views/ContentView.swift +++ b/ios/epac/Views/ContentView.swift @@ -32,6 +32,7 @@ struct ContentView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.scenePhase) private var scenePhase var fetch: Fetch + var hansardRepository: any HansardRepository var appDelegate: AppDelegate @State private var viewModel = ContentViewModel() @State private var router = NavigationRouter() @@ -40,8 +41,9 @@ struct ContentView: View { @State private var showOnboarding = !AppRuntime.isRunningTests && !AppEnvironment.isMarketingCaptureMode && !UserDefaults.standard.bool(forKey: "epac.onboarding.completed") @State private var showWhatsNew = false - init(modelContainer: ModelContainer, appDelegate: AppDelegate) { - self.fetch = Fetch(modelContainer: modelContainer) + init(fetch: Fetch, hansardRepository: any HansardRepository, appDelegate: AppDelegate) { + self.fetch = fetch + self.hansardRepository = hansardRepository self.appDelegate = appDelegate if AppEnvironment.isAppPreviewMode { Self.configureAppPreviewMode() @@ -80,6 +82,7 @@ struct ContentView: View { } } .environmentObject(fetch) + .environment(\.hansardRepository, hansardRepository) .environment(router) .environment(networkMonitor) .onOpenURL { url in diff --git a/ios/epac/epacApp.swift b/ios/epac/epacApp.swift index f751930b..696ccefb 100644 --- a/ios/epac/epacApp.swift +++ b/ios/epac/epacApp.swift @@ -15,6 +15,10 @@ enum AppRuntime { static let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil } +private enum SentryConfiguration { + static let tracesSampleRate: NSNumber = 0.1 +} + @MainActor class AppDelegate: NSObject, UIApplicationDelegate { /// Injected by ContentView once it has created the router. @@ -33,7 +37,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { @main struct epacApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - var sharedModelContainer: ModelContainer = { + let sharedModelContainer: ModelContainer + let fetch: Fetch + let hansardRepository: any HansardRepository + + private static func makeModelContainer() -> ModelContainer { do { let usesInMemoryStore = AppRuntime.isRunningTests || AppEnvironment.isMarketingCaptureMode return try ModelContainer( @@ -44,18 +52,29 @@ struct epacApp: App { } catch { fatalError("Could not create ModelContainer: \(error)") } - }() + } @Environment(\.scenePhase) private var scenePhase init() { + let modelContainer = Self.makeModelContainer() + let fetch = Fetch(modelContainer: modelContainer) + self.sharedModelContainer = modelContainer + self.fetch = fetch + self.hansardRepository = JurisdictionRoutedHansardRepository(adapters: [ + .federal: SwiftDataHansardRepository( + modelContext: modelContainer.mainContext, + fetch: fetch + ) + ]) + guard !AppRuntime.isRunningTests, !AppEnvironment.isMarketingCaptureMode else { return } if let dsn = Bundle.main.object(forInfoDictionaryKey: "SentryDSN") as? String, !dsn.isEmpty, !dsn.hasPrefix("$(") { SentrySDK.start { options in options.dsn = "https://\(dsn)" options.enableCrashHandler = true - options.tracesSampleRate = 0.1 + options.tracesSampleRate = SentryConfiguration.tracesSampleRate } } @@ -77,7 +96,11 @@ struct epacApp: App { var body: some Scene { WindowGroup { - ContentView(modelContainer: sharedModelContainer, appDelegate: appDelegate) + ContentView( + fetch: fetch, + hansardRepository: hansardRepository, + appDelegate: appDelegate + ) } .modelContainer(sharedModelContainer) .onChange(of: scenePhase) { _, newPhase in diff --git a/ios/epacTests/Data/HansardSpeakerParserTests.swift b/ios/epacTests/Data/HansardSpeakerParserTests.swift new file mode 100644 index 00000000..8f770e06 --- /dev/null +++ b/ios/epacTests/Data/HansardSpeakerParserTests.swift @@ -0,0 +1,98 @@ +@testable import epac +import Testing + +struct HansardSpeakerParserTests { + @Test func parsesStandardEnglishHonourable() { + let result = HansardSpeakerParser.parse("Hon. Mark Carney (Nepean, Lib.)") + + #expect(result.firstName == "Mark") + #expect(result.lastName == "Carney") + #expect(result.partyAbbreviation == "Lib") + #expect(result.ridingName == "Nepean") + } + + @Test func parsesMrPrefix() { + let result = HansardSpeakerParser.parse("Mr. Pierre Poilievre (Carleton, CPC)") + + #expect(result.firstName == "Pierre") + #expect(result.lastName == "Poilievre") + #expect(result.partyAbbreviation == "CPC") + #expect(result.ridingName == "Carleton") + } + + @Test func parsesMsPrefix() { + let result = HansardSpeakerParser.parse("Ms. Jenny Kwan (Vancouver East, NDP)") + + #expect(result.firstName == "Jenny") + #expect(result.lastName == "Kwan") + #expect(result.partyAbbreviation == "NDP") + #expect(result.ridingName == "Vancouver East") + } + + @Test func parsesFrenchHonourableWithApostrophe() { + let result = HansardSpeakerParser.parse("L'hon. Marc Garneau (Notre-Dame-de-Grâce—Westmount, Lib.)") + + #expect(result.firstName == "Marc") + #expect(result.lastName == "Garneau") + #expect(result.partyAbbreviation == "Lib") + #expect(result.ridingName == "Notre-Dame-de-Grâce\u{2014}Westmount") + } + + @Test func parsesFrenchHonourableWithSmartApostrophe() { + let result = HansardSpeakerParser.parse("L\u{2019}hon. Marc Garneau (Notre-Dame-de-Grâce—Westmount, Lib.)") + + #expect(result.firstName == "Marc") + #expect(result.lastName == "Garneau") + #expect(result.partyAbbreviation == "Lib") + } + + @Test func parsesFrenchMonsieurPrefix() { + let result = HansardSpeakerParser.parse("M. Yves-François Blanchet (Beloeil—Chambly, BQ)") + + #expect(result.firstName == "Yves-François") + #expect(result.lastName == "Blanchet") + #expect(result.partyAbbreviation == "BQ") + #expect(result.ridingName == "Beloeil\u{2014}Chambly") + } + + @Test func parsesFrenchMadamePrefix() { + let result = HansardSpeakerParser.parse("Mme Claude DeBellefeuille (Salaberry—Suroît, BQ)") + + #expect(result.firstName == "Claude") + #expect(result.lastName == "DeBellefeuille") + #expect(result.partyAbbreviation == "BQ") + } + + @Test func parsesDrPrefix() { + let result = HansardSpeakerParser.parse("Dr. Hedy Fry (Vancouver Centre, Lib.)") + + #expect(result.firstName == "Hedy") + #expect(result.lastName == "Fry") + #expect(result.partyAbbreviation == "Lib") + } + + @Test func parsesNameWithoutParentheses() { + let result = HansardSpeakerParser.parse("The Speaker") + + #expect(result.firstName == "") + #expect(result.lastName == "Speaker") + #expect(result.partyAbbreviation == "") + #expect(result.ridingName == "") + } + + @Test func parsesRightHonourable() { + let result = HansardSpeakerParser.parse("Rt. Hon. Justin Trudeau (Papineau, Lib.)") + + #expect(result.firstName == "Justin") + #expect(result.lastName == "Trudeau") + #expect(result.partyAbbreviation == "Lib") + } + + @Test func parsesRoleWithRiding() { + let result = HansardSpeakerParser.parse("Hon. Ruby Sahota (Secretary of State (Combatting Crime), Lib.)") + + #expect(result.firstName == "Ruby") + #expect(result.lastName == "Sahota") + #expect(result.partyAbbreviation == "Lib") + } +} diff --git a/ios/epacTests/Data/JurisdictionRoutedHansardRepositoryTests.swift b/ios/epacTests/Data/JurisdictionRoutedHansardRepositoryTests.swift new file mode 100644 index 00000000..28409e38 --- /dev/null +++ b/ios/epacTests/Data/JurisdictionRoutedHansardRepositoryTests.swift @@ -0,0 +1,114 @@ +@testable import epac +import Foundation +import Testing + +@MainActor +struct JurisdictionRoutedHansardRepositoryTests { + private static let federalDate = Date(timeIntervalSince1970: 1_800_000_000) + private static let albertaDate = Date(timeIntervalSince1970: 1_800_100_000) + + @Test func dispatchesFetchToFederalAdapter() async throws { + let transcript = Self.transcript(jurisdiction: .federal, date: Self.federalDate) + let federal = FixtureHansardRepository(transcripts: [transcript]) + let router = JurisdictionRoutedHansardRepository(adapters: [.federal: federal]) + + let result = try await router.fetchTranscript(jurisdiction: .federal, sittingDate: Self.federalDate) + + #expect(result == transcript) + #expect(federal.fetchRequests.count == 1) + #expect(federal.fetchRequests[0].jurisdiction == .federal) + } + + @Test func dispatchesListToFederalAdapter() async throws { + let start = Self.federalDate + let end = Date(timeIntervalSince1970: 1_800_200_000) + let federal = FixtureHansardRepository(sittingDates: [start]) + let router = JurisdictionRoutedHansardRepository(adapters: [.federal: federal]) + + let dates = try await router.listSittingDates(jurisdiction: .federal, from: start, through: end) + + #expect(dates == [start]) + #expect(federal.listRequests.count == 1) + } + + @Test func dispatchesStoreToCorrectAdapter() async throws { + let transcript = Self.transcript(jurisdiction: .federal, date: Self.federalDate) + let federal = FixtureHansardRepository() + let router = JurisdictionRoutedHansardRepository(adapters: [.federal: federal]) + + try await router.storeTranscript(transcript) + + #expect(federal.storedTranscripts == [transcript]) + } + + @Test func throwsForUnknownJurisdiction() async { + let federal = FixtureHansardRepository() + let router = JurisdictionRoutedHansardRepository(adapters: [.federal: federal]) + + await #expect(throws: HansardAdapterError.unsupportedJurisdiction(.alberta)) { + try await router.fetchTranscript(jurisdiction: .alberta, sittingDate: Self.albertaDate) + } + } + + @Test func throwsForUnknownJurisdictionOnList() async { + let router = JurisdictionRoutedHansardRepository(adapters: [:]) + + await #expect(throws: HansardAdapterError.unsupportedJurisdiction(.novaScotia)) { + try await router.listSittingDates( + jurisdiction: .novaScotia, + from: Self.federalDate, + through: Self.albertaDate + ) + } + } + + @Test func multiAdapterRegistrationDispatchesCorrectly() async throws { + let federalTranscript = Self.transcript(jurisdiction: .federal, date: Self.federalDate) + let albertaTranscript = Self.transcript(jurisdiction: .alberta, date: Self.albertaDate) + let federal = FixtureHansardRepository(transcripts: [federalTranscript]) + let alberta = FixtureHansardRepository(transcripts: [albertaTranscript]) + let router = JurisdictionRoutedHansardRepository(adapters: [ + .federal: federal, + .alberta: alberta + ]) + + let fedResult = try await router.fetchTranscript(jurisdiction: .federal, sittingDate: Self.federalDate) + let abResult = try await router.fetchTranscript(jurisdiction: .alberta, sittingDate: Self.albertaDate) + + #expect(fedResult == federalTranscript) + #expect(abResult == albertaTranscript) + #expect(federal.fetchRequests.count == 1) + #expect(alberta.fetchRequests.count == 1) + } + + @Test func registrationOrderDoesNotAffectDispatch() async throws { + let federalTranscript = Self.transcript(jurisdiction: .federal, date: Self.federalDate) + let albertaTranscript = Self.transcript(jurisdiction: .alberta, date: Self.albertaDate) + let federal = FixtureHansardRepository(transcripts: [federalTranscript]) + let alberta = FixtureHansardRepository(transcripts: [albertaTranscript]) + + let routerAB = JurisdictionRoutedHansardRepository(adapters: [ + .alberta: alberta, + .federal: federal + ]) + + let fedResult = try await routerAB.fetchTranscript(jurisdiction: .federal, sittingDate: Self.federalDate) + let abResult = try await routerAB.fetchTranscript(jurisdiction: .alberta, sittingDate: Self.albertaDate) + + #expect(fedResult == federalTranscript) + #expect(abResult == albertaTranscript) + } + + private static func transcript(jurisdiction: Jurisdiction, date: Date) -> HansardTranscript { + HansardTranscript( + jurisdiction: jurisdiction, + sittingDate: date, + parliamentNumber: jurisdiction == .federal ? 45 : nil, + sessionNumber: jurisdiction == .federal ? 1 : nil, + legislatureNumber: jurisdiction == .federal ? nil : 31, + sourceURL: URL(string: "https://example.com/hansard/\(jurisdiction.rawValue)/\(Int(date.timeIntervalSince1970))")!, + language: Locale(identifier: "en-CA"), + subjects: [] + ) + } +} diff --git a/ios/epacTests/Application/FixtureHansardRepository.swift b/ios/epacTests/Fixtures/Hansard/FixtureHansardRepository.swift similarity index 100% rename from ios/epacTests/Application/FixtureHansardRepository.swift rename to ios/epacTests/Fixtures/Hansard/FixtureHansardRepository.swift diff --git a/scripts/check-boundaries.sh b/scripts/check-boundaries.sh index a9e78cbb..90fef27f 100755 --- a/scripts/check-boundaries.sh +++ b/scripts/check-boundaries.sh @@ -37,6 +37,7 @@ grep_files() { IOS_FORBIDDEN_PATTERN='import SwiftUI\|import SwiftData\|import UIKit\|import StoreKit\|import UserNotifications\|URLSession\|UNUserNotificationCenter\|UNNotification\|UNNotificationRequest\|UNNotificationResponse\|UNMutableNotificationContent\|UIApplication\|registerForRemoteNotifications' IOS_APPLICATION_FORBIDDEN_PATTERN="$IOS_FORBIDDEN_PATTERN" +HANSARD_ADAPTER_FORBIDDEN_PATTERN='import SwiftUI\|import SwiftData\|import UIKit' # ── iOS Domain boundary (EPAC-1741) ─────────────────────────────────────────── header "iOS Domain layer (ios/epac/Domain/)" @@ -72,6 +73,32 @@ else skip "ios/epac/Application/ does not exist yet — boundary will be enforced by EPAC-1742" fi +# ── iOS Hansard adapter boundary (EPAC-2029) ───────────────────────────────── +header "iOS Hansard adapters (ios/epac/Data/Adapters/Hansard/**/*Adapter.swift)" + +HANSARD_ADAPTER_DIR="$REPO_ROOT/ios/epac/Data/Adapters/Hansard" +if [[ -d "$HANSARD_ADAPTER_DIR" ]]; then + ADAPTER_FILES=$(find "$HANSARD_ADAPTER_DIR" -type f -name "*Adapter.swift" 2>/dev/null || true) + if [[ -n "$ADAPTER_FILES" ]]; then + ADAPTER_VIOLATIONS=$( + while IFS= read -r file; do + grep -n "$HANSARD_ADAPTER_FORBIDDEN_PATTERN" "$file" 2>/dev/null | sed "s#^#$file:#" || true + done <<< "$ADAPTER_FILES" + ) + if [[ -n "$ADAPTER_VIOLATIONS" ]]; then + while IFS= read -r line; do + fail "$line — This adapter imports a UI/persistence framework. Move that concern to a Repository or ViewModel layer. See docs/architecture/use-case-catalog.md." + done <<< "$ADAPTER_VIOLATIONS" + else + pass "No SwiftUI/SwiftData/UIKit imports in Hansard adapter files" + fi + else + skip "No *Adapter.swift files in ios/epac/Data/Adapters/Hansard/ yet" + fi +else + skip "ios/epac/Data/Adapters/Hansard/ does not exist yet" +fi + # ── Backend application packages ────────────────────────────────────────────── header "Backend application packages (backend/*/application/*.go)"