From 978453c5827e155f5cc3984b4fa3b91bf9cc6aea Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Wed, 15 Jul 2026 12:08:41 +0200 Subject: [PATCH] test(web): cover batch scan review bucketing, progression, and done-count math --- web/test/scan_done_test.gleam | 41 ++++ web/test/scan_review_test.gleam | 98 +++++++++ web/test/scan_test.gleam | 349 +++++++++++++++++++++----------- 3 files changed, 373 insertions(+), 115 deletions(-) create mode 100644 web/test/scan_done_test.gleam create mode 100644 web/test/scan_review_test.gleam diff --git a/web/test/scan_done_test.gleam b/web/test/scan_done_test.gleam new file mode 100644 index 0000000..7d8111a --- /dev/null +++ b/web/test/scan_done_test.gleam @@ -0,0 +1,41 @@ +import at_record_web/model.{Model, ScanState, blank_scan} +import at_record_web/pages/scan_done +import gleam/string +import lustre/element +import support.{logged_in} + +fn rendered(old: Int, added: Int) -> String { + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), import_old_count: old, import_added: added), + ) + |> scan_done.view + |> element.to_string +} + +pub fn done_page_shows_the_added_count_test() { + let html = rendered(4, 3) + assert string.contains(html, "3 RECORDS ADDED") +} + +pub fn done_page_renders_old_and_new_totals_test() { + let html = rendered(4, 3) + assert string.contains(html, ">4<") + assert string.contains(html, "id=\"done-count\"") + // new = old + added; rendered up front as a no-JS/reduced-motion fallback. + assert string.contains(html, ">7<") +} + +pub fn done_page_zero_added_still_renders_test() { + let html = rendered(4, 0) + assert string.contains(html, "0 RECORDS ADDED") + assert string.contains(html, ">4<") +} + +pub fn done_page_offers_view_crate_and_scan_more_test() { + let html = rendered(4, 3) + assert string.contains(html, "VIEW CRATE") + assert string.contains(html, "href=\"/\"") + assert string.contains(html, "SCAN MORE") + assert string.contains(html, "href=\"/scan\"") +} diff --git a/web/test/scan_review_test.gleam b/web/test/scan_review_test.gleam new file mode 100644 index 0000000..ca10f15 --- /dev/null +++ b/web/test/scan_review_test.gleam @@ -0,0 +1,98 @@ +import at_record_web/model.{ + type NetworkMatch, type ScanRow, Matched, Model, NetworkMatch, NetworkMatched, + NoMatch, ScanRow, ScanState, blank_scan, scan_importable, +} +import at_record_web/pages/scan_review +import gleam/option.{None, Some} +import gleam/string +import lustre/element +import support.{discogs_result, logged_in} + +fn a_network_match() -> NetworkMatch { + NetworkMatch( + uri: "at://did:plc:pub/dev.mokkenstorm.crate.catalog.release/r1", + cid: "bafyrel", + title: "Spiderland", + artist_display: Some("Slint"), + thumb_url: None, + publisher_handle: Some("alice.test"), + ) +} + +fn a_haul() -> List(ScanRow) { + [ + ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False)), + ScanRow(barcode: "b2", outcome: NetworkMatched(a_network_match())), + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ScanRow(barcode: "b4", outcome: NoMatch([])), + ] +} + +// --- bucketing (model.scan_importable) --- + +pub fn scan_importable_counts_unowned_matches_and_network_matches_test() { + assert scan_importable(a_haul()) + == [ + ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False)), + ScanRow(barcode: "b2", outcome: NetworkMatched(a_network_match())), + ] +} + +pub fn scan_importable_excludes_duplicates_and_no_matches_test() { + let dup_and_no_match = [ + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ScanRow(barcode: "b4", outcome: NoMatch([])), + ] + assert scan_importable(dup_and_no_match) == [] +} + +// --- view: buckets render into the right sections --- + +fn rendered(rows: List(ScanRow)) -> String { + Model(..logged_in(), scan: ScanState(..blank_scan(), rows:)) + |> scan_review.view + |> element.to_string +} + +pub fn matched_bucket_renders_a_matched_sticker_test() { + let html = rendered(a_haul()) + assert string.contains(html, "MATCHED") + assert string.contains(html, "Spiderland") +} + +pub fn network_match_keeps_its_via_handle_line_test() { + let html = rendered(a_haul()) + assert string.contains(html, "via @alice.test") +} + +pub fn owned_matches_land_in_the_duplicate_bucket_test() { + let html = rendered(a_haul()) + assert string.contains(html, "DUPLICATE") + assert string.contains(html, "ALREADY IN YOUR CRATE") +} + +pub fn no_matches_land_in_the_needs_review_bucket_test() { + let html = rendered(a_haul()) + assert string.contains(html, "NEEDS REVIEW") + assert string.contains(html, "SEARCH") +} + +pub fn cta_counts_only_the_importable_rows_test() { + let html = rendered(a_haul()) + assert string.contains(html, "IMPORT 2 → CRATE") +} + +pub fn cta_is_disabled_when_nothing_is_importable_test() { + let html = + rendered([ + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ScanRow(barcode: "b4", outcome: NoMatch([])), + ]) + assert string.contains(html, "IMPORT 0 → CRATE") + assert string.contains(html, "disabled") +} + +pub fn empty_review_prompts_back_to_scan_test() { + let html = rendered([]) + assert string.contains(html, "Nothing scanned yet.") +} diff --git a/web/test/scan_test.gleam b/web/test/scan_test.gleam index 1908452..f8336d4 100644 --- a/web/test/scan_test.gleam +++ b/web/test/scan_test.gleam @@ -1,11 +1,13 @@ +import at_record/gen/defs.{Snapshot} import at_record_web/model.{ - type NetworkMatch, Adding, FromDiscogs, FromNetwork, Matched, Model, - NetworkMatch, NetworkMatched, NoMatch, Pending, ScanRow, ScanState, Suggestion, + type Model, type NetworkMatch, Adding, Crate, Entry, Failed, FromDiscogs, + FromNetwork, Matched, Model, NetworkMatch, NetworkMatched, NoMatch, Pending, + Scan, ScanDone, ScanReview, ScanRow, ScanState, ShelfLoaded, Suggestion, blank_scan, } import at_record_web/msg.{ - AddScannedItem, AddSuggestion, BarcodeDetected, GotScanResult, GotScanSeen, - RemoveScanRow, ScanItemAdded, ScanLookup, + BarcodeDetected, BatchItemDone, GotScanResult, GotScanSeen, OnRouteChange, + RemoveScanRow, RetryBatchItem, StartBatchImport, } import at_record_web/pages/scan import at_record_web/update.{update} @@ -44,7 +46,10 @@ pub fn got_scan_result_marks_owned_from_seen_ids_test() { let #(model, _) = update( seeded, - GotScanResult("b1", Ok(ScanLookup(Some(discogs_result(4577)), [], None))), + GotScanResult( + "b1", + Ok(msg.ScanLookup(Some(discogs_result(4577)), [], None)), + ), ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Matched(discogs_result(4577), True))] @@ -61,7 +66,7 @@ pub fn got_scan_result_unowned_match_test() { let #(model, _) = update( seeded, - GotScanResult("b1", Ok(ScanLookup(Some(discogs_result(9)), [], None))), + GotScanResult("b1", Ok(msg.ScanLookup(Some(discogs_result(9)), [], None))), ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False))] @@ -76,7 +81,7 @@ pub fn got_scan_result_no_match_test() { ]), ) let #(model, _) = - update(seeded, GotScanResult("b1", Ok(ScanLookup(None, [], None)))) + update(seeded, GotScanResult("b1", Ok(msg.ScanLookup(None, [], None)))) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch([]))] } @@ -90,30 +95,15 @@ pub fn got_scan_result_no_match_carries_suggestions_test() { ) let suggestions = [Suggestion(release: discogs_result(9), confidence: 62)] let #(model, _) = - update(seeded, GotScanResult("b1", Ok(ScanLookup(None, suggestions, None)))) - assert model.scan.rows - == [ScanRow(barcode: "b1", outcome: NoMatch(suggestions))] -} - -pub fn add_suggestion_transitions_row_to_adding_and_sets_busy_test() { - let release = discogs_result(9) - let seeded = - Model( - ..logged_in(), - scan: ScanState(..blank_scan(), rows: [ - ScanRow( - barcode: "b1", - outcome: NoMatch([Suggestion(release:, confidence: 62)]), - ), - ]), + update( + seeded, + GotScanResult("b1", Ok(msg.ScanLookup(None, suggestions, None))), ) - let #(model, _) = update(seeded, AddSuggestion("b1", release)) - assert model.busy == True assert model.scan.rows - == [ScanRow(barcode: "b1", outcome: Adding(FromDiscogs(release)))] + == [ScanRow(barcode: "b1", outcome: NoMatch(suggestions))] } -pub fn scan_page_renders_did_you_mean_suggestions_test() { +pub fn scan_page_renders_no_match_row_without_did_you_mean_test() { let model = Model( ..logged_in(), @@ -127,12 +117,13 @@ pub fn scan_page_renders_did_you_mean_suggestions_test() { ]), ) let html = model |> scan.view |> element.to_string - assert string.contains(html, "DID YOU MEAN?") - assert string.contains(html, "62%") - assert string.contains(html, "Spiderland") + // The tray never renders per-suggestion ADD affordances; that lives on + // the review page's SEARCH/DISMISS row instead. + assert !string.contains(html, "DID YOU MEAN?") + assert string.contains(html, "no match") } -pub fn matched_row_dismiss_carries_an_aria_label_test() { +pub fn scan_tray_dismiss_carries_an_aria_label_test() { let model = Model( ..logged_in(), @@ -148,33 +139,21 @@ pub fn matched_row_dismiss_carries_an_aria_label_test() { assert string.contains(html, "type=\"button\"") } -pub fn scan_page_no_suggestions_omits_did_you_mean_test() { +pub fn scan_tray_has_no_inline_add_button_test() { let model = Model( ..logged_in(), - scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "825646350919", outcome: NoMatch([])), - ]), - ) - let html = model |> scan.view |> element.to_string - assert !string.contains(html, "DID YOU MEAN?") -} - -pub fn add_suggestion_ignores_tap_while_busy_test() { - let release = discogs_result(9) - let seeded = - Model( - ..logged_in(), - busy: True, scan: ScanState(..blank_scan(), rows: [ ScanRow( - barcode: "b1", - outcome: NoMatch([Suggestion(release:, confidence: 62)]), + barcode: "825646350919", + outcome: Matched(result: discogs_result(9), owned: False), ), ]), ) - let #(model, _) = update(seeded, AddSuggestion("b1", release)) - assert model.scan.rows == seeded.scan.rows + let html = model |> scan.view |> element.to_string + assert !string.contains(html, ">ADD<") + assert string.contains(html, "1 SCANNED") + assert string.contains(html, "REVIEW") } pub fn got_scan_result_error_marks_no_match_test() { @@ -190,45 +169,6 @@ pub fn got_scan_result_error_marks_no_match_test() { assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch([]))] } -pub fn add_scanned_item_ignores_tap_while_busy_test() { - let seeded = - Model( - ..logged_in(), - busy: True, - scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False)), - ]), - ) - let #(model, _) = update(seeded, AddScannedItem("b1")) - assert model.scan.rows == seeded.scan.rows -} - -pub fn add_scanned_item_ignores_unmatched_row_test() { - let seeded = - Model( - ..logged_in(), - scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "b1", outcome: Pending), - ]), - ) - let #(model, _) = update(seeded, AddScannedItem("b1")) - assert model.busy == False -} - -pub fn scan_item_added_marks_row_added_and_clears_busy_test() { - let seeded = - Model( - ..logged_in(), - busy: True, - scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False)), - ]), - ) - let #(model, _) = update(seeded, ScanItemAdded("b1", Ok(Nil))) - assert model.busy == False - assert model.scan.rows == [ScanRow(barcode: "b1", outcome: model.Added)] -} - pub fn remove_scan_row_drops_it_test() { let seeded = Model( @@ -275,59 +215,238 @@ pub fn got_scan_result_network_hit_wins_over_a_discogs_result_test() { seeded, GotScanResult( "b1", - Ok(ScanLookup(Some(discogs_result(9)), [], Some(release))), + Ok(msg.ScanLookup(Some(discogs_result(9)), [], Some(release))), ), ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NetworkMatched(release))] } -pub fn add_scanned_item_on_network_match_dispatches_adopt_and_marks_adding_test() { - let release = a_network_match() +// --- batch import --- + +fn an_entry() -> model.Entry { + Entry( + entry_id: "e1", + status: "owned", + snapshot: Snapshot( + artist_display: "Slint", + cover: None, + format: Some("LP"), + thumb_url: None, + title: "Spiderland", + year: Some(1991), + ), + media_grade: None, + sleeve_grade: None, + rating: None, + folder: None, + notes: None, + price: None, + counterparty: None, + source: None, + ) +} + +fn seeded_for_batch() -> Model { + Model( + ..logged_in(), + shelf: ShelfLoaded([an_entry()]), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False)), + ScanRow(barcode: "b2", outcome: NetworkMatched(a_network_match())), + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ScanRow(barcode: "b4", outcome: NoMatch([])), + ]), + ) +} + +pub fn start_batch_import_snapshots_old_count_and_starts_the_first_row_test() { + let #(model, effect) = update(seeded_for_batch(), StartBatchImport) + assert model.scan.importing == True + assert model.scan.import_old_count == 1 + assert model.scan.import_added == 0 + assert model.scan.rows + == [ + ScanRow(barcode: "b1", outcome: Adding(FromDiscogs(discogs_result(9)))), + ScanRow(barcode: "b2", outcome: NetworkMatched(a_network_match())), + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ScanRow(barcode: "b4", outcome: NoMatch([])), + ] + assert effect != empty_effect() +} + +pub fn start_batch_import_ignores_a_second_tap_while_running_test() { + let seeded = + Model( + ..seeded_for_batch(), + scan: ScanState(..seeded_for_batch().scan, importing: True), + ) + let #(model, _) = update(seeded, StartBatchImport) + assert model.scan.rows == seeded.scan.rows +} + +pub fn start_batch_import_is_a_no_op_when_nothing_is_pending_test() { let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "b1", outcome: NetworkMatched(release)), + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ScanRow(barcode: "b4", outcome: NoMatch([])), ]), ) - let #(model, effect) = update(seeded, AddScannedItem("b1")) - assert model.busy == True + let #(model, _) = update(seeded, StartBatchImport) + assert model.scan.importing == False + assert model.scan.rows == seeded.scan.rows +} + +pub fn batch_item_done_success_advances_to_the_next_pending_row_test() { + let #(started, _) = update(seeded_for_batch(), StartBatchImport) + let #(model, effect) = update(started, BatchItemDone("b1", Ok(Nil))) + assert model.scan.import_added == 1 + assert model.scan.importing == True assert model.scan.rows - == [ScanRow(barcode: "b1", outcome: Adding(FromNetwork(release)))] - // The adopt effect, not the manual shelf.addEntry write. + == [ + model.ScanRow(barcode: "b1", outcome: model.Added), + ScanRow(barcode: "b2", outcome: Adding(FromNetwork(a_network_match()))), + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ScanRow(barcode: "b4", outcome: NoMatch([])), + ] assert effect != empty_effect() } -pub fn scan_item_added_error_reverts_a_network_row_test() { - let release = a_network_match() - let seeded = +pub fn batch_item_done_failure_keeps_the_row_listed_for_retry_test() { + let #(started, _) = update(seeded_for_batch(), StartBatchImport) + let #(model, _) = + update(started, BatchItemDone("b1", Error(rsvp.NetworkError))) + assert model.scan.import_added == 0 + assert model.scan.rows + == [ + ScanRow(barcode: "b1", outcome: Failed(FromDiscogs(discogs_result(9)))), + ScanRow(barcode: "b2", outcome: Adding(FromNetwork(a_network_match()))), + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ScanRow(barcode: "b4", outcome: NoMatch([])), + ] +} + +pub fn batch_run_finishing_with_a_success_clears_importing_and_navigates_test() { + let #(started, _) = update(seeded_for_batch(), StartBatchImport) + let #(after_b1, _) = update(started, BatchItemDone("b1", Ok(Nil))) + let #(model, effect) = update(after_b1, BatchItemDone("b2", Ok(Nil))) + assert model.scan.importing == False + assert model.scan.import_added == 2 + assert effect != empty_effect() +} + +pub fn batch_run_finishing_with_only_failures_does_not_navigate_test() { + let single = Model( ..logged_in(), - busy: True, scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "b1", outcome: Adding(FromNetwork(release))), + ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False)), ]), ) - let #(model, _) = - update(seeded, ScanItemAdded("b1", Error(rsvp.NetworkError))) - assert model.busy == False + let #(started, _) = update(single, StartBatchImport) + let #(model, effect) = + update(started, BatchItemDone("b1", Error(rsvp.NetworkError))) + assert model.scan.importing == False + assert model.scan.import_added == 0 + assert effect == empty_effect() +} + +pub fn retry_batch_item_re_fires_a_failed_row_test() { + let failed = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: Failed(FromDiscogs(discogs_result(9)))), + ]), + ) + let #(model, effect) = update(failed, RetryBatchItem("b1")) assert model.scan.rows - == [ScanRow(barcode: "b1", outcome: NetworkMatched(release))] + == [ScanRow(barcode: "b1", outcome: Adding(FromDiscogs(discogs_result(9))))] + assert effect != empty_effect() } -pub fn scan_page_renders_network_match_with_via_handle_test() { - let release = a_network_match() - let model = +pub fn retry_batch_item_ignores_a_non_failed_row_test() { + let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "825646350919", outcome: NetworkMatched(release)), + ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False)), ]), ) - let html = model |> scan.view |> element.to_string - assert string.contains(html, "Spiderland") - assert string.contains(html, "Slint") - assert string.contains(html, "via @alice.test") - assert string.contains(html, "ADD") + let #(model, _) = update(seeded, RetryBatchItem("b1")) + assert model.scan.rows == seeded.scan.rows +} + +pub fn retry_batch_item_ignores_a_tap_while_a_run_is_active_test() { + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), importing: True, rows: [ + ScanRow(barcode: "b1", outcome: Failed(FromDiscogs(discogs_result(9)))), + ]), + ) + let #(model, _) = update(seeded, RetryBatchItem("b1")) + assert model.scan.rows == seeded.scan.rows +} + +// --- navigating within the scan/review/done trio --- + +pub fn entering_scan_fresh_blanks_the_session_test() { + let seeded = + Model( + ..logged_in(), + route: Crate, + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: NoMatch([])), + ]), + ) + let #(model, _) = update(seeded, OnRouteChange(Scan)) + assert model.scan.rows == [] +} + +pub fn entering_scan_from_review_preserves_rows_test() { + let seeded = + Model( + ..logged_in(), + route: ScanReview, + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: NoMatch([])), + ]), + ) + let #(model, _) = update(seeded, OnRouteChange(Scan)) + assert model.scan.rows == seeded.scan.rows +} + +pub fn scan_more_from_done_clears_added_rows_but_keeps_unresolved_ones_test() { + let seeded = + Model( + ..logged_in(), + route: ScanDone, + scan: ScanState(..blank_scan(), importing: True, rows: [ + ScanRow(barcode: "b1", outcome: model.Added), + ScanRow(barcode: "b2", outcome: NoMatch([])), + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ]), + ) + let #(model, _) = update(seeded, OnRouteChange(Scan)) + assert model.scan.importing == False + assert model.scan.rows + == [ + ScanRow(barcode: "b2", outcome: NoMatch([])), + ScanRow(barcode: "b3", outcome: Matched(discogs_result(3), True)), + ] +} + +pub fn entering_scan_done_dispatches_the_count_up_effect_test() { + let seeded = + Model( + ..logged_in(), + route: ScanReview, + scan: ScanState(..blank_scan(), import_old_count: 4, import_added: 2), + ) + let #(model, effect) = update(seeded, OnRouteChange(ScanDone)) + assert model.route == ScanDone + assert effect != empty_effect() } -- 2.51.2