From 95a10beee2da7483f33277be63bdb7d38c8cb56d Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 13 Jul 2026 19:57:17 +0000 Subject: [PATCH] test(web): split the web test monolith into per-page files with a shared support module --- web/test/add_test.gleam | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/test/at_record_web_test.gleam | 1286 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- web/test/browse_test.gleam | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/test/crate_test.gleam | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/test/edit_inbox_test.gleam | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/test/effects_test.gleam | 13 +++++++++++++ web/test/login_test.gleam | 44 ++++++++++++++++++++++++++++++++++++++++++++ web/test/record_test.gleam | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/test/scan_test.gleam | 317 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/test/settings_test.gleam | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/test/support.gleam | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 11 file(s) changed, 1359 insertion(s)(+), 1286 deletion(s)(-) diff --git a/web/test/add_test.gleam b/web/test/add_test.gleam new file mode 100644 --- /dev/null +++ b/web/test/add_test.gleam @@ -0,0 +1,131 @@ +import at_record_web/model.{ + ArtistHit, Discogs, Failure, Form, Model, Notice, Success, blank_discogs, + blank_form, +} +import at_record_web/msg.{ + FormArtist, FormCounterparty, FormFolder, FormPriceAmount, FormPriceCurrency, + FormRating, FormSleeveGrade, GotAdd, GotArtists, SubmitAdd, UseArtist, +} +import at_record_web/pages/add +import at_record_web/update.{update} +import gleam/option.{None, Some} +import gleam/string +import lustre/element +import support.{base, logged_in} + +pub fn submit_add_rejects_whitespace_only_title_test() { + let model = + Model(..base(), form: Form(..blank_form(), title: " ", artist: "x")) + let #(after, _) = update(model, SubmitAdd) + assert after.notice == Some(Notice(Failure, "Title and artist are required.")) +} + +pub fn submit_add_rejects_an_unparseable_price_test() { + let model = + Model( + ..base(), + form: Form( + ..blank_form(), + title: "Spiderland", + artist: "Slint", + price_amount: "free", + ), + ) + let #(after, _) = update(model, SubmitAdd) + assert after.notice == Some(Notice(Failure, "Enter a valid price amount.")) + assert after.busy == False +} + +pub fn submit_add_accepts_a_blank_price_test() { + let model = + Model( + ..base(), + form: Form(..blank_form(), title: "Spiderland", artist: "Slint"), + ) + let #(after, _) = update(model, SubmitAdd) + assert after.busy == True + assert after.notice == None +} + +pub fn submit_add_accepts_a_valid_price_test() { + let model = + Model( + ..base(), + form: Form( + ..blank_form(), + title: "Spiderland", + artist: "Slint", + price_amount: "40.00", + ), + ) + let #(after, _) = update(model, SubmitAdd) + assert after.busy == True + assert after.notice == None +} + +pub fn form_sleeve_grade_updates_form_test() { + let #(model, _) = update(base(), FormSleeveGrade("VG+")) + assert model.form.sleeve_grade == "VG+" +} + +pub fn form_rating_updates_form_test() { + let #(model, _) = update(base(), FormRating("4")) + assert model.form.rating == "4" +} + +pub fn form_folder_updates_form_test() { + let #(model, _) = update(base(), FormFolder("Rock A-M")) + assert model.form.folder == "Rock A-M" +} + +pub fn form_price_amount_updates_form_test() { + let #(model, _) = update(base(), FormPriceAmount("40.00")) + assert model.form.price_amount == "40.00" +} + +pub fn form_price_currency_updates_form_test() { + let #(model, _) = update(base(), FormPriceCurrency("USD")) + assert model.form.price_currency == "USD" +} + +pub fn form_counterparty_updates_form_test() { + let #(model, _) = update(base(), FormCounterparty("Record shop")) + assert model.form.counterparty == "Record shop" +} + +pub fn got_add_success_sets_notice_with_title_test() { + let model = + Model(..logged_in(), form: Form(..blank_form(), title: "Spiderland")) + let #(after, _) = update(model, GotAdd(Ok(Nil))) + assert case after.notice { + Some(Notice(Success, message)) -> string.contains(message, "Spiderland") + _ -> False + } +} + +pub fn form_artist_debounces_and_clears_id_test() { + let seeded = + Model(..base(), discogs: Discogs(..blank_discogs(), artist_id: Some("123"))) + let #(model, effect) = update(seeded, FormArtist("Slint")) + assert model.form.artist == "Slint" + assert model.discogs.artist_id == None + assert effect != support.empty_effect() +} + +pub fn use_artist_sets_name_and_id_test() { + let hit = ArtistHit(id: "42", name: "Slint", thumb_url: None) + let #(model, _effect) = update(base(), UseArtist(hit)) + assert model.form.artist == "Slint" + assert model.discogs.artist_id == Some("42") +} + +pub fn got_artists_populates_suggestions_test() { + let hit = ArtistHit(id: "42", name: "Slint", thumb_url: None) + let #(model, _effect) = update(base(), GotArtists(Ok([hit]))) + assert model.discogs.artist_results == [hit] +} + +pub fn add_view_no_longer_carries_the_discogs_section_test() { + let html = add.view(logged_in()) |> element.to_string + assert !string.contains(html, "DISCOGS ACCOUNT") +} diff --git a/web/test/at_record_web_test.gleam b/web/test/at_record_web_test.gleam --- a/web/test/at_record_web_test.gleam +++ b/web/test/at_record_web_test.gleam @@ -1,1291 +1,5 @@ -import at_record/gen/defs.{type Snapshot, Price, Snapshot} -import at_record_web/effects -import at_record_web/model.{ - type BrowseRelease, type DiscogsResult, type EditProposal, type InboxCard, - type Model, type NetworkMatch, type ReleaseInfo, Added, Adding, ArtistHit, - BrowseRelease, Crate, Discogs, DiscogsResult, EditInbox, EditProposal, Entry, - Failure, Form, FromDiscogs, FromNetwork, Grid, ImportRun, InboxCard, - InboxFailed, InboxLoaded, InboxLoading, LoggedIn, LoggedOut, Matched, Model, - NetworkMatch, NetworkMatched, NoMatch, Notice, Pending, ProposalApplied, - ProposalApplying, ProposalFields, ProposalReviewing, ReleaseInfo, Rows, - ScanRow, ScanState, Settings, ShelfFailed, ShelfLoaded, ShelfLoading, Source, - Success, Suggestion, Warning, blank_amend, blank_discogs, blank_form, - blank_scan, -} -import at_record_web/msg.{ - AddScannedItem, AddSuggestion, AppliedProposal, ApplyProposal, ArmRemove, - BarcodeDetected, BrowseAdd, ClearNotice, DiscogsImportWantlist, EntryAction, - FormArtist, FormCounterparty, FormFolder, FormPriceAmount, FormPriceCurrency, - FormRating, FormSleeveGrade, FormTitle, GotAdd, GotApplyProposal, GotArtists, - GotAvatar, GotBrowse, GotBrowseAdd, GotDiscogsImport, GotDiscogsStatus, - GotEditInbox, GotLogout, GotScanResult, GotScanSeen, GotShelf, GotTimeline, - HandleChanged, IgnoreProposal, Logout, OnRouteChange, RemoveScanRow, - RetryShelf, ScanItemAdded, ScanLookup, SetDisplay, SetView, ShelfData, - ShowMoreCrate, SubmitAdd, TimelineData, UseArtist, -} -import at_record_web/pages/add -import at_record_web/pages/crate -import at_record_web/pages/edit_inbox -import at_record_web/pages/login -import at_record_web/pages/record -import at_record_web/pages/scan -import at_record_web/pages/settings -import at_record_web/route -import at_record_web/update.{update} -import at_record_web/view -import gleam/dict -import gleam/http/response.{Response} -import gleam/int -import gleam/list -import gleam/option.{None, Some} -import gleam/set -import gleam/string -import gleam/uri import gleeunit -import lustre/effect.{type Effect} -import lustre/element -import rsvp - -fn empty_effect() -> Effect(a) { - effect.none() -} - -fn unauthorized() -> rsvp.Error(String) { - rsvp.HttpError(Response(401, [], "")) -} pub fn main() -> Nil { gleeunit.main() -} - -fn base() -> Model { - Model( - auth: LoggedOut, - route: Crate, - login_handle: "", - login_suggestions: [], - login_error: None, - view: "owned", - display: Grid, - shelf: ShelfLoaded([]), - crate_window: model.crate_window_size, - via_handles: dict.new(), - form: blank_form(), - notice: None, - busy: False, - pending_revert: None, - selected: None, - editing: False, - confirm_remove: False, - timeline: [], - release_info: None, - discogs: blank_discogs(), - scan: blank_scan(), - amending: False, - publishing: False, - amend: blank_amend(), - avatar: None, - browse: [], - browse_adding: None, - browse_query: "", - browse_genre: "", - inbox: model.InboxLoading, - ) -} - -fn logged_in() -> Model { - Model(..base(), auth: LoggedIn("alice.test")) -} - -pub fn blank_form_defaults_to_owned_test() { - assert blank_form().status == "owned" -} - -pub fn handle_changed_updates_login_handle_test() { - let #(model, _effect) = update(base(), HandleChanged("alice.test")) - assert model.login_handle == "alice.test" -} - -pub fn form_title_updates_form_test() { - let #(model, _effect) = update(base(), FormTitle("Spiderland")) - assert model.form.title == "Spiderland" -} - -pub fn shelf_401_logs_out_test() { - let #(model, _) = - update(logged_in(), GotShelf(Error(rsvp.HttpError(Response(401, [], ""))))) - assert model.auth == LoggedOut -} - -pub fn shelf_500_keeps_auth_and_marks_failed_test() { - let #(model, _) = - update(logged_in(), GotShelf(Error(rsvp.HttpError(Response(500, [], ""))))) - assert model.auth == LoggedIn("alice.test") - assert model.shelf == ShelfFailed - assert case model.notice { - Some(Notice(Failure, _)) -> True - _ -> False - } -} - -pub fn shelf_network_error_keeps_auth_and_marks_failed_test() { - let #(model, _) = update(logged_in(), GotShelf(Error(rsvp.NetworkError))) - assert model.auth == LoggedIn("alice.test") - assert model.shelf == ShelfFailed - assert case model.notice { - Some(Notice(Failure, _)) -> True - _ -> False - } -} - -pub fn shelf_401_clears_to_empty_loaded_test() { - let #(model, _) = - update(logged_in(), GotShelf(Error(rsvp.HttpError(Response(401, [], ""))))) - assert model.auth == LoggedOut - assert model.shelf == ShelfLoaded([]) -} - -pub fn shelf_ok_marks_loaded_test() { - let #(model, _) = - update(logged_in(), GotShelf(Ok(ShelfData("alice.test", [], dict.new())))) - assert model.shelf == ShelfLoaded([]) - assert model.busy == False -} - -pub fn retry_shelf_resets_to_loading_and_reloads_test() { - let failed = Model(..logged_in(), shelf: ShelfFailed, busy: False) - let #(model, effect) = update(failed, RetryShelf) - assert model.shelf == ShelfLoading - assert model.busy == True - assert model.notice == None - // A real load effect is dispatched (not effect.none()). - assert effect != empty_effect() -} - -pub fn set_view_resets_to_loading_test() { - let seeded = Model(..logged_in(), crate_window: 120) - let #(updated, _) = update(seeded, SetView("wanted")) - assert updated.shelf == ShelfLoading - assert updated.view == "wanted" - // Switching filter rewinds the window so the new view starts from the top. - assert updated.crate_window == model.crate_window_size -} - -pub fn set_display_switches_to_rows_test() { - let #(model, _) = update(logged_in(), SetDisplay(Rows)) - assert model.display == Rows -} - -pub fn set_display_back_to_grid_is_a_pure_toggle_test() { - let rows_view = Model(..logged_in(), display: Rows) - let #(model, effect) = update(rows_view, SetDisplay(Grid)) - assert model.display == Grid - // Switching display is local UI state only, no shelf reload. - assert effect == empty_effect() -} - -pub fn session_expired_is_a_warning_notice_test() { - let #(model, _) = - update(logged_in(), GotBrowseAdd("at://x", Error(unauthorized()))) - assert case model.notice { - Some(Notice(Warning, _)) -> True - _ -> False - } -} - -pub fn clear_notice_drops_it_test() { - let noticed = Model(..logged_in(), notice: Some(Notice(Failure, "boom"))) - let #(model, _) = update(noticed, ClearNotice) - assert model.notice == None -} - -// --- crate page empty states --- - -fn rendered_empty_crate(view: String) -> String { - Model(..logged_in(), view:, shelf: ShelfLoaded([])) - |> crate.view - |> element.to_string -} - -pub fn empty_owned_view_shows_first_run_panel_with_toolbar_test() { - let html = rendered_empty_crate("owned") - assert string.contains(html, "NOTHING IN THE CRATE YET") - assert string.contains(html, "WANTED") - assert string.contains(html, "HISTORY") - // The first-run state is the only crate state without the FAB. - assert !string.contains(html, "class=\"fab\"") -} - -pub fn empty_wanted_view_keeps_toolbar_and_fab_test() { - let html = rendered_empty_crate("wanted") - assert string.contains(html, "OWNED") - assert string.contains(html, "HISTORY") - assert string.contains(html, "0 on your wishlist") - assert string.contains(html, "Nothing on your wishlist yet") - assert string.contains(html, "class=\"fab\"") - assert !string.contains(html, "NOTHING IN THE CRATE YET") -} - -pub fn empty_history_view_keeps_toolbar_and_fab_test() { - let html = rendered_empty_crate("history") - assert string.contains(html, "OWNED") - assert string.contains(html, "WANTED") - assert string.contains(html, "0 in your history") - assert string.contains(html, "No history yet") - assert string.contains(html, "class=\"fab\"") - assert !string.contains(html, "NOTHING IN THE CRATE YET") -} - -pub fn empty_owned_view_omits_the_display_toggle_test() { - let html = rendered_empty_crate("owned") - assert !string.contains(html, "class=\"display-toggle\"") -} - -// --- crate page loaded state / list display ----------------------------- - -fn rendered_loaded_crate(display: model.Display) -> String { - Model(..logged_in(), display:, shelf: ShelfLoaded([an_entry()])) - |> crate.view - |> element.to_string -} - -pub fn loaded_grid_shows_the_display_toggle_and_cover_cards_test() { - let html = rendered_loaded_crate(Grid) - assert string.contains(html, "class=\"display-toggle\"") - assert string.contains(html, "▦") - assert string.contains(html, "☰") - assert string.contains(html, "class=\"cover-card\"") - assert !string.contains(html, "class=\"row\"") -} - -pub fn loaded_rows_render_title_and_muted_artist_format_meta_test() { - let html = rendered_loaded_crate(Rows) - assert string.contains(html, "class=\"row\"") - assert string.contains(html, "class=\"row__title\"") - assert string.contains(html, "Spiderland") - assert string.contains(html, "Slint · LP / 1991") - assert !string.contains(html, "class=\"cover-card\"") -} - -// --- record detail page --- - -fn a_snapshot() -> Snapshot { - Snapshot( - artist_display: "Slint", - cover: None, - format: Some("LP"), - thumb_url: None, - title: "Spiderland", - year: Some(1991), - ) -} - -fn an_entry() -> model.Entry { - Entry( - entry_id: "e1", - status: "owned", - snapshot: a_snapshot(), - media_grade: Some("NM"), - sleeve_grade: Some("VG+"), - rating: Some(4), - folder: None, - notes: None, - price: Some(Price(amount: 4000, currency: "EUR")), - counterparty: Some("Record shop"), - source: None, - ) -} - -pub fn record_view_shows_the_sleeve_grade_card_when_present_test() { - let html = record.view(logged_in(), an_entry()) |> element.to_string - assert string.contains(html, "SLEEVE") - assert string.contains(html, "VG+") -} - -pub fn record_view_omits_the_sleeve_grade_card_when_absent_test() { - let html = - record.view(logged_in(), Entry(..an_entry(), sleeve_grade: None)) - |> element.to_string - assert !string.contains(html, "SLEEVE") -} - -pub fn record_view_shows_paid_and_from_rows_when_present_test() { - let html = record.view(logged_in(), an_entry()) |> element.to_string - assert string.contains(html, "PAID") - assert string.contains(html, "EUR 40.00") - assert string.contains(html, "FROM") - assert string.contains(html, "Record shop") -} - -pub fn record_view_omits_paid_and_from_rows_when_absent_test() { - let html = - record.view( - logged_in(), - Entry(..an_entry(), price: None, counterparty: None), - ) - |> element.to_string - assert !string.contains(html, "PAID") - assert !string.contains(html, "FROM") -} - -pub fn record_view_shows_the_provenance_line_when_source_is_present_test() { - let source = Source(origin: Some("scan"), provider: Some("discogs")) - let html = - record.view(logged_in(), Entry(..an_entry(), source: Some(source))) - |> element.to_string - assert string.contains(html, "class=\"titleblock__source\"") - assert string.contains(html, "scanned · via Discogs") -} - -pub fn record_view_shows_added_by_hand_for_manual_source_test() { - let source = Source(origin: Some("manual"), provider: None) - let html = - record.view(logged_in(), Entry(..an_entry(), source: Some(source))) - |> element.to_string - assert string.contains(html, "added by hand") -} - -pub fn record_view_omits_the_provenance_line_when_source_is_absent_test() { - let html = - record.view(logged_in(), Entry(..an_entry(), source: None)) - |> element.to_string - assert !string.contains(html, "class=\"titleblock__source\"") -} - -pub fn oauth_error_copy_maps_known_codes_test() { - assert string.contains( - login.error_copy("access_denied"), - "turned the request", - ) - assert login.error_copy("state_mismatch") == login.error_copy("bad_callback") - assert string.contains( - login.error_copy("token_exchange_failed"), - "token exchange", - ) - assert string.contains( - login.error_copy("session_failed"), - "save your session", - ) - assert string.contains(login.error_copy("resolve_failed"), "handle's PDS") - assert login.error_copy("something_new") == "Login failed. Try again." -} - -pub fn logout_clears_session_test() { - let #(model, _) = update(logged_in(), Logout) - assert model.auth == LoggedOut - assert model.shelf == ShelfLoading -} - -pub fn got_logout_logs_out_test() { - let #(model, _) = update(logged_in(), GotLogout) - assert model.auth == LoggedOut -} - -pub fn submit_add_rejects_whitespace_only_title_test() { - let model = - Model(..base(), form: Form(..blank_form(), title: " ", artist: "x")) - let #(after, _) = update(model, SubmitAdd) - assert after.notice == Some(Notice(Failure, "Title and artist are required.")) -} - -pub fn submit_add_rejects_an_unparseable_price_test() { - let model = - Model( - ..base(), - form: Form( - ..blank_form(), - title: "Spiderland", - artist: "Slint", - price_amount: "free", - ), - ) - let #(after, _) = update(model, SubmitAdd) - assert after.notice == Some(Notice(Failure, "Enter a valid price amount.")) - assert after.busy == False -} - -pub fn submit_add_accepts_a_blank_price_test() { - let model = - Model( - ..base(), - form: Form(..blank_form(), title: "Spiderland", artist: "Slint"), - ) - let #(after, _) = update(model, SubmitAdd) - assert after.busy == True - assert after.notice == None -} - -pub fn submit_add_accepts_a_valid_price_test() { - let model = - Model( - ..base(), - form: Form( - ..blank_form(), - title: "Spiderland", - artist: "Slint", - price_amount: "40.00", - ), - ) - let #(after, _) = update(model, SubmitAdd) - assert after.busy == True - assert after.notice == None -} - -pub fn form_sleeve_grade_updates_form_test() { - let #(model, _) = update(base(), FormSleeveGrade("VG+")) - assert model.form.sleeve_grade == "VG+" -} - -pub fn form_rating_updates_form_test() { - let #(model, _) = update(base(), FormRating("4")) - assert model.form.rating == "4" -} - -pub fn form_folder_updates_form_test() { - let #(model, _) = update(base(), FormFolder("Rock A-M")) - assert model.form.folder == "Rock A-M" -} - -pub fn form_price_amount_updates_form_test() { - let #(model, _) = update(base(), FormPriceAmount("40.00")) - assert model.form.price_amount == "40.00" -} - -pub fn form_price_currency_updates_form_test() { - let #(model, _) = update(base(), FormPriceCurrency("USD")) - assert model.form.price_currency == "USD" -} - -pub fn form_counterparty_updates_form_test() { - let #(model, _) = update(base(), FormCounterparty("Record shop")) - assert model.form.counterparty == "Record shop" -} - -pub fn got_add_success_sets_notice_with_title_test() { - let model = - Model(..logged_in(), form: Form(..blank_form(), title: "Spiderland")) - let #(after, _) = update(model, GotAdd(Ok(Nil))) - assert case after.notice { - Some(Notice(Success, message)) -> string.contains(message, "Spiderland") - _ -> False - } -} - -pub fn arm_remove_then_entry_action_disarms_and_sets_busy_test() { - let #(armed, _) = update(logged_in(), ArmRemove) - assert armed.confirm_remove == True - let #(after, _) = update(armed, EntryAction("e1", "dropped")) - assert after.confirm_remove == False - assert after.busy == True -} - -pub fn got_avatar_stores_it_and_logout_clears_it_test() { - let #(with_avatar, _) = - update(logged_in(), GotAvatar(Ok(Some("https://example.test/a.jpg")))) - assert with_avatar.avatar == Some("https://example.test/a.jpg") - let #(after_logout, _) = update(with_avatar, Logout) - assert after_logout.avatar == None -} - -pub fn form_artist_debounces_and_clears_id_test() { - let seeded = - Model(..base(), discogs: Discogs(..blank_discogs(), artist_id: Some("123"))) - let #(model, effect) = update(seeded, FormArtist("Slint")) - assert model.form.artist == "Slint" - assert model.discogs.artist_id == None - assert effect != empty_effect() -} - -pub fn use_artist_sets_name_and_id_test() { - let hit = ArtistHit(id: "42", name: "Slint", thumb_url: None) - let #(model, _effect) = update(base(), UseArtist(hit)) - assert model.form.artist == "Slint" - assert model.discogs.artist_id == Some("42") -} - -pub fn got_artists_populates_suggestions_test() { - let hit = ArtistHit(id: "42", name: "Slint", thumb_url: None) - let #(model, _effect) = update(base(), GotArtists(Ok([hit]))) - assert model.discogs.artist_results == [hit] -} - -pub fn discogs_search_url_includes_artist_when_present_test() { - let url = effects.discogs_search_url("spiderland", True, 1, Some("Slint")) - assert string.contains(url, "artist=Slint") -} - -pub fn discogs_search_url_omits_artist_when_absent_test() { - let url = effects.discogs_search_url("spiderland", True, 1, None) - assert !string.contains(url, "artist=") -} - -pub fn discogs_status_sets_username_test() { - let #(model, _) = - update(logged_in(), GotDiscogsStatus(Ok(Some("crate-digger")))) - assert model.discogs.username == Some("crate-digger") -} - -pub fn discogs_import_wantlist_marks_importing_and_clears_notice_test() { - let model = - Model( - ..logged_in(), - notice: Some(Notice(Failure, "stale")), - discogs: blank_discogs(), - ) - let #(after, _) = update(model, DiscogsImportWantlist) - assert after.discogs.importing == True - assert after.notice == None -} - -pub fn import_result_summarises_partial_run_test() { - let importing = - Model(..logged_in(), discogs: Discogs(..blank_discogs(), importing: True)) - let #(model, _) = - update( - importing, - GotDiscogsImport( - Ok(ImportRun( - imported: 100, - updated: 0, - skipped: 18, - done: False, - note: None, - )), - ), - ) - assert model.discogs.importing == False - assert model.notice - == Some(Notice( - Success, - "Imported 100, skipped 18 already in your crate. Run again to continue.", - )) -} - -pub fn import_result_complete_run_has_no_continue_hint_test() { - let #(model, _) = - update( - logged_in(), - GotDiscogsImport( - Ok(ImportRun( - imported: 3, - updated: 2, - skipped: 0, - done: True, - note: None, - )), - ), - ) - assert model.notice - == Some(Notice( - Success, - "Imported 3, skipped 0 already in your crate. Updated 2 with cover art.", - )) -} - -// --- barcode scan --- - -fn discogs_result(id: Int) -> DiscogsResult { - DiscogsResult( - discogs_id: id, - title: "Spiderland", - artist: "Slint", - year: Some(1991), - format: Some("LP"), - thumb_url: None, - cover_url: None, - ) -} - -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(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(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(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(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)]), - ), - ]), - ) - let #(model, _) = update(seeded, AddSuggestion("b1", release)) - assert model.busy == True - assert model.scan.rows - == [ScanRow(barcode: "b1", outcome: Adding(FromDiscogs(release)))] -} - -pub fn scan_page_renders_did_you_mean_suggestions_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 - assert string.contains(html, "DID YOU MEAN?") - assert string.contains(html, "62%") - assert string.contains(html, "Spiderland") -} - -pub fn scan_page_no_suggestions_omits_did_you_mean_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)]), - ), - ]), - ) - let #(model, _) = update(seeded, AddSuggestion("b1", release)) - assert model.scan.rows == seeded.scan.rows -} - -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 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: Added)] -} - -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"]) -} - -// --- barcode scan: network-first match --- - -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(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() - let seeded = - Model( - ..logged_in(), - scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "b1", outcome: NetworkMatched(release)), - ]), - ) - let #(model, effect) = update(seeded, AddScannedItem("b1")) - assert model.busy == True - assert model.scan.rows - == [ScanRow(barcode: "b1", outcome: Adding(FromNetwork(release)))] - // The adopt effect, not the manual shelf.addEntry write. - assert effect != empty_effect() -} - -pub fn scan_item_added_error_reverts_a_network_row_test() { - let release = a_network_match() - let seeded = - Model( - ..logged_in(), - busy: True, - scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "b1", outcome: Adding(FromNetwork(release))), - ]), - ) - let #(model, _) = - update(seeded, ScanItemAdded("b1", Error(rsvp.NetworkError))) - assert model.busy == False - assert model.scan.rows - == [ScanRow(barcode: "b1", outcome: NetworkMatched(release))] -} - -pub fn scan_page_renders_network_match_with_via_handle_test() { - let release = a_network_match() - let model = - Model( - ..logged_in(), - scan: ScanState(..blank_scan(), rows: [ - ScanRow(barcode: "825646350919", outcome: NetworkMatched(release)), - ]), - ) - 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") -} - -// --- record timeline / release info --- - -fn a_release_info() -> ReleaseInfo { - ReleaseInfo( - artist_display: Some("Slint"), - genres: ["Rock"], - styles: ["Math Rock"], - country: Some("US"), - released: Some("1991"), - ) -} - -pub fn got_timeline_with_release_stores_release_info_test() { - let #(model, _) = - update( - logged_in(), - GotTimeline(Ok(TimelineData(events: [], release: Some(a_release_info())))), - ) - assert model.release_info == Some(a_release_info()) -} - -pub fn got_timeline_without_release_leaves_release_info_none_test() { - let #(model, _) = - update( - logged_in(), - GotTimeline(Ok(TimelineData(events: [], release: None))), - ) - assert model.release_info == None -} - -pub fn route_change_clears_release_info_test() { - let seeded = Model(..logged_in(), release_info: Some(a_release_info())) - let #(model, _) = update(seeded, OnRouteChange(Crate)) - assert model.release_info == None -} - -// --- browse --- - -fn a_browse_release(uri: String) -> BrowseRelease { - BrowseRelease( - uri:, - cid: "cid-1", - title: "Spiderland", - genres: [], - styles: [], - publisher_did: "did:plc:abc", - publisher_handle: "alice.test", - owned: False, - wanted: False, - artist_display: Some("Slint"), - cover_url: None, - thumb_url: None, - released: None, - country: None, - ) -} - -pub fn got_browse_stores_rows_test() { - let rows = [a_browse_release("at://did:plc:abc/catalog.release/1")] - let #(model, _) = update(logged_in(), GotBrowse(Ok(rows))) - assert model.browse == rows - assert model.busy == False -} - -pub fn browse_add_marks_in_flight_test() { - let #(model, _) = - update(logged_in(), BrowseAdd("at://uri", "cid-1", "wanted")) - assert model.browse_adding == Some(#("at://uri", "wanted")) -} - -pub fn browse_add_is_guarded_while_already_adding_test() { - let seeded = - Model(..logged_in(), browse_adding: Some(#("at://other", "owned"))) - let #(model, _) = update(seeded, BrowseAdd("at://uri", "cid-1", "wanted")) - assert model.browse_adding == Some(#("at://other", "owned")) -} - -pub fn got_browse_add_success_flips_the_matching_row_and_clears_adding_test() { - let uri = "at://did:plc:abc/catalog.release/1" - let seeded = - Model( - ..logged_in(), - browse: [a_browse_release(uri)], - browse_adding: Some(#(uri, "owned")), - ) - let #(model, _) = update(seeded, GotBrowseAdd(uri, Ok(Nil))) - assert model.browse_adding == None - assert case model.browse { - [row] -> row.owned == True && row.wanted == False - _ -> False - } -} - -pub fn got_browse_add_error_clears_adding_with_notice_test() { - let uri = "at://did:plc:abc/catalog.release/1" - let seeded = - Model( - ..logged_in(), - browse: [a_browse_release(uri)], - browse_adding: Some(#(uri, "owned")), - ) - let #(model, _) = update(seeded, GotBrowseAdd(uri, Error(rsvp.NetworkError))) - assert model.browse_adding == None - assert model.notice != None - assert case model.browse { - [row] -> row.owned == False - _ -> False - } -} - -// --- edit inbox: update/msg flow --- - -fn an_edit_proposal(uri: String) -> EditProposal { - EditProposal( - uri:, - cid: "cid-1", - proposer_did: "did:plc:bob", - proposer_handle: Some("bob.test"), - target_uri: "at://did:plc:alice/dev.mokkenstorm.crate.catalog.release/r1", - release_title: "Spiderland", - current: ProposalFields( - title: Some("Spiderland"), - released: Some("1991"), - country: Some("US"), - genres: Some(["Rock"]), - styles: None, - ), - fields: ProposalFields( - title: Some("Spiderland (Remastered)"), - released: None, - country: None, - genres: None, - styles: None, - ), - created_at: "2026-02-01T00:00:00Z", - ) -} - -pub fn route_change_to_edit_inbox_starts_loading_and_dispatches_a_load_test() { - let #(model, effect) = update(logged_in(), OnRouteChange(EditInbox)) - assert model.inbox == InboxLoading - assert model.busy == True - assert effect != empty_effect() -} - -pub fn got_edit_inbox_ok_stores_cards_as_reviewing_test() { - let proposal = an_edit_proposal("at://proposer/edit/1") - let #(model, _) = update(logged_in(), GotEditInbox(Ok([proposal]))) - assert model.inbox - == InboxLoaded([InboxCard(proposal:, status: ProposalReviewing)]) - assert model.busy == False -} - -pub fn got_edit_inbox_401_logs_out_test() { - let #(model, _) = - update( - logged_in(), - GotEditInbox(Error(rsvp.HttpError(Response(401, [], "")))), - ) - assert model.auth == LoggedOut -} - -pub fn got_edit_inbox_500_marks_the_inbox_failed_with_a_notice_test() { - let #(model, _) = - update( - logged_in(), - GotEditInbox(Error(rsvp.HttpError(Response(500, [], "")))), - ) - assert model.inbox == InboxFailed - assert model.notice != None -} - -pub fn apply_proposal_marks_the_matching_card_applying_test() { - let proposal = an_edit_proposal("at://proposer/edit/1") - let seeded = - Model( - ..logged_in(), - inbox: InboxLoaded([InboxCard(proposal:, status: ProposalReviewing)]), - ) - let #(model, effect) = - update(seeded, ApplyProposal("at://proposer/edit/1", "cid-1")) - let assert InboxLoaded([InboxCard(status:, ..)]) = model.inbox - assert status == ProposalApplying - assert effect != empty_effect() -} - -pub fn got_apply_proposal_ok_marks_the_card_applied_and_refreshes_the_shelf_test() { - let proposal = an_edit_proposal("at://proposer/edit/1") - let seeded = - Model( - ..logged_in(), - inbox: InboxLoaded([InboxCard(proposal:, status: ProposalApplying)]), - ) - let #(model, effect) = - update( - seeded, - GotApplyProposal( - "at://proposer/edit/1", - Ok(AppliedProposal("at://alice/release/rNEW")), - ), - ) - let assert InboxLoaded([InboxCard(status:, ..)]) = model.inbox - assert status == ProposalApplied("at://alice/release/rNEW") - // The shelf reload effect, so the superseding release shows up. - assert effect != empty_effect() -} - -pub fn got_apply_proposal_error_reverts_to_reviewing_with_a_notice_test() { - let proposal = an_edit_proposal("at://proposer/edit/1") - let seeded = - Model( - ..logged_in(), - inbox: InboxLoaded([InboxCard(proposal:, status: ProposalApplying)]), - ) - let #(model, _) = - update( - seeded, - GotApplyProposal("at://proposer/edit/1", Error(rsvp.NetworkError)), - ) - let assert InboxLoaded([InboxCard(status:, ..)]) = model.inbox - assert status == ProposalReviewing - assert model.notice != None -} - -pub fn ignore_proposal_drops_the_card_from_the_model_test() { - let proposal = an_edit_proposal("at://proposer/edit/1") - let seeded = - Model( - ..logged_in(), - inbox: InboxLoaded([InboxCard(proposal:, status: ProposalReviewing)]), - ) - let #(model, _) = update(seeded, IgnoreProposal("at://proposer/edit/1")) - assert model.inbox == InboxLoaded([]) -} - -// --- edit inbox: view render --- - -fn rendered_inbox(cards: List(InboxCard)) -> String { - Model(..logged_in(), inbox: InboxLoaded(cards)) - |> edit_inbox.view - |> element.to_string -} - -pub fn edit_inbox_view_renders_diff_row_old_arrow_new_and_the_proposer_test() { - let proposal = an_edit_proposal("at://proposer/edit/1") - let html = rendered_inbox([InboxCard(proposal:, status: ProposalReviewing)]) - assert string.contains(html, "class=\"diff-row__old\"") - assert string.contains(html, "class=\"diff-row__new\"") - assert string.contains(html, "→") - assert string.contains(html, "Spiderland (Remastered)") - assert string.contains(html, "proposed by @bob.test") -} - -pub fn edit_inbox_view_renders_not_set_for_an_absent_current_value_test() { - let proposal = - EditProposal( - ..an_edit_proposal("at://proposer/edit/1"), - current: ProposalFields( - title: None, - released: None, - country: None, - genres: None, - styles: None, - ), - ) - let html = rendered_inbox([InboxCard(proposal:, status: ProposalReviewing)]) - assert string.contains(html, "(not set)") -} - -pub fn edit_inbox_view_omits_a_row_for_a_field_the_proposal_does_not_touch_test() { - let proposal = an_edit_proposal("at://proposer/edit/1") - let html = rendered_inbox([InboxCard(proposal:, status: ProposalReviewing)]) - // `fields` only carries `title`; released/country/genres/styles stay unset. - assert !string.contains(html, "COUNTRY") - assert !string.contains(html, "RELEASED") -} - -pub fn edit_inbox_view_renders_the_applied_confirmation_strip_test() { - let proposal = an_edit_proposal("at://proposer/edit/1") - let html = - rendered_inbox([ - InboxCard(proposal:, status: ProposalApplied("at://alice/release/rNEW")), - ]) - assert string.contains(html, "class=\"applied-strip\"") - // Element rendering HTML-escapes quotes, so match the escaped form. - assert string.contains( - html, - "Applied: minted a superseding release for "Spiderland"", - ) - // No diff/actions for an already-applied card. - assert !string.contains(html, "class=\"proposal-actions\"") -} - -pub fn edit_inbox_view_intro_counts_the_loaded_cards_test() { - let a = an_edit_proposal("at://proposer/edit/1") - let b = an_edit_proposal("at://proposer/edit/2") - let html = - rendered_inbox([ - InboxCard(proposal: a, status: ProposalReviewing), - InboxCard(proposal: b, status: ProposalReviewing), - ]) - assert string.contains(html, "2 proposals against your catalog records") -} - -pub fn edit_inbox_view_empty_state_has_no_intro_line_test() { - let html = rendered_inbox([]) - assert !string.contains(html, "class=\"inbox-intro\"") - assert string.contains(html, "No proposals against your catalog records") -} - -// --- app-bar avatar navigation (popout retired) --- - -pub fn crate_app_bar_avatar_links_to_settings_test() { - let html = view.view(logged_in()) |> element.to_string - assert string.contains(html, "href=\"/settings\"") - // The account popout is gone: its actions now live on the Settings page, - // never in the crate app bar itself. - assert !string.contains(html, "EDIT INBOX") - assert !string.contains(html, "LOG OUT") -} - -// --- settings page --- - -pub fn settings_route_round_trips_test() { - assert route.to_path(Settings) == "/settings" - let assert Ok(target) = uri.parse("/settings") - assert route.parse(target) == Settings -} - -pub fn settings_view_renders_discogs_section_logout_and_inbox_test() { - let html = settings.view(logged_in()) |> element.to_string - assert string.contains(html, "DISCOGS ACCOUNT") - assert string.contains(html, "LOG OUT") - assert string.contains(html, "EDIT INBOX") - assert string.contains(html, "@alice.test") -} - -pub fn add_view_no_longer_carries_the_discogs_section_test() { - let html = add.view(logged_in()) |> element.to_string - assert !string.contains(html, "DISCOGS ACCOUNT") -} - -// --- crate client-side windowing (LOAD MORE) --- - -fn many_entries(n: Int) -> List(model.Entry) { - list.repeat(an_entry(), n) - |> list.index_map(fn(e, i) { Entry(..e, entry_id: "e" <> int.to_string(i)) }) -} - -pub fn crate_windows_items_and_shows_load_more_when_more_remain_test() { - let seeded = - Model(..logged_in(), shelf: ShelfLoaded(many_entries(31)), crate_window: 30) - let html = crate.view(seeded) |> element.to_string - assert string.contains(html, "LOAD MORE") - assert string.contains(html, "showing 30 of 31") -} - -pub fn crate_without_overflow_omits_load_more_test() { - let seeded = - Model(..logged_in(), shelf: ShelfLoaded(many_entries(5)), crate_window: 30) - let html = crate.view(seeded) |> element.to_string - assert !string.contains(html, "LOAD MORE") -} - -pub fn show_more_crate_grows_the_window_by_a_page_test() { - let seeded = Model(..logged_in(), crate_window: 30) - let #(updated, effect) = update(seeded, ShowMoreCrate) - assert updated.crate_window == 30 + model.crate_window_size - // Windowing is pure local render state: no shelf reload. - assert effect == empty_effect() -} - -pub fn got_shelf_resets_the_window_test() { - let seeded = Model(..logged_in(), crate_window: 120) - let #(updated, _) = - update(seeded, GotShelf(Ok(ShelfData("alice.test", [], dict.new())))) - assert updated.crate_window == model.crate_window_size -} - -pub fn inbox_pending_count_is_zero_before_the_inbox_has_ever_loaded_test() { - assert model.inbox_pending_count(InboxLoading) == 0 - assert model.inbox_pending_count(InboxFailed) == 0 -} - -pub fn inbox_pending_count_counts_only_reviewing_cards_test() { - let proposal = an_edit_proposal("at://proposer/edit/1") - let applied = - InboxCard(proposal:, status: ProposalApplied("at://alice/release/rNEW")) - let reviewing = InboxCard(proposal:, status: ProposalReviewing) - assert model.inbox_pending_count(InboxLoaded([applied])) == 0 - assert model.inbox_pending_count(InboxLoaded([reviewing])) == 1 - assert model.inbox_pending_count(InboxLoaded([applied, reviewing])) == 1 } diff --git a/web/test/browse_test.gleam b/web/test/browse_test.gleam new file mode 100644 --- /dev/null +++ b/web/test/browse_test.gleam @@ -0,0 +1,78 @@ +import at_record_web/model.{type BrowseRelease, BrowseRelease, Model} +import at_record_web/msg.{BrowseAdd, GotBrowse, GotBrowseAdd} +import at_record_web/update.{update} +import gleam/option.{None, Some} +import rsvp +import support.{logged_in} + +fn a_browse_release(uri: String) -> BrowseRelease { + BrowseRelease( + uri:, + cid: "cid-1", + title: "Spiderland", + genres: [], + styles: [], + publisher_did: "did:plc:abc", + publisher_handle: "alice.test", + owned: False, + wanted: False, + artist_display: Some("Slint"), + cover_url: None, + thumb_url: None, + released: None, + country: None, + ) +} + +pub fn got_browse_stores_rows_test() { + let rows = [a_browse_release("at://did:plc:abc/catalog.release/1")] + let #(model, _) = update(logged_in(), GotBrowse(Ok(rows))) + assert model.browse == rows + assert model.busy == False +} + +pub fn browse_add_marks_in_flight_test() { + let #(model, _) = + update(logged_in(), BrowseAdd("at://uri", "cid-1", "wanted")) + assert model.browse_adding == Some(#("at://uri", "wanted")) +} + +pub fn browse_add_is_guarded_while_already_adding_test() { + let seeded = + Model(..logged_in(), browse_adding: Some(#("at://other", "owned"))) + let #(model, _) = update(seeded, BrowseAdd("at://uri", "cid-1", "wanted")) + assert model.browse_adding == Some(#("at://other", "owned")) +} + +pub fn got_browse_add_success_flips_the_matching_row_and_clears_adding_test() { + let uri = "at://did:plc:abc/catalog.release/1" + let seeded = + Model( + ..logged_in(), + browse: [a_browse_release(uri)], + browse_adding: Some(#(uri, "owned")), + ) + let #(model, _) = update(seeded, GotBrowseAdd(uri, Ok(Nil))) + assert model.browse_adding == None + assert case model.browse { + [row] -> row.owned == True && row.wanted == False + _ -> False + } +} + +pub fn got_browse_add_error_clears_adding_with_notice_test() { + let uri = "at://did:plc:abc/catalog.release/1" + let seeded = + Model( + ..logged_in(), + browse: [a_browse_release(uri)], + browse_adding: Some(#(uri, "owned")), + ) + let #(model, _) = update(seeded, GotBrowseAdd(uri, Error(rsvp.NetworkError))) + assert model.browse_adding == None + assert model.notice != None + assert case model.browse { + [row] -> row.owned == False + _ -> False + } +} diff --git a/web/test/crate_test.gleam b/web/test/crate_test.gleam new file mode 100644 --- /dev/null +++ b/web/test/crate_test.gleam @@ -0,0 +1,229 @@ +import at_record_web/model.{ + type Entry, Entry, Failure, Grid, LoggedIn, LoggedOut, Model, Notice, Rows, + ShelfFailed, ShelfLoaded, ShelfLoading, Warning, blank_form, +} +import at_record_web/msg.{ + ClearNotice, FormTitle, GotBrowseAdd, GotShelf, HandleChanged, RetryShelf, + SetDisplay, SetView, ShelfData, ShowMoreCrate, +} +import at_record_web/pages/crate +import at_record_web/update.{update} +import at_record_web/view +import gleam/dict +import gleam/http/response.{Response} +import gleam/int +import gleam/list +import gleam/option.{None, Some} +import gleam/string +import lustre/element +import rsvp +import support.{ + an_entry, base, empty_effect, logged_in, rendered_empty_crate, + rendered_loaded_crate, unauthorized, +} + +pub fn blank_form_defaults_to_owned_test() { + assert blank_form().status == "owned" +} + +pub fn handle_changed_updates_login_handle_test() { + let #(model, _effect) = update(base(), HandleChanged("alice.test")) + assert model.login_handle == "alice.test" +} + +pub fn form_title_updates_form_test() { + let #(model, _effect) = update(base(), FormTitle("Spiderland")) + assert model.form.title == "Spiderland" +} + +pub fn shelf_401_logs_out_test() { + let #(model, _) = + update(logged_in(), GotShelf(Error(rsvp.HttpError(Response(401, [], ""))))) + assert model.auth == LoggedOut +} + +pub fn shelf_500_keeps_auth_and_marks_failed_test() { + let #(model, _) = + update(logged_in(), GotShelf(Error(rsvp.HttpError(Response(500, [], ""))))) + assert model.auth == LoggedIn("alice.test") + assert model.shelf == ShelfFailed + assert case model.notice { + Some(Notice(Failure, _)) -> True + _ -> False + } +} + +pub fn shelf_network_error_keeps_auth_and_marks_failed_test() { + let #(model, _) = update(logged_in(), GotShelf(Error(rsvp.NetworkError))) + assert model.auth == LoggedIn("alice.test") + assert model.shelf == ShelfFailed + assert case model.notice { + Some(Notice(Failure, _)) -> True + _ -> False + } +} + +pub fn shelf_401_clears_to_empty_loaded_test() { + let #(model, _) = + update(logged_in(), GotShelf(Error(rsvp.HttpError(Response(401, [], ""))))) + assert model.auth == LoggedOut + assert model.shelf == ShelfLoaded([]) +} + +pub fn shelf_ok_marks_loaded_test() { + let #(model, _) = + update(logged_in(), GotShelf(Ok(ShelfData("alice.test", [], dict.new())))) + assert model.shelf == ShelfLoaded([]) + assert model.busy == False +} + +pub fn retry_shelf_resets_to_loading_and_reloads_test() { + let failed = Model(..logged_in(), shelf: ShelfFailed, busy: False) + let #(model, effect) = update(failed, RetryShelf) + assert model.shelf == ShelfLoading + assert model.busy == True + assert model.notice == None + // A real load effect is dispatched (not effect.none()). + assert effect != empty_effect() +} + +pub fn set_view_resets_to_loading_test() { + let seeded = Model(..logged_in(), crate_window: 120) + let #(updated, _) = update(seeded, SetView("wanted")) + assert updated.shelf == ShelfLoading + assert updated.view == "wanted" + // Switching filter rewinds the window so the new view starts from the top. + assert updated.crate_window == model.crate_window_size +} + +pub fn set_display_switches_to_rows_test() { + let #(model, _) = update(logged_in(), SetDisplay(Rows)) + assert model.display == Rows +} + +pub fn set_display_back_to_grid_is_a_pure_toggle_test() { + let rows_view = Model(..logged_in(), display: Rows) + let #(model, effect) = update(rows_view, SetDisplay(Grid)) + assert model.display == Grid + // Switching display is local UI state only, no shelf reload. + assert effect == empty_effect() +} + +pub fn session_expired_is_a_warning_notice_test() { + let #(model, _) = + update(logged_in(), GotBrowseAdd("at://x", Error(unauthorized()))) + assert case model.notice { + Some(Notice(Warning, _)) -> True + _ -> False + } +} + +pub fn clear_notice_drops_it_test() { + let noticed = Model(..logged_in(), notice: Some(Notice(Failure, "boom"))) + let #(model, _) = update(noticed, ClearNotice) + assert model.notice == None +} + +// --- crate page empty states --- + +pub fn empty_owned_view_shows_first_run_panel_with_toolbar_test() { + let html = rendered_empty_crate("owned") + assert string.contains(html, "NOTHING IN THE CRATE YET") + assert string.contains(html, "WANTED") + assert string.contains(html, "HISTORY") + // The first-run state is the only crate state without the FAB. + assert !string.contains(html, "class=\"fab\"") +} + +pub fn empty_wanted_view_keeps_toolbar_and_fab_test() { + let html = rendered_empty_crate("wanted") + assert string.contains(html, "OWNED") + assert string.contains(html, "HISTORY") + assert string.contains(html, "0 on your wishlist") + assert string.contains(html, "Nothing on your wishlist yet") + assert string.contains(html, "class=\"fab\"") + assert !string.contains(html, "NOTHING IN THE CRATE YET") +} + +pub fn empty_history_view_keeps_toolbar_and_fab_test() { + let html = rendered_empty_crate("history") + assert string.contains(html, "OWNED") + assert string.contains(html, "WANTED") + assert string.contains(html, "0 in your history") + assert string.contains(html, "No history yet") + assert string.contains(html, "class=\"fab\"") + assert !string.contains(html, "NOTHING IN THE CRATE YET") +} + +pub fn empty_owned_view_omits_the_display_toggle_test() { + let html = rendered_empty_crate("owned") + assert !string.contains(html, "class=\"display-toggle\"") +} + +// --- crate page loaded state / list display ----------------------------- + +pub fn loaded_grid_shows_the_display_toggle_and_cover_cards_test() { + let html = rendered_loaded_crate(Grid) + assert string.contains(html, "class=\"display-toggle\"") + assert string.contains(html, "▦") + assert string.contains(html, "☰") + assert string.contains(html, "class=\"cover-card\"") + assert !string.contains(html, "class=\"row\"") +} + +pub fn loaded_rows_render_title_and_muted_artist_format_meta_test() { + let html = rendered_loaded_crate(Rows) + assert string.contains(html, "class=\"row\"") + assert string.contains(html, "class=\"row__title\"") + assert string.contains(html, "Spiderland") + assert string.contains(html, "Slint · LP / 1991") + assert !string.contains(html, "class=\"cover-card\"") +} + +// --- app-bar avatar navigation (popout retired) --- + +pub fn crate_app_bar_avatar_links_to_settings_test() { + let html = view.view(logged_in()) |> element.to_string + assert string.contains(html, "href=\"/settings\"") + // The account popout is gone: its actions now live on the Settings page, + // never in the crate app bar itself. + assert !string.contains(html, "EDIT INBOX") + assert !string.contains(html, "LOG OUT") +} + +// --- crate client-side windowing (LOAD MORE) --- + +fn many_entries(n: Int) -> List(Entry) { + list.repeat(an_entry(), n) + |> list.index_map(fn(e, i) { Entry(..e, entry_id: "e" <> int.to_string(i)) }) +} + +pub fn crate_windows_items_and_shows_load_more_when_more_remain_test() { + let seeded = + Model(..logged_in(), shelf: ShelfLoaded(many_entries(31)), crate_window: 30) + let html = crate.view(seeded) |> element.to_string + assert string.contains(html, "LOAD MORE") + assert string.contains(html, "showing 30 of 31") +} + +pub fn crate_without_overflow_omits_load_more_test() { + let seeded = + Model(..logged_in(), shelf: ShelfLoaded(many_entries(5)), crate_window: 30) + let html = crate.view(seeded) |> element.to_string + assert !string.contains(html, "LOAD MORE") +} + +pub fn show_more_crate_grows_the_window_by_a_page_test() { + let seeded = Model(..logged_in(), crate_window: 30) + let #(updated, effect) = update(seeded, ShowMoreCrate) + assert updated.crate_window == 30 + model.crate_window_size + // Windowing is pure local render state: no shelf reload. + assert effect == empty_effect() +} + +pub fn got_shelf_resets_the_window_test() { + let seeded = Model(..logged_in(), crate_window: 120) + let #(updated, _) = + update(seeded, GotShelf(Ok(ShelfData("alice.test", [], dict.new())))) + assert updated.crate_window == model.crate_window_size +} diff --git a/web/test/edit_inbox_test.gleam b/web/test/edit_inbox_test.gleam new file mode 100644 --- /dev/null +++ b/web/test/edit_inbox_test.gleam @@ -0,0 +1,230 @@ +import at_record_web/model.{ + type EditProposal, type InboxCard, EditProposal, InboxCard, InboxFailed, + InboxLoaded, InboxLoading, Model, ProposalApplied, ProposalApplying, + ProposalFields, ProposalReviewing, +} +import at_record_web/msg.{ + AppliedProposal, ApplyProposal, GotApplyProposal, GotEditInbox, IgnoreProposal, + OnRouteChange, +} +import at_record_web/pages/edit_inbox +import at_record_web/update.{update} +import gleam/http/response.{Response} +import gleam/option.{None, Some} +import gleam/string +import lustre/element +import rsvp +import support.{logged_in} + +fn an_edit_proposal(uri: String) -> EditProposal { + EditProposal( + uri:, + cid: "cid-1", + proposer_did: "did:plc:bob", + proposer_handle: Some("bob.test"), + target_uri: "at://did:plc:alice/dev.mokkenstorm.crate.catalog.release/r1", + release_title: "Spiderland", + current: ProposalFields( + title: Some("Spiderland"), + released: Some("1991"), + country: Some("US"), + genres: Some(["Rock"]), + styles: None, + ), + fields: ProposalFields( + title: Some("Spiderland (Remastered)"), + released: None, + country: None, + genres: None, + styles: None, + ), + created_at: "2026-02-01T00:00:00Z", + ) +} + +pub fn route_change_to_edit_inbox_starts_loading_and_dispatches_a_load_test() { + let #(model, effect) = update(logged_in(), OnRouteChange(model.EditInbox)) + assert model.inbox == InboxLoading + assert model.busy == True + assert effect != support.empty_effect() +} + +pub fn got_edit_inbox_ok_stores_cards_as_reviewing_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let #(model, _) = update(logged_in(), GotEditInbox(Ok([proposal]))) + assert model.inbox + == InboxLoaded([InboxCard(proposal:, status: ProposalReviewing)]) + assert model.busy == False +} + +pub fn got_edit_inbox_401_logs_out_test() { + let #(model, _) = + update( + logged_in(), + GotEditInbox(Error(rsvp.HttpError(Response(401, [], "")))), + ) + assert model.auth == model.LoggedOut +} + +pub fn got_edit_inbox_500_marks_the_inbox_failed_with_a_notice_test() { + let #(model, _) = + update( + logged_in(), + GotEditInbox(Error(rsvp.HttpError(Response(500, [], "")))), + ) + assert model.inbox == InboxFailed + assert model.notice != None +} + +pub fn apply_proposal_marks_the_matching_card_applying_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let seeded = + Model( + ..logged_in(), + inbox: InboxLoaded([InboxCard(proposal:, status: ProposalReviewing)]), + ) + let #(model, effect) = + update(seeded, ApplyProposal("at://proposer/edit/1", "cid-1")) + let assert InboxLoaded([InboxCard(status:, ..)]) = model.inbox + assert status == ProposalApplying + assert effect != support.empty_effect() +} + +pub fn got_apply_proposal_ok_marks_the_card_applied_and_refreshes_the_shelf_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let seeded = + Model( + ..logged_in(), + inbox: InboxLoaded([InboxCard(proposal:, status: ProposalApplying)]), + ) + let #(model, effect) = + update( + seeded, + GotApplyProposal( + "at://proposer/edit/1", + Ok(AppliedProposal("at://alice/release/rNEW")), + ), + ) + let assert InboxLoaded([InboxCard(status:, ..)]) = model.inbox + assert status == ProposalApplied("at://alice/release/rNEW") + // The shelf reload effect, so the superseding release shows up. + assert effect != support.empty_effect() +} + +pub fn got_apply_proposal_error_reverts_to_reviewing_with_a_notice_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let seeded = + Model( + ..logged_in(), + inbox: InboxLoaded([InboxCard(proposal:, status: ProposalApplying)]), + ) + let #(model, _) = + update( + seeded, + GotApplyProposal("at://proposer/edit/1", Error(rsvp.NetworkError)), + ) + let assert InboxLoaded([InboxCard(status:, ..)]) = model.inbox + assert status == ProposalReviewing + assert model.notice != None +} + +pub fn ignore_proposal_drops_the_card_from_the_model_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let seeded = + Model( + ..logged_in(), + inbox: InboxLoaded([InboxCard(proposal:, status: ProposalReviewing)]), + ) + let #(model, _) = update(seeded, IgnoreProposal("at://proposer/edit/1")) + assert model.inbox == InboxLoaded([]) +} + +// --- edit inbox: view render --- + +fn rendered_inbox(cards: List(InboxCard)) -> String { + Model(..logged_in(), inbox: InboxLoaded(cards)) + |> edit_inbox.view + |> element.to_string +} + +pub fn edit_inbox_view_renders_diff_row_old_arrow_new_and_the_proposer_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let html = rendered_inbox([InboxCard(proposal:, status: ProposalReviewing)]) + assert string.contains(html, "class=\"diff-row__old\"") + assert string.contains(html, "class=\"diff-row__new\"") + assert string.contains(html, "→") + assert string.contains(html, "Spiderland (Remastered)") + assert string.contains(html, "proposed by @bob.test") +} + +pub fn edit_inbox_view_renders_not_set_for_an_absent_current_value_test() { + let proposal = + EditProposal( + ..an_edit_proposal("at://proposer/edit/1"), + current: ProposalFields( + title: None, + released: None, + country: None, + genres: None, + styles: None, + ), + ) + let html = rendered_inbox([InboxCard(proposal:, status: ProposalReviewing)]) + assert string.contains(html, "(not set)") +} + +pub fn edit_inbox_view_omits_a_row_for_a_field_the_proposal_does_not_touch_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let html = rendered_inbox([InboxCard(proposal:, status: ProposalReviewing)]) + // `fields` only carries `title`; released/country/genres/styles stay unset. + assert !string.contains(html, "COUNTRY") + assert !string.contains(html, "RELEASED") +} + +pub fn edit_inbox_view_renders_the_applied_confirmation_strip_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let html = + rendered_inbox([ + InboxCard(proposal:, status: ProposalApplied("at://alice/release/rNEW")), + ]) + assert string.contains(html, "class=\"applied-strip\"") + // Element rendering HTML-escapes quotes, so match the escaped form. + assert string.contains( + html, + "Applied: minted a superseding release for "Spiderland"", + ) + // No diff/actions for an already-applied card. + assert !string.contains(html, "class=\"proposal-actions\"") +} + +pub fn edit_inbox_view_intro_counts_the_loaded_cards_test() { + let a = an_edit_proposal("at://proposer/edit/1") + let b = an_edit_proposal("at://proposer/edit/2") + let html = + rendered_inbox([ + InboxCard(proposal: a, status: ProposalReviewing), + InboxCard(proposal: b, status: ProposalReviewing), + ]) + assert string.contains(html, "2 proposals against your catalog records") +} + +pub fn edit_inbox_view_empty_state_has_no_intro_line_test() { + let html = rendered_inbox([]) + assert !string.contains(html, "class=\"inbox-intro\"") + assert string.contains(html, "No proposals against your catalog records") +} + +pub fn inbox_pending_count_is_zero_before_the_inbox_has_ever_loaded_test() { + assert model.inbox_pending_count(InboxLoading) == 0 + assert model.inbox_pending_count(InboxFailed) == 0 +} + +pub fn inbox_pending_count_counts_only_reviewing_cards_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let applied = + InboxCard(proposal:, status: ProposalApplied("at://alice/release/rNEW")) + let reviewing = InboxCard(proposal:, status: ProposalReviewing) + assert model.inbox_pending_count(InboxLoaded([applied])) == 0 + assert model.inbox_pending_count(InboxLoaded([reviewing])) == 1 + assert model.inbox_pending_count(InboxLoaded([applied, reviewing])) == 1 +} diff --git a/web/test/effects_test.gleam b/web/test/effects_test.gleam new file mode 100644 --- /dev/null +++ b/web/test/effects_test.gleam @@ -0,0 +1,13 @@ +import at_record_web/effects +import gleam/option.{None, Some} +import gleam/string + +pub fn discogs_search_url_includes_artist_when_present_test() { + let url = effects.discogs_search_url("spiderland", True, 1, Some("Slint")) + assert string.contains(url, "artist=Slint") +} + +pub fn discogs_search_url_omits_artist_when_absent_test() { + let url = effects.discogs_search_url("spiderland", True, 1, None) + assert !string.contains(url, "artist=") +} diff --git a/web/test/login_test.gleam b/web/test/login_test.gleam new file mode 100644 --- /dev/null +++ b/web/test/login_test.gleam @@ -0,0 +1,44 @@ +import at_record_web/model.{LoggedOut, ShelfLoading} +import at_record_web/msg.{GotAvatar, GotLogout, Logout} +import at_record_web/pages/login +import at_record_web/update.{update} +import gleam/option.{None, Some} +import gleam/string +import support.{logged_in} + +pub fn oauth_error_copy_maps_known_codes_test() { + assert string.contains( + login.error_copy("access_denied"), + "turned the request", + ) + assert login.error_copy("state_mismatch") == login.error_copy("bad_callback") + assert string.contains( + login.error_copy("token_exchange_failed"), + "token exchange", + ) + assert string.contains( + login.error_copy("session_failed"), + "save your session", + ) + assert string.contains(login.error_copy("resolve_failed"), "handle's PDS") + assert login.error_copy("something_new") == "Login failed. Try again." +} + +pub fn logout_clears_session_test() { + let #(model, _) = update(logged_in(), Logout) + assert model.auth == LoggedOut + assert model.shelf == ShelfLoading +} + +pub fn got_logout_logs_out_test() { + let #(model, _) = update(logged_in(), GotLogout) + assert model.auth == LoggedOut +} + +pub fn got_avatar_stores_it_and_logout_clears_it_test() { + let #(with_avatar, _) = + update(logged_in(), GotAvatar(Ok(Some("https://example.test/a.jpg")))) + assert with_avatar.avatar == Some("https://example.test/a.jpg") + let #(after_logout, _) = update(with_avatar, Logout) + assert after_logout.avatar == None +} diff --git a/web/test/record_test.gleam b/web/test/record_test.gleam new file mode 100644 --- /dev/null +++ b/web/test/record_test.gleam @@ -0,0 +1,110 @@ +import at_record_web/model.{type ReleaseInfo, Entry, ReleaseInfo, Source} +import at_record_web/msg.{ + ArmRemove, EntryAction, GotTimeline, OnRouteChange, TimelineData, +} +import at_record_web/pages/record +import at_record_web/update.{update} +import gleam/option.{None, Some} +import gleam/string +import lustre/element +import support.{an_entry, logged_in} + +pub fn record_view_shows_the_sleeve_grade_card_when_present_test() { + let html = record.view(logged_in(), an_entry()) |> element.to_string + assert string.contains(html, "SLEEVE") + assert string.contains(html, "VG+") +} + +pub fn record_view_omits_the_sleeve_grade_card_when_absent_test() { + let html = + record.view(logged_in(), Entry(..an_entry(), sleeve_grade: None)) + |> element.to_string + assert !string.contains(html, "SLEEVE") +} + +pub fn record_view_shows_paid_and_from_rows_when_present_test() { + let html = record.view(logged_in(), an_entry()) |> element.to_string + assert string.contains(html, "PAID") + assert string.contains(html, "EUR 40.00") + assert string.contains(html, "FROM") + assert string.contains(html, "Record shop") +} + +pub fn record_view_omits_paid_and_from_rows_when_absent_test() { + let html = + record.view( + logged_in(), + Entry(..an_entry(), price: None, counterparty: None), + ) + |> element.to_string + assert !string.contains(html, "PAID") + assert !string.contains(html, "FROM") +} + +pub fn record_view_shows_the_provenance_line_when_source_is_present_test() { + let source = Source(origin: Some("scan"), provider: Some("discogs")) + let html = + record.view(logged_in(), Entry(..an_entry(), source: Some(source))) + |> element.to_string + assert string.contains(html, "class=\"titleblock__source\"") + assert string.contains(html, "scanned · via Discogs") +} + +pub fn record_view_shows_added_by_hand_for_manual_source_test() { + let source = Source(origin: Some("manual"), provider: None) + let html = + record.view(logged_in(), Entry(..an_entry(), source: Some(source))) + |> element.to_string + assert string.contains(html, "added by hand") +} + +pub fn record_view_omits_the_provenance_line_when_source_is_absent_test() { + let html = + record.view(logged_in(), Entry(..an_entry(), source: None)) + |> element.to_string + assert !string.contains(html, "class=\"titleblock__source\"") +} + +pub fn arm_remove_then_entry_action_disarms_and_sets_busy_test() { + let #(armed, _) = update(logged_in(), ArmRemove) + assert armed.confirm_remove == True + let #(after, _) = update(armed, EntryAction("e1", "dropped")) + assert after.confirm_remove == False + assert after.busy == True +} + +// --- record timeline / release info --- + +fn a_release_info() -> ReleaseInfo { + ReleaseInfo( + artist_display: Some("Slint"), + genres: ["Rock"], + styles: ["Math Rock"], + country: Some("US"), + released: Some("1991"), + ) +} + +pub fn got_timeline_with_release_stores_release_info_test() { + let #(model, _) = + update( + logged_in(), + GotTimeline(Ok(TimelineData(events: [], release: Some(a_release_info())))), + ) + assert model.release_info == Some(a_release_info()) +} + +pub fn got_timeline_without_release_leaves_release_info_none_test() { + let #(model, _) = + update( + logged_in(), + GotTimeline(Ok(TimelineData(events: [], release: None))), + ) + assert model.release_info == None +} + +pub fn route_change_clears_release_info_test() { + let seeded = model.Model(..logged_in(), release_info: Some(a_release_info())) + let #(model, _) = update(seeded, OnRouteChange(model.Crate)) + assert model.release_info == None +} diff --git a/web/test/scan_test.gleam b/web/test/scan_test.gleam new file mode 100644 --- /dev/null +++ b/web/test/scan_test.gleam @@ -0,0 +1,317 @@ +import at_record_web/model.{ + type NetworkMatch, Adding, FromDiscogs, FromNetwork, Matched, Model, + NetworkMatch, NetworkMatched, NoMatch, Pending, ScanRow, ScanState, Suggestion, + blank_scan, +} +import at_record_web/msg.{ + AddScannedItem, AddSuggestion, BarcodeDetected, GotScanResult, GotScanSeen, + RemoveScanRow, ScanItemAdded, ScanLookup, +} +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(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(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(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(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)]), + ), + ]), + ) + let #(model, _) = update(seeded, AddSuggestion("b1", release)) + assert model.busy == True + assert model.scan.rows + == [ScanRow(barcode: "b1", outcome: Adding(FromDiscogs(release)))] +} + +pub fn scan_page_renders_did_you_mean_suggestions_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 + assert string.contains(html, "DID YOU MEAN?") + assert string.contains(html, "62%") + assert string.contains(html, "Spiderland") +} + +pub fn scan_page_no_suggestions_omits_did_you_mean_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)]), + ), + ]), + ) + let #(model, _) = update(seeded, AddSuggestion("b1", release)) + assert model.scan.rows == seeded.scan.rows +} + +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 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( + ..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"]) +} + +// --- barcode scan: network-first match --- + +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(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() + let seeded = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: NetworkMatched(release)), + ]), + ) + let #(model, effect) = update(seeded, AddScannedItem("b1")) + assert model.busy == True + assert model.scan.rows + == [ScanRow(barcode: "b1", outcome: Adding(FromNetwork(release)))] + // The adopt effect, not the manual shelf.addEntry write. + assert effect != empty_effect() +} + +pub fn scan_item_added_error_reverts_a_network_row_test() { + let release = a_network_match() + let seeded = + Model( + ..logged_in(), + busy: True, + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "b1", outcome: Adding(FromNetwork(release))), + ]), + ) + let #(model, _) = + update(seeded, ScanItemAdded("b1", Error(rsvp.NetworkError))) + assert model.busy == False + assert model.scan.rows + == [ScanRow(barcode: "b1", outcome: NetworkMatched(release))] +} + +pub fn scan_page_renders_network_match_with_via_handle_test() { + let release = a_network_match() + let model = + Model( + ..logged_in(), + scan: ScanState(..blank_scan(), rows: [ + ScanRow(barcode: "825646350919", outcome: NetworkMatched(release)), + ]), + ) + 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") +} diff --git a/web/test/settings_test.gleam b/web/test/settings_test.gleam new file mode 100644 --- /dev/null +++ b/web/test/settings_test.gleam @@ -0,0 +1,93 @@ +import at_record_web/model.{ + Discogs, Failure, ImportRun, Model, Notice, Settings, Success, blank_discogs, +} +import at_record_web/msg.{ + DiscogsImportWantlist, GotDiscogsImport, GotDiscogsStatus, +} +import at_record_web/pages/settings +import at_record_web/route +import at_record_web/update.{update} +import gleam/option.{None, Some} +import gleam/string +import gleam/uri +import lustre/element +import support.{logged_in} + +pub fn discogs_status_sets_username_test() { + let #(model, _) = + update(logged_in(), GotDiscogsStatus(Ok(Some("crate-digger")))) + assert model.discogs.username == Some("crate-digger") +} + +pub fn discogs_import_wantlist_marks_importing_and_clears_notice_test() { + let model = + Model( + ..logged_in(), + notice: Some(Notice(Failure, "stale")), + discogs: blank_discogs(), + ) + let #(after, _) = update(model, DiscogsImportWantlist) + assert after.discogs.importing == True + assert after.notice == None +} + +pub fn import_result_summarises_partial_run_test() { + let importing = + Model(..logged_in(), discogs: Discogs(..blank_discogs(), importing: True)) + let #(model, _) = + update( + importing, + GotDiscogsImport( + Ok(ImportRun( + imported: 100, + updated: 0, + skipped: 18, + done: False, + note: None, + )), + ), + ) + assert model.discogs.importing == False + assert model.notice + == Some(Notice( + Success, + "Imported 100, skipped 18 already in your crate. Run again to continue.", + )) +} + +pub fn import_result_complete_run_has_no_continue_hint_test() { + let #(model, _) = + update( + logged_in(), + GotDiscogsImport( + Ok(ImportRun( + imported: 3, + updated: 2, + skipped: 0, + done: True, + note: None, + )), + ), + ) + assert model.notice + == Some(Notice( + Success, + "Imported 3, skipped 0 already in your crate. Updated 2 with cover art.", + )) +} + +// --- settings page --- + +pub fn settings_route_round_trips_test() { + assert route.to_path(Settings) == "/settings" + let assert Ok(target) = uri.parse("/settings") + assert route.parse(target) == Settings +} + +pub fn settings_view_renders_discogs_section_logout_and_inbox_test() { + let html = settings.view(logged_in()) |> element.to_string + assert string.contains(html, "DISCOGS ACCOUNT") + assert string.contains(html, "LOG OUT") + assert string.contains(html, "EDIT INBOX") + assert string.contains(html, "@alice.test") +} diff --git a/web/test/support.gleam b/web/test/support.gleam new file mode 100644 --- /dev/null +++ b/web/test/support.gleam @@ -0,0 +1,114 @@ +//// Shared model builders and rendering helpers reused across the web test +//// suite's per-page test modules. + +import at_record/gen/defs.{type Snapshot, Price, Snapshot} +import at_record_web/model.{ + type DiscogsResult, type Display, type Entry, type Model, Crate, DiscogsResult, + Entry, Grid, LoggedIn, LoggedOut, Model, ShelfLoaded, blank_amend, + blank_discogs, blank_form, blank_scan, +} +import at_record_web/pages/crate +import gleam/dict +import gleam/http/response.{Response} +import gleam/option.{None, Some} +import lustre/effect.{type Effect} +import lustre/element +import rsvp + +pub fn empty_effect() -> Effect(a) { + effect.none() +} + +pub fn unauthorized() -> rsvp.Error(String) { + rsvp.HttpError(Response(401, [], "")) +} + +pub fn base() -> Model { + Model( + auth: LoggedOut, + route: Crate, + login_handle: "", + login_suggestions: [], + login_error: None, + view: "owned", + display: Grid, + shelf: ShelfLoaded([]), + crate_window: model.crate_window_size, + via_handles: dict.new(), + form: blank_form(), + notice: None, + busy: False, + pending_revert: None, + selected: None, + editing: False, + confirm_remove: False, + timeline: [], + release_info: None, + discogs: blank_discogs(), + scan: blank_scan(), + amending: False, + publishing: False, + amend: blank_amend(), + avatar: None, + browse: [], + browse_adding: None, + browse_query: "", + browse_genre: "", + inbox: model.InboxLoading, + ) +} + +pub fn logged_in() -> Model { + Model(..base(), auth: LoggedIn("alice.test")) +} + +pub fn rendered_empty_crate(view: String) -> String { + Model(..logged_in(), view:, shelf: ShelfLoaded([])) + |> crate.view + |> element.to_string +} + +pub fn rendered_loaded_crate(display: Display) -> String { + Model(..logged_in(), display:, shelf: ShelfLoaded([an_entry()])) + |> crate.view + |> element.to_string +} + +fn a_snapshot() -> Snapshot { + Snapshot( + artist_display: "Slint", + cover: None, + format: Some("LP"), + thumb_url: None, + title: "Spiderland", + year: Some(1991), + ) +} + +pub fn an_entry() -> Entry { + Entry( + entry_id: "e1", + status: "owned", + snapshot: a_snapshot(), + media_grade: Some("NM"), + sleeve_grade: Some("VG+"), + rating: Some(4), + folder: None, + notes: None, + price: Some(Price(amount: 4000, currency: "EUR")), + counterparty: Some("Record shop"), + source: None, + ) +} + +pub fn discogs_result(id: Int) -> DiscogsResult { + DiscogsResult( + discogs_id: id, + title: "Spiderland", + artist: "Slint", + year: Some(1991), + format: Some("LP"), + thumb_url: None, + cover_url: None, + ) +} -- tangled.sh