diff --git a/backend/openapi/openapi.json b/backend/openapi/openapi.json index c3b8186c..a13062d4 100644 --- a/backend/openapi/openapi.json +++ b/backend/openapi/openapi.json @@ -902,6 +902,82 @@ } } }, + "/api/v1/bills/{id}/diff": { + "get": { + "tags": ["Bills"], + "summary": "Get bill version diff", + "description": "Returns a clause-aware diff between two published versions of a bill. The backend computes the structured diff against the ingested LEGISinfo version text; the iOS surface only renders the result and never parses LEGISinfo wire formats.", + "operationId": "getBillVersionDiff", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Bill identifier from the list bills response.", + "schema": { "type": "string", "example": "C-8" } + }, + { + "name": "from", + "in": "query", + "required": true, + "description": "Identifier of the \"before\" version, as published in the bill depth response's versions list.", + "schema": { "type": "string", "example": "C-8-v1" } + }, + { + "name": "to", + "in": "query", + "required": true, + "description": "Identifier of the \"after\" version, as published in the bill depth response's versions list.", + "schema": { "type": "string", "example": "C-8-v3" } + } + ], + "responses": { + "200": { + "description": "Structured clause-level diff between the two versions.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/BillVersionDiff" }, + "examples": { + "diff": { + "value": { + "from": { + "id": "C-8-v1", + "label": "First reading", + "stage": "First Reading", + "chamber": "House of Commons", + "published_on": "2026-04-27" + }, + "to": { + "id": "C-8-v3", + "label": "As passed by the House", + "stage": "Third Reading", + "chamber": "House of Commons", + "published_on": "2026-06-04" + }, + "clauses": [ + { + "id": "clause-3", + "label": "Clause 3", + "change_type": "added", + "from_text": "", + "to_text": "The Minister shall publish quarterly progress reports to Parliament.", + "hansard_anchor_url": "https://www.ourcommons.ca/DocumentViewer/en/45-1/house/sitting-42/hansard#clause-3" + } + ] + } + } + } + } + } + }, + "204": { "description": "Backend has no diff available for the requested version pair (text missing or diff job not run yet)." }, + "404": { "$ref": "#/components/responses/Error" }, + "429": { "$ref": "#/components/responses/RateLimit" }, + "500": { "$ref": "#/components/responses/Error" }, + "503": { "$ref": "#/components/responses/Error" } + } + } + }, "/api/v1/bills/{legisinfo_id}/lobbying-context": { "get": { "tags": ["Bills", "Lobbying"], @@ -2641,6 +2717,44 @@ "source_url": { "type": "string", "format": "uri" } } }, + "BillVersionDiff": { + "type": "object", + "required": ["from", "to", "clauses"], + "properties": { + "from": { "$ref": "#/components/schemas/BillVersion" }, + "to": { "$ref": "#/components/schemas/BillVersion" }, + "clauses": { + "type": "array", + "items": { "$ref": "#/components/schemas/BillClauseDiff" } + } + } + }, + "BillClauseDiff": { + "type": "object", + "required": ["change_type"], + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" }, + "change_type": { + "type": "string", + "enum": ["added", "removed", "modified", "unchanged"], + "description": "Kind of change the clause represents. Synonyms ('inserted', 'deleted', 'replaced', 'context', etc.) are normalised by the iOS adapter." + }, + "from_text": { + "type": "string", + "description": "Verbatim clause text from the 'before' version. Empty for purely added clauses." + }, + "to_text": { + "type": "string", + "description": "Verbatim clause text from the 'after' version. Empty for purely removed clauses." + }, + "hansard_anchor_url": { + "type": "string", + "format": "uri", + "description": "Hansard anchor for the chamber speech that introduced the change, when known." + } + } + }, "BillAmendment": { "type": "object", "properties": { diff --git a/docs/architecture/use-case-catalog.md b/docs/architecture/use-case-catalog.md index d7d05dfe..b8f7adbe 100644 --- a/docs/architecture/use-case-catalog.md +++ b/docs/architecture/use-case-catalog.md @@ -30,7 +30,9 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/` | `Bill` | A Parliament of Canada bill with number, title, stage, sponsor, Royal Assent date when available, and LEGISinfo source URL. | | `BillCommitteeStage` | A bill's active committee study stage, carrying the committee, study dates, upcoming meetings, and past meetings. | | `BillCommitteeMeeting` | A committee meeting tied to a bill study, including meeting number, date, optional evidence URL, and witness count. | -| `BillVersion` | Backend-only bill publication/version row with source links for text, PDF, and XML artifacts. | +| `BillVersion` | A published version of a bill (e.g. "First reading", "As passed by the House") with label, stage, chamber, publication date, and source URL. Surfaced on the iOS bill page via `LoadBillVersions` to drive the "Compare versions" picker. | +| `BillVersionDiff` | A clause-level diff between two published bill versions, carrying the `fromVersion`/`toVersion` metadata and an ordered list of `BillClauseDiff` entries. Surfaced on the iOS diff viewer via `LoadBillVersionDiff`. | +| `BillClauseDiff` | One clause within a `BillVersionDiff`: label, change type (added/removed/modified/unchanged), verbatim before/after text, and optional Hansard anchor for the chamber speech that introduced the change. | | `BillAmendment` | House or committee amendment record associated with a bill and chamber stage, with number, sponsor name, status, stage, verbatim amendment text, and source link. Surfaced on the iOS bill page via `LoadBillAmendments`. | | `PBOCosting` | Parliamentary Budget Officer independent costing note linked to a bill, with verbatim headline figure (5-year cost in millions), methodology category, publication date, report PDF URL, and optional summary text. Surfaced on the iOS bill page via `LoadPBOCosting`. | | `ParliamentaryCommittee` | A House or Senate committee reference with acronym, name, chamber code, and authoritative source URL. | @@ -102,6 +104,8 @@ to the issue that will build the missing artifact. | `BillRepository` | backend Go | outbound | Implemented: `backend/bills/internal/usecase/bills.go`; adapter: `backend/bills/internal/adapter/sqlite/repository.go`. | List bills and load bill-depth rows from the verified bills SQLite artifact. | | `BillCommitteeStageRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillCommitteeStageRepository.swift`; adapter: `ios/epac/Data/Repositories/BackendBillCommitteeStageRepository.swift`. | Load the committee currently studying a bill, including study dates and meeting rows. | | `BillAmendmentsRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillAmendmentsRepository.swift`; adapter: `ios/epac/Data/Repositories/BackendBillAmendmentsRepository.swift`. | Load amendments tabled against a bill (number, mover, stage, status, verbatim text) from the backend bill-depth endpoint. | +| `BillVersionsRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillVersionsRepository.swift`; adapter: `ios/epac/Data/Repositories/BackendBillVersionsRepository.swift`. | Load the published versions of a bill (label, stage, chamber, publication date, source URL) from the backend bill-depth endpoint. | +| `BillVersionDiffRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillVersionDiffRepository.swift`; adapter: `ios/epac/Data/Repositories/BackendBillVersionDiffRepository.swift`. | Load a clause-level diff between two published bill versions from the backend `GET /api/v1/bills/{id}/diff?from=…&to=…` endpoint. | | `PBOCostingQueryPort` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/PBOCostingQueryPort.swift`; adapter: `ios/epac/Data/Repositories/BackendPBOCostingRepository.swift`. | Load Parliamentary Budget Officer costing notes linked to a bill from the backend `GET /pbo/by-bill/{legisinfo_id}` endpoint; `nil` when no PBO link record exists. | | `RecentLawQueryPort` | iOS Swift | outbound | Implemented by `ios/epac/Application/TrackRoyalAssent.swift`; adapter input is `BillRepository`. | Query current-session bills that received Royal Assent within the recent-law window. | | `MemberRepository` | backend Go | outbound | Implemented: `backend/members/internal/usecase/members.go`; adapter: `backend/members/internal/adapter/sqlite/repository.go`. | List members and load member-profile attendance rows from the verified members SQLite artifact. | @@ -690,6 +694,51 @@ Current implementation: --- +### LoadBillVersions + +``` +Actor: User (iOS app, bill detail) +Goal: Pick which two published versions of a bill to compare in the diff viewer (e.g. "First reading" vs. "As passed by the House"). +Inputs: LEGISinfo bill ID. +Outputs: Optional [BillVersion]; nil when the backend has no versions record for the bill (hide the "Compare versions" entry point), empty array when the bill is tracked but no version text has been ingested yet (same UI treatment), single-element array when only one version exists (the diff viewer renders an empty state), multi-element array otherwise. +Entities / values: Bill, BillVersion. +Ports: iOS Swift: `BillVersionsRepository`. +Primary adapters: BackendBillVersionsRepository (GET /api/v1/bills/{id}), BillDetailView, BillVersionsDiffView. +Current implementation: + ios/epac/Application/LoadBillVersions.swift + ios/epac/Domain/Entities/BillVersion.swift + ios/epac/Domain/Ports/BillVersionsRepository.swift + ios/epac/Data/Repositories/BackendBillVersionsRepository.swift + ios/epac/Views/Bills/BillVersionsDiffView.swift + ios/epac/Views/Bills/BillDetailView.swift +``` + +> **Boundary rule:** Version metadata is reproduced verbatim from the backend's typed JSON. The iOS layer never parses LEGISinfo or parl.ca HTML/XML wire formats; published-version ingestion is a backend responsibility. The bill page hides the "Compare versions" entry point when the use case returns `nil` or an empty array, and the diff viewer renders an empty state when only one version has been published. + +--- + +### LoadBillVersionDiff + +``` +Actor: User (iOS app, bill diff viewer) +Goal: See what changed at the clause level between two published versions of a bill (additions, deletions, modifications) with the verbatim before/after clause text, and follow the change back to the chamber speech that introduced it when known. +Inputs: LEGISinfo bill ID, "before" version ID, "after" version ID. +Outputs: Optional BillVersionDiff; nil when the backend cannot produce a diff for the requested version pair (either version is missing text, or the diff job has not run yet) — the diff viewer renders an unavailable state. +Entities / values: Bill, BillVersion, BillVersionDiff, BillClauseDiff, BillClauseChangeType. +Ports: iOS Swift: `BillVersionDiffRepository`. +Primary adapters: BackendBillVersionDiffRepository (GET /api/v1/bills/{id}/diff?from=…&to=…), BillVersionsDiffView. +Current implementation: + ios/epac/Application/LoadBillVersionDiff.swift + ios/epac/Domain/Entities/BillVersionDiff.swift + ios/epac/Domain/Ports/BillVersionDiffRepository.swift + ios/epac/Data/Repositories/BackendBillVersionDiffRepository.swift + ios/epac/Views/Bills/BillVersionsDiffView.swift +``` + +> **Boundary rule:** The clause-aware diff algorithm lives in the backend — respecting clause/sub-clause structure is a use-case policy enforced on the backend side; iOS only renders the structured result. Clause text is rendered verbatim; the diff viewer never paraphrases or summarises the change with generated text. Hansard anchors are surfaced when the backend has them and omitted otherwise — the view does not invent them. + +--- + ### TagPrivateMembersBill ``` diff --git a/ios/epac/Application/LoadBillVersionDiff.swift b/ios/epac/Application/LoadBillVersionDiff.swift new file mode 100644 index 00000000..bd1cf24a --- /dev/null +++ b/ios/epac/Application/LoadBillVersionDiff.swift @@ -0,0 +1,21 @@ +/// Loads the structured clause-level diff between two published bill versions. +/// +/// Returns `nil` when the backend cannot produce a diff for the requested pair +/// — the diff viewer renders an unavailable state in that case. The clause- +/// aware diff algorithm lives in the backend; this use case is the application +/// policy boundary that the iOS view sits behind. +struct LoadBillVersionDiff: Sendable { + let repository: any BillVersionDiffRepository + + func execute( + billID: String, + fromVersionID: String, + toVersionID: String + ) async throws -> BillVersionDiff? { + try await repository.loadBillVersionDiff( + billID: billID, + fromVersionID: fromVersionID, + toVersionID: toVersionID + ) + } +} diff --git a/ios/epac/Application/LoadBillVersions.swift b/ios/epac/Application/LoadBillVersions.swift new file mode 100644 index 00000000..ada43d2c --- /dev/null +++ b/ios/epac/Application/LoadBillVersions.swift @@ -0,0 +1,13 @@ +/// Loads the published versions of one bill. +/// +/// Returns `nil` when the backend has no versions record for the bill — the +/// caller hides the "Compare versions" entry point in that case. An empty +/// array gets the same UI treatment. A single-element array means only one +/// version has been ingested and the diff viewer's empty state applies. +struct LoadBillVersions: Sendable { + let repository: any BillVersionsRepository + + func execute(billID: String) async throws -> [BillVersion]? { + try await repository.loadBillVersions(billID: billID) + } +} diff --git a/ios/epac/Data/Repositories/BackendBillVersionDiffRepository.swift b/ios/epac/Data/Repositories/BackendBillVersionDiffRepository.swift new file mode 100644 index 00000000..30a05b87 --- /dev/null +++ b/ios/epac/Data/Repositories/BackendBillVersionDiffRepository.swift @@ -0,0 +1,203 @@ +import Foundation + +/// Loads a clause-level diff between two published bill versions from the +/// epac backend. +/// +/// Contract — `GET /api/v1/bills/{id}/diff?from={fromVersionID}&to={toVersionID}`: +/// - `200`: diff JSON (see `BillVersionDiffResponse`). The backend has already +/// run the clause-aware diff against the published version text, so the iOS +/// side only decodes and renders. +/// - `204` / `404`: backend cannot produce a diff for the requested pair (one +/// of the versions is missing text, or the diff job has not run for that +/// pair yet); the diff viewer renders an unavailable state. +/// +/// The iOS layer never parses LEGISinfo or parl.ca wire formats; this typed +/// JSON shape is the boundary between backend and app, and the clause-aware +/// diff algorithm lives in the backend. +struct BackendBillVersionDiffRepository: BillVersionDiffRepository { + fileprivate enum Constants { + static let requestTimeout: TimeInterval = 25 + static let successStatusLowerBound = 200 + static let successStatusUpperBound = 300 + static let noContentStatus = 204 + static let notFoundStatus = 404 + static let pathPrefix = "api/v1/bills" + static let pathSuffix = "diff" + static let fromQueryItem = "from" + static let toQueryItem = "to" + + static var successStatusCodes: Range { + successStatusLowerBound.. BillVersionDiff? { + guard let url = makeURL( + billID: billID, + fromVersionID: fromVersionID, + toVersionID: toVersionID + ) else { + return nil + } + guard let data = try await get(url: url) else { + return nil + } + return try decoder.decode(BillVersionDiffResponse.self, from: data).domain + } + + private func makeURL( + billID: String, + fromVersionID: String, + toVersionID: String + ) -> URL? { + let basePath = baseURL.appending(path: "\(Constants.pathPrefix)/\(billID)/\(Constants.pathSuffix)") + guard var components = URLComponents(url: basePath, resolvingAgainstBaseURL: false) else { + return nil + } + components.queryItems = [ + URLQueryItem(name: Constants.fromQueryItem, value: fromVersionID), + URLQueryItem(name: Constants.toQueryItem, value: toVersionID) + ] + return components.url + } + + private func get(url: URL) async throws -> Data? { + var request = URLRequest(url: url, timeoutInterval: Constants.requestTimeout) + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, response) = try await network.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + if http.statusCode == Constants.noContentStatus || http.statusCode == Constants.notFoundStatus { + return nil + } + guard Constants.successStatusCodes.contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + return data + } + + fileprivate static func parseDate(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return dateFormatter.date(from: value) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} + +// MARK: - Backend JSON contract + +private struct BillVersionDiffResponse: Decodable { + let from: VersionDTO + let to: VersionDTO + let clauses: [ClauseDiffDTO] + + enum CodingKeys: String, CodingKey { + case from + case to + case clauses + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + from = try container.decode(VersionDTO.self, forKey: .from) + to = try container.decode(VersionDTO.self, forKey: .to) + clauses = try container.decodeIfPresent([ClauseDiffDTO].self, forKey: .clauses) ?? [] + } + + var domain: BillVersionDiff { + BillVersionDiff( + fromVersion: from.domain, + toVersion: to.domain, + clauseDiffs: clauses.map(\.domain) + ) + } +} + +private struct VersionDTO: Decodable { + let id: String? + let label: String? + let title: String? + let stage: String? + let chamber: String? + let publishedOn: String? + let sourceURL: URL? + + enum CodingKeys: String, CodingKey { + case id + case label + case title + case stage + case chamber + case publishedOn = "published_on" + case sourceURL = "source_url" + } + + var domain: BillVersion { + BillVersion( + id: id ?? label ?? UUID().uuidString, + label: label ?? "", + title: title?.isEmpty == false ? title : nil, + stage: stage?.isEmpty == false ? stage : nil, + chamber: chamber?.isEmpty == false ? chamber : nil, + publishedOn: BackendBillVersionDiffRepository.parseDate(publishedOn), + sourceURL: sourceURL + ) + } +} + +private struct ClauseDiffDTO: Decodable { + let id: String? + let label: String? + let changeType: String? + let fromText: String? + let toText: String? + let hansardAnchorURL: URL? + + enum CodingKeys: String, CodingKey { + case id + case label + case changeType = "change_type" + case fromText = "from_text" + case toText = "to_text" + case hansardAnchorURL = "hansard_anchor_url" + } + + var domain: BillClauseDiff { + let resolvedLabel = label ?? "" + let resolvedID = id ?? (resolvedLabel.isEmpty ? UUID().uuidString : resolvedLabel) + return BillClauseDiff( + id: resolvedID, + label: resolvedLabel, + changeType: BillClauseChangeType.from(changeType ?? ""), + fromText: fromText ?? "", + toText: toText ?? "", + hansardAnchorURL: hansardAnchorURL + ) + } +} diff --git a/ios/epac/Data/Repositories/BackendBillVersionsRepository.swift b/ios/epac/Data/Repositories/BackendBillVersionsRepository.swift new file mode 100644 index 00000000..8b4d0f1f --- /dev/null +++ b/ios/epac/Data/Repositories/BackendBillVersionsRepository.swift @@ -0,0 +1,133 @@ +import Foundation + +/// Loads a bill's published-versions list from the epac backend. +/// +/// Contract — `GET /api/v1/bills/{id}` (bill-depth endpoint): +/// - `200`: bill JSON whose `versions` field carries the list (see +/// `BillDepthVersionsResponse`). The backend has already ingested LEGISinfo +/// published versions into the bill artifact, so the iOS side only decodes +/// and renders. +/// - `204` / `404`: backend has no record for that bill; the "Compare +/// versions" entry point hides. +/// +/// The iOS layer never parses LEGISinfo or parl.ca wire formats; this typed +/// JSON shape is the boundary between backend and app. +struct BackendBillVersionsRepository: BillVersionsRepository { + fileprivate enum Constants { + static let requestTimeout: TimeInterval = 20 + static let successStatusLowerBound = 200 + static let successStatusUpperBound = 300 + static let noContentStatus = 204 + static let notFoundStatus = 404 + static let pathPrefix = "api/v1/bills" + + static var successStatusCodes: Range { + successStatusLowerBound.. [BillVersion]? { + let path = "\(Constants.pathPrefix)/\(billID)" + guard let data = try await get(path: path) else { + return nil + } + let response = try decoder.decode(BillDepthVersionsResponse.self, from: data) + return response.bill.versions.map(\.domain) + } + + private func get(path: String) async throws -> Data? { + let url = baseURL.appending(path: path) + var request = URLRequest(url: url, timeoutInterval: Constants.requestTimeout) + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, response) = try await network.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + if http.statusCode == Constants.noContentStatus || http.statusCode == Constants.notFoundStatus { + return nil + } + guard Constants.successStatusCodes.contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + return data + } + + fileprivate static func parseDate(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return dateFormatter.date(from: value) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} + +// MARK: - Backend JSON contract + +private struct BillDepthVersionsResponse: Decodable { + let bill: BillDTO +} + +private struct BillDTO: Decodable { + let versions: [VersionDTO] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + versions = try container.decodeIfPresent([VersionDTO].self, forKey: .versions) ?? [] + } + + enum CodingKeys: String, CodingKey { + case versions + } +} + +private struct VersionDTO: Decodable { + let id: String? + let label: String? + let title: String? + let stage: String? + let chamber: String? + let publishedOn: String? + let sourceURL: URL? + + enum CodingKeys: String, CodingKey { + case id + case label + case title + case stage + case chamber + case publishedOn = "published_on" + case sourceURL = "source_url" + } + + var domain: BillVersion { + BillVersion( + id: id ?? label ?? UUID().uuidString, + label: label ?? "", + title: title?.isEmpty == false ? title : nil, + stage: stage?.isEmpty == false ? stage : nil, + chamber: chamber?.isEmpty == false ? chamber : nil, + publishedOn: BackendBillVersionsRepository.parseDate(publishedOn), + sourceURL: sourceURL + ) + } +} diff --git a/ios/epac/Domain/Entities/BillVersion.swift b/ios/epac/Domain/Entities/BillVersion.swift new file mode 100644 index 00000000..8329254d --- /dev/null +++ b/ios/epac/Domain/Entities/BillVersion.swift @@ -0,0 +1,36 @@ +import Foundation + +/// One published version of a bill, e.g. "First reading" or "As passed by the +/// House". Sourced from LEGISinfo by the epac backend; the iOS layer never +/// scrapes LEGISinfo directly. +/// +/// Civic-content rule: `label`, `chamber`, and `stage` are reproduced verbatim +/// from the backend, which in turn carries the wording Parliament publishes. +/// The diff viewer never paraphrases or summarizes these values. +struct BillVersion: Identifiable, Equatable, Sendable { + /// Stable identifier from the backend artifact (e.g. "C-8-v1"). Used as the + /// `from=` / `to=` argument when requesting a diff. + let id: String + + /// Human-readable label as published by Parliament, e.g. "First reading", + /// "Third reading", "As passed by the House". + let label: String + + /// Optional short title carried by the backend record. Some upstream rows + /// only carry a label, so this is a courtesy display field. + let title: String? + + /// Legislative stage at which the version was published, verbatim from the + /// backend (e.g. "First Reading", "Third Reading"). + let stage: String? + + /// Originating chamber for the version, verbatim from the backend + /// (typically "House of Commons" or "Senate"). + let chamber: String? + + /// When the version was published, if known. + let publishedOn: Date? + + /// Source link on parl.ca / LEGISinfo when the backend has one. + let sourceURL: URL? +} diff --git a/ios/epac/Domain/Entities/BillVersionDiff.swift b/ios/epac/Domain/Entities/BillVersionDiff.swift new file mode 100644 index 00000000..b8b292c0 --- /dev/null +++ b/ios/epac/Domain/Entities/BillVersionDiff.swift @@ -0,0 +1,80 @@ +import Foundation + +/// A structured clause-level diff between two published bill versions. +/// +/// The diff respects the clause/sub-clause structure published by Parliament — +/// it is not a raw line-by-line text diff. The backend computes the diff and +/// emits one `BillClauseDiff` per clause that differs between the two +/// versions, with optional `unchanged` clauses for context when the backend +/// includes them. +/// +/// Civic-content rule: every `fromText`/`toText` value is the authoritative +/// clause text. The view renders text verbatim — no LLM summaries. +struct BillVersionDiff: Equatable, Sendable { + /// Version the diff is computed against (the "before" side). + let fromVersion: BillVersion + + /// Version the diff is computed against (the "after" side). + let toVersion: BillVersion + + /// Clause-level diffs in the order Parliament publishes the clauses, from + /// the start of the bill onwards. + let clauseDiffs: [BillClauseDiff] +} + +/// One clause within a bill diff, carrying the change kind, the verbatim +/// before/after clause text, and an optional anchor back to the chamber speech +/// that introduced the change (when the backend knows it). +struct BillClauseDiff: Identifiable, Equatable, Sendable { + /// Stable identifier from the backend (typically the clause anchor, e.g. + /// "clause-3-1"). Falls back to a synthesised string if the backend omits. + let id: String + + /// Human-readable label as published by Parliament (e.g. "Clause 3", + /// "Subclause 5(2)"). + let label: String + + /// Change kind for the clause. + let changeType: BillClauseChangeType + + /// Verbatim clause text from the "before" version. Empty for purely + /// added clauses. + let fromText: String + + /// Verbatim clause text from the "after" version. Empty for purely + /// removed clauses. + let toText: String + + /// Anchor URL into Hansard for the chamber speech that introduced the + /// change, when the backend has one. The bill page links the clause row + /// out to this URL. + let hansardAnchorURL: URL? +} + +/// Kind of change a `BillClauseDiff` represents. +/// +/// `unchanged` is included for backend records that emit context clauses — +/// the view collapses unchanged clauses by default. +enum BillClauseChangeType: String, Equatable, Sendable { + case added + case removed + case modified + case unchanged + + /// Map a raw backend change-type string onto a known case. Unknown values + /// collapse to `.modified` (the safer "treat as a change" default). + static func from(_ rawValue: String) -> BillClauseChangeType { + switch rawValue.lowercased() { + case "added", "insert", "inserted", "new": + return .added + case "removed", "deleted", "delete": + return .removed + case "unchanged", "same", "context": + return .unchanged + case "modified", "changed", "replace", "replaced": + return .modified + default: + return .modified + } + } +} diff --git a/ios/epac/Domain/Ports/BillVersionDiffRepository.swift b/ios/epac/Domain/Ports/BillVersionDiffRepository.swift new file mode 100644 index 00000000..d27a3a75 --- /dev/null +++ b/ios/epac/Domain/Ports/BillVersionDiffRepository.swift @@ -0,0 +1,16 @@ +/// Reads a clause-level diff between two published bill versions. +/// +/// `nil` means the backend cannot produce a diff for the requested version +/// pair (either version is missing text, or the diff job has not run yet for +/// that pair) — the diff viewer renders an unavailable state. Throws only on +/// transport or decoding failures. +/// +/// The clause-aware diff algorithm lives in the backend; iOS only renders the +/// structured result. +protocol BillVersionDiffRepository: Sendable { + func loadBillVersionDiff( + billID: String, + fromVersionID: String, + toVersionID: String + ) async throws -> BillVersionDiff? +} diff --git a/ios/epac/Domain/Ports/BillVersionsRepository.swift b/ios/epac/Domain/Ports/BillVersionsRepository.swift new file mode 100644 index 00000000..bf9b20e6 --- /dev/null +++ b/ios/epac/Domain/Ports/BillVersionsRepository.swift @@ -0,0 +1,11 @@ +/// Reads the published versions of a bill from the backend. +/// +/// `nil` means the backend has no versions record for the bill at all (404 +/// or 204) — the bill page hides the "Compare versions" entry point. An +/// empty array means "bill is tracked but no version text has been ingested +/// yet" — same UI treatment as `nil`. A single-element array means only one +/// version has been ingested, and the diff viewer shows an empty state. +/// Throws only on transport or decoding failures. +protocol BillVersionsRepository: Sendable { + func loadBillVersions(billID: String) async throws -> [BillVersion]? +} diff --git a/ios/epac/Views/Bills/BillDetailView.swift b/ios/epac/Views/Bills/BillDetailView.swift index f70eb8d6..ced4dded 100644 --- a/ios/epac/Views/Bills/BillDetailView.swift +++ b/ios/epac/Views/Bills/BillDetailView.swift @@ -32,10 +32,13 @@ struct BillDetailView: View { private let loadBillCommitteeStage: LoadBillCommitteeStage private let loadBillAmendments: LoadBillAmendments private let loadPBOCosting: LoadPBOCosting + private let loadBillVersions: LoadBillVersions + private let loadBillVersionDiff: LoadBillVersionDiff private let autoloadLobbyingContext: Bool private let autoloadCommitteeStage: Bool private let autoloadAmendments: Bool private let autoloadPBOCosting: Bool + private let autoloadVersions: Bool @State private var billStore = BillFollowStore.shared @State private var matchingVotes: [RecordedVote] = [] @@ -44,6 +47,8 @@ struct BillDetailView: View { @State private var committeeStage: BillCommitteeStage? @State private var amendments: [BillAmendment]? @State private var pboCosting: PBOCostingResult? + @State private var versions: [BillVersion]? + @State private var isShowingDiffSheet = false @State private var shareItem: ActivityItem? @State private var myMP: ParliamentMember? @State private var sponsorMember: ParliamentMember? @@ -62,20 +67,30 @@ struct BillDetailView: View { loadPBOCosting: LoadPBOCosting = LoadPBOCosting( queryPort: BackendPBOCostingRepository() ), + loadBillVersions: LoadBillVersions = LoadBillVersions( + repository: BackendBillVersionsRepository() + ), + loadBillVersionDiff: LoadBillVersionDiff = LoadBillVersionDiff( + repository: BackendBillVersionDiffRepository() + ), autoloadLobbyingContext: Bool = true, autoloadCommitteeStage: Bool = true, autoloadAmendments: Bool = true, - autoloadPBOCosting: Bool = true + autoloadPBOCosting: Bool = true, + autoloadVersions: Bool = true ) { self.bill = bill self.loadBillLobbyingContext = loadBillLobbyingContext self.loadBillCommitteeStage = loadBillCommitteeStage self.loadBillAmendments = loadBillAmendments self.loadPBOCosting = loadPBOCosting + self.loadBillVersions = loadBillVersions + self.loadBillVersionDiff = loadBillVersionDiff self.autoloadLobbyingContext = autoloadLobbyingContext self.autoloadCommitteeStage = autoloadCommitteeStage self.autoloadAmendments = autoloadAmendments self.autoloadPBOCosting = autoloadPBOCosting + self.autoloadVersions = autoloadVersions } var body: some View { @@ -181,6 +196,18 @@ struct BillDetailView: View { myMP: myMP, template: ContactMyMP.billTemplate(bill: bill) ) + if let versions, !versions.isEmpty { + Button { + isShowingDiffSheet = true + } label: { + Label( + NSLocalizedString("billDiff.entry", comment: ""), + systemImage: "doc.on.doc" + ) + } + .foregroundStyle(Color.accentColor) + .accessibilityIdentifier("bill-detail-compare-versions") + } Link(NSLocalizedString("bills.detail.legisinfo", comment: ""), destination: bill.legisInfoURL) .foregroundStyle(Color.accentColor) @@ -231,9 +258,18 @@ struct BillDetailView: View { await loadCommitteeStage() await loadAmendments() await loadLobbyingContext() + await loadVersions() BillFollowStore.shared.markAsRead(bill.number) } .activitySheet($shareItem) + .sheet(isPresented: $isShowingDiffSheet) { + BillVersionsDiffView( + billNumber: bill.number, + billID: bill.id, + versions: versions ?? [], + loadBillVersionDiff: loadBillVersionDiff + ) + } } private var billHeaderSection: some View { @@ -498,6 +534,17 @@ struct BillDetailView: View { pboCosting = nil } } + + @MainActor + private func loadVersions() async { + guard autoloadVersions else { return } + + do { + versions = try await loadBillVersions.execute(billID: bill.id) + } catch { + versions = nil + } + } } private enum BillTimelineStageState: Equatable { diff --git a/ios/epac/Views/Bills/BillVersionsDiffView.swift b/ios/epac/Views/Bills/BillVersionsDiffView.swift new file mode 100644 index 00000000..f05c5819 --- /dev/null +++ b/ios/epac/Views/Bills/BillVersionsDiffView.swift @@ -0,0 +1,517 @@ +import SwiftUI + +private enum BillVersionsDiffLayout { + static let rowSpacing = EpacSpacing.xs + static let badgePaddingH: CGFloat = 6 + static let badgePaddingV: CGFloat = 3 + static let textPaddingTop: CGFloat = 4 + static let columnSpacing: CGFloat = 12 + static let dividerWidth: CGFloat = 1 + static let pillCorner: CGFloat = 4 + static let highlightOpacity: Double = 0.18 + + /// A diff requires at least one "before" and one "after" version. Below + /// this threshold the diff viewer renders an empty state and the + /// view-mode picker is hidden. + static let minimumVersionsForDiff = 2 +} + +/// Display mode for the diff list — toggleable from the toolbar. +enum BillVersionsDiffViewMode: String, CaseIterable, Identifiable, Sendable { + case inline + case sideBySide + + var id: String { rawValue } + + var displayName: String { + switch self { + case .inline: + return NSLocalizedString("billDiff.mode.inline", comment: "") + case .sideBySide: + return NSLocalizedString("billDiff.mode.sideBySide", comment: "") + } + } +} + +/// "Compare versions" sheet for a bill. +/// +/// Renders one row per clause-level diff between the two selected versions, +/// with additions / deletions / modifications highlighted, an inline ↔ side- +/// by-side toggle, and a tap-through to the Hansard speech that introduced +/// each change when the backend has the anchor. Verbatim text only — there is +/// no LLM summary anywhere on the surface. +/// +/// Three structural states drive the UI: +/// - `nil` versions: backend has no version record at all — the bill page +/// hides the entry point before this view is ever instantiated. +/// - `versions.count <= 1`: empty-state row explaining only one version +/// has been published. +/// - `versions.count >= 2`: pickers to choose `from`/`to`, then the diff. +struct BillVersionsDiffView: View { + let billNumber: String + let versions: [BillVersion] + let loadBillVersionDiff: LoadBillVersionDiff + let billID: String + + @State private var fromVersionID: String + @State private var toVersionID: String + @State private var viewMode: BillVersionsDiffViewMode = .inline + @State private var diff: BillVersionDiff? + @State private var isLoading = false + @State private var loadFailed = false + + @Environment(\.dismiss) private var dismiss + + init( + billNumber: String, + billID: String, + versions: [BillVersion], + loadBillVersionDiff: LoadBillVersionDiff = LoadBillVersionDiff( + repository: BackendBillVersionDiffRepository() + ) + ) { + self.billNumber = billNumber + self.billID = billID + self.versions = versions + self.loadBillVersionDiff = loadBillVersionDiff + + let sorted = Self.sortVersions(versions) + self._fromVersionID = State(initialValue: sorted.first?.id ?? "") + self._toVersionID = State(initialValue: sorted.last?.id ?? "") + } + + var body: some View { + NavigationStack { + content + .navigationTitle(billNumber) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(NSLocalizedString("billDiff.close", comment: "")) { + dismiss() + } + } + if versions.count >= BillVersionsDiffLayout.minimumVersionsForDiff { + ToolbarItem(placement: .topBarTrailing) { + Picker(NSLocalizedString("billDiff.mode.picker", comment: ""), selection: $viewMode) { + ForEach(BillVersionsDiffViewMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("bill-diff-mode-picker") + } + } + } + } + } + + @ViewBuilder + private var content: some View { + if versions.count < BillVersionsDiffLayout.minimumVersionsForDiff { + emptyState + } else { + List { + versionPickersSection + diffSection + sourceSection + } + .listStyle(.insetGrouped) + .task(id: diffTaskKey) { await loadDiff() } + } + } + + private var diffTaskKey: String { + "\(fromVersionID)::\(toVersionID)" + } + + private var versionPickersSection: some View { + Section(NSLocalizedString("billDiff.compare.title", comment: "")) { + Picker(NSLocalizedString("billDiff.from", comment: ""), selection: $fromVersionID) { + ForEach(versions) { version in + Text(displayLabel(for: version)).tag(version.id) + } + } + .accessibilityIdentifier("bill-diff-from-picker") + + Picker(NSLocalizedString("billDiff.to", comment: ""), selection: $toVersionID) { + ForEach(versions) { version in + Text(displayLabel(for: version)).tag(version.id) + } + } + .accessibilityIdentifier("bill-diff-to-picker") + } + } + + @ViewBuilder + private var diffSection: some View { + if isLoading { + Section { + HStack { + Spacer() + ProgressView() + .accessibilityIdentifier("bill-diff-loading") + Spacer() + } + } + } else if loadFailed { + Section { + Text(NSLocalizedString("billDiff.error", comment: "")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("bill-diff-error") + } + } else if let diff { + if diff.clauseDiffs.isEmpty { + Section { + Text(NSLocalizedString("billDiff.noChanges", comment: "")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("bill-diff-no-changes") + } + } else { + Section(NSLocalizedString("billDiff.changes.title", comment: "")) { + ForEach(diff.clauseDiffs) { clause in + BillClauseDiffRow( + clause: clause, + viewMode: viewMode + ) + .accessibilityIdentifier("bill-diff-row") + } + } + } + } + } + + private var sourceSection: some View { + Section { + Text(NSLocalizedString("billDiff.source", comment: "")) + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityIdentifier("bill-diff-source") + } + } + + private var emptyState: some View { + VStack(spacing: EpacSpacing.m) { + Spacer() + Image(systemName: "doc.text.magnifyingglass") + .font(.largeTitle) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(NSLocalizedString("billDiff.empty.title", comment: "")) + .font(.headline) + .multilineTextAlignment(.center) + Text(NSLocalizedString("billDiff.empty.body", comment: "")) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, EpacSpacing.l) + Spacer() + } + .frame(maxWidth: .infinity) + .accessibilityIdentifier("bill-diff-empty") + } + + private func displayLabel(for version: BillVersion) -> String { + var parts: [String] = [] + if !version.label.isEmpty { + parts.append(version.label) + } + if let published = version.publishedOn { + parts.append(published.billVersionDateText) + } + if parts.isEmpty { + return version.id + } + return parts.joined(separator: " — ") + } + + @MainActor + private func loadDiff() async { + guard !fromVersionID.isEmpty, !toVersionID.isEmpty else { + diff = nil + return + } + isLoading = true + loadFailed = false + defer { isLoading = false } + do { + diff = try await loadBillVersionDiff.execute( + billID: billID, + fromVersionID: fromVersionID, + toVersionID: toVersionID + ) + loadFailed = diff == nil + } catch { + diff = nil + loadFailed = true + } + } + + private static func sortVersions(_ versions: [BillVersion]) -> [BillVersion] { + versions.sorted { lhs, rhs in + switch (lhs.publishedOn, rhs.publishedOn) { + case let (l?, r?): + return l < r + case (nil, _?): + return true + case (_?, nil): + return false + case (nil, nil): + return lhs.label < rhs.label + } + } + } +} + +/// Pure-rendering surface for an already-loaded diff. Lets snapshot tests +/// exercise the diff list without going through the async loader on +/// `BillVersionsDiffView`. +struct BillVersionsDiffContent: View { + let diff: BillVersionDiff + let viewMode: BillVersionsDiffViewMode + + var body: some View { + List { + if diff.clauseDiffs.isEmpty { + Section { + Text(NSLocalizedString("billDiff.noChanges", comment: "")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("bill-diff-no-changes") + } + } else { + Section(NSLocalizedString("billDiff.changes.title", comment: "")) { + ForEach(diff.clauseDiffs) { clause in + BillClauseDiffRow( + clause: clause, + viewMode: viewMode + ) + .accessibilityIdentifier("bill-diff-row") + } + } + } + + Section { + Text(NSLocalizedString("billDiff.source", comment: "")) + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityIdentifier("bill-diff-source") + } + } + .listStyle(.insetGrouped) + } +} + +/// One row inside the diff list. Shows the clause label, a change-type badge, +/// and either an inline before/after stack or a two-column side-by-side +/// presentation. Tapping the Hansard anchor link opens the chamber speech +/// that introduced the change when the backend records it. +struct BillClauseDiffRow: View { + let clause: BillClauseDiff + let viewMode: BillVersionsDiffViewMode + + var body: some View { + VStack(alignment: .leading, spacing: BillVersionsDiffLayout.rowSpacing) { + HStack(alignment: .firstTextBaseline, spacing: EpacSpacing.s) { + Text(clauseLabel) + .font(.subheadline.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: EpacSpacing.s) + BillClauseChangeBadge( + changeType: clause.changeType, + label: changeTypeLabel + ) + } + + switch viewMode { + case .inline: + inlineBody + case .sideBySide: + sideBySideBody + } + + if let hansardURL = clause.hansardAnchorURL { + Link( + NSLocalizedString("billDiff.openHansard", comment: ""), + destination: hansardURL + ) + .font(.caption) + .foregroundStyle(Color.accentColor) + .accessibilityIdentifier("bill-diff-hansard-link") + } + } + .padding(.vertical, BillVersionsDiffLayout.textPaddingTop) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + @ViewBuilder + private var inlineBody: some View { + switch clause.changeType { + case .added: + highlightedText(clause.toText, kind: .added) + case .removed: + highlightedText(clause.fromText, kind: .removed) + case .modified: + VStack(alignment: .leading, spacing: BillVersionsDiffLayout.rowSpacing) { + highlightedText(clause.fromText, kind: .removed) + highlightedText(clause.toText, kind: .added) + } + case .unchanged: + Text(clause.toText.isEmpty ? clause.fromText : clause.toText) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + + @ViewBuilder + private var sideBySideBody: some View { + HStack(alignment: .top, spacing: BillVersionsDiffLayout.columnSpacing) { + sideColumn( + title: NSLocalizedString("billDiff.column.before", comment: ""), + text: clause.fromText, + kind: .removed, + isEmpty: clause.changeType == .added + ) + Rectangle() + .fill(Color.appDivider) + .frame(width: BillVersionsDiffLayout.dividerWidth) + sideColumn( + title: NSLocalizedString("billDiff.column.after", comment: ""), + text: clause.toText, + kind: .added, + isEmpty: clause.changeType == .removed + ) + } + } + + private func sideColumn( + title: String, + text: String, + kind: BillClauseHighlightKind, + isEmpty: Bool + ) -> some View { + VStack(alignment: .leading, spacing: EpacSpacing.xxs) { + Text(title) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + if isEmpty || text.isEmpty { + Text(NSLocalizedString("billDiff.column.absent", comment: "")) + .font(.callout) + .italic() + .foregroundStyle(.secondary) + } else if clause.changeType == .unchanged { + Text(text) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } else { + highlightedText(text, kind: kind) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func highlightedText(_ text: String, kind: BillClauseHighlightKind) -> some View { + Text(text) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .padding(EpacSpacing.xs) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: BillVersionsDiffLayout.pillCorner) + .fill(kind.color.opacity(BillVersionsDiffLayout.highlightOpacity)) + ) + .accessibilityIdentifier("bill-diff-text-\(kind.identifierSuffix)") + } + + private var clauseLabel: String { + clause.label.isEmpty + ? NSLocalizedString("billDiff.clausePlaceholder", comment: "") + : clause.label + } + + private var changeTypeLabel: String { + switch clause.changeType { + case .added: + return NSLocalizedString("billDiff.changeType.added", comment: "") + case .removed: + return NSLocalizedString("billDiff.changeType.removed", comment: "") + case .modified: + return NSLocalizedString("billDiff.changeType.modified", comment: "") + case .unchanged: + return NSLocalizedString("billDiff.changeType.unchanged", comment: "") + } + } + + private var accessibilityLabel: String { + [clauseLabel, changeTypeLabel] + .filter { !$0.isEmpty } + .joined(separator: ", ") + } +} + +private enum BillClauseHighlightKind { + case added + case removed + + var color: Color { + switch self { + case .added: return .appPositive + case .removed: return .appDestructive + } + } + + var identifierSuffix: String { + switch self { + case .added: return "added" + case .removed: return "removed" + } + } +} + +private struct BillClauseChangeBadge: View { + let changeType: BillClauseChangeType + let label: String + + var body: some View { + Text(label) + .font(.caption2.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(.white) + .padding(.horizontal, BillVersionsDiffLayout.badgePaddingH) + .padding(.vertical, BillVersionsDiffLayout.badgePaddingV) + .background(colorFor(changeType), in: Capsule()) + .accessibilityHidden(true) + } + + private func colorFor(_ changeType: BillClauseChangeType) -> Color { + switch changeType { + case .added: return .appPositive + case .removed: return .appDestructive + case .modified: return .appWarning + case .unchanged: return .appNeutral + } + } +} + +private extension Date { + var billVersionDateText: String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = .current + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: self) + } +} diff --git a/ios/epac/en.lproj/Localizable.strings b/ios/epac/en.lproj/Localizable.strings index 25973152..75a95ac4 100644 --- a/ios/epac/en.lproj/Localizable.strings +++ b/ios/epac/en.lproj/Localizable.strings @@ -353,6 +353,31 @@ "billAmendments.openSource" = "Open on parl.ca"; "billAmendments.expand" = "Show amendment text"; "billAmendments.collapse" = "Hide amendment text"; + +/* Bill versions diff viewer (EPAC-962) */ +"billDiff.entry" = "Compare versions"; +"billDiff.close" = "Close"; +"billDiff.compare.title" = "Compare"; +"billDiff.from" = "From"; +"billDiff.to" = "To"; +"billDiff.changes.title" = "Changes"; +"billDiff.noChanges" = "No clause-level changes between these two versions."; +"billDiff.error" = "Couldn't load the diff for the selected versions. Try a different pair or try again later."; +"billDiff.source" = "Source: LEGISinfo published bill text on parl.ca"; +"billDiff.empty.title" = "Only one version published"; +"billDiff.empty.body" = "Parliament has only published one version of this bill so far. Once a second version is published, you'll be able to compare them here."; +"billDiff.mode.picker" = "View mode"; +"billDiff.mode.inline" = "Inline"; +"billDiff.mode.sideBySide" = "Side by side"; +"billDiff.column.before" = "Before"; +"billDiff.column.after" = "After"; +"billDiff.column.absent" = "Not present"; +"billDiff.changeType.added" = "Added"; +"billDiff.changeType.removed" = "Removed"; +"billDiff.changeType.modified" = "Modified"; +"billDiff.changeType.unchanged" = "Unchanged"; +"billDiff.clausePlaceholder" = "Clause"; +"billDiff.openHansard" = "Open chamber speech in Hansard"; "bills.chamber.house" = "House of Commons"; "bills.chamber.senate" = "Senate"; "bills.stage.houseFirst" = "House — First Reading"; diff --git a/ios/epac/fr.lproj/Localizable.strings b/ios/epac/fr.lproj/Localizable.strings index 8539518a..311d9f3d 100644 --- a/ios/epac/fr.lproj/Localizable.strings +++ b/ios/epac/fr.lproj/Localizable.strings @@ -355,6 +355,31 @@ "billAmendments.openSource" = "Ouvrir sur parl.ca"; "billAmendments.expand" = "Afficher le texte de l'amendement"; "billAmendments.collapse" = "Masquer le texte de l'amendement"; + +/* Visionneuse de différences entre versions (EPAC-962) */ +"billDiff.entry" = "Comparer les versions"; +"billDiff.close" = "Fermer"; +"billDiff.compare.title" = "Comparer"; +"billDiff.from" = "De"; +"billDiff.to" = "À"; +"billDiff.changes.title" = "Modifications"; +"billDiff.noChanges" = "Aucune modification au niveau des articles entre ces deux versions."; +"billDiff.error" = "Impossible de charger la comparaison pour les versions sélectionnées. Essayez une autre paire ou réessayez plus tard."; +"billDiff.source" = "Source : texte des projets de loi publié sur LEGISinfo (parl.ca)"; +"billDiff.empty.title" = "Une seule version publiée"; +"billDiff.empty.body" = "Le Parlement n'a publié qu'une seule version de ce projet de loi pour l'instant. Dès qu'une deuxième version sera publiée, vous pourrez les comparer ici."; +"billDiff.mode.picker" = "Mode d'affichage"; +"billDiff.mode.inline" = "En ligne"; +"billDiff.mode.sideBySide" = "Côte à côte"; +"billDiff.column.before" = "Avant"; +"billDiff.column.after" = "Après"; +"billDiff.column.absent" = "Absent"; +"billDiff.changeType.added" = "Ajouté"; +"billDiff.changeType.removed" = "Retiré"; +"billDiff.changeType.modified" = "Modifié"; +"billDiff.changeType.unchanged" = "Inchangé"; +"billDiff.clausePlaceholder" = "Article"; +"billDiff.openHansard" = "Ouvrir le discours dans le Hansard"; "bills.chamber.house" = "Chambre des communes"; "bills.chamber.senate" = "Sénat"; "bills.stage.houseFirst" = "Chambre — Première lecture"; diff --git a/ios/epacTests/Application/LoadBillVersionDiffTests.swift b/ios/epacTests/Application/LoadBillVersionDiffTests.swift new file mode 100644 index 00000000..36bd0204 --- /dev/null +++ b/ios/epacTests/Application/LoadBillVersionDiffTests.swift @@ -0,0 +1,116 @@ +@testable import epac +import Foundation +import Testing + +struct LoadBillVersionDiffTests { + @Test func executeReturnsDiffFromRepository() async throws { + let diff = Self.sampleDiff() + let repository = StubBillVersionDiffRepository(diff: diff) + let useCase = LoadBillVersionDiff(repository: repository) + + let result = try await useCase.execute( + billID: "C-8", + fromVersionID: "C-8-v1", + toVersionID: "C-8-v3" + ) + + #expect(repository.requests == [StubBillVersionDiffRepository.Request( + billID: "C-8", + fromVersionID: "C-8-v1", + toVersionID: "C-8-v3" + )]) + #expect(result == diff) + } + + @Test func executeReturnsNilWhenRepositoryHasNoDiff() async throws { + let repository = StubBillVersionDiffRepository(diff: nil) + let useCase = LoadBillVersionDiff(repository: repository) + + let result = try await useCase.execute( + billID: "C-9", + fromVersionID: "v1", + toVersionID: "v2" + ) + + #expect(result == nil) + } + + @Test func executePropagatesRepositoryErrors() async { + let repository = StubBillVersionDiffRepository(error: StubBillVersionDiffRepositoryError.failed) + let useCase = LoadBillVersionDiff(repository: repository) + + await #expect(throws: StubBillVersionDiffRepositoryError.failed) { + _ = try await useCase.execute(billID: "C-9", fromVersionID: "v1", toVersionID: "v2") + } + } + + private static func sampleDiff() -> BillVersionDiff { + BillVersionDiff( + fromVersion: BillVersion( + id: "C-8-v1", + label: "First reading", + title: nil, + stage: "First Reading", + chamber: "House of Commons", + publishedOn: nil, + sourceURL: nil + ), + toVersion: BillVersion( + id: "C-8-v3", + label: "As passed by the House", + title: nil, + stage: "Third Reading", + chamber: "House of Commons", + publishedOn: nil, + sourceURL: nil + ), + clauseDiffs: [ + BillClauseDiff( + id: "clause-3", + label: "Clause 3", + changeType: .added, + fromText: "", + toText: "The Minister shall publish quarterly progress reports.", + hansardAnchorURL: nil + ) + ] + ) + } +} + +private enum StubBillVersionDiffRepositoryError: Error { + case failed +} + +private final class StubBillVersionDiffRepository: BillVersionDiffRepository, @unchecked Sendable { + struct Request: Equatable, Sendable { + let billID: String + let fromVersionID: String + let toVersionID: String + } + + private let diff: BillVersionDiff? + private let error: Error? + private(set) var requests: [Request] = [] + + init(diff: BillVersionDiff? = nil, error: Error? = nil) { + self.diff = diff + self.error = error + } + + func loadBillVersionDiff( + billID: String, + fromVersionID: String, + toVersionID: String + ) async throws -> BillVersionDiff? { + requests.append(Request( + billID: billID, + fromVersionID: fromVersionID, + toVersionID: toVersionID + )) + if let error { + throw error + } + return diff + } +} diff --git a/ios/epacTests/Application/LoadBillVersionsTests.swift b/ios/epacTests/Application/LoadBillVersionsTests.swift new file mode 100644 index 00000000..9053cb73 --- /dev/null +++ b/ios/epacTests/Application/LoadBillVersionsTests.swift @@ -0,0 +1,91 @@ +@testable import epac +import Foundation +import Testing + +struct LoadBillVersionsTests { + @Test func executeReturnsVersionsFromRepository() async throws { + let versions = Self.sampleVersions() + let repository = StubBillVersionsRepository(versions: versions) + let useCase = LoadBillVersions(repository: repository) + + let result = try await useCase.execute(billID: "C-8") + + #expect(repository.requestedBillIDs == ["C-8"]) + #expect(result == versions) + } + + @Test func executeReturnsEmptyArrayWhenRepositoryHasNoVersionsForBill() async throws { + let repository = StubBillVersionsRepository(versions: []) + let useCase = LoadBillVersions(repository: repository) + + let result = try await useCase.execute(billID: "C-9") + + #expect(repository.requestedBillIDs == ["C-9"]) + #expect(result == []) + } + + @Test func executeReturnsNilWhenRepositoryHasNoVersionsRecord() async throws { + let repository = StubBillVersionsRepository(versions: nil) + let useCase = LoadBillVersions(repository: repository) + + let result = try await useCase.execute(billID: "C-10") + + #expect(repository.requestedBillIDs == ["C-10"]) + #expect(result == nil) + } + + @Test func executePropagatesRepositoryErrors() async { + let repository = StubBillVersionsRepository(error: StubBillVersionsRepositoryError.failed) + let useCase = LoadBillVersions(repository: repository) + + await #expect(throws: StubBillVersionsRepositoryError.failed) { + _ = try await useCase.execute(billID: "C-11") + } + } + + private static func sampleVersions() -> [BillVersion] { + [ + BillVersion( + id: "C-8-v1", + label: "First reading", + title: nil, + stage: "First Reading", + chamber: "House of Commons", + publishedOn: Date(timeIntervalSince1970: 1_777_910_400), + sourceURL: URL(string: "https://www.parl.ca/legisinfo/bill/C-8/v1") + ), + BillVersion( + id: "C-8-v3", + label: "As passed by the House", + title: nil, + stage: "Third Reading", + chamber: "House of Commons", + publishedOn: Date(timeIntervalSince1970: 1_780_896_000), + sourceURL: URL(string: "https://www.parl.ca/legisinfo/bill/C-8/v3") + ) + ] + } +} + +private enum StubBillVersionsRepositoryError: Error { + case failed +} + +private final class StubBillVersionsRepository: BillVersionsRepository, @unchecked Sendable { + private let versions: [BillVersion]? + private let error: Error? + private(set) var requestedBillIDs: [String] = [] + + init(versions: [BillVersion]? = nil, error: Error? = nil) { + self.versions = versions + self.error = error + } + + func loadBillVersions(billID: String) async throws -> [BillVersion]? { + requestedBillIDs.append(billID) + if let error { + throw error + } + return versions + } +} diff --git a/ios/epacTests/BillVersionDiffRepositoryTests.swift b/ios/epacTests/BillVersionDiffRepositoryTests.swift new file mode 100644 index 00000000..6378383f --- /dev/null +++ b/ios/epacTests/BillVersionDiffRepositoryTests.swift @@ -0,0 +1,273 @@ +@testable import epac +import Foundation +import Testing + +@Suite(.serialized) +struct BillVersionDiffRepositoryTests { + @Test func billVersionDiffDecodesBackendResponse() async throws { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + var capturedRequest: URLRequest? + + BillVersionDiffMockURLProtocol.requestHandler = { request in + capturedRequest = request + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Self.billVersionDiffJSON() + ) + } + + defer { harness.cleanup() } + defer { BillVersionDiffMockURLProtocol.requestHandler = nil } + + let repository = BackendBillVersionDiffRepository( + network: harness.service, + baseURL: baseURL + ) + + let loadedDiff = try await repository.loadBillVersionDiff( + billID: "C-8", + fromVersionID: "C-8-v1", + toVersionID: "C-8-v3" + ) + let diff = try #require(loadedDiff) + let request = try #require(capturedRequest) + let requestURL = try #require(request.url) + let components = try #require(URLComponents(url: requestURL, resolvingAgainstBaseURL: false)) + + #expect(requestURL.path == "/api/v1/bills/C-8/diff") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(components.queryItems?.contains(URLQueryItem(name: "from", value: "C-8-v1")) == true) + #expect(components.queryItems?.contains(URLQueryItem(name: "to", value: "C-8-v3")) == true) + + #expect(diff.fromVersion.id == "C-8-v1") + #expect(diff.fromVersion.label == "First reading") + #expect(diff.toVersion.id == "C-8-v3") + #expect(diff.toVersion.label == "As passed by the House") + #expect(diff.clauseDiffs.count == 4) + + let added = try #require(diff.clauseDiffs.first { $0.changeType == .added }) + #expect(added.label == "Clause 3") + #expect(added.fromText.isEmpty) + #expect(added.toText.contains("The Minister shall")) + #expect(added.hansardAnchorURL?.absoluteString == "https://example.test/hansard/c-8-clause-3") + + let removed = try #require(diff.clauseDiffs.first { $0.changeType == .removed }) + #expect(removed.label == "Clause 7") + #expect(removed.fromText.contains("repealed")) + #expect(removed.toText.isEmpty) + + let modified = try #require(diff.clauseDiffs.first { $0.changeType == .modified }) + #expect(modified.label == "Clause 5") + #expect(modified.fromText.contains("annually")) + #expect(modified.toText.contains("quarterly")) + + let unchanged = try #require(diff.clauseDiffs.first { $0.changeType == .unchanged }) + #expect(unchanged.label == "Clause 1") + } + + @Test func billVersionDiffReturnsNilOnNoContent() async throws { + let setup = try makeRepository(statusCode: 204) + defer { setup.harness.cleanup() } + defer { BillVersionDiffMockURLProtocol.requestHandler = nil } + + let diff = try await setup.repository.loadBillVersionDiff( + billID: "C-8", + fromVersionID: "v1", + toVersionID: "v2" + ) + + #expect(diff == nil) + } + + @Test func billVersionDiffReturnsNilOnNotFound() async throws { + let setup = try makeRepository(statusCode: 404) + defer { setup.harness.cleanup() } + defer { BillVersionDiffMockURLProtocol.requestHandler = nil } + + let diff = try await setup.repository.loadBillVersionDiff( + billID: "C-8", + fromVersionID: "v1", + toVersionID: "v2" + ) + + #expect(diff == nil) + } + + @Test func billVersionDiffThrowsOnServerError() async throws { + let setup = try makeRepository(statusCode: 500) + defer { setup.harness.cleanup() } + defer { BillVersionDiffMockURLProtocol.requestHandler = nil } + + await #expect(throws: URLError.self) { + _ = try await setup.repository.loadBillVersionDiff( + billID: "C-8", + fromVersionID: "v1", + toVersionID: "v2" + ) + } + } + + @Test func changeTypeMapsKnownSynonyms() { + #expect(BillClauseChangeType.from("added") == .added) + #expect(BillClauseChangeType.from("inserted") == .added) + #expect(BillClauseChangeType.from("removed") == .removed) + #expect(BillClauseChangeType.from("deleted") == .removed) + #expect(BillClauseChangeType.from("modified") == .modified) + #expect(BillClauseChangeType.from("replaced") == .modified) + #expect(BillClauseChangeType.from("unchanged") == .unchanged) + #expect(BillClauseChangeType.from("context") == .unchanged) + #expect(BillClauseChangeType.from("") == .modified) + #expect(BillClauseChangeType.from("garbage") == .modified) + } + + private func makeRepository( + statusCode: Int + ) throws -> (repository: BackendBillVersionDiffRepository, harness: BillVersionDiffNetworkHarness) { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + + BillVersionDiffMockURLProtocol.requestHandler = { request in + ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )!, + Data() + ) + } + + return ( + repository: BackendBillVersionDiffRepository( + network: harness.service, + baseURL: baseURL + ), + harness: harness + ) + } + + private func makeHarness() throws -> BillVersionDiffNetworkHarness { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [BillVersionDiffMockURLProtocol.self] + let session = URLSession(configuration: configuration) + + let suiteName = "BillVersionDiffRepositoryTests.\(UUID().uuidString)" + let userDefaults = try #require(UserDefaults(suiteName: suiteName)) + userDefaults.removePersistentDomain(forName: suiteName) + + let cacheDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("BillVersionDiffRepositoryTests-\(UUID().uuidString)", isDirectory: true) + let cacheStore = HTTPResponseCacheStore(userDefaults: userDefaults, cacheDirectory: cacheDirectory) + + return BillVersionDiffNetworkHarness( + service: NetworkService(session: session, cacheStore: cacheStore), + userDefaultsSuiteName: suiteName, + cacheDirectory: cacheDirectory + ) + } + + private static func billVersionDiffJSON() -> Data { + Data( + """ + { + "from": { + "id": "C-8-v1", + "label": "First reading", + "stage": "First Reading", + "chamber": "House of Commons", + "published_on": "2026-04-27" + }, + "to": { + "id": "C-8-v3", + "label": "As passed by the House", + "stage": "Third Reading", + "chamber": "House of Commons", + "published_on": "2026-06-04" + }, + "clauses": [ + { + "id": "clause-1", + "label": "Clause 1", + "change_type": "unchanged", + "from_text": "Short title.", + "to_text": "Short title." + }, + { + "id": "clause-3", + "label": "Clause 3", + "change_type": "added", + "from_text": "", + "to_text": "The Minister shall publish quarterly progress reports to Parliament.", + "hansard_anchor_url": "https://example.test/hansard/c-8-clause-3" + }, + { + "id": "clause-5", + "label": "Clause 5", + "change_type": "modified", + "from_text": "The Minister shall report annually on the program.", + "to_text": "The Minister shall report quarterly on the program." + }, + { + "id": "clause-7", + "label": "Clause 7", + "change_type": "removed", + "from_text": "Section 12 of the Act is repealed.", + "to_text": "" + } + ] + } + """.utf8 + ) + } +} + +private struct BillVersionDiffNetworkHarness { + let service: NetworkService + let userDefaultsSuiteName: String + let cacheDirectory: URL + + func cleanup() { + UserDefaults.standard.removePersistentDomain(forName: userDefaultsSuiteName) + try? FileManager.default.removeItem(at: cacheDirectory) + } +} + +private enum BillVersionDiffMockURLProtocolError: Error { + case missingHandler +} + +private final class BillVersionDiffMockURLProtocol: URLProtocol { + nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + do { + guard let handler = Self.requestHandler else { + throw BillVersionDiffMockURLProtocolError.missingHandler + } + + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/ios/epacTests/BillVersionsRepositoryTests.swift b/ios/epacTests/BillVersionsRepositoryTests.swift new file mode 100644 index 00000000..8e54f188 --- /dev/null +++ b/ios/epacTests/BillVersionsRepositoryTests.swift @@ -0,0 +1,256 @@ +@testable import epac +import Foundation +import Testing + +@Suite(.serialized) +struct BillVersionsRepositoryTests { + @Test func billVersionsDecodesBackendResponse() async throws { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + var capturedRequest: URLRequest? + + BillVersionsMockURLProtocol.requestHandler = { request in + capturedRequest = request + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Self.billDepthJSON() + ) + } + + defer { harness.cleanup() } + defer { BillVersionsMockURLProtocol.requestHandler = nil } + + let repository = BackendBillVersionsRepository( + network: harness.service, + baseURL: baseURL + ) + + let loadedVersions = try await repository.loadBillVersions(billID: "C-8") + let versions = try #require(loadedVersions) + let request = try #require(capturedRequest) + let requestURL = try #require(request.url) + + #expect(requestURL.path == "/api/v1/bills/C-8") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(versions.count == 3) + + let first = try #require(versions.first) + #expect(first.id == "C-8-v1") + #expect(first.label == "First reading") + #expect(first.stage == "First Reading") + #expect(first.chamber == "House of Commons") + #expect(first.publishedOn == expectedUTCDate(year: 2026, month: 4, day: 27)) + #expect(first.sourceURL?.absoluteString == "https://www.parl.ca/legisinfo/bill/C-8/v1") + + let last = try #require(versions.last) + #expect(last.id == "C-8-v3") + #expect(last.label == "As passed by the House") + #expect(last.title == nil) + #expect(last.publishedOn == expectedUTCDate(year: 2026, month: 6, day: 4)) + } + + @Test func billVersionsReturnsEmptyArrayWhenBillHasNoVersions() async throws { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + + BillVersionsMockURLProtocol.requestHandler = { request in + ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Data(#"{"bill": {"id": "C-9", "number": "C-9", "title": "Test"}}"#.utf8) + ) + } + + defer { harness.cleanup() } + defer { BillVersionsMockURLProtocol.requestHandler = nil } + + let repository = BackendBillVersionsRepository(network: harness.service, baseURL: baseURL) + let versions = try await repository.loadBillVersions(billID: "C-9") + + #expect(versions == []) + } + + @Test func billVersionsReturnsNilOnNoContent() async throws { + let setup = try makeRepository(statusCode: 204) + defer { setup.harness.cleanup() } + defer { BillVersionsMockURLProtocol.requestHandler = nil } + + let versions = try await setup.repository.loadBillVersions(billID: "C-10") + + #expect(versions == nil) + } + + @Test func billVersionsReturnsNilOnNotFound() async throws { + let setup = try makeRepository(statusCode: 404) + defer { setup.harness.cleanup() } + defer { BillVersionsMockURLProtocol.requestHandler = nil } + + let versions = try await setup.repository.loadBillVersions(billID: "C-11") + + #expect(versions == nil) + } + + @Test func billVersionsThrowsOnServerError() async throws { + let setup = try makeRepository(statusCode: 500) + defer { setup.harness.cleanup() } + defer { BillVersionsMockURLProtocol.requestHandler = nil } + + await #expect(throws: URLError.self) { + _ = try await setup.repository.loadBillVersions(billID: "C-12") + } + } + + private func makeRepository( + statusCode: Int + ) throws -> (repository: BackendBillVersionsRepository, harness: BillVersionsNetworkHarness) { + let baseURL = URL(string: "https://example.test")! + let harness = try makeHarness() + + BillVersionsMockURLProtocol.requestHandler = { request in + ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )!, + Data() + ) + } + + return ( + repository: BackendBillVersionsRepository( + network: harness.service, + baseURL: baseURL + ), + harness: harness + ) + } + + private func makeHarness() throws -> BillVersionsNetworkHarness { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [BillVersionsMockURLProtocol.self] + let session = URLSession(configuration: configuration) + + let suiteName = "BillVersionsRepositoryTests.\(UUID().uuidString)" + let userDefaults = try #require(UserDefaults(suiteName: suiteName)) + userDefaults.removePersistentDomain(forName: suiteName) + + let cacheDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("BillVersionsRepositoryTests-\(UUID().uuidString)", isDirectory: true) + let cacheStore = HTTPResponseCacheStore(userDefaults: userDefaults, cacheDirectory: cacheDirectory) + + return BillVersionsNetworkHarness( + service: NetworkService(session: session, cacheStore: cacheStore), + userDefaultsSuiteName: suiteName, + cacheDirectory: cacheDirectory + ) + } + + private func expectedUTCDate(year: Int, month: Int, day: Int) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? calendar.timeZone + return calendar.date( + from: DateComponents( + timeZone: calendar.timeZone, + year: year, + month: month, + day: day + ) + ) ?? Date(timeIntervalSince1970: 0) + } + + private static func billDepthJSON() -> Data { + Data( + """ + { + "bill": { + "id": "C-8-45-1", + "number": "C-8", + "title": "An Act respecting example data", + "versions": [ + { + "id": "C-8-v1", + "label": "First reading", + "title": "First reading text", + "stage": "First Reading", + "chamber": "House of Commons", + "published_on": "2026-04-27", + "source_url": "https://www.parl.ca/legisinfo/bill/C-8/v1" + }, + { + "id": "C-8-v2", + "label": "As reported by committee", + "stage": "Committee Report", + "chamber": "House of Commons", + "published_on": "2026-05-20", + "source_url": "https://www.parl.ca/legisinfo/bill/C-8/v2" + }, + { + "id": "C-8-v3", + "label": "As passed by the House", + "stage": "Third Reading", + "chamber": "House of Commons", + "published_on": "2026-06-04", + "source_url": "https://www.parl.ca/legisinfo/bill/C-8/v3" + } + ] + } + } + """.utf8 + ) + } +} + +private struct BillVersionsNetworkHarness { + let service: NetworkService + let userDefaultsSuiteName: String + let cacheDirectory: URL + + func cleanup() { + UserDefaults.standard.removePersistentDomain(forName: userDefaultsSuiteName) + try? FileManager.default.removeItem(at: cacheDirectory) + } +} + +private enum BillVersionsMockURLProtocolError: Error { + case missingHandler +} + +private final class BillVersionsMockURLProtocol: URLProtocol { + nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + do { + guard let handler = Self.requestHandler else { + throw BillVersionsMockURLProtocolError.missingHandler + } + + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/ios/epacTests/SnapshotTests.swift b/ios/epacTests/SnapshotTests.swift index 5bf4f6e6..0c5bba18 100644 --- a/ios/epacTests/SnapshotTests.swift +++ b/ios/epacTests/SnapshotTests.swift @@ -335,6 +335,64 @@ final class SnapshotTests: XCTestCase { ) } + // MARK: - Bill versions diff viewer (EPAC-962) + + func testBillVersionsDiff_inline() { + snapshot( + NavigationStack { + BillVersionsDiffContent( + diff: Self.billDiffPopulated, + viewMode: .inline + ) + } + .frame(width: 375, height: 720), + name: "BillVersionsDiff_inline" + ) + } + + func testBillVersionsDiff_sideBySide() { + snapshot( + NavigationStack { + BillVersionsDiffContent( + diff: Self.billDiffPopulated, + viewMode: .sideBySide + ) + } + .frame(width: 375, height: 720), + name: "BillVersionsDiff_sideBySide" + ) + } + + func testBillVersionsDiff_noChanges() { + snapshot( + NavigationStack { + BillVersionsDiffContent( + diff: BillVersionDiff( + fromVersion: Self.billVersionV1, + toVersion: Self.billVersionV3, + clauseDiffs: [] + ), + viewMode: .inline + ) + } + .frame(width: 375, height: 320), + name: "BillVersionsDiff_noChanges" + ) + } + + func testBillVersionsDiff_emptyOnlyOneVersion() { + snapshot( + BillVersionsDiffView( + billNumber: "C-8", + billID: "C-8-45-1", + versions: [Self.billVersionV1], + loadBillVersionDiff: Self.stubLoadBillVersionDiff + ) + .frame(width: 375, height: 480), + name: "BillVersionsDiff_emptyOnlyOneVersion" + ) + } + // MARK: - Bill PBO costing panel (EPAC-1006) func testPBOCostingPanel_present() { @@ -1044,6 +1102,71 @@ final class SnapshotTests: XCTestCase { ) ] + // MARK: - Bill versions diff fixtures (EPAC-962) + + private static let billVersionV1 = BillVersion( + id: "C-8-v1", + label: "First reading", + title: nil, + stage: "First Reading", + chamber: "House of Commons", + publishedOn: date("2026-04-27"), + sourceURL: URL(string: "https://www.parl.ca/legisinfo/bill/C-8/v1") + ) + + private static let billVersionV3 = BillVersion( + id: "C-8-v3", + label: "As passed by the House", + title: nil, + stage: "Third Reading", + chamber: "House of Commons", + publishedOn: date("2026-06-04"), + sourceURL: URL(string: "https://www.parl.ca/legisinfo/bill/C-8/v3") + ) + + private static let billDiffPopulated = BillVersionDiff( + fromVersion: billVersionV1, + toVersion: billVersionV3, + clauseDiffs: [ + BillClauseDiff( + id: "clause-1", + label: "Clause 1", + changeType: .unchanged, + fromText: "This Act may be cited as the National School Food Program Act.", + toText: "This Act may be cited as the National School Food Program Act.", + hansardAnchorURL: nil + ), + BillClauseDiff( + id: "clause-3", + label: "Clause 3", + changeType: .added, + fromText: "", + toText: "The Minister shall publish quarterly progress reports to Parliament on the implementation of the program.", + hansardAnchorURL: URL(string: "https://www.ourcommons.ca/DocumentViewer/en/45-1/house/sitting-42/hansard#clause-3") + ), + BillClauseDiff( + id: "clause-5", + label: "Clause 5", + changeType: .modified, + fromText: "The Minister shall report annually on the program.", + toText: "The Minister shall report quarterly on the program.", + hansardAnchorURL: nil + ), + BillClauseDiff( + id: "clause-7", + label: "Clause 7", + changeType: .removed, + fromText: "Section 12 of the Act is repealed effective on Royal Assent.", + toText: "", + hansardAnchorURL: nil + ) + ] + ) + + private static let stubLoadBillVersionDiff = LoadBillVersionDiff( + repository: StubBillVersionDiffRepository() + ) + // MARK: - PBO costing fixtures (EPAC-1006) private static let pboCostingLatest = PBOCosting( @@ -1686,3 +1809,13 @@ final class SnapshotTests: XCTestCase { ) } } + +private struct StubBillVersionDiffRepository: BillVersionDiffRepository { + func loadBillVersionDiff( + billID: String, + fromVersionID: String, + toVersionID: String + ) async throws -> BillVersionDiff? { + nil + } +} diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_emptyOnlyOneVersion.BillVersionsDiff_emptyOnlyOneVersion_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_emptyOnlyOneVersion.BillVersionsDiff_emptyOnlyOneVersion_a11y.png new file mode 100644 index 00000000..06c311e6 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_emptyOnlyOneVersion.BillVersionsDiff_emptyOnlyOneVersion_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_emptyOnlyOneVersion.BillVersionsDiff_emptyOnlyOneVersion_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_emptyOnlyOneVersion.BillVersionsDiff_emptyOnlyOneVersion_dark.png new file mode 100644 index 00000000..88bcc5bd Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_emptyOnlyOneVersion.BillVersionsDiff_emptyOnlyOneVersion_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_emptyOnlyOneVersion.BillVersionsDiff_emptyOnlyOneVersion_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_emptyOnlyOneVersion.BillVersionsDiff_emptyOnlyOneVersion_light.png new file mode 100644 index 00000000..7600e3cc Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_emptyOnlyOneVersion.BillVersionsDiff_emptyOnlyOneVersion_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_inline.BillVersionsDiff_inline_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_inline.BillVersionsDiff_inline_a11y.png new file mode 100644 index 00000000..4a045683 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_inline.BillVersionsDiff_inline_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_inline.BillVersionsDiff_inline_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_inline.BillVersionsDiff_inline_dark.png new file mode 100644 index 00000000..3c239e4c Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_inline.BillVersionsDiff_inline_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_inline.BillVersionsDiff_inline_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_inline.BillVersionsDiff_inline_light.png new file mode 100644 index 00000000..d463f9ec Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_inline.BillVersionsDiff_inline_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_noChanges.BillVersionsDiff_noChanges_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_noChanges.BillVersionsDiff_noChanges_a11y.png new file mode 100644 index 00000000..a4ca60a2 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_noChanges.BillVersionsDiff_noChanges_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_noChanges.BillVersionsDiff_noChanges_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_noChanges.BillVersionsDiff_noChanges_dark.png new file mode 100644 index 00000000..96e367ff Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_noChanges.BillVersionsDiff_noChanges_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_noChanges.BillVersionsDiff_noChanges_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_noChanges.BillVersionsDiff_noChanges_light.png new file mode 100644 index 00000000..fef9c3b0 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_noChanges.BillVersionsDiff_noChanges_light.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_sideBySide.BillVersionsDiff_sideBySide_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_sideBySide.BillVersionsDiff_sideBySide_a11y.png new file mode 100644 index 00000000..880a0efe Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_sideBySide.BillVersionsDiff_sideBySide_a11y.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_sideBySide.BillVersionsDiff_sideBySide_dark.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_sideBySide.BillVersionsDiff_sideBySide_dark.png new file mode 100644 index 00000000..7198800c Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_sideBySide.BillVersionsDiff_sideBySide_dark.png differ diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_sideBySide.BillVersionsDiff_sideBySide_light.png b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_sideBySide.BillVersionsDiff_sideBySide_light.png new file mode 100644 index 00000000..166f7b29 Binary files /dev/null and b/ios/epacTests/__Snapshots__/SnapshotTests/testBillVersionsDiff_sideBySide.BillVersionsDiff_sideBySide_light.png differ