From f85e9bcc370b19c7c9b251bb698611f7667fe75b Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 10 Aug 2026 13:54:19 +0200 Subject: [PATCH] test: make the amend title assertion falsifiable and stop pinning attribute order --- web/test/add_test.gleam | 15 +------ web/test/controls_test.gleam | 22 ++++++++- web/test/crate_test.gleam | 77 ++++++++++---------------------- web/test/login_test.gleam | 11 +++++ web/test/nav_test.gleam | 26 +++++------ web/test/record_amend_test.gleam | 7 ++- web/test/record_test.gleam | 18 ++++---- web/test/states_test.gleam | 63 ++++++++++++++++---------- web/test/support.gleam | 24 ++++++++++ 9 files changed, 147 insertions(+), 116 deletions(-) diff --git a/web/test/add_test.gleam b/web/test/add_test.gleam index 02e2869..b165fa7 100644 --- a/web/test/add_test.gleam +++ b/web/test/add_test.gleam @@ -11,10 +11,9 @@ import crate_web/pages/add import crate_web/update.{update} import gleam/list import gleam/option.{None, Some} -import gleam/result import gleam/string import lustre/element -import support.{base, empty_effect, logged_in} +import support.{base, empty_effect, logged_in, open_tags} // Validation failures are page-local: they never touch the entered fields, // and they show as an inline region above SAVE TO CRATE rather than the @@ -180,7 +179,7 @@ pub fn no_form_error_renders_nothing_extra_test() { pub fn no_button_in_the_add_form_is_a_bare_button_test() { [logged_in(), Model(..logged_in(), form_error: Some(WriteFailed("boom")))] |> list.each(fn(model) { - let tags = button_tags(add.view(model) |> element.to_string) + let tags = open_tags(add.view(model) |> element.to_string, "button") assert tags != [] list.each(tags, fn(tag) { assert string.contains(tag, "type=\"") @@ -195,16 +194,6 @@ pub fn the_status_segment_still_renders_test() { assert string.contains(html, "class=\"segment\"") } -/// The open tag of every `", - ) + let back = "aria-label=\"Back\"" + assert string.contains(tag_with(html, "button", back), "class=\"icon-btn\"") + assert list.filter(open_tags(html, "a"), string.contains(_, back)) == [] } pub fn every_route_change_bumps_nav_depth_test() { diff --git a/web/test/record_amend_test.gleam b/web/test/record_amend_test.gleam index 3f22d81..9bbfac8 100644 --- a/web/test/record_amend_test.gleam +++ b/web/test/record_amend_test.gleam @@ -140,7 +140,12 @@ pub fn record_amend_page_titles_itself_amend_regardless_of_provenance_test() { ShelfLoaded(Own, [an_entry()]), ) let html = view.view(seeded) |> element.to_string - assert string.contains(html, "AMEND") + // "AMEND" alone is always present: the submit button reads PUBLISH + // AMENDMENT. Assert the app bar's own title instead. + assert string.contains( + html, + "AMEND", + ) }) } diff --git a/web/test/record_test.gleam b/web/test/record_test.gleam index c29127b..ac5b76e 100644 --- a/web/test/record_test.gleam +++ b/web/test/record_test.gleam @@ -14,7 +14,7 @@ import gleam/dict import gleam/option.{type Option, None, Some} import gleam/string import lustre/element -import support.{a_detail, an_entry, logged_in} +import support.{a_detail, an_entry, logged_in, tag_with} /// `a_detail()` with its folded entry swapped for `entry`. fn detail(entry: Entry) -> EntryDetail { @@ -188,10 +188,8 @@ pub fn record_view_shows_a_copy_share_link_button_with_the_public_path_test() { // screen, not a button dispatching a toggle message. pub fn record_view_renders_the_amend_action_as_a_link_to_its_own_route_test() { let html = record.view(logged_in(), a_detail()) |> element.to_string - assert string.contains( - html, - "AMEND", - ) + assert string.contains(amend_link(html), "class=\"btn btn--ghost\"") + assert string.contains(html, ">AMEND") } // AMEND is the one canonical label for the action regardless of who minted @@ -201,13 +199,15 @@ pub fn record_view_labels_the_amend_link_as_amend_for_an_adopted_entry_too_test( let html = record.view(Model(..logged_in(), via_handles:), a_detail()) |> element.to_string - assert string.contains( - html, - "AMEND", - ) + assert string.contains(amend_link(html), "class=\"btn btn--ghost\"") + assert string.contains(html, ">AMEND") assert !string.contains(html, "SUGGEST A FIX") } +fn amend_link(html: String) -> String { + tag_with(html, "a", "href=\"/record/e1/amend\"") +} + pub fn record_view_labels_move_to_history_not_remove_test() { let html = record.view(logged_in(), a_detail()) |> element.to_string assert string.contains(html, "MOVE TO HISTORY") diff --git a/web/test/states_test.gleam b/web/test/states_test.gleam index 03a724d..0fea21c 100644 --- a/web/test/states_test.gleam +++ b/web/test/states_test.gleam @@ -1,38 +1,53 @@ //// Shared async-state primitives: the ARIA live regions on loading/error, -//// and that the retry variants wire RETRY without forcing it on callers -//// that have nothing to retry. +//// which sticker headline each failure earns, and which of them offer a +//// RETRY the visitor can actually act on. import crate_web/ui/states +import gleam/list import gleam/string -import lustre/element +import lustre/element.{type Element} pub type TestMsg { Retry } -pub fn loading_page_announces_as_status_test() { - let html = states.loading_page() |> element.to_string - assert string.contains(html, "role=\"status\"") +fn rendered(el: Element(TestMsg)) -> String { + element.to_string(el) |> string.replace("'", "'") } -pub fn failed_page_announces_as_alert_test() { - let html = states.failed_page("Couldn't load.") |> element.to_string - assert string.contains(html, "role=\"alert\"") +/// A RETRY only belongs on a state where re-running the same action could +/// land differently: a fetch or a write, never client-side validation. +pub fn each_state_announces_itself_and_offers_retry_only_where_it_helps_test() { + [ + #(states.loading_page(), "status", False), + #(states.failed_page("Couldn't load."), "alert", False), + #(states.failed_page_retry("Couldn't load.", Retry), "alert", True), + #(states.error_sticker("Nope."), "alert", False), + #(states.error_sticker_retry("Nope.", Retry), "alert", True), + #(states.write_error_sticker("Nope.", Retry), "alert", True), + #(states.invalid_input_sticker("Nope."), "alert", False), + ] + |> list.each(fn(row) { + let #(el, role, retryable) = row + let html = rendered(el) + assert string.contains(html, "role=\"" <> role <> "\"") + assert string.contains(html, "RETRY") == retryable + }) } -pub fn failed_page_has_no_retry_action_by_default_test() { - let html = states.failed_page("Couldn't load.") |> element.to_string - assert !string.contains(html, "RETRY") -} - -pub fn failed_page_retry_wires_the_retry_message_test() { - let html = - states.failed_page_retry("Couldn't load.", Retry) |> element.to_string - assert string.contains(html, "RETRY") - assert string.contains(html, "role=\"alert\"") -} - -pub fn error_sticker_retry_wires_the_retry_message_test() { - let html = states.error_sticker_retry("Nope.", Retry) |> element.to_string - assert string.contains(html, "RETRY") +/// The headline names what actually went wrong: a failed write never loaded +/// anything, and a rejected input is not a failure of the app at all. +pub fn each_sticker_names_its_own_kind_of_failure_test() { + [ + #(states.error_sticker("x"), "✕ COULDN'T LOAD"), + #(states.error_sticker_retry("x", Retry), "✕ COULDN'T LOAD"), + #(states.write_error_sticker("x", Retry), "✕ COULDN'T SAVE"), + #(states.invalid_input_sticker("x"), "✕ CHECK YOUR ENTRIES"), + ] + |> list.each(fn(row) { + let #(el, sticker) = row + let html = rendered(el) + assert string.contains(html, sticker) + assert string.contains(html, "x") + }) } diff --git a/web/test/support.gleam b/web/test/support.gleam index e62802d..94a8883 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -13,7 +13,10 @@ import crate_web/model.{ import crate_web/msg.{type ApiError} import crate_web/pages/crate import gleam/dict +import gleam/list import gleam/option.{None, Some} +import gleam/result +import gleam/string import lustre/effect.{type Effect} import lustre/element @@ -164,3 +167,24 @@ pub fn discogs_result(id: Int) -> DiscogsResult { cover_url: None, ) } + +/// Every `` open tag in `html`, attributes only. Assertions use +/// this instead of a literal tag string so a Lustre release that reorders +/// attribute serialisation can't red the suite over nothing. +pub fn open_tags(html: String, name: String) -> List(String) { + string.split(html, "<" <> name) + |> list.drop(1) + |> list.filter(fn(rest) { + string.starts_with(rest, " ") || string.starts_with(rest, ">") + }) + |> list.map(fn(rest) { + string.split(rest, ">") |> list.first |> result.unwrap("") + }) +} + +/// The first `name` open tag carrying `needle`, or "" when there is none. +pub fn tag_with(html: String, name: String, needle: String) -> String { + open_tags(html, name) + |> list.find(string.contains(_, needle)) + |> result.unwrap("") +} -- 2.51.2