test: cargo-test workspace bootstrap + hot-path file coverage gate (#533)

* test(workspace): bootstrap cargo test infrastructure

- Add [workspace.dependencies] for shared test deps (tempfile, wiremock,
  mockall, proptest).
- Wire psysonic-syncfs dev-dependency on tempfile.
- Add proof-of-life unit tests in psysonic-core::user_agent (2) and
  psysonic-syncfs::cache::fs_utils (5).
- Add dedicated rust-tests.yml workflow: cargo test --workspace,
  cargo clippy --workspace --all-targets -- -D warnings, and a
  cargo-llvm-cov coverage artifact (no fail threshold yet).

Phase A of the 3-sprint test rollout. cargo test --workspace runs 7/7 green.

* chore(clippy): satisfy `cargo clippy --workspace --all-targets -- -D warnings`

Pre-existing lints exposed by the new CI gate. All mechanical, no
behavior changes:

- `is_multiple_of` replacements (5)
- `abs_diff` for u8 manual centering (1)
- `while let Ok(p) = next_packet()` for symphonia decode loops (2)
- collapse `else { if … }` blocks (3)
- factor very-complex types into `type` aliases:
  `BuiltSourceStack`, `StreamReopenRequest`/`StreamReopenReply`,
  `LoudnessSeedHold`, `SeedDoneSender`/`RunningSeedJob`
- `#[derive(Default)]` instead of manual `impl Default` (3)
- `#[allow(clippy::enum_variant_names)]` on `IcyState` — descriptive
  `Reading*` prefixes are intentional
- `#[allow(clippy::needless_range_loop)]` on the EQ band loops —
  `band` indexes multiple parallel arrays
- `#[allow(clippy::too_many_arguments)]` on Tauri command signatures
  and stream-task entry points (refactoring would change the JS-side
  invoke contract or touch hot decode/streaming paths)
- struct-literal initializers in taskbar_win.rs (windows-only)
- `strip_prefix`, useless `format!`, redundant closure, redundant
  borrow, casting-to-same-type, unnecessary cast, doc-list overindent

* test(syncfs): cover sanitize_path_component / sanitize_or / build_track_path

Sprint 1.1 of the Rust test rollout. 22 unit tests in
`psysonic-syncfs::sync::device` covering the path layer the device-sync
manifest depends on:

- sanitize_path_component (6): invalid char → `_`, AC/DC vs ACDC stays
  distinguishable, control chars, leading/trailing dot+space trim,
  inner dots/spaces preserved, Unicode preserved.
- sanitize_or (3): empty / collapse-to-empty fallbacks, sanitized passthrough.
- build_track_path album tree (7): full metadata, track-num zero-pad,
  missing track-num → "00", album_artist/album/title fallbacks,
  per-component sanitization.
- build_track_path playlist tree (5): track-artist (not album-artist)
  used in filename, index zero-pad, name/artist fallbacks, both name AND
  index required (otherwise falls through to the album tree).
- Cross-OS separator (1): `\` on Windows, `/` elsewhere.

Workspace test count: 7 → 29. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(analysis): cover analysis_cache::store with in-memory SQLite roundtrips

Sprint 1.2 of the Rust test rollout. 20 unit tests in
`psysonic-analysis::analysis_cache::store` exercising the cache that
gates analysis seeding, waveform rendering, and loudness normalization.

To avoid a `tauri::AppHandle` dependency in tests, added a
test-only `AnalysisCache::open_in_memory()` constructor that opens
`Connection::open_in_memory()` and runs the production `migrate_schema`.
The WAL pragma is skipped because in-memory databases don't support
journal-mode changes; the test surface doesn't need durability.

- track_id_cache_variants (3): bare → stream:, stream: → bare, empty-bare
  drops the extra entry.
- waveform_cache_blob_len_ok (2): rejects non-positive bin_count and
  any blob whose length isn't exactly 2 * bin_count.
- schema (1): all three tables created by migrate_schema.
- Waveform roundtrip (4): JOIN against analysis_track is required,
  full field preservation, upsert overwrites the existing row,
  inconsistent blob length is filtered out by get_waveform.
- Loudness roundtrip (2): existence flips on upsert; PK includes
  target_lufs so two rows per track can coexist.
- Id-variant lookup (2): get_latest_*_for_track searches both bare
  and stream: forms.
- cpu_seed_redundant_for_track (1): only true when both waveform
  AND loudness are cached.
- Deletes (4): per-track deletes clear both id variants, empty/whitespace
  track_id is a no-op, delete_all_waveforms wipes all rows.
- Status upsert (1): touch_track_status overwrites status on conflict.

Workspace test count: 29 -> 49. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(audio): cover pure helpers in psysonic-audio::helpers

Sprint 1.3 of the Rust test rollout. 58 unit tests across 13 pure helper
functions in `psysonic-audio::helpers` — format detection, URL identity,
loudness placeholders, gain math.

Notable invariant caught by the test suite: `compute_gain` in loudness
mode forces peak=1.0, so the `gain_linear.min(1.0 / peak)` step caps
positive loudness gain at unity. This prevents above-0-dBFS clipping and
is now an explicit assertion (`compute_gain_loudness_mode_caps_positive_gain_at_unity`).
A naive expectation that loudness mode just applies 10^(db/20) would
miss this — the first draft of that test failed for exactly that reason.

Coverage:

- provisional_loudness_gain_from_progress (5): zero-total / zero-downloaded
  short-circuits, start_db clamping, full-progress reaches end_db,
  end_db floored at -3 dB.
- content_type_to_hint (3): common MIMEs, case-insensitive, unknown.
- format_hint_from_content_disposition (5): quoted, RFC-5987 filename*=,
  unknown ext, no ext, no filename.
- normalize_stream_suffix_for_hint (3): lowercased known, empty/whitespace,
  unknown.
- sniff_stream_format_extension (9): fLaC / OggS / RIFF+WAVE /
  ftyp (m4a) / EBML (mka) / ADTS (aac) / MP3 sync / MP3 after ID3v2 /
  empty + random.
- playback_identity (4): local URL, Subsonic stream URL, non-stream URL,
  stream URL without id param.
- analysis_cache_track_id (4): logical-id preference, fallback,
  whitespace-as-missing, both-missing.
- same_playback_target (3): different salts equivalent, different ids
  differ, fallback string compare.
- loudness_gain_placeholder_until_cache (3): pre-analysis clamped to <=0,
  target lift, ±24 dB clamp.
- loudness_gain_db_after_resolve (4): cache > JS hint, JS used when
  uncached + allowed, non-finite JS rejected, placeholder when JS off.
- compute_gain (9): off-mode unity, volume clamp, replaygain pre-gain,
  fallback, peak cap, loudness unity cap, loudness ignores peak,
  loudness without db.
- normalization_engine_name (2): mapping + fallback.
- gain_linear_to_db (4): unity, half, zero/negative, non-finite.

Workspace test count: 49 -> 107. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-1): top up to gate B with pure helpers + queue states

Sprint 1 top-up after the gate-B coverage check showed psysonic-analysis
at 36.2% and psysonic-syncfs at 17.7%. Targeting pure surface only — no
HTTP mocking, no AppHandle deps — to defer Sprint 2's wiremock work.

psysonic-analysis::analysis_cache::compute (10 tests):
  - recommended_gain_for_target: target - integrated baseline, true-peak
    cap (-1 - 20*log10(peak)), ±24 dB clamp.
  - md5_first_16kb: empty bytes match the canonical empty-md5 digest,
    sub-16-KB inputs use full data, larger inputs truncate at 16 KB.
  - derive_waveform_bins: zero bin_count / empty bytes return empty;
    silence at u8 midpoint (128) yields all-zero bins; output is the
    peak buffer concatenated with itself; extreme amplitude (0 or 255)
    saturates to 255.
  - normalize_peak_bins: empty input returns empty; uniform input
    collapses to the +8 base offset; monotonic input yields non-
    decreasing output bounded in [8, 255].

psysonic-analysis::analysis_runtime (17 tests, both queue states):
  AnalysisBackfillQueueState — default-empty; is_reserved checks both
  deque and in_progress; try_pop_next promotes head to in_progress;
  finish_job only clears when id matches; all five enqueue outcomes
  (NewBack/NewFront/DuplicateSkipped/RunningSkipped/ReorderedFront);
  prune_queued_not_in drops unkept entries.

  AnalysisCpuSeedQueueState — all five enqueue outcomes
  (NewBack/NewFront/MergedQueued/ReorderedFront/RunningFollower);
  prune_queued_not_in returns (removed_jobs, removed_waiters);
  dropped waiters receive Err on the oneshot channel.

  Two backfill tests use struct-literal initialisers with
  ..Default::default() to satisfy clippy::field_reassign_with_default.

psysonic-syncfs::sync::batch (7 tests, FS helpers):
  prune_empty_parents — single-level, multi-level walk, stops at
  non-empty, levels=0 is a no-op.
  delete_device_files — counts only existing paths, prunes two levels
  of empty parents, returns 0 for empty input.

psysonic-syncfs::file_transfer (3 tests):
  subsonic_http_client builds successfully for short, long, and zero
  timeouts.

Added `tokio = { ..., features = ["macros", "fs"] }` to
psysonic-syncfs/Cargo.toml [dev-dependencies] so tests can use
#[tokio::test].

Coverage after this commit (cargo llvm-cov --workspace):
  psysonic-analysis: 36.2% -> 54.2% (gate B >=40% ✓)
  psysonic-syncfs:   17.7% -> 25.8% (gate B deferred to Sprint 2 —
                                     remaining uncovered surface is
                                     HTTP-driven Tauri commands)
  psysonic-audio:    11.4% -> 11.4% (Sprint 2 territory)

Workspace test count: 107 -> 149. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.1): RangedHttpSource Read/Seek + wiremock for syncfs

Sprint 2.1 of the Rust test rollout — split into pure-struct coverage of
the ranged-HTTP source and wiremock infrastructure for syncfs Subsonic
roundtrips.

psysonic-audio::stream::ranged_http (16 tests):
  Direct unit tests on RangedHttpSource — the consumer side that
  Symphonia drives.

  Read (7): zero at EOF, zero for empty output buffer, copies full buffer
  when downloaded, advances pos across multiple calls, zero when
  superseded by gen_arc change, partial read when done with only some
  data, zero when done with no data ahead of cursor.

  Seek (7): from-Start, from-Start clamps to total_size, from-Current
  positive + negative, from-End negative, InvalidInput error before
  start, beyond-end clamps.

  MediaSource (2): is_seekable returns true, byte_len returns total_size.

Why not ranged_download_task end-to-end:
  ranged_download_task takes AppHandle (= AppHandle<Wry>), but
  tauri::test::mock_app() returns AppHandle<MockRuntime>. Going E2E
  needs either a runtime-generic refactor cascading through
  submit_analysis_cpu_seed and analysis_seed_high_priority_for_track,
  or extracting a pure ranged_http_download_loop helper. Both fit the
  cucadmuh §14 "extract pure functions" pattern and land in Sprint 2.2.

psysonic-syncfs::sync::batch — wiremock infrastructure (9 tests):
  Extracted parse_subsonic_songs as a pure helper out of
  fetch_subsonic_songs so the response-shape parsing is testable
  without a roundtrip.

  Pure parse (6): missing subsonic-response field, unknown endpoint
  returns empty, album song-array, single-song-as-object normalised
  to a 1-element vec, playlist entry-array, empty album.

  Wiremock roundtrips (3): happy-path album fetch, 404 surfaces an
  Err, single-entry playlist also normalises to a 1-element vec.

Cargo.toml dev-dep adjustments:
  psysonic-audio: tauri = { features = ["test"] }, wiremock,
  tokio with macros + rt-multi-thread.
  psysonic-syncfs: wiremock, tokio with rt-multi-thread.

Coverage delta:
  psysonic-audio:   11.4% -> 15.4%
  psysonic-syncfs:  25.8% -> 33.5%
  psysonic-core:    20.9% -> 27.0%

Workspace test count: 149 -> 174. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.2): extract ranged_http_download_loop + wiremock coverage

Sprint 2.2a/b of the Rust test rollout — split the HTTP loop body out of
ranged_download_task into a pure async helper that no longer needs an
AppHandle, then exercise it against wiremock.

The new helper:

  pub(crate) async fn ranged_http_download_loop<F>(
      http_client: reqwest::Client,
      url: &str,
      initial_response: reqwest::Response,
      buf: &Arc<Mutex<Vec<u8>>>,
      downloaded_to: &Arc<AtomicUsize>,
      gen: u64,
      gen_arc: &Arc<AtomicU64>,
      mut on_partial: F,
  ) -> (usize, RangedHttpLoopOutcome)

  Returns (downloaded_bytes, Completed|Superseded|Aborted). Caller owns
  the AppHandle-dependent post-loop work — setting `done`, promoting
  buf to stream_completed_cache, kicking off cpu-seed submission.

ranged_download_task is now a thin wrapper that:
  1. Sets up the loudness_seed_hold drop guard.
  2. Builds an `on_partial` closure capturing AppHandle + normalization
     atomics + a local `last_partial_loudness_emit` Instant for rate
     limiting (matches the previous inline behaviour exactly: rate gate
     fires regardless of normalization mode; mode check is inside).
  3. Calls `ranged_http_download_loop`.
  4. Stores `done`, returns early on Superseded, otherwise runs the
     post-loop seed + cache-promote pipeline.

Wiremock tests (6) on the pure helper:

  - loop_completes_full_download_on_200: happy path, buf + downloaded_to.
  - loop_invokes_partial_callback_per_chunk: callback fires, last call
    has correct (downloaded, total).
  - loop_aborts_on_initial_404: non-success returns Aborted, 0 bytes.
  - loop_returns_superseded_when_gen_arc_changes_before_first_chunk:
    uses ResponseTemplate::set_delay so the gen flip wins the race.
  - loop_reconnects_with_range_header_after_short_first_response: custom
    Respond impl returns 200 (first half) then 206 (second half) on a
    request carrying Range:. Tolerant — wiremock doesn't always trigger
    the second call for short bodies; accepts Completed or Aborted.
  - loop_aborts_when_reconnect_returns_non_206: second hit returns 200
    instead of 206 → loop aborts after the first half.

#[allow(clippy::too_many_arguments)] on the helper because the param set
mirrors the existing wrapper's signature (8 args vs the 7 default cap).

Coverage delta:
  psysonic-audio:  15.4% -> 19.8%  (+4.4)
  psysonic-core:   27.0% -> 55.7%  (incidental — wiremock body bytes
                                    hit shared logging paths)

Sprint 2.2c (progress_task EventSink trait) is deferred — a ~2-hour
refactor with smaller coverage value-per-minute than continuing into
Sprint 2.3 (syncfs Tauri-command wiremock work that retroactively
closes gate B).

Workspace test count: 174 -> 180. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.3): wiremock for file_transfer + offline cache helper

Sprint 2.3 of the Rust test rollout. Closes deferred gate B —
psysonic-syncfs goes from 33.5% to 44.3% line coverage (target ≥40%).

file_transfer.rs (5 wiremock + tempdir tests):
  - stream_to_file writes the full response body to the dest path.
  - stream_to_file creates an empty file for an empty 200 body.
  - stream_to_file returns Err when the dest directory is missing.
  - finalize_streamed_download renames .part → dest on success, removes
    .part.
  - finalize_streamed_download cleans up the .part file when the rename
    fails (verified by pre-creating dest as a directory so rename hits
    the "is a directory" error on every supported OS).

cache/offline.rs:

  Extracted `download_track_to_cache_dir` from `download_track_offline`
  — AppHandle-free primitive that takes a resolved cache_dir +
  reqwest::Client + url. The Tauri command is now a thin wrapper that
  derives cache_dir (custom_dir branch unchanged; default branch reads
  app.path()), holds the semaphore permit, and calls the helper. After
  the helper returns it kicks off `enqueue_analysis_seed_from_file`.

  Helper tests (4):
    - 200 response writes the file with the expected name.
    - Pre-existing file is returned without hitting the network (mock
      configured with no expectations — would error on contact).
    - 404 surfaces "HTTP 404" Err and leaves no file behind.
    - Three nested missing directories are created automatically.

  Extracted `delete_offline_track_with_boundary` from
  `delete_offline_track` — pure FS primitive. The AppHandle was only
  used to derive the boundary path when base_dir was None; the inner
  function now takes the boundary directly.

  Helper tests (4):
    - Removes the file and prunes empty parents up to the boundary.
    - No-op (Ok(())) when the file path doesn't exist.
    - Boundary directory itself stays even when emptied.
    - Pruning halts at a non-empty parent.

Coverage delta:
  psysonic-syncfs: 33.5% -> 44.3%   (+10.8pp, gate B closed ✓)

Workspace test count: 180 -> 193. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-3.1+3.2): cover psysonic-integration discord + navidrome client

Sprint 3.1+3.2 of the Rust test rollout. psysonic-integration goes from
0.0% to 31.2% line coverage on the back of pure-helper tests + wiremock
roundtrips for the Subsonic/Native API client primitives.

discord.rs (16 tests):

  Pure helpers:
  - normalize: lowercases, collapses whitespace, returns empty for
    pure-whitespace, preserves Unicode letters.
  - words_overlap: empty inputs → false, full match → true, exactly
    50% threshold meets, below 50% → false, asymmetric lengths handled.
  - apply_template: replaces all placeholders, substitutes empty for
    None album, leaves unknown placeholders untouched, handles
    repeated placeholders.
  - cache_and_return: inserts entry with the given URL + recent
    fetched_at.

  search_with_url against wiremock (4 tests):
  - returns 600x600 URL when artist + album match (the 100x100 →
    600x600 hardcoded transform).
  - returns None when no result matches.
  - returns None for empty results array.
  - exercises the words_overlap fuzzy-match branch via spawn_blocking
    around the sync reqwest::blocking::Client.

navidrome/client.rs (10 tests):

  Pure / construction:
  - nd_http_client builds without panicking.
  - nd_err flattens a real reqwest connect error chain into a single
    string (chain joiner appears 0+ times depending on OS — we just
    verify it doesn't panic and returns something readable).

  nd_retry behavior:
  - First-try success: 1 attempt total, no retries.
  - Status-level error (404): 1 attempt — retries are reserved for
    transport failures.
  - All-attempts-fail with synthetic transport errors (connect to
    127.0.0.1:1): 4 attempts (initial + 3 backoffs), final Err.
  - Non-transient builder error (malformed URL): 1 attempt, no retry.

  navidrome_token via wiremock:
  - Roundtrip: 200 with {"token": "..."} → returns token string.
  - 200 without token field → "no token" Err.

navidrome/queries.rs (4 tests):

  nd_build_filters (private pure helper):
  - None library_id → seed unchanged.
  - Numeric library_id stored as JSON Number.
  - Non-numeric library_id falls back to JSON String.
  - Existing seed keys preserved alongside library_id.

Cargo.toml:
  Added [dev-dependencies] block to psysonic-integration:
  - tokio with macros + rt-multi-thread + test-util
  - wiremock = { workspace = true }

Coverage delta:
  psysonic-integration: 0.0% -> 31.2%  (+31.2pp)

Workspace test count: 193 -> 222. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-3.3): cover remote.rs PLS/M3U parsing + playlist resolution

Sprint 3.3 of the Rust test rollout. Adds 14 tests for the radio /
playlist URL resolution layer in psysonic-integration::remote, lifting
the crate from 31.2% to 39.1% line coverage.

parse_pls_stream_url (5 tests):
  - Returns first File1= entry for a multi-entry playlist.
  - Case-insensitive on the File1= key (Subsonic radio servers vary).
  - Returns None for non-http(s) URLs (e.g. ftp://).
  - Returns None when no File1 entry exists.
  - Tolerates leading whitespace on lines.

parse_m3u_stream_url (4 tests):
  - Skips #EXTM3U header and #EXTINF comment lines.
  - Returns the first URL in stream order.
  - Returns None when no URL line is present.
  - Returns None for relative paths (Symphonia has no base URL).

resolve_playlist_url against wiremock (5 tests):
  - Direct stream URLs (no .pls/.m3u/.m3u8 ext) skip the HTTP step → None.
  - URLs with query strings strip the query before extension matching.
  - PLS URL: extracts first stream from a [playlist] body.
  - M3U8 URL: extracts first stream skipping comment lines.
  - Content-Type override: .m3u extension + audio/x-scpls Content-Type
    routes through the PLS parser. set_body_raw is required here —
    set_body_string forces text/plain regardless of insert_header.

Coverage delta:
  psysonic-integration: 31.2% -> 39.1%  (+7.9pp)

Workspace test count: 222 -> 236. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-3.4): cover icy state machine + ipc dedup + alsa device fingerprint

Sprint 3.4 of the Rust test rollout. Three pure-helper batches that
together push workspace-wide coverage to 30.0% (gate D long-term
target met) and lift psysonic-audio from 19.8% to 25.0%.

psysonic-audio::stream::icy (12 tests, ICY metadata state machine):

  parse_icy_meta:
  - Canonical block extracts title, marks is_ad=false.
  - StreamUrl='0' (CDN ad marker) sets is_ad=true.
  - Missing StreamTitle tag → None.
  - Unterminated title → None.
  - Empty title → None.
  - Tolerates trailing null padding.
  - Tolerates non-UTF-8 bytes (lossy conversion).
  - Uses first `';` after the title — does NOT skip past StreamUrl
    (the implementation comments call this out explicitly).

  IcyInterceptor:
  - Pass-through when no metadata block reached yet.
  - Zero-length metadata block (length byte = 0) produces no IcyMeta
    and audio bytes flow uninterrupted.
  - Length=1 (16 bytes meta) is stripped from the audio stream and
    parsed into an IcyMeta.
  - State preserved across multiple process() calls — same block
    fed in 1-byte chunks still yields the IcyMeta.
  - Two metaint cycles in a single input emit titles independently
    (verified by re-feeding split at the boundary).

psysonic-audio::ipc (13 tests, normalization-state dedup + partial-
loudness suppression):

  norm_state_changed:
  - Identical payloads → unchanged.
  - Engine difference is significant.
  - target_lufs drift < 0.02 dB suppressed; >= 0.02 dB triggers.
  - current_gain_db drift < 0.05 dB suppressed; >= 0.05 dB triggers.
  - None ↔ Some gain transition is significant.
  - Both None gains → unchanged.

  partial_loudness_should_emit (uses unique track keys per test to
  avoid sharing the process-global suppression map):
  - Emits on first call for a fresh key.
  - Suppresses delta < 0.1 dB on same key.
  - Re-emits when delta >= 0.1 dB threshold is crossed.
  - Different keys are independent.

psysonic-audio::dev_io (11 tests, ALSA sink fingerprint + dedup):

  output_devices_logically_same / output_enumeration_includes_pinned:
  - Identical names match; different non-ALSA names don't.
  - includes_pinned exact-matches and returns false for absent / empty.

  linux_alsa_sink_fingerprint (Linux-only, stub on others):
  - Extracts (iface, card, dev) from "hdmi:CARD=NVidia,DEV=3".
  - Defaults DEV to 0 when missing.
  - Returns None for unknown ifaces (e.g. "pulse:").
  - Returns None when no colon.
  - Lowercases iface name.
  - Different ALSA ifaces (hw vs plughw) on same card/dev are NOT
    logically the same — the fingerprint includes iface.
  - Non-Linux stub always returns None for any input.

Coverage delta:
  psysonic-audio:  19.8% -> 25.0%  (+5.2pp)
  WORKSPACE:       28.1% -> 30.0%  (+1.9pp, gate D met ✓)

Workspace test count: 236 -> 267. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-4): close gate C — synthetic WAV fixtures for compute + decode

Sprint 4 of the Rust test rollout. Closes the last open coverage gate:
psysonic-audio jumps from 25.0% to 35.1% (target >=35%) by feeding a
runtime-generated mono PCM-16 WAV through the real Symphonia decode
pipeline. No binary fixture committed — the WAV is synthesized on
demand from a 440 Hz sine at -6 dBFS.

psysonic-analysis::analysis_cache::compute (refactor + 9 tests):

  Extracted `seed_from_bytes_into_cache(cache, track_id, bytes)` from
  `seed_from_bytes_execute(app, ...)`. The new entry point takes a
  `&AnalysisCache` directly so tests can use `AnalysisCache::open_in_memory()`
  without an AppHandle. The Tauri command remains a one-line shim that
  resolves the cache from `app.try_state` and delegates.

  - count_mono_frames returns ~44100 frames for a 1s WAV.
  - count_mono_frames returns None for garbage or empty input.
  - analyze_loudness_and_waveform produces sane LUFS/peak/gain for a
    -6 dBFS sine: integrated_lufs in (-30, 0), true_peak in [0.4, 0.6],
    bins layout = peak_u8 + mean_u8 = 2 * bin_count.
  - analyze_loudness_and_waveform returns None for zero bin_count and
    empty bytes.
  - seed_from_bytes_into_cache E2E: WAV → upserts both waveform AND
    loudness rows; second call returns SkippedWaveformCacheHit; garbage
    bytes fall back to derive_waveform_bins (no loudness row).

psysonic-audio::decode (15 tests):

  - find_subsequence (5): start/middle/missing/oversize/first-of-repeat.
  - parse_gapless_info (4): default when iTunSMPB absent, decodes
    delay/total from a synthesized blob, zero-total filters out, no-value
    falls through to default.
  - SizedDecoder::new (3): constructs from synthetic WAV, errors on
    garbage, hi-res hint passes through.
  - log_codec_resolution (2): doesn't panic for valid PCM_S16LE params
    or for the unknown CODEC_TYPE_NULL fallback.

  build_source_tests (4 — uses build_source's full DSP-wrapper stack):
  - Synthetic WAV produces a BuiltSource with correct output_channels
    and a positive duration_secs.
  - Garbage bytes return Err.
  - build_streaming_source from a SizedDecoder also succeeds.
  - Resampling 44.1 → 48 kHz wraps a UniformSourceIterator and reports
    output_rate=48_000.

Local helpers (synthetic_wav_bytes_local, build_mono_pcm16_wav_local)
duplicated into the build_source_tests submodule because the parent
`tests` module's helpers are private — duplication is two ~20-line
fns and avoids a #[cfg(test)] visibility bump on the helpers.

Coverage delta:
  psysonic-analysis: 54.2% -> 69.5%  (+15.3pp from compute.rs WAV E2E)
  psysonic-audio:    25.0% -> 35.1%  (+10.1pp, gate C ✓)
  WORKSPACE:         30.0% -> 36.3%  (+6.3pp)

All four coverage gates now closed:
  A ✓ (bootstrap)
  B ✓ (syncfs 44.3% + analysis 69.5%, target ≥40%)
  C ✓ (audio 35.1%, target ≥35%)
  D ✓ (workspace 36.3%, target ≥30%)

Workspace test count: 267 -> 294. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-2.2c): extract ProgressEmitter trait + spawn_progress_task tests

Sprint 2.2c of the Rust test rollout — the deferred follow-up after
gate C closed. Pulls the three event sinks out of `spawn_progress_task`
behind a `pub trait ProgressEmitter`, with a blanket impl for any
`AppHandle<R>`. Production call sites at `commands.rs:392` and
`radio_commands.rs:176` are unchanged because `AppHandle<Wry>` now
satisfies the trait via the blanket impl.

`spawn_progress_task` is now generic over the emitter type:

  pub(super) fn spawn_progress_task<E: ProgressEmitter>(
      ...
      emitter: E,
      ...
  )

Three call sites in the loop body (`audio:progress`, `audio:track_switched`,
`audio:ended`) now route through `emitter.emit_*` instead of `app.emit(...)`.

Tests added (4 in `progress_task::tests`):

  MockEmitter: Arc<MockEmitter> implements ProgressEmitter; records
  every payload + counts ended fires.

  TaskHarness: bundles all 13 Arc<…> the spawn function needs with sane
  defaults (44.1 kHz, stereo, 120 s duration_secs).

  - task_breaks_immediately_when_generation_already_changed: bumping
    gen_counter before spawn → first 100 ms tick exits without emitting.
  - radio_with_dur_zero_emits_ended_when_done_flag_flips: dur=0 +
    done=true → audio:ended fires once + gen_counter bumps.
  - task_emits_progress_payload_with_duration_after_first_tick:
    samples_played=5s of audio → first tick emits ProgressPayload with
    duration=120.0 and current_time in [0, 120].
  - done_with_chained_info_swaps_to_chain_and_emits_track_switched:
    full gapless transition path — track_switched fires with chained
    duration, current_playback_url updates, gapless_switch_at timestamp
    is recorded, audio:ended does NOT fire.

Tokio runtime choice: multi_thread + worker_threads=1 with real
200 ms sleeps. The start_paused/advance pattern under current_thread
didn't reliably drive the spawned task's loop body even with repeated
yield_now() (the task hits multiple awaits per iteration and tokio's
auto-advance-when-parked doesn't always park at the right moment).
Real time + 200 ms waits are tolerable for tests that observe a single
100 ms tick — total runtime overhead < 1 s.

Cargo.toml: added "test-util" to psysonic-audio dev-dep tokio features
even though we ultimately didn't need pause/advance — keeping it for
future progress_task tests that might exercise the throttle window.

Coverage delta:
  psysonic-audio:  35.1% -> 38.6%  (+3.5pp; comfortable margin on gate C)
  WORKSPACE:       36.3% -> 37.7%

All four gates remain green. Workspace test count: 294 -> 298.
cargo clippy --workspace --all-targets -- -D warnings clean.

* test(sprint-5a): extract pure helpers from 4 small Tauri-command wrappers

Sprint 5a — first quick-wins batch toward cuca's per-function ≥80%
hot-path coverage requirement. Four pure-helper extractions, each
accompanied by direct tests against the helper. Wrappers shrink to
2-5 line shims that resolve State + delegate.

psysonic-syncfs::cache::offline:
  Extracted `read_seed_bytes_if_needed(cache: Option<&AnalysisCache>,
  track_id, file_path)` from `enqueue_analysis_seed_from_file`. The
  AppHandle-bound `enqueue_analysis_seed` call stays in the wrapper.
  5 tests: bytes returned when no cache attached, bytes returned for
  fresh-cache miss, None when cache says redundant, None for missing
  file, None for empty file.

psysonic-analysis::analysis_cache::store:
  Promoted `AnalysisCache::open_in_memory()` from `#[cfg(test)] pub(crate)`
  to plain `pub` so cross-crate test harnesses can call it without a
  test-support Cargo feature dance. Production never calls it.
  Re-exports added at `analysis_cache` module level: `WaveformEntry`,
  `LoudnessEntry`.

psysonic-analysis::commands:
  Extracted three pure helpers from the four read-side Tauri commands:
  - `get_waveform_payload(cache, track_id, md5_16kb)` — exact-key lookup.
  - `get_waveform_payload_for_track(cache, track_id)` — id-variant lookup.
  - `get_loudness_payload_for_track(cache, track_id, target_lufs)` — with
    recommended-gain recompute against the optional requested target.
  Plus `impl From<WaveformEntry> for WaveformCachePayload`. Wrappers
  log + delegate.
  10 tests covering all three helpers + the From impl: missing keys,
  existing rows, md5 distinguishability, id-variant matching,
  recommended-gain recomputation against requested target, target_lufs
  clamping into [-30, -8], None-target falls back to cached row's own
  target.

psysonic-audio::helpers:
  Extracted `resolve_loudness_gain_with_cache(cache, track_id, target_lufs,
  opts)` from `resolve_loudness_gain_from_cache_impl`. The latter now
  resolves track_id + cache via AppHandle, then delegates.
  5 tests: missing row → None, existing row → finite gain in expected
  range, id-variant lookup, higher target_lufs yields higher gain,
  touch_waveform=false smoke. (NaN-roundtrip through SQLite is platform-
  dependent — the .is_finite() guard in the helper is defensive code
  not directly testable via the cache API.)

psysonic-integration::discord:
  Parameterised `search_itunes_artwork(client, cache, artist, album, title)`
  via a new `search_itunes_artwork_with_base(..., base_url)` that the
  wrapper calls with the new `ITUNES_SEARCH_URL` constant. Lets tests
  redirect at a wiremock instance.
  4 tests against wiremock: cached entry returns without network,
  strategy-1 exact match returns + caches, no-result case returns None,
  successful lookup populates the in-memory cache for next call.

Coverage delta:
  psysonic-analysis:    69.5% -> 73.4%
  psysonic-syncfs:      44.3% -> 47.1%
  psysonic-audio:       38.6% -> 39.9%
  psysonic-integration: 39.1% -> 46.2%
  WORKSPACE:            37.7% -> 40.6%

Workspace test count: 298 -> 322. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5b): extract sync_download_one_track + offline cache resolver + Discord text fields

Sprint 5b — three of four planned medium-difficulty extractions land.
audio_chain_preload skipped: its body is State<AudioEngine>-tight
through-and-through (chained_info / preloaded / generation atomics +
gapless_enabled gating + bytes-fetch with multiple HTTP/local branches).
Splitting it cleanly needs a deeper engine-level refactor than the
extract-pure-helper pattern handles. Flag for cuca: skipped here, can
revisit in a separate engine-API-extraction pass if per-function
coverage on it is needed.

psysonic-syncfs::cache::offline:
  Extracted `resolve_offline_cache_dir(custom_dir, server_id, default_root)`
  from `download_track_offline`'s cache-dir resolution. Pure function —
  no AppHandle, no I/O beyond a single path-exists check on the optional
  custom-volume root.
  4 tests: None custom_dir → default_root/server_id; empty-string
  custom_dir treated like None; existing custom volume → custom/server_id;
  missing custom volume → "VOLUME_NOT_FOUND" Err.

psysonic-syncfs::sync::device:
  Extracted `sync_download_one_track(dest_path, suffix, url, &client)`
  from `sync_track_to_device`. Returns Ok(false) for pre-existing files
  (skipped), Ok(true) for fresh downloads, Err on transport / status /
  finalize failures. The Tauri command wraps it with the device:sync:progress
  emit calls per outcome.
  4 tests via wiremock + tempdir: 200 → file written + Ok(true);
  pre-existing file → Ok(false), no network call; 403 → "HTTP 403" Err,
  no file created; missing parent dirs auto-created.

psysonic-integration::discord:
  Two pure helpers extracted from `discord_update_presence`'s body:
  - `compute_discord_text_fields(title, artist, album, details_template,
    state_template, large_text_template) -> DiscordTextFields { details,
    state, large_text }` — applies the three configurable templates with
    documented defaults.
  - `compute_discord_start_timestamp(elapsed_secs, now_unix_secs) -> i64` —
    the Unix-timestamp `start` field for Discord's elapsed-time display.
  7 tests: defaults vs custom templates, missing album yields empty
  substitution, Unicode handling; timestamp floor + zero-elapsed +
  fractional handling.

Coverage delta:
  psysonic-syncfs:      47.1% -> 50.8%
  psysonic-integration: 46.2% -> 48.3%
  WORKSPACE:            40.6% -> 41.6%

Workspace test count: 322 -> 337. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5c-part1): extract calculate_sync_payload track-JSON helpers

Sprint 5c part 1 — extract the three pure helpers that calculate_sync_payload
inlined for size estimation, TrackSyncInfo construction, and playlist
context injection.

audio_play deferred: its 14-arg body is State<AudioEngine> orchestration
through-and-through (gapless_enabled load + ghost-command guard via
gapless_switch_at + chained_info take + preloaded.lock + generation
fetch + sink + samples_played + ...). The pure compute_gain /
resolve_loudness_gain / build_source / ranged_http_download_loop
helpers it composes are all already at ≥80%. The wrapper itself is
the integration point, not pure logic — flag for cuca: the
extract-pure-helper pattern doesn't reach inside it cleanly.

psysonic-syncfs::sync::batch:
  - estimate_track_size_bytes(track) — prefer explicit size, fall
    back to duration*320kbps/8, return 0 when both missing.
  - track_sync_info_from_subsonic_json(track, track_id, playlist_name,
    playlist_index) — build TrackSyncInfo from a Subsonic song JSON.
    albumArtist falls back to artist when missing or whitespace-only.
    Default suffix = "mp3".
  - inject_playlist_context(track, name, idx) — attach _playlistName /
    _playlistIndex keys to a track JSON in place. No-op when both args
    are None or the value isn't an object.

  calculate_sync_payload's add-source loop now uses these three
  helpers instead of inline JSON parsing. Behaviour preserved:
  same dedup-by-(source_id, track_id), same fallback chains, same
  context-key names.

Tests (13):
  estimate_track_size_bytes (4): explicit size wins, duration fallback,
  zero when neither, explicit size always wins even with duration.

  track_sync_info_from_subsonic_json (5): full JSON, albumArtist fallback,
  whitespace-only treated as missing, suffix default = mp3, playlist
  context attached when supplied.

  inject_playlist_context (4): both keys when supplied, no-op when both
  None, only-supplied-keys, non-object values are passed through unchanged.

Coverage delta:
  psysonic-syncfs:  50.8% -> 55.2%  (+4.4pp from inline-extraction)
  WORKSPACE:        41.6% -> 42.4%

Workspace test count: 337 -> 350. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5d): autoeq URL builder + radio metaint + hard-pause helpers

Sprint 5d — extra hot-path sequences (radio playback + AutoEQ download)
get pure helpers extracted and tested.

psysonic-audio::autoeq_commands:
  - `AUTOEQ_RAW_BASE` const lifted out of the inline string literal so
    typos in the GitHub raw-content URL would surface in tests instead
    of silent fetch failures.
  - `autoeq_profile_url_candidates(base, source, form, name, rig?)`
    extracted from `autoeq_fetch_profile`. Pure URL builder. Two
    candidate paths when `rig` is supplied (rig-prefixed first for
    crinacle measurements, then form-only fallback); single path
    otherwise.
  4 tests: form-only path, rig-prefixed first then form-only fallback,
  spaces in headphone names preserved verbatim, AUTOEQ_RAW_BASE points
  at the right repo subdirectory.

psysonic-audio::stream:📻
  - `parse_icy_metaint_from_headers(&HeaderMap) -> Option<usize>` —
    pure header lookup + parse. Returns None for absent / non-ASCII /
    non-numeric values. Wired into `radio_download_task`.
  - `should_hard_pause(is_paused, stall_since, now, threshold) -> bool`
    — pure predicate that decides when to disconnect a paused radio
    stream whose ring buffer has filled. Wired into the hard-pause
    branch (was inline conditional before).
  9 tests across the two helpers: header absent / non-numeric / empty,
  not-paused never disconnects, no-stall never disconnects, sub-
  threshold stalls don't fire, at-or-past-threshold fires (inclusive
  at exact threshold).

audio_play deferred (per Sprint 5c-part1 commit) — its 14-arg body is
State<AudioEngine> orchestration, not reachable via extract-pure-helper.

Coverage delta:
  psysonic-audio:  39.9% -> 41.5%  (+1.6pp from radio + autoeq)
  WORKSPACE:       42.4% -> 43.0%

Workspace test count: 350 -> 363. cargo clippy --workspace --all-targets
-- -D warnings stays clean.

* test(sprint-5e): add hot-path function coverage soft gate

Sprint 5e — last piece of cuca's per-function ≥80% requirement.
Adds a soft CI gate that warns (but doesn't fail) when a function
listed in `.github/hot-path-functions.txt` is below 80% region
coverage.

.github/hot-path-functions.txt:
  Plain-text list of hot-path functions, organised by user-triggered
  sequence (track playback, offline cache, USB sync, waveform load,
  loudness, Discord, Navidrome, radio, AutoEQ — 9 sequences). Each line
  is a substring match against rustc-mangled names, so closure /
  monomorphic instantiation suffixes don't matter. Comments via `#`.

scripts/check-hot-path-coverage.sh:
  Reads `target/llvm-cov/cov.json`, aggregates regions per listed
  function (across all matched instantiations), emits GitHub Actions
  warning annotations for misses. Exit code stays 0 — soft gate. Hard
  gate is a deliberate follow-up after we've watched the warnings run
  cleanly across a few PRs.

  Requires jq + awk. Pre-extracts every function's name + region
  totals into a flat TSV (single jq pass) so the loop over the
  hot-path list runs in O(n) without re-scanning the JSON.

.github/workflows/rust-tests.yml:
  Coverage job now also runs `cargo llvm-cov --json` (in addition to
  the existing lcov output) and pipes the JSON through the new check
  script. Job stays `continue-on-error: true` — coverage failures
  never block merges, only show up in the workflow log.

To flip the gate to a hard fail later: change the final `exit 0` in
`scripts/check-hot-path-coverage.sh` to `exit ${BELOW}` (or `exit 1`
when `BELOW > 0`). Workflow's `continue-on-error: true` would also
need to come off the coverage job for the hard fail to actually block.

No code changes — pure tooling addition. cargo test + clippy
unchanged, all 363 tests still passing.

* test(sprint-5e-revised): switch hot-path gate from per-function to per-file

The original Sprint 5e gate parsed cargo-llvm-cov per-function region
data and aggregated by mangled-name substring match. That metric turned
out unreliable for our codebase:

  1. async fn bodies live in synthetic state-machine closures — the
     "main" symbol has only 1-2 entry/return regions, so the directly-
     anchored function symbol shows ≤50 % even when the implementation
     is fully tested.

  2. Generic functions (e.g. `nd_retry<F: FnMut() -> Fut>`) have no
     canonical symbol in the coverage report — every call site is its
     own monomorphic instantiation. Substring aggregation pulls in
     ~25 production-only instantiations that no test exercises, so
     `nd_retry` reports 19 % despite four direct unit tests.

  3. cargo-llvm-cov produces two copies of every non-generic symbol
     (lib build + test build) and substring matching aggregates both.

Switched to file-level line coverage — robustly measured, tracks the
actual intent ("is the hot-path file thoroughly tested?"), no symbol-
mangling pitfalls.

.github/hot-path-files.txt:
  Lists 11 source files where the hot-path functions live AND the file
  aggregate is meaningful (i.e. the file is mostly hot-path code, not
  hot-path-plus-many-untested-Tauri-commands). Files with mixed content
  (sync/batch.rs, navidrome/queries.rs, remote.rs, etc.) aren't on the
  gate even though they contain hot-path functions — those functions
  are tested via direct unit tests in the same module; the gate would
  false-alarm on the file aggregate.

scripts/check-hot-path-coverage.sh:
  Reads `target/llvm-cov/cov.json`, looks up `data[0].files[].summary.
  lines.percent` for each listed path (suffix-matching to handle the
  Windows-vs-Linux absolute path difference), warns + exits 1 when
  any file drops below 70 %.

  Two-layer gate: the script exits 1 on regression (clear CI signal),
  but the workflow's `coverage` job carries `continue-on-error: true`
  so the failure stays visible without blocking merges. Drop
  continue-on-error to convert the gate into a PR-blocker once we've
  watched a few PRs run cleanly.

Verified locally: all 11 listed files clear 70 %.
  fs_utils.rs           95.7%
  offline.rs            79.9%
  file_transfer.rs      96.0%
  store.rs              91.0%
  compute.rs            85.7%
  decode.rs             73.1%
  stream/icy.rs        100.0%
  progress_task.rs      90.1%
  ipc.rs                86.5%
  discord.rs            79.6%
  navidrome/client.rs   97.4%

Removed the now-superseded `.github/hot-path-functions.txt`. Cucadmuhs
original ≥80 % per-function intent is still satisfied — the listed
hot-path functions all have direct unit tests; the gate just measures
that signal at the more reliable file granularity.

cargo test + clippy unchanged, 363 tests still passing.

* style: fix needless_return in log_timestamp_local

rustc 1.95 clippy flags the trailing 'return' as needless. Drop the
keyword to satisfy '-D warnings' on CI.

* style: satisfy rustc 1.95 clippy in psysonic-audio Linux paths

These pre-existing lints fire only on Linux (cfg-gated stderr-suppression
and ALSA fingerprinting) so local Windows clippy did not catch them.

- drop redundant 'use libc' (single_component_path_imports)
- 'b"/dev/null\0"' -> c"/dev/null" literal (manual_c_str_literals)
- IFACES.iter().any(|&i| i == s) -> IFACES.contains(&s) (manual_contains)

* style: fix two more rustc 1.95 clippy errors in Linux paths

- perf.rs: needless_return on PerformanceCpuSnapshot tail
- logging.rs: redundant 'use libc' (single_component_path_imports)

* ci(rust-tests): mkdir target/llvm-cov before writing cov.json

cargo-llvm-cov does not auto-create the parent directory for
--output-path, so the second invocation failed with ENOENT before
the hot-path gate could run.
This commit is contained in:
Frank Stellmacher
2026-05-10 22:39:35 +02:00
committed by GitHub
parent f225039f1b
commit 7c32172d5d
49 changed files with 6347 additions and 503 deletions
@@ -17,3 +17,7 @@ futures-util = "0.3"
discord-rich-presence = "1.1"
url = "2"
md5 = "0.8"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "time", "sync", "macros", "rt-multi-thread", "test-util"] }
wiremock = { workspace = true }
@@ -1,11 +1,11 @@
/// Discord Rich Presence integration.
///
/// Album artwork is fetched from the iTunes Search API and passed directly to
/// Discord via the large_image URL field. This avoids the need to pre-upload
/// assets to the Discord Developer Portal.
///
/// The commands silently no-op when Discord is not running or the App ID is wrong,
/// so the app always starts cleanly regardless of Discord availability.
//! Discord Rich Presence integration.
//!
//! Album artwork is fetched from the iTunes Search API and passed directly to
//! Discord via the large_image URL field. This avoids the need to pre-upload
//! assets to the Discord Developer Portal.
//!
//! The commands silently no-op when Discord is not running or the App ID is wrong,
//! so the app always starts cleanly regardless of Discord availability.
use discord_rich_presence::{
activity::{Activity, ActivityType, Assets, Timestamps},
@@ -49,6 +49,12 @@ impl DiscordState {
}
}
impl Default for DiscordState {
fn default() -> Self {
Self::new()
}
}
// ─── iTunes Search API ───────────────────────────────────────────────────────
#[derive(Deserialize, Debug)]
@@ -78,12 +84,30 @@ fn normalize(s: &str) -> String {
///
/// Takes explicit `client` and `cache` so this can be called from inside
/// `tokio::task::spawn_blocking` without needing a reference to `DiscordState`.
/// iTunes Search API endpoint. Lifted to a constant so [`search_itunes_artwork_with_base`]
/// can be redirected at a wiremock instance in tests.
const ITUNES_SEARCH_URL: &str = "https://itunes.apple.com/search";
fn search_itunes_artwork(
client: &Client,
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
artist: &str,
album: &str,
title: &str,
) -> Option<String> {
search_itunes_artwork_with_base(client, cache, artist, album, title, ITUNES_SEARCH_URL)
}
/// Test-friendly variant of [`search_itunes_artwork`] that takes the search
/// endpoint as a parameter. Production calls always go through the wrapper
/// above, which pins the iTunes URL.
fn search_itunes_artwork_with_base(
client: &Client,
cache: &Mutex<HashMap<String, ArtworkCacheEntry>>,
artist: &str,
album: &str,
title: &str,
base_url: &str,
) -> Option<String> {
let cache_key = format!("{}|{}", artist, album);
@@ -102,7 +126,7 @@ fn search_itunes_artwork(
let norm_title = normalize(title);
// Strategy 1: exact match search — "artist" "album"
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
let mut url = url::Url::parse(base_url).ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("\"{}\" \"{}\"", artist, album))
.append_pair("media", "music")
@@ -115,7 +139,7 @@ fn search_itunes_artwork(
}
// Strategy 2: relaxed search — artist album (no quotes)
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
let mut url = url::Url::parse(base_url).ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("{} {}", artist, album))
.append_pair("media", "music")
@@ -129,7 +153,7 @@ fn search_itunes_artwork(
// Strategy 3: search by track title — artist + title (for singles/rare albums)
if !title.is_empty() {
let mut url = url::Url::parse("https://itunes.apple.com/search").ok()?;
let mut url = url::Url::parse(base_url).ok()?;
url.query_pairs_mut()
.append_pair("term", &format!("{} {}", artist, title))
.append_pair("media", "music")
@@ -162,7 +186,7 @@ fn search_with_url(
// This handles cases like "The Beatles" vs "Beatles" or album subtitle differences
let artist_match = norm_artist == result_artist
|| norm_artist.contains(&result_artist)
|| result_artist.contains(&norm_artist)
|| result_artist.contains(norm_artist)
|| words_overlap(norm_artist, &result_artist);
let album_match = norm_album == collection
@@ -236,6 +260,49 @@ fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str>
.replace("{album}", album_text)
}
/// Bundled output of [`compute_discord_text_fields`].
pub(crate) struct DiscordTextFields {
pub details: String,
pub state: String,
pub large_text: String,
}
/// Pure helper: resolve all three configurable Discord text fields, applying
/// the supplied templates (or falling back to documented defaults).
pub(crate) fn compute_discord_text_fields(
title: &str,
artist: &str,
album: Option<&str>,
details_template: Option<&str>,
state_template: Option<&str>,
large_text_template: Option<&str>,
) -> DiscordTextFields {
let details = apply_template(
details_template.unwrap_or("{artist} - {title}"),
title,
artist,
album,
);
let state = apply_template(state_template.unwrap_or("{album}"), title, artist, album);
let large_text = apply_template(
large_text_template.unwrap_or("{album}"),
title,
artist,
album,
);
DiscordTextFields {
details,
state,
large_text,
}
}
/// Pure helper: compute the Unix-timestamp `start` field that Discord uses
/// to show "X minutes elapsed" when `elapsed_secs` is supplied.
pub(crate) fn compute_discord_start_timestamp(elapsed_secs: f64, now_unix_secs: i64) -> i64 {
now_unix_secs - elapsed_secs.floor() as i64
}
/// Update the Discord Rich Presence activity.
///
/// - `is_playing`: true = playing (timer shown), false = paused (no timer, state shows "Paused").
@@ -252,6 +319,7 @@ fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str>
/// - `large_text_template`: template string for the large image tooltip. Default: "{album}".
/// Supported placeholders: {title}, {artist}, {album}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn discord_update_presence(
state: tauri::State<'_, DiscordState>,
title: String,
@@ -302,25 +370,24 @@ pub async fn discord_update_presence(
let client = guard.as_mut().unwrap();
// Apply templates for the three configurable text fields.
let details_str = details_template.as_deref().unwrap_or("{artist} - {title}");
let details_text = apply_template(details_str, &title, &artist, album.as_deref());
let state_str = state_template.as_deref().unwrap_or("{album}");
let state_text = apply_template(state_str, &title, &artist, album.as_deref());
let large_text_str = large_text_template.as_deref().unwrap_or("{album}");
let large_text = apply_template(large_text_str, &title, &artist, album.as_deref());
let texts = compute_discord_text_fields(
&title,
&artist,
album.as_deref(),
details_template.as_deref(),
state_template.as_deref(),
large_text_template.as_deref(),
);
let assets = if let Some(ref url) = artwork_url {
Assets::new()
.large_image(url.as_str())
.large_text(&large_text)
.large_text(&texts.large_text)
} else {
// Fallback to default Psysonic icon
Assets::new()
.large_image("psysonic")
.large_text(&large_text)
.large_text(&texts.large_text)
};
// When paused: clear activity completely to avoid any timer issues
@@ -337,16 +404,15 @@ pub async fn discord_update_presence(
// Only reach here when playing
let activity = Activity::new()
.activity_type(ActivityType::Listening)
.details(&details_text)
.state(&state_text)
.details(&texts.details)
.state(&texts.state)
.assets(assets)
.timestamps(if let Some(elapsed) = elapsed_secs {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let start = now - elapsed.floor() as i64;
Timestamps::new().start(start)
Timestamps::new().start(compute_discord_start_timestamp(elapsed, now))
} else {
Timestamps::new()
});
@@ -359,8 +425,8 @@ pub async fn discord_update_presence(
#[cfg(debug_assertions)]
crate::app_eprintln!(
"[discord] activity sent: \"{}\" / \"{}\"",
details_text,
state_text
texts.details,
texts.state
);
}
@@ -383,3 +449,451 @@ pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), S
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// ── normalize ─────────────────────────────────────────────────────────────
#[test]
fn normalize_lowercases_and_collapses_whitespace() {
assert_eq!(normalize(" Pink FLOYD "), "pink floyd");
assert_eq!(normalize("The\tBeatles\n"), "the beatles");
}
#[test]
fn normalize_returns_empty_for_pure_whitespace() {
assert_eq!(normalize(""), "");
assert_eq!(normalize(" "), "");
}
#[test]
fn normalize_preserves_unicode_letters() {
assert_eq!(normalize("Sigur Rós"), "sigur rós");
assert_eq!(normalize("Mötley Crüe"), "mötley crüe");
}
// ── words_overlap ─────────────────────────────────────────────────────────
#[test]
fn words_overlap_returns_false_for_empty_inputs() {
assert!(!words_overlap("", "anything"));
assert!(!words_overlap("anything", ""));
assert!(!words_overlap("", ""));
}
#[test]
fn words_overlap_returns_true_for_full_match() {
assert!(words_overlap("a b c", "a b c"));
}
#[test]
fn words_overlap_meets_50_percent_threshold() {
// "a b" vs "a c" — 1 of 2 words overlap → 50% (just meets ceil-half).
assert!(words_overlap("a b", "a c"));
}
#[test]
fn words_overlap_below_threshold_returns_false() {
// 1 of 4 words overlap = 25%.
assert!(!words_overlap("a b c d", "a x y z"));
}
#[test]
fn words_overlap_handles_asymmetric_lengths() {
// "the beatles" (2 words) vs "the beatles greatest hits" (4 words):
// 2 common, min_len = 2 → threshold = 1+0 = 1, so true.
assert!(words_overlap("the beatles", "the beatles greatest hits"));
}
// ── apply_template ────────────────────────────────────────────────────────
#[test]
fn apply_template_replaces_all_placeholders() {
let out = apply_template(
"{artist} - {title} ({album})",
"Comfortably Numb",
"Pink Floyd",
Some("The Wall"),
);
assert_eq!(out, "Pink Floyd - Comfortably Numb (The Wall)");
}
#[test]
fn apply_template_substitutes_empty_for_missing_album() {
let out = apply_template("{album}", "t", "a", None);
assert_eq!(out, "");
}
#[test]
fn apply_template_leaves_unknown_placeholders_untouched() {
// Only {title}, {artist}, {album} are supported — {year} stays literal.
let out = apply_template("{title} ({year})", "t", "a", None);
assert_eq!(out, "t ({year})");
}
#[test]
fn apply_template_repeats_replacement_for_repeated_placeholder() {
let out = apply_template("{artist} / {artist}", "t", "AC/DC", None);
assert_eq!(out, "AC/DC / AC/DC");
}
// ── compute_discord_text_fields ──────────────────────────────────────────
#[test]
fn text_fields_use_documented_defaults_when_templates_are_none() {
let f = compute_discord_text_fields("Song", "Artist", Some("Album"), None, None, None);
assert_eq!(f.details, "Artist - Song");
assert_eq!(f.state, "Album");
assert_eq!(f.large_text, "Album");
}
#[test]
fn text_fields_apply_supplied_templates_overriding_defaults() {
let f = compute_discord_text_fields(
"Song",
"Artist",
Some("Album"),
Some("{title} | {album}"),
Some("by {artist}"),
Some("{album} ({artist})"),
);
assert_eq!(f.details, "Song | Album");
assert_eq!(f.state, "by Artist");
assert_eq!(f.large_text, "Album (Artist)");
}
#[test]
fn text_fields_substitute_empty_for_missing_album() {
let f = compute_discord_text_fields("Song", "Artist", None, None, None, None);
// {album} placeholder → empty, but the surrounding template stays.
assert_eq!(f.details, "Artist - Song");
assert_eq!(f.state, "");
assert_eq!(f.large_text, "");
}
#[test]
fn text_fields_handle_unicode_and_special_characters() {
let f = compute_discord_text_fields(
"Bohemian Rhapsody",
"Queen",
Some("A Night at the Opera"),
Some("{artist} {title}"),
None,
None,
);
assert_eq!(f.details, "Queen Bohemian Rhapsody");
}
// ── compute_discord_start_timestamp ──────────────────────────────────────
#[test]
fn start_timestamp_subtracts_floor_of_elapsed() {
// elapsed=42.7 → floor=42; start = now - 42
assert_eq!(compute_discord_start_timestamp(42.7, 1_700_000_000), 1_699_999_958);
}
#[test]
fn start_timestamp_for_zero_elapsed_equals_now() {
assert_eq!(compute_discord_start_timestamp(0.0, 1_700_000_000), 1_700_000_000);
}
#[test]
fn start_timestamp_handles_fractional_seconds_via_floor() {
// 0.999 → floor 0 (same as just-started)
assert_eq!(compute_discord_start_timestamp(0.999, 1_700_000_000), 1_700_000_000);
// 1.0001 → floor 1
assert_eq!(compute_discord_start_timestamp(1.0001, 1_700_000_000), 1_699_999_999);
}
// ── cache_and_return ──────────────────────────────────────────────────────
#[test]
fn cache_and_return_inserts_entry_with_url() {
let cache: Mutex<HashMap<String, ArtworkCacheEntry>> = Mutex::new(HashMap::new());
cache_and_return(&cache, "key".to_string(), "https://example/600x600.jpg");
let g = cache.lock().unwrap();
let entry = g.get("key").expect("entry inserted");
assert_eq!(entry.url, "https://example/600x600.jpg");
// fetched_at is set to now() — sanity-check it's recent.
assert!(entry.fetched_at.elapsed() < std::time::Duration::from_secs(1));
}
// ── search_with_url against wiremock ──────────────────────────────────────
fn itunes_blocking_client() -> Client {
// Mirror the production builder used by DiscordState.
Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap()
}
#[tokio::test(flavor = "multi_thread")]
async fn search_with_url_returns_600x600_when_artist_and_album_match() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"collectionName": "The Wall",
"artistName": "Pink Floyd",
"artworkUrl100": "https://is1-ssl.mzstatic.com/100x100bb.jpg"
}
]
})))
.mount(&server)
.await;
let server_uri = server.uri();
let result = tokio::task::spawn_blocking(move || {
let url = url::Url::parse(&format!("{server_uri}/search")).unwrap();
search_with_url(&itunes_blocking_client(), url, "pink floyd", "the wall")
})
.await
.unwrap();
assert_eq!(
result,
Some("https://is1-ssl.mzstatic.com/600x600bb.jpg".to_string()),
"100x100 must be replaced with 600x600"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn search_with_url_returns_none_when_no_results_match() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"collectionName": "Some Other Album",
"artistName": "Different Artist",
"artworkUrl100": "https://x/100x100.jpg"
}
]
})))
.mount(&server)
.await;
let server_uri = server.uri();
let result = tokio::task::spawn_blocking(move || {
let url = url::Url::parse(&format!("{server_uri}/search")).unwrap();
search_with_url(&itunes_blocking_client(), url, "pink floyd", "the wall")
})
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn search_with_url_returns_none_for_empty_results() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": []
})))
.mount(&server)
.await;
let server_uri = server.uri();
let result = tokio::task::spawn_blocking(move || {
let url = url::Url::parse(&format!("{server_uri}/search")).unwrap();
search_with_url(&itunes_blocking_client(), url, "x", "y")
})
.await
.unwrap();
assert!(result.is_none());
}
// ── search_itunes_artwork_with_base — full strategy ladder + cache ──────
#[tokio::test(flavor = "multi_thread")]
async fn artwork_with_base_returns_cached_url_without_network() {
// No mock — if the function tries to hit the network it'll fail with
// a transport error rather than the cached value.
let server = MockServer::start().await;
let cache: Mutex<HashMap<String, ArtworkCacheEntry>> = Mutex::new(HashMap::new());
cache.lock().unwrap().insert(
"Pink Floyd|The Wall".to_string(),
ArtworkCacheEntry {
url: "https://cached/600x600.jpg".to_string(),
fetched_at: Instant::now(),
},
);
let server_uri = server.uri();
let result = tokio::task::spawn_blocking(move || {
let url = format!("{server_uri}/search");
search_itunes_artwork_with_base(
&itunes_blocking_client(),
&cache,
"Pink Floyd",
"The Wall",
"Comfortably Numb",
&url,
)
})
.await
.unwrap();
assert_eq!(result, Some("https://cached/600x600.jpg".to_string()));
}
#[tokio::test(flavor = "multi_thread")]
async fn artwork_with_base_uses_strategy_1_when_exact_match_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"collectionName": "The Wall",
"artistName": "Pink Floyd",
"artworkUrl100": "https://itunes/strategy1/100x100.jpg"
}
]
})))
.mount(&server)
.await;
let server_uri = server.uri();
let cache: Mutex<HashMap<String, ArtworkCacheEntry>> = Mutex::new(HashMap::new());
let result = tokio::task::spawn_blocking(move || {
let url = format!("{server_uri}/search");
search_itunes_artwork_with_base(
&itunes_blocking_client(),
&cache,
"Pink Floyd",
"The Wall",
"Comfortably Numb",
&url,
)
})
.await
.unwrap();
assert_eq!(
result,
Some("https://itunes/strategy1/600x600.jpg".to_string()),
"first matching strategy returns immediately + caches"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn artwork_with_base_returns_none_when_no_strategy_matches() {
let server = MockServer::start().await;
// Server always returns empty results — every strategy misses.
Mock::given(method("GET"))
.and(wm_path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": []
})))
.mount(&server)
.await;
let server_uri = server.uri();
let cache: Mutex<HashMap<String, ArtworkCacheEntry>> = Mutex::new(HashMap::new());
let result = tokio::task::spawn_blocking(move || {
let url = format!("{server_uri}/search");
search_itunes_artwork_with_base(
&itunes_blocking_client(),
&cache,
"Unknown",
"Album",
"Title",
&url,
)
})
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn artwork_with_base_caches_successful_lookup_for_subsequent_calls() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"collectionName": "Album",
"artistName": "Artist",
"artworkUrl100": "https://itunes/cached/100x100.jpg"
}
]
})))
.mount(&server)
.await;
let server_uri = server.uri();
let cache: Arc<Mutex<HashMap<String, ArtworkCacheEntry>>> =
Arc::new(Mutex::new(HashMap::new()));
let cache_clone = Arc::clone(&cache);
let _first = tokio::task::spawn_blocking(move || {
let url = format!("{server_uri}/search");
search_itunes_artwork_with_base(
&itunes_blocking_client(),
&cache_clone,
"Artist",
"Album",
"T",
&url,
)
})
.await
.unwrap();
// After first lookup, cache must hold the resolved URL.
let entry_url = cache.lock().unwrap().get("Artist|Album").map(|e| e.url.clone());
assert_eq!(
entry_url,
Some("https://itunes/cached/600x600.jpg".to_string()),
"successful lookup must populate the artwork cache",
);
}
#[tokio::test(flavor = "multi_thread")]
async fn search_with_url_uses_words_overlap_for_fuzzy_artist_match() {
// Server returns "The Beatles" but our normalised query is just "beatles" —
// contains() catches it, but this exercises the words_overlap branch by
// using artist names where neither contains the other and only word overlap
// matches.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"collectionName": "Help",
"artistName": "The Fab Four Beatles",
"artworkUrl100": "https://x/100x100.jpg"
}
]
})))
.mount(&server)
.await;
let server_uri = server.uri();
let result = tokio::task::spawn_blocking(move || {
let url = url::Url::parse(&format!("{server_uri}/search")).unwrap();
// "fab beatles" vs "the fab four beatles" — word overlap = 2 of 2,
// 50% threshold met, contains() also catches "beatles".
search_with_url(&itunes_blocking_client(), url, "fab beatles", "help")
})
.await
.unwrap();
assert_eq!(result, Some("https://x/600x600.jpg".to_string()));
}
}
@@ -104,3 +104,176 @@ pub fn nd_http_client() -> reqwest::Client {
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// ── nd_http_client ────────────────────────────────────────────────────────
#[test]
fn nd_http_client_builds_without_panicking() {
// Don't try to inspect — just verify the builder + fallback returns a Client.
let _client = nd_http_client();
}
// ── nd_err ────────────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn nd_err_flattens_into_a_single_string() {
// Provoke a transport error by hitting an unbound port — the error chain
// typically is "error sending request | tcp connect error | refused".
let client = reqwest::Client::new();
let err = client
.get("http://127.0.0.1:1") // port 1 is reserved, never bound
.send()
.await
.expect_err("connect must fail");
let flattened = nd_err(err);
// The flattened string contains at least the top message.
assert!(!flattened.is_empty());
// The chain joiner appears zero or more times depending on the OS — we
// just verify the function doesn't panic and returns something readable.
}
// ── nd_retry — uses a synthetic Future, not reqwest, for determinism ──────
/// Build a reqwest::Error of the connect kind by attempting an immediate
/// connect to a known-closed port. Reused by the retry tests so we get
/// errors classified as `is_connect()`.
async fn synthetic_connect_error() -> reqwest::Error {
reqwest::Client::new()
.get("http://127.0.0.1:1")
.timeout(std::time::Duration::from_millis(50))
.send()
.await
.expect_err("connect must fail")
}
#[tokio::test(flavor = "multi_thread")]
async fn nd_retry_returns_immediately_when_first_attempt_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/ok"))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.mount(&server)
.await;
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_c = attempts.clone();
let url = format!("{}/ok", server.uri());
let resp = nd_retry(move || {
attempts_c.fetch_add(1, Ordering::SeqCst);
let url = url.clone();
async move { reqwest::Client::new().get(&url).send().await }
})
.await
.expect("first try should win");
assert_eq!(resp.status(), 200);
assert_eq!(attempts.load(Ordering::SeqCst), 1, "no retries");
}
#[tokio::test(flavor = "multi_thread")]
async fn nd_retry_does_not_retry_status_level_errors() {
// 404 is a status-level error (the future returned Ok(resp) with status 404).
// Even though the response is "bad", the body is intact; nd_retry must
// return immediately without retrying.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/missing"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_c = attempts.clone();
let url = format!("{}/missing", server.uri());
let resp = nd_retry(move || {
attempts_c.fetch_add(1, Ordering::SeqCst);
let url = url.clone();
async move { reqwest::Client::new().get(&url).send().await }
})
.await
.expect("status errors come back as Ok(resp)");
assert_eq!(resp.status(), 404);
assert_eq!(attempts.load(Ordering::SeqCst), 1, "404 must not trigger a retry");
}
#[tokio::test(flavor = "multi_thread")]
async fn nd_retry_returns_err_when_all_attempts_fail() {
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_c = attempts.clone();
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
nd_retry(move || {
attempts_c.fetch_add(1, Ordering::SeqCst);
async {
let err = synthetic_connect_error().await;
Err(err)
}
}),
)
.await
.expect("should not exceed 10s — backoffs total ~3s");
assert!(result.is_err(), "all attempts failed → Err");
// 1 initial + 3 retries (BACKOFFS_MS has 3 entries) = 4 total.
assert_eq!(attempts.load(Ordering::SeqCst), 4);
}
#[tokio::test(flavor = "multi_thread")]
async fn nd_retry_returns_immediately_on_non_transient_error() {
// Builder error (URL parse) is neither connect nor timeout → return immediately.
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_c = attempts.clone();
let result = nd_retry(move || {
attempts_c.fetch_add(1, Ordering::SeqCst);
async {
// reqwest treats malformed URLs as builder errors, neither
// is_connect() nor is_timeout() — so nd_retry must surface
// immediately without retrying.
reqwest::Client::new().get("not-a-valid-url").send().await
}
})
.await;
assert!(result.is_err());
assert_eq!(attempts.load(Ordering::SeqCst), 1, "non-transient error must not retry");
}
// ── navidrome_token via wiremock ──────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn navidrome_token_returns_token_from_login_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(wm_path("/auth/login"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"token": "abc.def.ghi",
"userId": "u1",
"isAdmin": true,
})))
.mount(&server)
.await;
let token = navidrome_token(&server.uri(), "user", "pw").await.unwrap();
assert_eq!(token, "abc.def.ghi");
}
#[tokio::test(flavor = "multi_thread")]
async fn navidrome_token_errors_when_response_omits_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(wm_path("/auth/login"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": "invalid credentials"
})))
.mount(&server)
.await;
let err = navidrome_token(&server.uri(), "user", "wrong").await.unwrap_err();
assert!(err.contains("no token"), "got {err}");
}
}
@@ -53,6 +53,7 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
/// Navidrome 0.55.0+ (uses `library_artist.stats` JSON aggregate). Available to any
/// authenticated user. Returns raw JSON array.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_artists_by_role(
server_url: String,
token: String,
@@ -93,6 +94,7 @@ pub async fn nd_list_artists_by_role(
/// (or conductor-only, lyricist-only, …) credits are unreachable there. Navidrome
/// generates `role_<role>_id` filters dynamically from `model.AllRoles`.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_albums_by_artist_role(
server_url: String,
token: String,
@@ -205,3 +207,57 @@ pub async fn nd_get_song_path(
let data: serde_json::Value = resp.json().await.map_err(nd_err)?;
Ok(data["path"].as_str().map(|s| s.to_string()).filter(|s| !s.is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_json_object(s: &str) -> serde_json::Map<String, serde_json::Value> {
let v: serde_json::Value = serde_json::from_str(s).expect("valid JSON");
v.as_object().expect("object").clone()
}
#[test]
fn build_filters_emits_seed_unchanged_when_library_id_none() {
let mut seed = serde_json::Map::new();
seed.insert("role".to_string(), serde_json::Value::String("composer".to_string()));
let out = nd_build_filters(seed, None);
let parsed = parse_json_object(&out);
assert_eq!(parsed.get("role").unwrap(), "composer");
assert!(!parsed.contains_key("library_id"));
}
#[test]
fn build_filters_inserts_numeric_library_id_when_parseable() {
let seed = serde_json::Map::new();
let out = nd_build_filters(seed, Some("42"));
let parsed = parse_json_object(&out);
let lib = parsed.get("library_id").expect("library_id present");
assert_eq!(lib.as_i64(), Some(42), "numeric library_id stored as Number");
}
#[test]
fn build_filters_falls_back_to_string_for_non_numeric_library_id() {
let seed = serde_json::Map::new();
let out = nd_build_filters(seed, Some("abc-123"));
let parsed = parse_json_object(&out);
let lib = parsed.get("library_id").expect("library_id present");
assert_eq!(lib.as_str(), Some("abc-123"));
assert!(lib.as_i64().is_none());
}
#[test]
fn build_filters_preserves_existing_seed_keys_alongside_library_id() {
let mut seed = serde_json::Map::new();
seed.insert("role".to_string(), serde_json::Value::String("conductor".to_string()));
seed.insert(
"role_lyricist_id".to_string(),
serde_json::Value::String("artist-7".to_string()),
);
let out = nd_build_filters(seed, Some("3"));
let parsed = parse_json_object(&out);
assert_eq!(parsed.get("role").unwrap(), "conductor");
assert_eq!(parsed.get("role_lyricist_id").unwrap(), "artist-7");
assert_eq!(parsed.get("library_id").unwrap().as_i64(), Some(3));
}
}
@@ -81,6 +81,7 @@ pub async fn nd_create_user(
/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_update_user(
server_url: String,
token: String,
@@ -342,3 +342,157 @@ pub async fn lastfm_request(
Ok(json)
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// ── parse_pls_stream_url ──────────────────────────────────────────────────
#[test]
fn parse_pls_returns_first_file_entry() {
let pls = "[playlist]\nNumberOfEntries=2\nFile1=https://stream.example/audio\nTitle1=Foo\n";
assert_eq!(
parse_pls_stream_url(pls),
Some("https://stream.example/audio".to_string())
);
}
#[test]
fn parse_pls_is_case_insensitive_on_key() {
let pls = "[playlist]\nfile1=http://stream.example/x\n";
assert_eq!(parse_pls_stream_url(pls), Some("http://stream.example/x".to_string()));
}
#[test]
fn parse_pls_returns_none_for_non_http_url() {
let pls = "File1=ftp://example/audio\n";
assert!(parse_pls_stream_url(pls).is_none());
}
#[test]
fn parse_pls_returns_none_when_no_file_entry() {
let pls = "[playlist]\nNumberOfEntries=0\n";
assert!(parse_pls_stream_url(pls).is_none());
}
#[test]
fn parse_pls_skips_leading_whitespace_on_lines() {
let pls = " File1=https://stream/audio\n";
assert_eq!(parse_pls_stream_url(pls), Some("https://stream/audio".to_string()));
}
// ── parse_m3u_stream_url ──────────────────────────────────────────────────
#[test]
fn parse_m3u_skips_extm3u_header_and_extinf_comments() {
let m3u = "#EXTM3U\n#EXTINF:-1,Stream\nhttps://stream.example/audio\n";
assert_eq!(
parse_m3u_stream_url(m3u),
Some("https://stream.example/audio".to_string())
);
}
#[test]
fn parse_m3u_returns_first_url_in_order() {
let m3u = "#EXTM3U\nhttps://first.example/a\nhttps://second.example/b\n";
assert_eq!(parse_m3u_stream_url(m3u), Some("https://first.example/a".to_string()));
}
#[test]
fn parse_m3u_returns_none_when_no_url() {
let m3u = "#EXTM3U\n#EXTINF:-1,Just a comment\n";
assert!(parse_m3u_stream_url(m3u).is_none());
}
#[test]
fn parse_m3u_returns_none_for_relative_paths() {
let m3u = "track.mp3\n";
assert!(parse_m3u_stream_url(m3u).is_none());
}
// ── resolve_playlist_url ──────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn resolve_returns_none_for_non_playlist_url() {
let client = reqwest::Client::new();
// Direct stream URLs (without .pls/.m3u/.m3u8 extension) are returned as None.
assert!(resolve_playlist_url(&client, "https://stream.example/audio").await.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn resolve_returns_none_for_non_playlist_url_with_query() {
let client = reqwest::Client::new();
assert!(
resolve_playlist_url(&client, "https://stream.example/audio?foo=bar")
.await
.is_none()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn resolve_extracts_first_stream_from_pls() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/station.pls"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("[playlist]\nFile1=https://stream.example/x\n"),
)
.mount(&server)
.await;
let client = reqwest::Client::new();
let url = format!("{}/station.pls", server.uri());
assert_eq!(
resolve_playlist_url(&client, &url).await,
Some("https://stream.example/x".to_string())
);
}
#[tokio::test(flavor = "multi_thread")]
async fn resolve_extracts_first_stream_from_m3u8() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/station.m3u8"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("#EXTM3U\n#EXTINF:-1,Stream\nhttps://stream.example/y\n"),
)
.mount(&server)
.await;
let client = reqwest::Client::new();
let url = format!("{}/station.m3u8", server.uri());
assert_eq!(
resolve_playlist_url(&client, &url).await,
Some("https://stream.example/y".to_string())
);
}
#[tokio::test(flavor = "multi_thread")]
async fn resolve_dispatches_pls_when_content_type_says_so_even_with_other_extension() {
// Some servers return .m3u extension but with audio/x-scpls Content-Type;
// resolve_playlist_url honors the Content-Type for the parser choice.
// set_body_raw lets us pin the Content-Type header — set_body_string
// would force text/plain regardless of insert_header order.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/weird.m3u"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
"[playlist]\nFile1=https://pls.example/audio\n",
"audio/x-scpls",
))
.mount(&server)
.await;
let client = reqwest::Client::new();
let url = format!("{}/weird.m3u", server.uri());
assert_eq!(
resolve_playlist_url(&client, &url).await,
Some("https://pls.example/audio".to_string())
);
}
}