ML-142: Improve scrobble UI in the release component
- Sticky bar for easier track-based scrobbling - Support setting custom finish time
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
defmodule MusicLibraryWeb.Components.ReleaseTest do
|
||||
@moduledoc """
|
||||
Integration tests for the Release LiveComponent driven through `CollectionLive.Show`.
|
||||
|
||||
Covers the UI changes introduced by ML-142: the `Finished at` picker, the sticky
|
||||
selection bar, the re-enabled per-medium scrobble button, and the fact that all
|
||||
three scrobble entry points now use the picker value (initialised to
|
||||
`DateTime.utc_now/0` at mount, resettable via the `Now` button).
|
||||
"""
|
||||
use MusicLibraryWeb.ConnCase, async: false
|
||||
|
||||
import MusicLibrary.Fixtures.Records
|
||||
|
||||
import Phoenix.LiveViewTest,
|
||||
only: [element: 2, render_async: 1, render_change: 2, render_click: 1]
|
||||
|
||||
alias MusicBrainz.Fixtures.Release, as: ReleaseFixtures
|
||||
alias MusicLibrary.Secrets
|
||||
|
||||
@finished_at ~U[2026-03-15 21:00:00Z]
|
||||
# `@sheet_form` scopes form-change events to the LiveComponent; button
|
||||
# clicks are scoped by `[phx-target]` to avoid colliding with the parent
|
||||
# LiveView's own scrobble_release button at `lib/music_library_web/live/collection_live/show.ex:58`.
|
||||
@sheet_form "#release-with-tracks-sheet-form"
|
||||
|
||||
defp stub_musicbrainz_release(_) do
|
||||
Req.Test.stub(MusicBrainz.API, fn conn ->
|
||||
case conn.request_path do
|
||||
"/ws/2/release/" <> _id ->
|
||||
Req.Test.json(conn, ReleaseFixtures.release_with_media(:marbles))
|
||||
|
||||
_ ->
|
||||
Req.Test.json(conn, %{})
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp capture_lastfm_scrobble(_) do
|
||||
test_pid = self()
|
||||
|
||||
Req.Test.stub(LastFm.API, fn conn ->
|
||||
{:ok, body, conn} = Plug.Conn.read_body(conn)
|
||||
params = URI.decode_query(body)
|
||||
send(test_pid, {:lastfm_scrobble, params})
|
||||
Req.Test.json(conn, %{"scrobbles" => %{"@attr" => %{"accepted" => 1}}})
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp store_lastfm_session_key(_) do
|
||||
Secrets.store("last_fm_session_key", "test_session_key")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp release_with_tracks do
|
||||
:marbles
|
||||
|> ReleaseFixtures.release_with_media()
|
||||
|> MusicBrainz.Release.from_api_response()
|
||||
end
|
||||
|
||||
defp first_track_id(release),
|
||||
do: release.media |> List.first() |> Map.get(:tracks) |> List.first() |> Map.get(:id)
|
||||
|
||||
defp second_medium_first_track_id(release),
|
||||
do: release.media |> Enum.at(1) |> Map.get(:tracks) |> List.first() |> Map.get(:id)
|
||||
|
||||
defp latest_timestamp(params) do
|
||||
params
|
||||
|> Enum.filter(fn {k, _v} -> String.starts_with?(k, "timestamp[") end)
|
||||
|> Enum.map(fn {_k, v} -> String.to_integer(v) end)
|
||||
|> Enum.max()
|
||||
end
|
||||
|
||||
describe "Finished at picker" do
|
||||
setup [:stub_musicbrainz_release, :store_lastfm_session_key, :capture_lastfm_scrobble]
|
||||
|
||||
test "renders 'Finished at' label when Last.fm is connected", %{conn: conn} do
|
||||
record = record()
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> unwrap(&render_async/1)
|
||||
|> assert_has("div[data-part=inner-prefix]", text: "Finished at")
|
||||
end
|
||||
|
||||
test "release scrobble uses mount-time utc_now when picker is not modified", %{conn: conn} do
|
||||
record = record()
|
||||
before_click = DateTime.utc_now()
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> unwrap(&render_async/1)
|
||||
|> unwrap(fn view ->
|
||||
view
|
||||
|> element("button[phx-click=scrobble_release][phx-target]")
|
||||
|> render_click()
|
||||
end)
|
||||
|
||||
after_click = DateTime.utc_now()
|
||||
|
||||
assert_received {:lastfm_scrobble, params}
|
||||
latest = latest_timestamp(params)
|
||||
|
||||
# Scrobble timestamps reflect started_at + track offsets, so the latest
|
||||
# timestamp is close to the click time (within a couple of seconds).
|
||||
assert latest >= DateTime.to_unix(before_click) - 2
|
||||
assert latest <= DateTime.to_unix(after_click) + 2
|
||||
end
|
||||
|
||||
test "release scrobble uses the picker value when set", %{conn: conn} do
|
||||
record = record()
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> unwrap(&render_async/1)
|
||||
|> unwrap(fn view ->
|
||||
view
|
||||
|> element(@sheet_form)
|
||||
|> render_change(%{"release" => %{"finished_at" => DateTime.to_iso8601(@finished_at)}})
|
||||
|
||||
view
|
||||
|> element("button[phx-click=scrobble_release][phx-target]")
|
||||
|> render_click()
|
||||
end)
|
||||
|
||||
assert_received {:lastfm_scrobble, params}
|
||||
assert latest_timestamp(params) == DateTime.to_unix(@finished_at)
|
||||
end
|
||||
|
||||
test "reset_to_now event resets the picker back to the current time", %{conn: conn} do
|
||||
record = record()
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> unwrap(&render_async/1)
|
||||
|> unwrap(fn view ->
|
||||
view
|
||||
|> element(@sheet_form)
|
||||
|> render_change(%{"release" => %{"finished_at" => DateTime.to_iso8601(@finished_at)}})
|
||||
|
||||
view
|
||||
|> element("button[phx-click=reset_to_now]")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("button[phx-click=scrobble_release][phx-target]")
|
||||
|> render_click()
|
||||
end)
|
||||
|
||||
now_unix = DateTime.to_unix(DateTime.utc_now())
|
||||
assert_received {:lastfm_scrobble, params}
|
||||
latest = latest_timestamp(params)
|
||||
|
||||
# After resetting, the scrobble should use `utc_now/0`, not the stale value.
|
||||
assert abs(latest - now_unix) < 5
|
||||
refute latest == DateTime.to_unix(@finished_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Per-medium scrobble" do
|
||||
setup [:stub_musicbrainz_release, :store_lastfm_session_key, :capture_lastfm_scrobble]
|
||||
|
||||
test "medium-2 scrobble fires when a track on medium-1 is selected (AC#3 regression)",
|
||||
%{conn: conn} do
|
||||
record = record()
|
||||
release = release_with_tracks()
|
||||
medium_1_track = first_track_id(release)
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> unwrap(&render_async/1)
|
||||
|> unwrap(fn view ->
|
||||
view
|
||||
|> element(@sheet_form)
|
||||
|> render_change(%{"release" => %{"selected_tracks" => [medium_1_track]}})
|
||||
|
||||
view
|
||||
|> element("button[phx-click=scrobble_medium][phx-value-number='2']")
|
||||
|> render_click()
|
||||
end)
|
||||
|> assert_has("#toast-group", text: "Disc scrobbled successfully")
|
||||
|
||||
assert_received {:lastfm_scrobble, params}
|
||||
# Medium 2's tracks were scrobbled, not the medium-1 selection.
|
||||
medium_2_first_title =
|
||||
release.media |> Enum.at(1) |> Map.get(:tracks) |> List.first() |> Map.get(:title)
|
||||
|
||||
assert params["track[0]"] == medium_2_first_title
|
||||
end
|
||||
|
||||
test "medium scrobble uses picker value when set", %{conn: conn} do
|
||||
record = record()
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> unwrap(&render_async/1)
|
||||
|> unwrap(fn view ->
|
||||
view
|
||||
|> element(@sheet_form)
|
||||
|> render_change(%{"release" => %{"finished_at" => DateTime.to_iso8601(@finished_at)}})
|
||||
|
||||
view
|
||||
|> element("button[phx-click=scrobble_medium][phx-value-number='2']")
|
||||
|> render_click()
|
||||
end)
|
||||
|
||||
assert_received {:lastfm_scrobble, params}
|
||||
assert latest_timestamp(params) == DateTime.to_unix(@finished_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Sticky selection bar" do
|
||||
setup [:stub_musicbrainz_release, :store_lastfm_session_key, :capture_lastfm_scrobble]
|
||||
|
||||
test "is hidden when no tracks are selected", %{conn: conn} do
|
||||
record = record()
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> unwrap(&render_async/1)
|
||||
|> refute_has("button", text: "Scrobble selected")
|
||||
end
|
||||
|
||||
test "appears with a singular count when one track is selected", %{conn: conn} do
|
||||
record = record()
|
||||
release = release_with_tracks()
|
||||
track_id = first_track_id(release)
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> unwrap(&render_async/1)
|
||||
|> unwrap(fn view ->
|
||||
view
|
||||
|> element(@sheet_form)
|
||||
|> render_change(%{"release" => %{"selected_tracks" => [track_id]}})
|
||||
end)
|
||||
|> assert_has("p", text: "1 track selected")
|
||||
|> assert_has("button", text: "Scrobble selected")
|
||||
end
|
||||
|
||||
test "shows cross-medium copy when selection spans discs", %{conn: conn} do
|
||||
record = record()
|
||||
release = release_with_tracks()
|
||||
t1 = first_track_id(release)
|
||||
t2 = second_medium_first_track_id(release)
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> unwrap(&render_async/1)
|
||||
|> unwrap(fn view ->
|
||||
view
|
||||
|> element(@sheet_form)
|
||||
|> render_change(%{"release" => %{"selected_tracks" => [t1, t2]}})
|
||||
end)
|
||||
|> assert_has("p", text: "2 tracks selected")
|
||||
|> assert_has("span", text: "across 2 discs")
|
||||
end
|
||||
|
||||
test "Scrobble selected uses picker value", %{conn: conn} do
|
||||
record = record()
|
||||
release = release_with_tracks()
|
||||
track_id = first_track_id(release)
|
||||
|
||||
conn
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> unwrap(&render_async/1)
|
||||
|> unwrap(fn view ->
|
||||
view
|
||||
|> element(@sheet_form)
|
||||
|> render_change(%{
|
||||
"release" => %{
|
||||
"selected_tracks" => [track_id],
|
||||
"finished_at" => DateTime.to_iso8601(@finished_at)
|
||||
}
|
||||
})
|
||||
|
||||
view
|
||||
|> element("button[phx-click=scrobble_selected_tracks]")
|
||||
|> render_click()
|
||||
end)
|
||||
|
||||
assert_received {:lastfm_scrobble, params}
|
||||
assert latest_timestamp(params) == DateTime.to_unix(@finished_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -83,7 +83,7 @@ defmodule MusicLibraryWeb.CollectionLive.ShowTest do
|
||||
|> visit(~p"/collection/#{record.id}")
|
||||
|> assert_has("button", "Show Tracks")
|
||||
|> unwrap(&render_async/1)
|
||||
|> assert_has("a", "Connect your Last.fm account")
|
||||
|> assert_has("a", "Connect Last.fm")
|
||||
|
||||
release =
|
||||
MusicBrainz.Release.from_api_response(release_response)
|
||||
|
||||
@@ -112,6 +112,23 @@ defmodule MusicLibraryWeb.ScrobbleLive.ShowTest do
|
||||
|> assert_has("#toast-group", "Disc scrobbled successfully")
|
||||
end
|
||||
|
||||
test "scrobble single medium still works with tracks selected elsewhere", %{conn: conn} do
|
||||
track_id = first_track_id()
|
||||
session = visit(conn, ~p"/scrobble/#{@release_id}")
|
||||
|
||||
session
|
||||
|> unwrap(fn view ->
|
||||
# Select a track first — AC#3 regression: medium scrobble must not be
|
||||
# blocked just because something is ticked.
|
||||
view
|
||||
|> element("#scrobble-release-form")
|
||||
|> render_change(%{"release" => %{"selected_tracks" => [track_id]}})
|
||||
|
||||
render_click(view, "scrobble_medium", %{"number" => "1"})
|
||||
end)
|
||||
|> assert_has("#toast-group", "Disc scrobbled successfully")
|
||||
end
|
||||
|
||||
test "toggle track on and off changes button label", %{conn: conn} do
|
||||
track_id = first_track_id()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user