import at_record/gen/defs.{Snapshot} import at_record_web/model.{ 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.{ AcceptSuggestion, BarcodeDetected, BatchItemDone, GotScanResult, GotScanSeen, OnRouteChange, RemoveScanRow, RetryBatchItem, StartBatchImport, } import at_record_web/pages/scan import at_record_web/update.{update} import gleam/option.{None, Some} import gleam/set import gleam/string import lustre/element import rsvp import support.{discogs_result, empty_effect, logged_in} pub fn barcode_detected_adds_pending_row_test() { let #(model, _) = update(logged_in(), BarcodeDetected("0123456789")) assert model.scan.rows == [ScanRow(barcode: "0123456789", outcome: Pending)] } pub fn barcode_detected_ignores_already_scanned_code_test() { let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow(barcode: "0123456789", outcome: Pending), ]), ) let #(model, _) = update(seeded, BarcodeDetected("0123456789")) assert model.scan.rows == seeded.scan.rows } pub fn got_scan_result_marks_owned_from_seen_ids_test() { let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), seen_ids: set.from_list(["4577"]), rows: [ ScanRow(barcode: "b1", outcome: Pending), ]), ) let #(model, _) = update( seeded, GotScanResult( "b1", Ok(msg.ScanLookup(Some(discogs_result(4577)), [], None)), ), ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Matched(discogs_result(4577), True))] } pub fn got_scan_result_unowned_match_test() { let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow(barcode: "b1", outcome: Pending), ]), ) let #(model, _) = update( seeded, GotScanResult("b1", Ok(msg.ScanLookup(Some(discogs_result(9)), [], None))), ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False))] } pub fn got_scan_result_no_match_test() { let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow(barcode: "b1", outcome: Pending), ]), ) let #(model, _) = update(seeded, GotScanResult("b1", Ok(msg.ScanLookup(None, [], None)))) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch([]))] } pub fn got_scan_result_no_match_carries_suggestions_test() { let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow(barcode: "b1", outcome: Pending), ]), ) let suggestions = [Suggestion(release: discogs_result(9), confidence: 62)] let #(model, _) = update( seeded, GotScanResult("b1", Ok(msg.ScanLookup(None, suggestions, None))), ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch(suggestions))] } pub fn scan_page_renders_no_match_row_without_did_you_mean_test() { let model = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow( barcode: "825646350919", outcome: NoMatch([ Suggestion(release: discogs_result(9), confidence: 62), ]), ), ]), ) let html = model |> scan.view |> element.to_string // The tray only hints at suggestions; the cards render on the review page. assert !string.contains(html, "DID YOU MEAN?") assert string.contains(html, "no match ยท 1 suggestions") } pub fn scan_tray_no_match_without_candidates_omits_the_hint_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, "no match") assert !string.contains(html, "suggestions") } pub fn scan_tray_dismiss_carries_an_aria_label_test() { let model = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow( barcode: "825646350919", outcome: Matched(result: discogs_result(9), owned: False), ), ]), ) let html = model |> scan.view |> element.to_string assert string.contains(html, "aria-label=\"Dismiss 825646350919\"") assert string.contains(html, "type=\"button\"") } pub fn scan_tray_has_no_inline_add_button_test() { let model = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow( barcode: "825646350919", outcome: Matched(result: discogs_result(9), owned: False), ), ]), ) 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() { let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow(barcode: "b1", outcome: Pending), ]), ) let #(model, _) = update(seeded, GotScanResult("b1", Error(rsvp.NetworkError))) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NoMatch([]))] } pub fn remove_scan_row_drops_it_test() { let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow(barcode: "b1", outcome: NoMatch([])), ScanRow(barcode: "b2", outcome: Pending), ]), ) let #(model, _) = update(seeded, RemoveScanRow("b1")) assert model.scan.rows == [ScanRow(barcode: "b2", outcome: Pending)] } pub fn got_scan_seen_stores_ids_test() { let #(model, _) = update(logged_in(), GotScanSeen(Ok(set.from_list(["1", "2"])))) assert model.scan.seen_ids == set.from_list(["1", "2"]) } 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"), ) } pub fn got_scan_result_network_hit_wins_over_a_discogs_result_test() { let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow(barcode: "b1", outcome: Pending), ]), ) let release = a_network_match() let #(model, _) = update( seeded, GotScanResult( "b1", Ok(msg.ScanLookup(Some(discogs_result(9)), [], Some(release))), ), ) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: NetworkMatched(release))] } pub fn accept_suggestion_promotes_the_row_to_matched_without_a_write_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)]), ), ]), ) let #(model, effect) = update(seeded, AcceptSuggestion("b1", release)) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Matched(result: release, owned: False))] assert effect == empty_effect() } pub fn accept_suggestion_grows_the_importable_count_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)]), ), ]), ) assert model.scan_importable(seeded.scan.rows) == [] let #(model, _) = update(seeded, AcceptSuggestion("b1", release)) assert model.scan_importable(model.scan.rows) == [ScanRow(barcode: "b1", outcome: Matched(result: release, owned: False))] } pub fn accept_suggestion_ignores_a_row_that_is_not_no_match_test() { let release = discogs_result(9) let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow(barcode: "b1", outcome: model.Added), ]), ) let #(model, _) = update(seeded, AcceptSuggestion("b1", release)) assert model.scan.rows == seeded.scan.rows } // Accepting mid-run stays pure; the promoted row joins the loop's pending set. pub fn accept_suggestion_still_promotes_while_a_run_is_active_test() { let release = discogs_result(9) let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), importing: True, rows: [ ScanRow( barcode: "b1", outcome: NoMatch([Suggestion(release:, confidence: 62)]), ), ]), ) let #(model, _) = update(seeded, AcceptSuggestion("b1", release)) assert model.scan.rows == [ScanRow(barcode: "b1", outcome: Matched(result: release, owned: False))] } 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: "b3", outcome: Matched(discogs_result(3), True)), ScanRow(barcode: "b4", outcome: NoMatch([])), ]), ) 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 == [ 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 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(), scan: ScanState(..blank_scan(), rows: [ ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), 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: Adding(FromDiscogs(discogs_result(9))))] assert effect != empty_effect() } pub fn retry_batch_item_ignores_a_non_failed_row_test() { let seeded = Model( ..logged_in(), scan: ScanState(..blank_scan(), rows: [ ScanRow(barcode: "b1", outcome: Matched(discogs_result(9), False)), ]), ) 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 } 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() }