Commit Graph

13 Commits

Author SHA1 Message Date
cucadmuh 418b25914a feat(cover): unify cover pipeline and stabilize mainstage/now-playing (#870)
* chore(cover): scaffold cover module and rust cover_cache stub

Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands
in cover_cache/mod.rs (no-op returns until phase B).

* feat(cover): add unified cover module and tier resolver (phase A)

Wave 1A: tiers, storage keys, resolveJs with cold/sibling races,
useCoverArt, CoverArtImage, layoutSizes, playback scope helpers,
coverSiblings tier ladder, deprecated shims on subsonicStreamUrl.

* feat(cover): rust disk cache and tier-ready events (phase B)

Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch,
cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests.

* feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C)

Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB
cover key clear, prefetch registry drain, MainApp wiring.

* feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D)

Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense,
coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid.

* feat(cover): migrate sparse surfaces and integrations (phase E sparse)

Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover,
MPRIS/Discord/export integrations, playback chrome and detail heroes.

* feat(cover): revalidation scheduler and disk pressure gate (phase E+)

Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure,
useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks.

* docs: CHANGELOG and credits for cover art pipeline PR #869

* fix(cover): stop webview getCoverArt storm on dense grids (429)

Dense surfaces no longer put rotating getCoverArt URLs in img src; load
disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead
of invalidating IDB. Throttle background prefetch and cap Home registry.

* fix(cover): omit empty img src until cover URL is ready

React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB
resolves; queue current track shows placeholder when src is still empty.

* fix(cover): disk cache by host index key, parallel ensure, asset protocol

Bind cover storage to serverIndexKey (library host), rename cover IPC/events,
fix REST base URL and Tauri flat args, enable protocol-asset for disk paths,
add prioritized ensure queue, and wipe legacy profile-UUID cache once.
Limit Vite dep scan to index.html so research/target HTML is ignored.

* fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage

Encode lossy WebP (~82), write only missing tiers, library cover backfill,
and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP
asset protocol; no IDB fallback when server is up. Session Home feed cache
with warm peek on return; BecauseYouLike deduped cover hook and high prefetch.

* feat(cover): per-server cache strategy and native library backfill

Move cover disk cache settings to Offline & cache with Lazy/Aggressive
per server, per-server clear, and no size cap. Run full-catalog backfill
on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes
without flooding the webview). Drop global prefetch limits from auth store
and waveform clear from the offline storage block.

* fix(build): CSP connect-src for Subsonic API; quieter prod nix build

Prod webview blocked axios ping after cover CSP (missing connect-src).
Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo.

* fix(cover): complete WebP ladder in library bulk backfill

Aggressive backfill now writes all derived tiers (128–800), skips IDs
only when the full ladder exists (not 800 alone), avoids fetch-failed
markers on bulk HTTP errors, and stops the pass when the active server
changes.

* fix(cover,home): navigation-priority backfill and Because You Like UX

Pause library cover backfill while navigating; split peek/ensure traffic
so grids and rails win over bulk work. Disk src lookup, grid warm hooks,
and non-blocking mainstage prime for faster visible covers.

Because You Like: session snapshot, staggered horizontal skeleton row,
text hidden until cover is ready, and layout aligned with loaded cards.

* feat(random-albums,library): local-first album fetch + cover art pipeline

Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM()
LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер
исчезает практически мгновенно; сеть используется только как фолбэк.

- advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок
- browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums
- RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра
  и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve
  прогревает следующий батч в фоне после каждого Refresh

Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue,
diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers,
useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие
компоненты/хуки).

* fix(random-albums): prevent double-load on Zustand rehydration

useEffect([selectedGenres, load]) fired twice on every visit: first with
default store values, then again ~50 ms later when Zustand rehydrated
mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage.

Previously this was invisible because the first network fetch took ~1.5 s,
so loadingRef.current was still true on the second fire. With the new
local-first SQLite path the first load completes in ~50 ms, leaving the
guard cleared before rehydration triggers a second random batch.

Fix: ref-pattern — keep loadRef.current fresh on every render, effect
depends only on selectedGenres. Manual Refresh and genre-filter changes
still call the latest closure correctly.

* fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash

fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что
вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило
всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку
примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает
сетевые запросы к рейтингам артистов).

- fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при
  consume резерва через primeAlbumCoversForDisplay
- reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums
  (аналогично non-reserve пути; при уже прогретом кэше — мгновенно)

* feat(because-you-like): reserve-first pattern — instant display on return visits

Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку
мгновенно, вместо spinner → сетевые запросы → контент.

Архитектура:
- resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из
  замыкания useEffect); читают текущий localStorage, возвращают
  { anchor, recs, nextAnchorHistory, nextPicksHistory }
- fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу
  после отображения результата, кладёт следующий батч в _becauseReserve.
  Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не
  нужен); они прогреваются через primeAlbumCoversForDisplay при consume.
- useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton
  (контент появляется без мигания)
- useEffect: reserve-first path — consume → primeCovers → setState → fill;
  full-fetch path сохранён как fallback при первом визите или промахе

Поведение:
- Визит 1: full fetch (как раньше) → показ → fillReserve R1
- Визит 2+: consume R1 → мгновенный показ → fillReserve R2
- При сетевом сбое: restore из session cache (как раньше)

* fix(because-you-like): initialise state from reserve — no skeleton flash on remount

При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и
показывал skeleton на один тик до того как useEffect отработает.

Теперь useState() использует lazy initializers, которые читают _becauseReserve
прямо в первом рендере: если reserve валиден — state сразу refreshing=false,
anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache
(из предыдущего показа) и появляются без дополнительных запросов.

useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton
только если reserve отсутствует (для случая navigation без ремаунта).

* fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival

Lazy initializers не могли применить reserve при первом рендере, потому что
mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[]
на первом рендере, poolKey не совпадает с reserve.

useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает):
когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно
до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false.
При отсутствии reserve — сбрасывает в skeleton как прежде.

* fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount

Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect,
поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным
poolKey → mismatch → lazy initializers запускали skeleton.

Теперь двухуровневый fallback без зависимости от poolKey:
1. reserve (serverId + poolKey совпадают) → мгновенный новый батч
2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент
   доступен сразу с mount, обновляется тихо в фоне
3. skeleton → только при полном отсутствии данных (первый визит)

Применяется одинаково в lazy useState initializers, useLayoutEffect и
full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент).

* fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool

Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey=''
→ mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал
mostPlayed.

Исправления:
- BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния
  на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать
  reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms
- hasValidReserve: проверяет только serverId
- fillBecauseReserve: убран poolKey из сигнатуры и хранилища
- useEffect: guard pool.length === 0 → возврат без fetch/consume;
  effect перезапустится когда pool заполнится (реальные deps изменятся)
  → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша

Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate)
> skeleton (только первый визит)

* fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch

Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps
useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId,
потом mix-rating значения. Это вызывало двойной запуск эффекта:
- Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра
- Второй запуск (после rehydration): cache miss или повторный fetch с
  реальными mix-настройками → ~500ms → новый набор

Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор
первые ~500ms при каждом возврате на Mainstage.

Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth()
читается внутри эффекта через getState() — всегда актуальные значения без
пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch,
но не вызывают двойной запуск при rehydration.

* feat(home): local-first discover songs via SQLite ORDER BY RANDOM()

Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков)
в browseTextSearch.ts — использует libraryAdvancedSearch с sort random,
field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку.

В Home.tsx: discoverSongs теперь сначала пробует локальный индекс,
и только при недоступности (индекс не готов, ошибка) падает обратно
на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки
берутся из SSD вместо сети.

* fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits

Причина: Home.tsx размонтируется при навигации. При возврате первый рендер
всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом
useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым
состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]).

Решение: getInitialHomeFeed() читает homeFeedCache синхронно через
useAuthStore.getState() (не hook) в lazy useState initializers. К моменту
повторного визита store уже rehydrated — все state получают кэшированные
данные до первого рендера.

Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot
в useEffect когда state уже заполнен — иначе новые ссылки на массивы
вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными.

* fix(mainstage): keep refresh without return flicker

Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation.

* fix(mainstage): stabilize because rail and hero background framing

Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount.

* fix(now-playing): prewarm track data and prevent stale carry-over

Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track.

* fix(cover,test): refresh playback scope and default tauri cover mocks

Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI.

* fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks

Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs.

* test(cover): hoist mocked useCoverArt and clean EOF whitespace

Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line.

* fix(cover): align playback ensure auth and harden backfill retry flow

Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch.

* fix(ci): resolve clippy lint and update frontend node runtime

Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20.

* chore(settings): simplify cover and analytics strategy copy

Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
2026-05-26 19:35:08 +03:00
Maxim Isaev 94cfb3b58d test(frontend): verify global stylesheet @import graph after vitest
Add scripts/check-css-import-graph.mjs and run it from npm test and
test:coverage so missing relative CSS imports fail CI like Vite/postcss.
Document the step in src/test/README.md; trigger frontend workflow when
the script changes.
2026-05-13 21:09:53 +03:00
Frank Stellmacher 9606a99efb refactor(api): F.50 — extract 7 small domain modules (#615)
Seven domain-eng splits peel ~200 LOC of read endpoints out of
`api/subsonic.ts`:

- `subsonicStreamUrl.ts` — `buildStreamUrl`, `coverArtCacheKey`,
  `buildCoverArtUrl`, `buildDownloadUrl` (token-signed URL builders
  for the four /rest endpoints we hand to the browser).
- `subsonicStarRating.ts` — `getStarred`, `star`, `unstar`,
  `setRating`, `probeEntityRatingSupport`. `setRating` still triggers
  the lazy `navidromeBrowse` cache invalidation; the same-folder
  lazy import path is preserved.
- `subsonicSearch.ts` — `search`, `searchSongsPaged`.
- `subsonicScrobble.ts` — `scrobbleSong`, `reportNowPlaying`,
  `getNowPlaying`.
- `subsonicAlbumInfo.ts` — `getAlbumInfo2`.
- `subsonicLyrics.ts` — `getLyricsBySongId`.
- `subsonicGenres.ts` — `getGenres`, `getAlbumsByGenre`.

63 external call sites migrated to direct imports. Four `vi.mock`
targets in the store-level tests pointed at `../api/subsonic` and
were updated to the new module paths.

Pure code-move. subsonic.ts: 762 → 561 LOC (−201).
2026-05-13 00:46:13 +02:00
Frank Stellmacher 006635de4b refactor(api): F.49 — extract library + artists + ratings (#614)
Three domain-eng modules peel ~316 LOC of fetch/mapping out of
`api/subsonic.ts`:

- `subsonicLibrary.ts` — browse + random + per-song fetch
  (`getMusicDirectory`, `getMusicIndexes`, `getMusicFolders`,
  `getRandomAlbums`, `getAlbumList`, `getRandomSongs`,
  `getRandomSongsFiltered`, `getSong`, `getAlbum`,
  `filterSongsToActiveLibrary`, `similarSongsRequestCount`, plus
  the private `albumIdsInActiveLibraryScope` cache).
- `subsonicArtists.ts` — artist endpoints (`getArtists`, `getArtist`,
  `getArtistInfo`, `getTopSongs`, `getSimilarSongs2`,
  `getSimilarSongs`). Uses Library's `filterSongsToActiveLibrary` and
  `similarSongsRequestCount` for the per-library scoping fallback.
- `subsonicRatings.ts` — `parseSubsonicEntityStarRating` parser plus
  `prefetchArtistUserRatings` and `prefetchAlbumUserRatings` workers
  with the shared 7-min cache. Calls back into Library/Artists for
  the per-id fetch.

51 external call sites migrated to direct imports. No re-export
shims in `subsonic.ts`. Statistics endpoints still in `subsonic.ts`
keep their `RATING_CACHE_TTL` constant locally (same 7-min window).

subsonic.ts: 1078 → 762 LOC (−316).
2026-05-13 00:23:53 +02:00
Frank Stellmacher 72030f17fd refactor(api): F.48 — extract subsonic types + client primitives (#613)
First Phase F slice. Splits the 1333-LOC `api/subsonic.ts` along its
two most obvious axes:

- `subsonicTypes.ts` — all ~24 exported interfaces + type aliases
  (album/song/artist/playlist/directory/genre/now-playing/radio,
  random-songs filters, three statistics shapes, search + starred
  results, AlbumInfo, structured-lyrics types, etc.) plus the
  `RADIO_PAGE_SIZE` constant.
- `subsonicClient.ts` — token-auth + `getClient` + `api<T>()` +
  `libraryFilterParams` + `secureRandomSalt` / `getAuthParams` /
  `SUBSONIC_CLIENT`. The credential-bearing API helpers
  (`pingWithCredentials`, `apiWithCredentials`, `restBaseFromUrl`,
  `probeInstantMixWithCredentials`) stay in `subsonic.ts` for now —
  they could move into the client module in a follow-up.

66 external call sites migrated to direct imports from the new
modules (no re-export shims in `subsonic.ts`). Pure code-move;
contract test stays green.

subsonic.ts: 1333 → 1078 LOC (−255).
2026-05-13 00:05:58 +02:00
Frank Stellmacher 4d564e5016 refactor(auth): E.43 — extract authStore types + defaults + helpers (#608)
First slice of the authStore split. Pure code-move: no behaviour change.

- `authStoreTypes.ts` — `ServerProfile`, `AuthState`, plus all union
  types (`SeekbarStyle`, `LoggingMode`, `NormalizationEngine`,
  `DiscordCoverSource`, `LoudnessLufsPreset`, `LyricsSourceId`,
  `LyricsSourceConfig`, `TrackPreviewLocation`, `TrackPreviewLocations`).
- `authStoreDefaults.ts` — `LOUDNESS_LUFS_PRESETS`,
  `DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB`,
  `TRACK_PREVIEW_LOCATIONS`, `DEFAULT_TRACK_PREVIEW_LOCATIONS`,
  `DEFAULT_LYRICS_SOURCES`, `MIX_MIN_RATING_FILTER_MAX_STARS`,
  `RANDOM_MIX_SIZE_OPTIONS`.
- `authStoreHelpers.ts` — `generateId`,
  `sanitizeLoudnessLufsPreset`, `sanitizeLoudnessPreAnalysisFromStorage`,
  `clampMixFilterMinStars`, `clampRandomMixSize`,
  `clampSkipStarThreshold`, `skipStarCountStorageKey`,
  `sanitizeSkipStarCounts`.

12 external call sites migrated to direct imports from the new
modules (no re-export shims left in authStore.ts — applies the
[feedback_prevent_god_modules] rule 5: avoid re-export debt).

authStore.ts: 889 → 518 LOC (−371).
2026-05-12 23:22:28 +02:00
Frank Stellmacher 9fac6eb490 refactor(player): E.42 — migrate playerStore re-exports to direct imports (#607)
Migrates ~74 call sites away from the playerStore re-export shims that
were kept during M0–E.41 to avoid touching 30+ imports per PR. Now
that the bigger refactor work is done, each helper goes back to its
real home:

- `initAudioListeners`, `installQueueUndoHotkey`, `flushPlayQueuePosition`
  → from their own store modules
- `getPlaybackProgressSnapshot`, `subscribePlaybackProgress`,
  `PlaybackProgressSnapshot` → from `playbackProgress`
- `resolveReplayGainDb`, `shuffleArray`, `songToTrack`
  → from `utils/*`
- `_resetQueueUndoStacksForTest`, `consumePendingQueueListScrollTop`,
  `registerQueueListScrollTopReader` → from `queueUndo`
- `PlayerState`, `Track` types → from `playerStoreTypes`

Drops the corresponding 13 re-export stubs from `playerStore.ts` and
the now-unused imports. Also drops dead section banners + per-wrapper
comments above one-line action delegates. Trims one stale "(separate
PR)" note in `transportLightActions.ts` since that follow-up landed
in E.39.

`playerStore.ts`: 180 → 112 LOC (−68). Down from Phase E's starting
3732 LOC.

`bootstrap.test.ts` mock target updated from `../store/playerStore`
to `../store/queueUndoHotkey` to keep the spy reachable after the
import change.
2026-05-12 22:46:13 +02:00
Frank Stellmacher 377675ae94 test(ui): MiniPlayer + FullscreenPlayer (§4.5 regression) (Phase F5c) (#548)
MiniPlayer.test.tsx (4): mounts without throwing, renders the
always-present titlebar controls (Pin + Open main window — Close is
Linux-only and lives on the manual smoke list per pick 4a), click on
Open main window / Close does not throw, clicking the Pin button
flips the alwaysOnTop label between "Unpin" and "Pin on top". Bridge
contract (mini:ready / mini:sync, geometry persistence) deferred to
B-tier phase B5 -- jsdom does not model two webviews.

FullscreenPlayer.test.tsx (9): renders the labelled Fullscreen Player
dialog + Close Fullscreen control. Control wiring: Close calls
onClose, Stop calls stop, Previous calls previous, Next calls next,
Repeat cycles via toggleRepeat.

§4.5 of the v2 plan -- useCachedUrl(coverUrl, coverKey, false)
regression. Mocks the CachedImage module so the call args are
observable. Pins:
  - the 500 px cover-art call passes opt=false (no fetchUrl fallback;
    prevents double crossfade fetchUrl -> blobUrl);
  - the 300 px art-box call passes opt=true (default behaviour).
A refactor that "tidies up" the useCachedUrl call sites would silently
regress the FS player cover; this test makes it loud.

Harness fix: vi.mock for @tauri-apps/api/event now returns an async
emit that resolves -- components chain .catch() on emit which crashed
on the bare vi.fn() return value during first-render useEffect.
Benefits any future component test that mounts something using emit.

Frontend suite: 399 -> 412 tests (+13). F5 (and Phases F0-F6) complete.
2026-05-11 23:24:40 +02:00
Frank Stellmacher 2c38db6ea6 test(ui): QueuePanel (§4.4 DnD regression) + PlayerBar (Phase F5b) (#547)
QueuePanel.test.tsx (8): empty-queue affordance, one row per queue track
with matching data-queue-idx, track titles render. Toolbar exposes
Shuffle queue / Save Playlist / Load Playlist / Copy queue share link /
Clear via aria-label; Shuffle is disabled when the queue has fewer than
2 tracks.

§4.4 of the v2 plan -- DnD architecture pin. Queue rows do NOT declare
draggable=true (no HTML5 native drag); the source file has no
dataTransfer.setData / dataTransfer.getData / onDragStart / onDrop JSX
usage; no application/json MIME anywhere. The project's psy-drop custom
event system sidesteps WebView2's text/plain-only restriction by
avoiding HTML5 DnD entirely -- a refactor that re-introduces native DnD
on the queue would silently break Windows.

PlayerBar.test.tsx (9): renders the labelled "Music Player" region.
Surfaces Previous Track / Play / Next Track / Repeat / Stop when a
track is loaded. The middle control flips between "Play" and "Pause"
based on isPlaying. Control wiring: clicking Play/Pause calls
togglePlay, Previous calls previous, Next calls next, Repeat cycles
off -> all -> one, Stop calls stop. The region landmark is still
rendered when no track is loaded.

Adds a generic scrollIntoView no-op stub in src/test/mocks/browser.ts --
jsdom does not implement it and QueuePanel's queueAutoScroll calls it
on mount with auto-advance enabled. Benefits any future component test
that touches scroll affordances.

Frontend suite: 382 -> 399 tests (+17).
2026-05-11 23:16:08 +02:00
Frank Stellmacher 42e3fdb976 test(playerStore): characterize pure helpers + queue mutations (Phase F1 / PR 2a) (#540)
trackShape.test.ts: songToTrack mapping (required, optional, replayGain
flattening, missing-block fallback, no invented flags); resolveReplayGainDb
precedence (disabled, track, album, auto with prev/next neighbour match,
missing albumId fallback, both gains missing); shuffleArray (non-mutating,
preserves multiset, copies, empty/single, deterministic under mocked RNG).

queue.test.ts: enqueue / enqueueAt (auto-added separator placement,
queueIndex shift, index clamping), playNext (playNextAdded tag, position,
empty input), clearQueue (state reset, audio_stop call), reorderQueue
(splice, currentTrack-id-following queueIndex), removeTrack (clamp on
shrink, keep when after cursor), undo / redo (empty stacks return false,
rollback, replay, new edit drops pending redo).

Adds a 5-line test-only export _resetQueueUndoStacksForTest in playerStore.ts
because the undo/redo arrays live at module scope outside the Zustand state
graph; storeReset.ts now calls it so resetPlayerStore is fully complete.

playerStore.ts coverage 4.76% -> 18.1% lines. PR 2b + 2c push further toward
the F1 floor of 50%.
2026-05-11 21:40:47 +02:00
Frank Stellmacher 4f9ad07d65 test(frontend): harness expansion + utility coverage push (F0 + F6) (#539)
* test(frontend): expand harness for store/component/contract tests

- factories: makeSubsonicSong, makeServer, makeAuthState, makeQueueState
- storeReset.ts: per-test reset for player/auth/preview/orbit stores
- mocks/subsonic.ts: realistic fixtures + stream/cover URL helpers
- mocks/browser.ts: ResizeObserver/IntersectionObserver/matchMedia/clipboard/object URLs
- mocks/tauri.ts: tauriMockListenerCount for listener-lifecycle regression tests
- renderWithProviders: pin i18n language to 'en' by default; { language } opt-out
- vitest.config: pool 'forks' + isolate to avoid module-mock + Zustand-global flake
- README: documented patterns, store-reset policy, i18n rule, isolation rationale

* test(frontend): bump utility coverage + expand hot-path gate

serverMagicString: 71→100% (encode/decode rejection branches, clipboard
fallback paths). shareLink: 69→97% (all entity kinds, queue trim, orbit
decoder, findServerIdForShareUrl). dynamicColors: 44→100% (extractCoverColors
DOM paths via Image / canvas / fetch mocks).

Gate adds shareLink.ts and dynamicColors.ts — both stable above 95%.
Comments updated for the new floor and the M4 hard-gate handoff.
2026-05-11 21:11:23 +02:00
Frank Stellmacher a228ce1c91 chore: fix stale doc references (#538)
- src/test/README.md: layout listed wrong filename for the readme itself
- miniPlayerBridge.ts: comment pointed at a doc that lives outside the repo
2026-05-11 16:57:54 +02:00
Frank Stellmacher 02d533e949 test(frontend): vitest framework bootstrap + hot-path coverage gate (#536)
* test(frontend): vitest framework bootstrap + hot-path file coverage gate

Adds the harness for component, hook and store tests on top of the existing
util tests in src/utils/. Mirrors the backend rust-tests rollout: jsdom env,
v8 coverage, soft hot-path file gate, dedicated CI workflow.

What's in:
- vitest.config.ts: jsdom environment, v8 coverage, alias @ -> src
- src/test/setup.ts: jest-dom, @testing-library cleanup, vi.mock for
  @tauri-apps/api/{core,event} + plugin-shell, Map-backed Storage polyfill
  for Node 25 + jsdom 26 (both ship a broken native localStorage)
- src/test/mocks/tauri.ts: programmable onInvoke() / emitTauriEvent() helpers,
  auto-reset between tests
- src/test/helpers/factories.ts: makeTrack / makeTracks
- src/test/helpers/renderWithProviders.tsx: render() wrapped with
  MemoryRouter + I18nextProvider
- src/test/README.md: conventions doc (where tests go, how to mock Tauri,
  what to never mock)

Sample tests showing the patterns:
- src/components/CoverLightbox.test.tsx: component, queries by role
- src/store/previewStore.test.ts: store characterization, event handlers
  + stopPreview (startPreview deferred until the cross-store provider
  strategy is decided)

CI:
- .github/workflows/frontend-tests.yml: jobs for vitest, tsc, coverage +
  hot-path gate. coverage job carries continue-on-error: true (soft).
- .github/frontend-hot-path-files.txt: initial list (3 utils at >=70%).
  playerStore + the unfinished half of previewStore are deferred until
  Phase 1 coverage work lands.
- scripts/check-frontend-hot-path-coverage.sh: mirror of the rust gate.

npm scripts:
- test: one-shot run (unchanged)
- test vitest in watch mode
- test:coverage: v8 coverage + html / lcov / json-summary reports

57 / 57 tests pass; tsc --noEmit clean.

* chore(nix): sync npmDepsHash with package-lock.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-11 12:25:48 +02:00