From ef1b547677bf1e89d12edfac95581dfa8e759813 Mon Sep 17 00:00:00 2001 From: riddim-developer-bot Date: Sun, 14 Jun 2026 21:59:03 -0400 Subject: [PATCH] [EPAC-2302]: Generate comparable bill diff pairs for iOS selections --- .../internal/adapter/sqlite/writer_test.go | 168 ++++++++++++++++++ .../usecase/compute_bill_version_diff.go | 73 ++++---- .../usecase/compute_bill_version_diff_test.go | 96 ++++++++-- backend/bills/internal/usecase/bills.go | 18 ++ backend/bills/internal/usecase/bills_test.go | 56 ++++++ .../Views/Bills/BillVersionsDiffView.swift | 9 + ...cameLawCard.RecentlyBecameLawCard_a11y.png | Bin 0 -> 138478 bytes ...cameLawCard.RecentlyBecameLawCard_dark.png | Bin 0 -> 136837 bytes ...ameLawCard.RecentlyBecameLawCard_light.png | Bin 0 -> 134492 bytes 9 files changed, 374 insertions(+), 46 deletions(-) create mode 100644 ios/epacTests/__Snapshots__/SnapshotTests/testRecentlyBecameLawCard.RecentlyBecameLawCard_a11y.png create mode 100644 ios/epacTests/__Snapshots__/SnapshotTests/testRecentlyBecameLawCard.RecentlyBecameLawCard_dark.png create mode 100644 ios/epacTests/__Snapshots__/SnapshotTests/testRecentlyBecameLawCard.RecentlyBecameLawCard_light.png diff --git a/backend/bills-indexer/internal/adapter/sqlite/writer_test.go b/backend/bills-indexer/internal/adapter/sqlite/writer_test.go index 78d677c9..e92117e7 100644 --- a/backend/bills-indexer/internal/adapter/sqlite/writer_test.go +++ b/backend/bills-indexer/internal/adapter/sqlite/writer_test.go @@ -8,6 +8,7 @@ import ( "time" "epac/bills-indexer/internal/domain" + "epac/bills-indexer/internal/usecase" _ "modernc.org/sqlite" ) @@ -114,6 +115,173 @@ func TestWriterCreatesBillsRelationalSchema(t *testing.T) { } } +func TestWriterStoresMultipleBillVersionDiffPairs(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "bills_pairs.db") + writer := NewWriter(WithClock(fixedClock{})) + + v1 := domain.BillVersion{ + ID: "v1", + SortOrder: 1, + Sections: []domain.VersionSection{ + {Label: "1", Text: "A"}, + {Label: "2", Text: "B"}, + }, + TextHash: ptrString("h1"), + TextSourceURL: ptrString("s1"), + } + v2 := domain.BillVersion{ + ID: "v2", + SortOrder: 2, + Sections: []domain.VersionSection{ + {Label: "1", Text: "A modified"}, + {Label: "2", Text: "B"}, + }, + TextHash: ptrString("h2"), + TextSourceURL: ptrString("s2"), + } + v3 := domain.BillVersion{ + ID: "v3", + SortOrder: 3, + Sections: []domain.VersionSection{ + {Label: "1", Text: "A modified"}, + {Label: "3", Text: "C"}, + }, + TextHash: ptrString("h3"), + TextSourceURL: ptrString("s3"), + } + + // Compute all pairs using the use case policy + diffs := usecase.ComputeBillVersionDiff("C-2", []domain.BillVersion{v1, v2, v3}, "https://example.test/bill") + + bill := domain.Bill{ + ID: "13543613", + Number: "C-2", + Title: "Border bill", + Versions: []domain.BillVersion{v1, v2, v3}, + Diffs: diffs, + } + + stats, err := writer.Write(context.Background(), dbPath, domain.Batch{Bills: []domain.Bill{bill}}) + if err != nil { + t.Fatalf("Write: %v", err) + } + if stats.DiffCount != 3 { + t.Errorf("expected 3 diffs written to stats, got %d", stats.DiffCount) + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer db.Close() + + // Assert pair count (should be 3 pairs: v1->v2, v1->v3, v2->v3) + var pairCount int + if err := db.QueryRow("SELECT COUNT(*) FROM bill_diffs WHERE bill_id = ?", "13543613").Scan(&pairCount); err != nil { + t.Fatalf("query bill_diffs count: %v", err) + } + if pairCount != 3 { + t.Fatalf("expected 3 diff pairs, got %d", pairCount) + } + + // Assert stable IDs and presence of adjacent and non-adjacent pairs + expectedPairs := map[string]struct { + from string + to string + }{ + "diff-c-2-v1-v2": {from: "v1", to: "v2"}, + "diff-c-2-v1-v3": {from: "v1", to: "v3"}, + "diff-c-2-v2-v3": {from: "v2", to: "v3"}, + } + + rows, err := db.Query("SELECT id, from_version_id, to_version_id FROM bill_diffs WHERE bill_id = ?", "13543613") + if err != nil { + t.Fatalf("query bill_diffs: %v", err) + } + defer rows.Close() + + for rows.Next() { + var id, fromVal, toVal string + if err := rows.Scan(&id, &fromVal, &toVal); err != nil { + t.Fatalf("scan bill_diff: %v", err) + } + expected, found := expectedPairs[id] + if !found { + t.Errorf("unexpected diff ID stored: %q", id) + continue + } + if expected.from != fromVal || expected.to != toVal { + t.Errorf("mismatched versions for diff %q: expected %s->%s, got %s->%s", id, expected.from, expected.to, fromVal, toVal) + } + } + + // Assert representative bill_clause_diffs rows + // For v1->v2 (adjacent): clause 1 modified, clause 2 unchanged + var v1v2C1Change, v1v2C2Change string + err = db.QueryRow("SELECT change_type FROM bill_clause_diffs WHERE diff_id = ? AND label = ?", "diff-c-2-v1-v2", "1").Scan(&v1v2C1Change) + if err != nil { + t.Fatalf("query v1->v2 clause 1 change: %v", err) + } + if v1v2C1Change != "modified" { + t.Errorf("expected v1->v2 clause 1 to be modified, got %q", v1v2C1Change) + } + err = db.QueryRow("SELECT change_type FROM bill_clause_diffs WHERE diff_id = ? AND label = ?", "diff-c-2-v1-v2", "2").Scan(&v1v2C2Change) + if err != nil { + t.Fatalf("query v1->v2 clause 2 change: %v", err) + } + if v1v2C2Change != "unchanged" { + t.Errorf("expected v1->v2 clause 2 to be unchanged, got %q", v1v2C2Change) + } + + // For v1->v3 (non-adjacent): clause 1 modified, clause 2 removed, clause 3 added + var v1v3C1Change, v1v3C2Change, v1v3C3Change string + err = db.QueryRow("SELECT change_type FROM bill_clause_diffs WHERE diff_id = ? AND label = ?", "diff-c-2-v1-v3", "1").Scan(&v1v3C1Change) + if err != nil { + t.Fatalf("query v1->v3 clause 1 change: %v", err) + } + if v1v3C1Change != "modified" { + t.Errorf("expected v1->v3 clause 1 to be modified, got %q", v1v3C1Change) + } + err = db.QueryRow("SELECT change_type FROM bill_clause_diffs WHERE diff_id = ? AND label = ?", "diff-c-2-v1-v3", "2").Scan(&v1v3C2Change) + if err != nil { + t.Fatalf("query v1->v3 clause 2 change: %v", err) + } + if v1v3C2Change != "removed" { + t.Errorf("expected v1->v3 clause 2 to be removed, got %q", v1v3C2Change) + } + err = db.QueryRow("SELECT change_type FROM bill_clause_diffs WHERE diff_id = ? AND label = ?", "diff-c-2-v1-v3", "3").Scan(&v1v3C3Change) + if err != nil { + t.Fatalf("query v1->v3 clause 3 change: %v", err) + } + if v1v3C3Change != "added" { + t.Errorf("expected v1->v3 clause 3 to be added, got %q", v1v3C3Change) + } + + // For v2->v3 (adjacent): clause 1 unchanged, clause 2 removed, clause 3 added + var v2v3C1Change, v2v3C2Change, v2v3C3Change string + err = db.QueryRow("SELECT change_type FROM bill_clause_diffs WHERE diff_id = ? AND label = ?", "diff-c-2-v2-v3", "1").Scan(&v2v3C1Change) + if err != nil { + t.Fatalf("query v2->v3 clause 1 change: %v", err) + } + if v2v3C1Change != "unchanged" { + t.Errorf("expected v2->v3 clause 1 to be unchanged, got %q", v2v3C1Change) + } + err = db.QueryRow("SELECT change_type FROM bill_clause_diffs WHERE diff_id = ? AND label = ?", "diff-c-2-v2-v3", "2").Scan(&v2v3C2Change) + if err != nil { + t.Fatalf("query v2->v3 clause 2 change: %v", err) + } + if v2v3C2Change != "removed" { + t.Errorf("expected v2->v3 clause 2 to be removed, got %q", v2v3C2Change) + } + err = db.QueryRow("SELECT change_type FROM bill_clause_diffs WHERE diff_id = ? AND label = ?", "diff-c-2-v2-v3", "3").Scan(&v2v3C3Change) + if err != nil { + t.Fatalf("query v2->v3 clause 3 change: %v", err) + } + if v2v3C3Change != "added" { + t.Errorf("expected v2->v3 clause 3 to be added, got %q", v2v3C3Change) + } +} + type fixedClock struct{} func (fixedClock) Now() time.Time { diff --git a/backend/bills-indexer/internal/usecase/compute_bill_version_diff.go b/backend/bills-indexer/internal/usecase/compute_bill_version_diff.go index df3937c8..3adfc94c 100644 --- a/backend/bills-indexer/internal/usecase/compute_bill_version_diff.go +++ b/backend/bills-indexer/internal/usecase/compute_bill_version_diff.go @@ -27,39 +27,52 @@ func ComputeBillVersionDiff(number string, versions []domain.BillVersion, detail } ordered := append([]domain.BillVersion(nil), versions...) sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].SortOrder < ordered[j].SortOrder }) - diffs := make([]domain.BillDiff, 0, len(ordered)-1) - for i := 1; i < len(ordered); i++ { - fromVer := ordered[i-1] - toVer := ordered[i] - diffID := stableID("diff", number, fromVer.ID, toVer.ID) - - var clauseDiffs []domain.BillClauseDiff - if len(fromVer.Sections) > 0 && len(toVer.Sections) > 0 { - rawDiffs := DiffClauses(fromVer.Sections, toVer.Sections) - clauseDiffs = make([]domain.BillClauseDiff, 0, len(rawDiffs)) - for idx, rd := range rawDiffs { - clauseID := stableID("clause", number, diffID, rd.Label) - if rd.Label == "" { - clauseID = stableID("clause", number, diffID, strconv.Itoa(idx)) + n := len(ordered) + diffs := make([]domain.BillDiff, 0, n*(n-1)/2) + for i := 0; i < n; i++ { + for j := i + 1; j < n; j++ { + fromVer := ordered[i] + toVer := ordered[j] + + // We only emit a diff artifact if: + // 1. It is an adjacent pair (to keep compatibility and stability for existing smoke tests), OR + // 2. Both versions have clause text (len(Sections) > 0). + isAdjacent := (j == i + 1) + hasTextBothSides := (len(fromVer.Sections) > 0 && len(toVer.Sections) > 0) + if !isAdjacent && !hasTextBothSides { + continue + } + + diffID := stableID("diff", number, fromVer.ID, toVer.ID) + + var clauseDiffs []domain.BillClauseDiff + if len(fromVer.Sections) > 0 && len(toVer.Sections) > 0 { + rawDiffs := DiffClauses(fromVer.Sections, toVer.Sections) + clauseDiffs = make([]domain.BillClauseDiff, 0, len(rawDiffs)) + for idx, rd := range rawDiffs { + clauseID := stableID("clause", number, diffID, rd.Label) + if rd.Label == "" { + clauseID = stableID("clause", number, diffID, strconv.Itoa(idx)) + } + clauseDiffs = append(clauseDiffs, domain.BillClauseDiff{ + ID: clauseID, + Label: rd.Label, + ChangeType: rd.ChangeType, + FromText: rd.FromText, + ToText: rd.ToText, + HansardAnchorURL: nil, + }) } - clauseDiffs = append(clauseDiffs, domain.BillClauseDiff{ - ID: clauseID, - Label: rd.Label, - ChangeType: rd.ChangeType, - FromText: rd.FromText, - ToText: rd.ToText, - HansardAnchorURL: nil, - }) } - } - diffs = append(diffs, domain.BillDiff{ - ID: diffID, - FromVersionID: fromVer.ID, - ToVersionID: toVer.ID, - SourceURL: detailURL, - Clauses: clauseDiffs, - }) + diffs = append(diffs, domain.BillDiff{ + ID: diffID, + FromVersionID: fromVer.ID, + ToVersionID: toVer.ID, + SourceURL: detailURL, + Clauses: clauseDiffs, + }) + } } return diffs } diff --git a/backend/bills-indexer/internal/usecase/compute_bill_version_diff_test.go b/backend/bills-indexer/internal/usecase/compute_bill_version_diff_test.go index e52c9a02..3b918f1e 100644 --- a/backend/bills-indexer/internal/usecase/compute_bill_version_diff_test.go +++ b/backend/bills-indexer/internal/usecase/compute_bill_version_diff_test.go @@ -55,7 +55,7 @@ func TestDiffClauses(t *testing.T) { } func TestComputeBillVersionDiffCases(t *testing.T) { - // Case 1: Multi-version bill with text available + // Case 1: Multi-version bill (3 versions) with text available v1 := domain.BillVersion{ ID: "v1", SortOrder: 1, @@ -74,16 +74,43 @@ func TestComputeBillVersionDiffCases(t *testing.T) { TextHash: ptrString("hash-2"), TextSourceURL: ptrString("https://example.test/xml2"), } + v3 := domain.BillVersion{ + ID: "v3", + SortOrder: 3, + Sections: []domain.VersionSection{ + {Label: "1", Text: "Hello Universe"}, + }, + TextHash: ptrString("hash-3"), + TextSourceURL: ptrString("https://example.test/xml3"), + } - diffs := ComputeBillVersionDiff("C-2", []domain.BillVersion{v1, v2}, "https://example.test/bill") - if len(diffs) != 1 { - t.Fatalf("expected 1 diff, got %d", len(diffs)) + diffs := ComputeBillVersionDiff("C-2", []domain.BillVersion{v1, v2, v3}, "https://example.test/bill") + if len(diffs) != 3 { + t.Fatalf("expected 3 diffs, got %d", len(diffs)) } + + // Check v1 -> v2 if diffs[0].FromVersionID != "v1" || diffs[0].ToVersionID != "v2" { - t.Errorf("incorrect version pair in diff: %+v", diffs[0]) + t.Errorf("incorrect version pair in diff 0: %+v", diffs[0]) } if len(diffs[0].Clauses) != 1 || diffs[0].Clauses[0].Label != "1" || diffs[0].Clauses[0].ChangeType != "modified" { - t.Errorf("expected modified clause, got: %+v", diffs[0].Clauses) + t.Errorf("expected modified clause in diff 0, got: %+v", diffs[0].Clauses) + } + + // Check v1 -> v3 + if diffs[1].FromVersionID != "v1" || diffs[1].ToVersionID != "v3" { + t.Errorf("incorrect version pair in diff 1: %+v", diffs[1]) + } + if len(diffs[1].Clauses) != 1 || diffs[1].Clauses[0].Label != "1" || diffs[1].Clauses[0].ChangeType != "modified" { + t.Errorf("expected modified clause in diff 1, got: %+v", diffs[1].Clauses) + } + + // Check v2 -> v3 + if diffs[2].FromVersionID != "v2" || diffs[2].ToVersionID != "v3" { + t.Errorf("incorrect version pair in diff 2: %+v", diffs[2]) + } + if len(diffs[2].Clauses) != 1 || diffs[2].Clauses[0].Label != "1" || diffs[2].Clauses[0].ChangeType != "modified" { + t.Errorf("expected modified clause in diff 2, got: %+v", diffs[2].Clauses) } // Case 2: One-version bill -> no diff records should be built @@ -92,25 +119,62 @@ func TestComputeBillVersionDiffCases(t *testing.T) { t.Errorf("expected 0 diffs for single version, got %d", len(diffsOne)) } - // Case 3: Multi-version bill with missing text -> creates diff records but no clauses - v1Missing := domain.BillVersion{ + // Case 3: Multi-version bill with missing text on some versions + // v1 and v3 have text, v2 has missing text. + // - v1 -> v2 (adjacent): emitted, 0 clauses + // - v2 -> v3 (adjacent): emitted, 0 clauses + // - v1 -> v3 (non-adjacent, both have text): emitted, 1 clause + v1Partial := domain.BillVersion{ ID: "v1", SortOrder: 1, - TextHash: nil, - TextSourceURL: nil, + Sections: []domain.VersionSection{{Label: "1", Text: "Hello"}}, + TextHash: ptrString("hash-1"), + TextSourceURL: ptrString("https://example.test/xml1"), } - v2Missing := domain.BillVersion{ + v2Partial := domain.BillVersion{ ID: "v2", SortOrder: 2, TextHash: nil, TextSourceURL: nil, } - diffsMissing := ComputeBillVersionDiff("C-2", []domain.BillVersion{v1Missing, v2Missing}, "https://example.test/bill") - if len(diffsMissing) != 1 { - t.Fatalf("expected 1 diff, got %d", len(diffsMissing)) + v3Partial := domain.BillVersion{ + ID: "v3", + SortOrder: 3, + Sections: []domain.VersionSection{{Label: "1", Text: "Hello Universe"}}, + TextHash: ptrString("hash-3"), + TextSourceURL: ptrString("https://example.test/xml3"), + } + diffsPartial := ComputeBillVersionDiff("C-2", []domain.BillVersion{v1Partial, v2Partial, v3Partial}, "https://example.test/bill") + if len(diffsPartial) != 3 { + t.Fatalf("expected 3 diffs, got %d", len(diffsPartial)) + } + // Check v1 -> v2 (adjacent, no clauses) + if diffsPartial[0].FromVersionID != "v1" || diffsPartial[0].ToVersionID != "v2" || len(diffsPartial[0].Clauses) != 0 { + t.Errorf("expected empty adjacent v1->v2, got: %+v", diffsPartial[0]) + } + // Check v1 -> v3 (non-adjacent, has clauses) + if diffsPartial[1].FromVersionID != "v1" || diffsPartial[1].ToVersionID != "v3" || len(diffsPartial[1].Clauses) != 1 { + t.Errorf("expected populated non-adjacent v1->v3, got: %+v", diffsPartial[1]) + } + // Check v2 -> v3 (adjacent, no clauses) + if diffsPartial[2].FromVersionID != "v2" || diffsPartial[2].ToVersionID != "v3" || len(diffsPartial[2].Clauses) != 0 { + t.Errorf("expected empty adjacent v2->v3, got: %+v", diffsPartial[2]) + } + + // Case 4: Multi-version bill with all versions missing text + // Only adjacent pairs (v1->v2, v2->v3) should be emitted. Non-adjacent (v1->v3) should be skipped. + v1NoText := domain.BillVersion{ID: "v1", SortOrder: 1} + v2NoText := domain.BillVersion{ID: "v2", SortOrder: 2} + v3NoText := domain.BillVersion{ID: "v3", SortOrder: 3} + diffsNoText := ComputeBillVersionDiff("C-2", []domain.BillVersion{v1NoText, v2NoText, v3NoText}, "https://example.test/bill") + if len(diffsNoText) != 2 { + t.Fatalf("expected 2 diffs (adjacent only) when all lack text, got %d: %+v", len(diffsNoText), diffsNoText) + } + if diffsNoText[0].FromVersionID != "v1" || diffsNoText[0].ToVersionID != "v2" { + t.Errorf("expected v1->v2 as first diff, got %+v", diffsNoText[0]) } - if len(diffsMissing[0].Clauses) != 0 { - t.Errorf("expected 0 clauses in diff when text is missing, got %d", len(diffsMissing[0].Clauses)) + if diffsNoText[1].FromVersionID != "v2" || diffsNoText[1].ToVersionID != "v3" { + t.Errorf("expected v2->v3 as second diff, got %+v", diffsNoText[1]) } } diff --git a/backend/bills/internal/usecase/bills.go b/backend/bills/internal/usecase/bills.go index 19fb7d16..8ff16fbf 100644 --- a/backend/bills/internal/usecase/bills.go +++ b/backend/bills/internal/usecase/bills.go @@ -97,6 +97,10 @@ func (u *LoadBillVersionDiff) Execute(ctx context.Context, input LoadBillVersion return nil, ErrDiffMissingTo } + if fromVersionID == toVersionID { + return nil, nil + } + diff, err := u.repo.GetBillVersionDiff(ctx, billID, fromVersionID, toVersionID) if err != nil || diff == nil { return diff, err @@ -104,6 +108,20 @@ func (u *LoadBillVersionDiff) Execute(ctx context.Context, input LoadBillVersion if len(diff.Clauses) == 0 { return nil, nil } + + // Check if all clauses are unchanged. If they are, return the diff but with + // an empty clauses slice so the client receives HTTP 200 with an empty clauses array. + allUnchanged := true + for _, c := range diff.Clauses { + if c.ChangeType != "unchanged" { + allUnchanged = false + break + } + } + if allUnchanged { + diff.Clauses = []domain.BillClauseDiff{} + } + return diff, nil } diff --git a/backend/bills/internal/usecase/bills_test.go b/backend/bills/internal/usecase/bills_test.go index 216624ff..03426033 100644 --- a/backend/bills/internal/usecase/bills_test.go +++ b/backend/bills/internal/usecase/bills_test.go @@ -100,6 +100,62 @@ func TestLoadBillVersionDiffMapsEmptyClauseDiffToUnavailable(t *testing.T) { } } +func TestLoadBillVersionDiffReturns204ForSameVersion(t *testing.T) { + repo := &fakeBillRepository{} + diff, err := NewLoadBillVersionDiff(repo).Execute(context.Background(), LoadBillVersionDiffInput{ + BillID: "C-2", + FromVersionID: "v1", + ToVersionID: "v1", + }) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if diff != nil { + t.Fatalf("expected nil diff for same version comparison, got %+v", diff) + } + if repo.called { + t.Fatal("repository should not be called for same version comparison") + } +} + +func TestLoadBillVersionDiffReturnsEmptyClausesForIdenticalText(t *testing.T) { + repo := &fakeBillRepository{ + diff: &domain.BillVersionDiff{ + From: domain.BillVersion{ID: "v1"}, + To: domain.BillVersion{ID: "v2"}, + Clauses: []domain.BillClauseDiff{ + { + ID: "c1", + ChangeType: "unchanged", + FromText: "Same", + ToText: "Same", + }, + { + ID: "c2", + ChangeType: "unchanged", + FromText: "Also Same", + ToText: "Also Same", + }, + }, + }, + } + + diff, err := NewLoadBillVersionDiff(repo).Execute(context.Background(), LoadBillVersionDiffInput{ + BillID: "C-2", + FromVersionID: "v1", + ToVersionID: "v2", + }) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if diff == nil { + t.Fatal("expected non-nil diff for identical versions") + } + if len(diff.Clauses) != 0 { + t.Fatalf("expected empty clauses slice, got %d clauses: %+v", len(diff.Clauses), diff.Clauses) + } +} + type fakeBillRepository struct { diff *domain.BillVersionDiff err error diff --git a/ios/epac/Views/Bills/BillVersionsDiffView.swift b/ios/epac/Views/Bills/BillVersionsDiffView.swift index f05c5819..3795dd8a 100644 --- a/ios/epac/Views/Bills/BillVersionsDiffView.swift +++ b/ios/epac/Views/Bills/BillVersionsDiffView.swift @@ -235,6 +235,15 @@ struct BillVersionsDiffView: View { diff = nil return } + let sorted = Self.sortVersions(versions) + if let fromIndex = sorted.firstIndex(where: { $0.id == fromVersionID }), + let toIndex = sorted.firstIndex(where: { $0.id == toVersionID }), + fromIndex > toIndex { + let temp = fromVersionID + fromVersionID = toVersionID + toVersionID = temp + return + } isLoading = true loadFailed = false defer { isLoading = false } diff --git a/ios/epacTests/__Snapshots__/SnapshotTests/testRecentlyBecameLawCard.RecentlyBecameLawCard_a11y.png b/ios/epacTests/__Snapshots__/SnapshotTests/testRecentlyBecameLawCard.RecentlyBecameLawCard_a11y.png new file mode 100644 index 0000000000000000000000000000000000000000..f599ae2027221353ccc9d7fabc565ec85e8fe4b4 GIT binary patch literal 138478 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