mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
70c2fdfbf90fd2c57160893f7027c32cd25da524
1152 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b68bddd034 |
refactor(player): E.5 — extract playback-URL routing into module (#568)
Three file-private helpers + their shared module-scoped track id move into src/store/playbackUrlRouting.ts: recordEnginePlayUrl, playbackSourceHintForResolvedUrl, shouldRebindPlaybackToHotCache. The `lastOpenedWithHttpTrackId` mutable goes with them — only those three read it. No external callers, no re-exports needed. Adds 12 focused tests covering the source-kind classifier (stream / hot / offline), the rebind decision across stream:-prefix forms, the empty-serverId / un-recorded edge cases, and the test-only reset helper. playerStore 3490 → 3470 LOC. |
||
|
|
5b89051817 |
refactor(player): E.4 — extract playback-progress pub/sub into module (#567)
The high-frequency PlaybackProgressSnapshot channel — type, mutable snapshot, listener Set, emit/get/subscribe — moves into a dedicated src/store/playbackProgress.ts. playerStore re-exports the public 3 (getPlaybackProgressSnapshot, subscribePlaybackProgress, the type) so the 5+ external callers (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, TauriEventBridge) keep their existing imports. Direct unit tests pin the delta short-circuit (`currentTime <0.005`, `progress/buffered <0.0002`) that keeps idle CPU bounded. The end-to-end Tauri-event drive path remains covered by the existing playerStore.progress characterization test (unchanged). playerStore 3516 → 3490 LOC. |
||
|
|
d24514d67e |
refactor(player): E.3 — extract waveform/normalization/seek pure helpers (#566)
Three small tranches out of playerStore.ts: - src/utils/waveformParse.ts — waveformBlobLenOk + coerceWaveformBins (the parser that handles number[] / Uint8Array / ArrayLike payloads Rust serializes as). - src/utils/normalizationCompare.ts — normalizationAlmostEqual (tolerant null-aware dB comparison). - src/utils/seekErrors.ts — isRecoverableSeekError (retry classifier for the Rust seek pipeline). Each gets focused unit tests. All four were file-private, no external callers — pure code move. playerStore 3556 → 3516 LOC. |
||
|
|
1521e4ea8f |
refactor(player): E.2 — extract queue-undo machinery into queueUndo.ts (#565)
Move the bounded undo/redo stacks, snapshot factory, scroll-top reader registry, and pending-scroll-top channel into a dedicated module under src/store/. playerStore re-exports the three public APIs that QueuePanel and the test harness already imported (registerQueueListScrollTopReader, consumePendingQueueListScrollTop, _resetQueueUndoStacksForTest), so no caller-side changes are needed. The new module adds explicit push/pop accessors so the undo/redo store actions stop reaching into module-scoped arrays directly. Inline `pendingQueueListScrollTop = …` writes inside applyQueueHistorySnapshot become `setPendingQueueListScrollTop(…)` calls — same effect, scoped through the module. PlayerState gets the `export` keyword so queueUndo.ts can type its state-shaped parameters. playerStore.ts 3618 → 3576 LOC. queueUndo gains a focused test file covering snapshot deep-cloning, the redo-stack invalidation on a fresh undo push, max-size enforcement, and the scroll-top channel. Pre-PR check: PASS. |
||
|
|
dcf3dd98e0 |
refactor(player): E.1 — extract queue-identity helpers to utils/ (#563)
Four pure helpers move out of playerStore: normalizeAnalysisTrackId, sameQueueTrackId, queuesStructuralEqual, shallowCloneQueueTracks. Adds focused unit tests for the stream:-prefix normalization and the no-op detection that prevents unnecessary queue rewrites. Behaviour preserved verbatim. playerStore 3618 → 3598 LOC. |
||
|
|
f0971d5108 |
docs(contributing): expand contributor guide structure and checks (#564)
Add quick start, repository layout, explicit main vs promotion branches, Tauri boundary as its own section, security handling, Nix/Cachix and non-Linux setup notes, lint/format reality (tsc + clippy), i18n file locations, consolidated hot-path gate wording, and clearer local coverage reproduction steps. Remove redundant summary block. |
||
|
|
558abba6af |
docs: add CONTRIBUTING.md for contributors (#562)
Document where to ask questions, local commands that mirror CI, PR expectations, caution around disruptive UI, stability of the Tauri Rust-frontend contract, and impact of on-disk settings changes. Link the guide from the README Development section. |
||
|
|
c0f2bc00dd |
refactor(app): Phase D — move TauriEventBridge into src/app/ (#561)
Final piece of the App.tsx slim-down. The `TauriEventBridge` component — ZIP download progress, track-preview lifecycle, audio device changed/reset, the full `cli:*` listener surface (audio-device-set, instant-mix, library list/set, server list/set, search, player-command), tray-icon visibility sync, in-app keybindings keydown handler, media keys + tray actions, `shortcut:global-action` / `shortcut:run-action`, seek-relative / seek-absolute / set-volume, the window:close-requested + app:force-quit flow (with the shared `performExit` Orbit teardown), and the `psysonic --info` snapshot publisher — moves into `src/app/TauriEventBridge.tsx`. `MainApp` imports it from the new file. `App.tsx` is now 57 LOC: just the `App()` default-export that branches between `MiniPlayerApp` and `MainApp` after wiring the shared theme / font / track-preview document attributes. A.K.A. App.tsx 1453 → 57 LOC over Phase 2 (M0 + B.1 + B.2 + C.1 + C.2 + D). Pure code move at every step — no behaviour change. Pre-PR check: PASS (frontend tests, tsc, coverage gates, prod build, backend tests, clippy, backend coverage gates). |
||
|
|
796c7567ea |
refactor(app): Phase C.2 — move AppShell into src/app/AppShell.tsx (#560)
Companion to C.1. The full `AppShell` component — the persistent sidebar / header / route host / queue-resizer / player-bar layout plus its ~25 effects (tray-tooltip + title sync, Orbit role/phase body marker, platform attribute, fullscreen tracking, music-folders + rating-support probe, sidebar persistence, queue drag, WebKitGTK DnD/select-all blockers, blur/hidden cosmetic-animation pause) — moves into `src/app/AppShell.tsx` together with its three private helpers (`readInitialSidebarCollapsed`, `persistSidebarCollapsed`, `shouldSuppressQueueResizerMouseDown`). `MainApp` now imports `AppShell` from the new file instead of the App.tsx re-export. `App.tsx` 1232 → 560 LOC. What's left is the `TauriEventBridge` (~475 LOC, Phase D) plus the ~50-LOC `App()` default-export that splits between `MiniPlayerApp` and `MainApp`. Imports that were AppShell-only (Sidebar / PlayerBar / 9 components / 7 hooks / 3 platform helpers / useOfflineStore / useConnectionStatus / useEqStore / usePerfProbeFlags / useTranslation / useIsMobile / probeEntityRatingSupport / Suspense / useCallback / useRef / useState / useLocation / getCurrentWindow's UI use / ConnectionIndicator / LastfmIndicator / AppUpdater / TitleBar / OrbitSessionBar / OrbitStartTrigger / useOrbitHost / useOrbitGuest / cleanupOrphanedOrbitPlaylists / IS_MACOS / IS_WINDOWS / IS_LINUX / APP_MAIN_SCROLL_VIEWPORT_ID / AppRoutes / lucide icons) all leave with the component. No behaviour change — pure code move + import-graph shuffle. Tests unchanged; the existing AppShell behaviour is already covered indirectly by the per-component tests it composes. Pre-PR check: PASS (frontend tests, tsc, coverage gates, prod build, backend tests, clippy, backend coverage gates). |
||
|
|
2b1ad1542a |
refactor(app): Phase C.1 — extract AppRoutes + RequireAuth from App.tsx (#559)
Continues the App.tsx slim-down. Two pieces move out of the monolith: - `src/app/AppRoutes.tsx` — the route table and its 32 lazy page imports. AppShell now renders `<AppRoutes />` inside the existing `<Suspense>`; the `perfFlags.disableMainRouteContentMount` placeholder stays in AppShell because that branch is a layout concern, not a routing one. `useIsMobile()` moves inside AppRoutes so the `/now-playing` mobile swap stays self-contained. - `src/app/RequireAuth.tsx` — the 4-line auth guard, with a focused test that covers all three reject paths (no login, no active server id, empty server list) plus the happy path. MainApp imports it from the new file instead of routing through the App.tsx re-export. Side-cleanup of imports that B.2 had already orphaned in App.tsx (`version`, `initAudioListeners`, `lazy`, `Routes`, `Route`, `Navigate`, `MobilePlayerView`). `App.tsx` 1308 → 1232 LOC. `AppShell` stays in App.tsx for Phase C.2. Pre-PR check: PASS (frontend tests, tsc, coverage gates, prod build, backend tests, clippy, backend coverage gates). |
||
|
|
f09da2d2a3 |
refactor(app): Phase B.2 — split App() into MiniPlayerApp + MainApp (#557)
The 186-LOC default export shrinks to a thin window-kind switch with
shared document-attribute hooks. The mini-player tree and the main-app
tree each move into their own module under src/app/.
- src/app/MiniPlayerApp.tsx (48 LOC):
DragDropProvider + MiniPlayer + cross-window storage sync
- src/app/MainApp.tsx (129 LOC):
BrowserRouter + Routes + main-only lifecycle hooks
(audio listeners, hot cache, global shortcuts, mini-player
bridge, easter egg, scrollbar auto-hide)
AppShell + RequireAuth + TauriEventBridge are now named exports from
App.tsx so MainApp can compose them; Phase C/D will extract those into
their own modules.
App.tsx: 1453 -> 1308 LOC. Behaviour-preserving.
|
||
|
|
0cd8998dc9 |
refactor(app): Phase B.1 — extract pre-React bootstrap into src/app/ (#555)
main.tsx shrinks from 56 -> 17 LOC. New module surface:
- src/app/windowKind.ts: cached getWindowKind() detector,
replaces the global __PSY_WINDOW_LABEL__ string everywhere
- src/app/bootstrap.ts: pushUserAgentToBackend +
pushLoggingModeToBackend + runPreReactBootstrap orchestrator
App.tsx + playerStore.ts now read getWindowKind() instead of poking
window.__PSY_WINDOW_LABEL__ directly. Behaviour-preserving.
|
||
|
|
d3a8160b37 |
refactor(player): M0 — extract pure helpers from playerStore.ts (#554)
Moves four self-contained helpers into src/utils/, each with co-located characterization tests. playerStore re-exports them for the ~30 existing call sites; Phase E will migrate those imports. - shuffleArray (Fisher-Yates, generic) - resolveReplayGainDb (track/album/auto mode resolution) - songToTrack (Subsonic -> Track shape) - buildInfiniteQueueCandidates (Instant-Mix top-up source) playerStore.ts: 3732 -> 3618 LOC (-114). |
||
|
|
6afbdf9c60 |
chore(test): activate aggregate vitest coverage thresholds (#553)
Replaces 0/0/0/0 placeholders with a measured floor (~1pp under current state) so the suite blocks regressions across the whole tree. Per-file hot-path gate keeps doing the heavy lifting on critical files. |
||
|
|
b3646daabd |
test(playerStore): miscellaneous actions push F1 toward the 50% floor (#550)
24 new tests covering the smaller action surfaces F1 / 2a-c skipped: - setStarredOverride / setUserRatingOverride (per-id maps) - openContextMenu / closeContextMenu (state + isOpen flip) - openSongInfo / closeSongInfo (modal state) - toggleQueue / setQueueVisible (visibility flip with persisted side effect) - toggleFullscreen (boolean flip) - setLastfmLoved (writes verbatim + caches under title::artist when track is set; does NOT cache without a track) + toggleLastfmLove (no-op without track or session-key; flips state + cache otherwise) - setLastfmLovedForSong (caches under title::artist key) - setProgress (currentTime + derived progress) - stop (invokes audio_stop, resets playback bookkeeping) - shuffleQueue (no-op when queue < 2; deterministic with mocked RNG, current track stays at queueIndex 0) - shuffleUpcomingQueue (no-op when upcoming < 2; head + current untouched, upcoming tail shuffled) - pruneUpcomingToCurrent (drops everything after queueIndex; clears the queue entirely when no current track; early return when queue is already empty) - setRadioArtistId (does-not-throw smoke; no public getter for the module-level state it writes) playerStore.ts coverage 40.48% -> 48.02% lines (functions 37.84% -> 50.34%). Close to but not over the F1 50% line floor — the remaining ~2pp lives in playTrack's async hot-cache/replay-gain body, which is the same surface PR 2c flagged for a separate follow-up. Not adding playerStore.ts to the gate yet; staying out one more PR to see the number stabilise. Frontend suite: 451 -> 475 tests (+24). |
||
|
|
568f3aeb7d |
test(api): subsonic.ts async endpoint contracts (F3 follow-up) (#549)
39 new tests targeting the response normalization paths the F3 PR (#544) deferred. Mocks axios at the module boundary; pins: - api() helper envelope: unwrap subsonic-response on status=ok, throw "Invalid response" without envelope, throw the server message on status=failed, throw a generic on failed-without-message, propagate network failures. - song-array vs single-object normalization paths: - getMusicDirectory normalizes child (object -> [object]), empty -> []. - getMusicIndexes flattens index -> artist arrays (object or array). - getMusicFolders coerces numeric ids to strings + defaults name. - getRandomSongs pass-through behaviour pinned (no normalization -- Navidrome always returns the array form). - collection-shape contracts: getAlbum splits { album, songs } + empty fallback when album.song is absent, getStarred returns empty arrays on missing starred2 / missing fields / pass-through arrays. - single endpoint behaviours: getSong null on failure, getTopSongs [] + slice to 5, getArtists flatten + empty, search whitespace-query short-circuit (no HTTP), getAlbumInfo2 null on error, ping true/false based on status. - pingWithCredentials (explicit-URL path): full response (type + serverVersion + openSubsonic), http:// prepend when scheme missing, trailing-slash strip before /rest/ping.view, ok=false on any failure / status=failed, openSubsonic defaults to false when omitted. subsonic.ts coverage 12.66% -> 31.87% lines (+19pp). The remaining surface lives in niche endpoints (playlist mutations, statistics overview/aggregates, internet radio CRUD, cover-art uploads, ratings prefetch). Not gate-eligible yet; a follow-up could push to ~60% but diminishing returns relative to other backlog items. Frontend suite: 412 -> 451 tests (+39). |
||
|
|
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.
|
||
|
|
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). |
||
|
|
fae615fdb8 |
test(ui): ContextMenu + WaveformSeek behaviour pins (Phase F5a) (#546)
ContextMenu.test.tsx (11): renders nothing when closed, renders when openContextMenu has run, closeContextMenu hides on next render. Song-type shows Play Now / Play Next / Add to Queue; album-type shows Open Album / Play Next / Enqueue Album / Go to Artist; artist-type shows Start Radio + share affordances; queue-item shows a Remove option the song menu does not. Clicking an action calls the expected store method (playNext, enqueue) and closes the menu. Escape on the menu closes it. WaveformSeek.test.tsx (8): renders a canvas, cursor=default without trackId (no-track-loaded affordance), cursor=pointer with a trackId. Wheel guards: no seek without a trackId, no seek with duration=0. Wheel commit wiring: 350 ms trailing debounce delays the seek call until activity settles; rapid wheel events coalesce (fewer commits than events). Mount + unmount completes without throwing. Neither component joins the hot-path gate -- jsdom skips canvas drawing and these files have large branch surfaces (ContextMenu has 13 menu types, WaveformSeek has the animation loop + 11 seekbar styles) that need behavioural smoke rather than line coverage. The tests cover the contract the refactor must preserve; visual / interactive layers stay on the manual smoke list. Frontend suite: 363 -> 382 tests (+19). |
||
|
|
6e646351ee |
test(previewStore): startPreview + main-player volume sync (Phase F4) (#545)
Adds 22 new tests on top of the existing 7 _on* / stopPreview ones. startPreview happy path: invokes audio_preview_play with the configured args (id, url, durationSec, startSec, volume) and stores the previewing track + duration + reset elapsed / audioStarted. Short tracks (duration <= previewDuration * 1.5) start at 0; longer tracks seek to duration * trackPreviewStartRatio. Camel-case IPC keys pinned (startSec / durationSec, not snake_case -- CLAUDE.md gotcha). Cross-store guard tests: no-op when previews globally disabled, no-op when disabled at the calling location, no-op while a host or guest is inside any Orbit phase (active / joining / starting), falls through to play when role is null (no session). Same-id re-click: treats it as a stop -- audio_preview_stop fires, audio_preview_play does not. Failure path: engine invoke rejects -> store state rolls back (previewingId / previewingTrack / audioStarted) and the error propagates to the caller. Loudness pre-attenuation folding: with normalizationEngine=loudness + loudnessPreAnalysisAttenuationDb=-6 dB, volume is multiplied by 10^(-6/20). normalizationEngine=off keeps volume verbatim. Positive pre-attenuation values are pulled to 0 by the Math.min(0, ...) guard. Main-player volume sync side-effect (module-level usePlayerStore.subscribe): pings audio_preview_set_volume when volume changes during a preview, skips when no preview is active, skips when the new value equals the prior value (subscription guard). previewStore.ts coverage 33% -> 100% lines. Added to the hot-path gate. Plus the typed `OrbitRole` is `'host' | 'guest'` (null when no session), not 'idle' as a string -- minor type-correctness alignment. |
||
|
|
d2898ebaf6 |
test(api): URL builders + playback URL resolver + share link composition (Phase F3) (#544)
subsonic.contract.test.ts (21): parseSubsonicEntityStarRating (userRating first then rating fallback, numeric-string coercion, undefined for null / NaN / non-numeric), libraryFilterParams (empty without active server, empty on "all" filter, returns musicFolderId on specific filter), getClient (throws without a server, returns baseUrl + auth params, rotates token + salt across calls), coverArtCacheKey (serverId:cover:id:size shape, "_" fallback without active server, no ephemeral salt embedded -- stays cacheable), buildStreamUrl (URL shape + Subsonic auth params: id u t s v=1.16.1 c=psysonic/* f=json, rotates t/s across calls so Rust matches by id, special character ids encoded once not twice), buildCoverArtUrl (default size=256), buildDownloadUrl (download.view path), trailing-slash + scheme handling on base URL. resolvePlaybackUrl.test.ts (15): precedence offline > hot-cache > stream (first priority wins even when later sources also have the track), forwards trackId + serverId to both stores. getPlaybackSourceKind for offline / hot / stream / engine-preload-hint cases. streamUrlTrackId parser (id from stream.view query, null for non-stream URLs / no query / missing id, decodes URL-encoded ids, manual-query fallback for relative paths). copyEntityShareLink.test.ts (5): writes a psysonic2-prefixed payload that round-trips, returns false without an active server, returns false on empty / whitespace id, trims surrounding whitespace before encoding, propagates clipboard-failure return. Gate broadens with src/utils/resolvePlaybackUrl.ts (95.8 %) + src/utils/copyEntityShareLink.ts (100 %). subsonic.ts at 12.7 % stays out -- the URL-builder + parser surface this PR covers is the structural part; the async API endpoints need axios mocking, deferred to a follow-up. authStore.ts (79 %) and playerStore.ts (40 %) deferred-list comments updated to reflect F2 + F1 actuals. |
||
|
|
ae23bf61eb |
test(authStore): characterize login + servers + persistence + settings (Phase F2) (#543)
login.test.ts: composed addServer -> setActiveServer -> setLoggedIn flow, failed-login leaves prior state intact, addServer assigns unique ids, setConnecting / setConnectionError independence, logout clears isLoggedIn + musicFolders but keeps server entry, Last.fm session (setLastfm / connectLastfm / disconnectLastfm contracts). servers.test.ts: addServer / updateServer (patch by id, no-op on unknown), setActiveServer (clears musicFolders), removeServer (non-active no-effect, active picks newServers[0] fallback, last server -> null + isLoggedIn false, cleans per-server bookkeeping maps), getActiveServer / getBaseUrl selectors. Includes the gapless / crossfade mutex regression test from the v2 plan section 4.3 -- callers clear the other flag before setting, the setters themselves do NOT auto-clear (contract pin). persistence.test.ts: hydration loads existing localStorage shape, defaults missing fields, preserves saved values verbatim. Robust to corrupt JSON and missing top-level state. onRehydrate migrations: clears conflicting hotCacheEnabled + preloadMode!=off legacy combo, keeps hotCache when preload was already off, migrates legacy waveform seekbarStyle to truewave, strips removed animationMode / reducedAnimations fields. partialize strips musicFolders. Includes the synchronous-storage invariant regression from the v2 plan section 2 (CLAUDE.md gotcha) -- getActiveServer is visible in the same tick after addServer + setActiveServer with no await. settings.test.ts: API-pin sweep across 22 trivial setters via it.each (rename-resistant), focused tests for setters with logic (clamping in setTrackPreviewStartRatio / setTrackPreviewDurationSec / setRandomMixSize, boolean coercion in setTrackPreviewsEnabled, finite-number guard in setLoudnessPreAnalysisAttenuationDb, default reset in resetLoudnessPreAnalysisAttenuationDbDefault), per-server bookkeeping contracts (setEntityRatingSupport / setAudiomuseNavidromeEnabled positive-opt-in semantics), enum-value setters (setPreloadMode / setDiscordCoverSource / setLoggingMode / setReplayGainMode / setNormalizationEngine / setLyricsMode), genre blacklist + audio output device replacement. authStore.ts coverage 45.37% -> 79.29% lines (target was 60%). authStore.ts not yet added to the hot-path gate -- want to see it stable across a few real PRs first per the gate's curation rule. |
||
|
|
8569a17797 |
test(playerStore): progress snapshot + persistence flush (Phase F1 / PR 2c) (#542)
progress.test.ts: getPlaybackProgressSnapshot shape + post-emit reflection, subscribePlaybackProgress (notify, (next, prev) pair, near-duplicate epsilon coalesce at 0.005 s, unsub stops notifications, multiple subscribers independent), live-emit throttling guard (drops within 1500 ms + < 0.9 s delta; large delta passes; time threshold passes). persistence.test.ts: flushPlayQueuePosition forwards (ids, currentId, posMs) to savePlayQueue, caps song-id list at 1000, no-ops on radio / no-track / empty queue, swallows backend errors, floors position to whole ms. playerStore.ts coverage 39.55% -> 40.48%. F1 50% floor not met -- remaining ~10pp lives in playTrack's async hot-cache/replay-gain body, shuffleQueue, stop, enqueueRadio, initializeFromServerQueue and the orbit auto-merge paths. Either a follow-up PR 2d or a revised F1 floor; flagged in the PR body. Gate unchanged -- playerStore.ts stays out until the floor is met. Discovered + fixed during this PR: the spread ...await vi.importActual() mock pattern lets the real savePlayQueue leak through to playerStore.ts's relative import (../api/subsonic) even when the alias-form is mocked. Switched persistence.test.ts to an explicit non-spread mock map listing every export the store uses. |
||
|
|
4a18e15489 |
test(playerStore): playback actions + audio event handlers (Phase F1 / PR 2b) (#541)
playbackActions.test.ts: pause (invoke + failed-invoke controlled), resume (warm path, no-track guard), togglePlay (both branches), seek (clamp to dur-0.25, 100 ms debounce, coalesce rapid drags, no-op guards), next (advance, repeat=all wrap, repeat=off audio_stop + reset), previous (>3 s restart, jump-back, queueIndex=0 no-op), toggleRepeat cycle. events.test.ts: audio:progress (commit on active transport, drop without track / paused, duration=0 falls back to track.duration), audio:track_switched (advance, repeat=one pin, repeat=all wrap, end+off no-op, scrobbled+ lastfmLoved reset), audio:ended (immediate playback reset, radio path clears currentRadio without queue advance). 4 listener-lifecycle regression tests cover section 4.2 of the pre-refactor testing plan v2 -- cleanup actually unsubs; re-init keeps count=1; double-init without cleanup stacks (contract pin so a refactor that drops the cleanup return value fails loudly). playerStore.ts coverage 18.1% -> 39.55% lines. PR 2c (progress snapshot + persistence flush) pushes past the F1 50% floor. |
||
|
|
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%. |
||
|
|
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.
|
||
|
|
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 |
||
|
|
123fbcc802 |
fix(orbit): event-driven host push + guest seekbar lock (#537)
* fix(orbit): event-driven host push on play/pause flips Without this, the worst-case delay between "host hits pause" and "guest stops" is two full polling windows (host's 2.5 s push tick + guest's 2.5 s read tick, plus network) — long enough for the guest to noticeably run past the host. Subscribing to playerStore.isPlaying changes adds at most one extra remote write per flip; non-flip state ticks still ride the existing 2.5 s timer. The listener filters on isPlaying so the per-second currentTime ticks don't trigger spurious pushes. * fix(orbit): lock seekbar for guests — sync follows the host Guests could drag/click/wheel the seekbar, which would jump the local player and then snap back at the next host poll (2.5 s of inconsistent UX) — or push the guest into a diverged state where Catch Up was the only way back. The seekbar is host-controlled in Orbit; the guest input path now reflects that. - App.tsx exposes `data-orbit-role="host"|"guest"` on the root element alongside the existing `data-orbit-active` marker. - WaveformSeek's container gains a `.waveform-seek-container` class so CSS can target it. - Guest rule: `pointer-events: none` on children blocks click / drag / wheel / hover; the parent keeps `cursor: not-allowed` + reduced opacity so the disabled state is visually unambiguous. Hosts and non-orbit users see no change. * docs(changelog): credit PR #537 (orbit sync latency + guest seekbar) |
||
|
|
64b33e6941 |
feat: customizable queue toolbar with drag-and-drop reordering and visibility toggles (#534)
* Add drag-and-drop reordering and visibility toggles for queue toolbar * docs(changelog): credit PR #534 (queue toolbar customization) Adds the v1.46.0 CHANGELOG entry and a new bullet on kveld9's contributors block in Settings → System. --------- Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> |
||
|
|
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>
|
||
|
|
1cc43dc669 |
fix(dev): support local Rust coverage checks (#535)
Add the coverage and lint tools to the Nix dev shell so local pre-PR checks can match CI, and make the hot-path coverage gate locale-stable. |
||
|
|
7c32172d5d |
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.
|
||
|
|
f225039f1b |
Merge pull request #532 from Psychotoxical/refactor/backend-pilot
refactor(rust): Cargo workspace with 5 domain crates (M0–M7) |
||
|
|
673d4ffe56 |
docs(changelog): add Cargo workspace refactor entry
Foundational Rust workspace split landed via #532. Crediting both cucadmuh (M0–M7 structural work + analysis fixes) and Frank (orbit batch + macOS gates + bonus PRs that rode along on the branch). |
||
|
|
cdd7cb192d |
fix(analysis): map waveform bins to decoded length, not inflated n_frames
Container-reported frame counts can exceed decoded samples on some VBR or badly tagged files; using max() squashed energy into the leading bins. |
||
|
|
308eb36f05 |
feat(analysis): re-analyze waveform when clearing loudness cache
Add analysis_delete_waveform_for_track, invoke it from loudness reseed, clear waveformBins in the UI, and extend queue strings for tooltips/toast. |
||
|
|
7a0dd93f3e |
fix(refactor): cfg-gate two items so macOS build is warning-clean (#530)
Mac smoke build surfaced 5 dead-code / unused-import warnings, all cfg-leaks of items that are conditionally compiled on Windows + Linux: - `psysonic-audio::power_resume` is consumed by `power_notify_win` and `power_notify_linux` only — `register_post_sleep_audio_recovery` intentionally falls through to a no-op on macOS (the generic device watcher covers the resume case there). Gate the module declaration to `#[cfg(any(target_os = "windows", target_os = "linux"))]`. - `lib_commands::ui::build_mini_player_window` is re-exported for the Windows-only pre-create path in `lib.rs:setup` (other platforms create the mini-player webview lazily on first invoke). Gate the re-export to `#[cfg(target_os = "windows")]` so non-Windows builds don't warn on an unused import. Both are non-functional — the items themselves are already correctly scoped via cfg in their consumers; only the declarations / re-exports were missing the matching gate. |
||
|
|
25507888a9 |
fix(orbit): host single-track playTrack appends instead of replacing (#529)
* fix(orbit): host single-track playTrack appends instead of replacing Reported by cucadmuh: "When playing from offline, the Orbit queue doesn't get appended to — it gets overwritten." Root cause: the Orbit bulk-guard fires only when `queue.length > 1`. A `playTrack(track, [track])` call (one example: OfflineLibrary's "Play this album" on a single-track album, but any UI that passes an explicit 1-track replacement queue triggers it) slips past the guard and replaces the host's `playerStore.queue`. The host's queue *is* the shared Orbit queue — replacing it wipes every guest suggestion + every upcoming track in one click. Re-route to append + jump when role is host: if the track is already in the queue, jump to that slot; otherwise append it and jump to the new tail. The guest path is intentionally left alone — guests opting out of host-sync via a local Play is the existing "guest running their own show" divergence behaviour. `useOrbitGuest`'s `syncToHost` is the only guest-side caller of `playTrack(track, [track])`, and it never matches `role === 'host'` so it's never intercepted. * docs(changelog): add host single-track Orbit-queue protection bullet |
||
|
|
eae649bcad |
fix(orbit): make initial-sync seek visually stick on join (#528)
* fix(orbit): make initial-sync seek visually stick on join Reported: "I join the room, the waveform shows the host's live position for a second or two, then snaps back to 0:00 and audio is still playing from the start. Only after that snap can I press Catch Up." Two compounding causes in `useOrbitGuest`: **1. Poll fires `applyMirror` before the engine is genuinely playing.** `playTrack` flips `isPlaying` to `true` *synchronously* in its optimistic store write, so the post-`playTrack` poll satisfied its "engine ready" check before the Tauri `audio_play` had even started producing sample. The `seek` inside `applyMirror` updates the store position immediately (waveform jumps to host's live position), but `audio_seek` is debounced and lands on a not-ready engine — it silently no-ops. The engine then starts playing from 0, and its first progress events overwrite the optimistic seek position, snapping the waveform back. Add `currentTime > 0.1` to the poll's "engine genuinely playing" condition: once audio has flowed past the cold-start barrier, the seek commits and the seek-target guard correctly filters subsequent progress events. **2. `applyMirror`'s play-state mirror raced its seek**, same shape as the `onCatchUp` race fixed in #527. `player.seek` debounces `audio_seek` via setTimeout(0) while `pause`/`resume` invoke synchronously — pause arriving first leaves the engine paused at the old position. Defer the play-state mirror by 200 ms so the seek lands first. * docs(changelog): add initial-sync seek-stickiness bullet |
||
|
|
6c5465e9c7 |
fix(orbit): hysteresis on Catch Up visibility so the button stays clickable (#527)
* fix(orbit): keep Catch Up button visible long enough to click Reported during testing: the Catch Up button "appears for a moment, then disappears too fast to click". The single-stage debounce only filtered the show direction (require ≥ 3 s sustained over-threshold before showing), but on a real-world high-latency session the genuine drift fluctuates between ~1 s and ~8 s in lockstep with both sides' chunked `currentTime` updates — so the button vanished as soon as drift dipped briefly under 3 s, even though the drift baseline was still 5–8 s. Add a hide-side hysteresis: once visible, the button stays visible until drift has been under a tighter 1 s threshold for ≥ 1 s. Otherwise the noisy 1–3 s drift valleys keep the button up so the user can actually click it. Constants left as locals; if testing wants different floors we can extract. * docs(changelog): add catch-up hysteresis bullet * fix(orbit): show Catch Up when host paused + serialise seek-then-pause Two follow-ons after the hysteresis change: **1. Show Catch Up even when the host is paused.** The visibility gate required `state.isPlaying === true`, but a guest who joined while the host was paused still benefits from Catch Up if their sync to the host's paused position failed (engine drop, brief network blip during initial-sync, manual local seek). Drop the `state.isPlaying` gate — the only signal that should matter is "drift between us and the host's reported state" — which `computeOrbitDriftMs` already computes correctly in the paused case (no time-extrapolation, just `guestPos - hostPos`). **2. Defer the play-state mirror after `player.seek`.** Reported symptom: "I press Catch Up, the player pauses; when I press play, it resumes from the *old* position and the waveform jumps back." Root cause: `player.seek` debounces `audio_seek` via `setTimeout(0)`, while `player.pause` / `player.resume` fire their invokes synchronously. Pause arrives at the engine first, leaving it paused at the pre-Catch-Up position with the seek queued behind. When resume happens later, the engine plays from the old spot. Push the play-state mirror behind a 200 ms `setTimeout` so the seek's invoke lands first. |
||
|
|
27c740b760 |
fix(orbit): kick a fresh playTrack when engine is stuck mid-load (#526)
* fix(orbit): kick a fresh playTrack when engine is stuck mid-load Follow-up to #525. Symptom: occasionally on join, the guest gets no audio until the next host-driven track change, at which point a fresh `playTrack` runs and audio plays normally. Root cause: when the initial `syncToHost` poll hits its 5 s deadline without the engine reporting `isPlaying === true` (slow Navidrome cold-start), the next pull tick takes the cheap "track already loaded" shortcut and calls `applyMirror`. `applyMirror` fires `seek` + `resume` on an engine that is stuck in a "loaded but never started" limbo — seek silently no-ops and `resume` can't kick a track that never began. Guest is silent until something else triggers a fresh `playTrack`. Tighten the shortcut: take the cheap path only when the engine is already in the state the host expects (or playing while host is paused, which is fine to align via pause). Otherwise fall through to a fresh `playTrack` and the existing 5 s ready-poll, which re-initialises the engine and lets audio actually start. * docs(changelog): add engine-limbo follow-up bullet * fix(orbit): re-sync when engine silently fell back to paused The optimistic `isPlaying: true` `playTrack` writes synchronously masks a `audio_play` failure: the post-playTrack poll sees the optimistic flag, fires `applyMirror`, and the outer tick records `lastAppliedRef = { ..., isPlaying: true }` as a successful sync. But if the underlying `invoke('audio_play')` rejects later (network blip / cold-start exhaustion), the catch handler flips the flag back to `false` and schedules `next()`, which short-circuits to `audio_stop` in an Orbit guest — leaving the player silent while `lastAppliedRef` still claims we're playing. None of the divergence-detection branches (track-change / play-pause-flip) match, so the guest never re-syncs. Add a recovery check before the if/else-if chain: when the captured `last` says we applied playing, the engine is currently not playing, and the host is still playing the same track, reset `lastAppliedRef.current = null`. The next iteration of the chain re-runs initial-sync (with the 500 ms fast-poll cadence) which fires a fresh `playTrack`. If `audio_play` succeeds the second time, audio finally starts; if it keeps failing, we loop with no extra harm (audio was already silent). Adds an `engine-recovery` event scope to the diagnostics buffer so future captures can see when this re-sync fired. * docs(changelog): expand engine-recovery bullet to cover both cases |
||
|
|
af1b9661f5 |
fix(orbit): three interlocking guest playback bugs (#525)
* fix(orbit): guest short-circuits queue-exhaustion fallback paths
When a guest's local queue runs out (single-track queue from `syncToHost`
empties on `audio:ended`), the player walks the standard fallback chain
in `next()`: radio top-up → infinite-queue → stop. The infinite-queue
branch builds a 6-track queue and calls `playTrack`, which trips
`orbitBulkGuard` and pops a "Add 6 tracks to the Orbit queue?" modal.
Hitting Cancel leaves playback frozen; "Add them all" injects unrelated
tracks into the host's shared queue.
In an active Orbit guest session the host owns the queue. Skip the
fallback paths entirely and just stop — the next `useOrbitGuest` pull
tick will sync to whatever the host advanced to.
Bonus side-effect: kills the deferred-promise race where a
`buildInfiniteQueueCandidates().then(...)` from a guest's track end
could resolve *after* a Catch Up replaced the queue and pop the modal
a second time against the now-current 1-track queue.
* fix(orbit): treat natural track-end as not-diverged in guest sync
When a guest's track ended naturally before the host advanced, the
divergence-detection branch read `player.isPlaying === false` and
classified it as the user manually paused — so it refused to load the
host's next track. The guest sat silent until they clicked Catch Up.
`handleAudioEnded` keeps `currentTrack` pinned to the just-ended track
and resets `currentTime` to 0, while a real manual pause leaves
`currentTime` somewhere mid-track. Use the 0-position discriminator to
classify natural-end as not-diverged so the host's new track loads.
Confirmed via the captured guest log buffer:
18:43:08.598 [track-change] host: VJkV5… → 6i6RP… BUT guest diverged
(player.isPlaying=false ≠ last.isPlaying=true)
— guest stuck for ~33s until Catch Up was pressed.
* fix(orbit): Catch Up polls until engine is ready before seeking
The 400 ms blind setTimeout in `onCatchUp` was too short for an
HTTP-streamed cold-start on high-latency links. If the audio engine
wasn't ready by then, `seek(fraction)` silently no-oped and playback
started at 0:00, making Catch Up effectively useless on exactly the
slow links where it's needed. Captured log shows a Catch Up bringing
the guest to posSec=30, then 5 s later the guest was at posSec=6
(playback restarted from the head).
Replace with the same poll-until-ready pattern `syncToHost` already
uses: check every 100 ms, fire the seek as soon as the engine reports
playing, fall back to a blind apply at the 4 s deadline.
* docs(changelog): add orbit guest playback fixes entry
* fix(orbit): debounce Catch Up button + match bar item height
Two follow-on UX fixes after PR #525's three primary bugs landed:
1. **Debounce visibility.** Drift is computed from an asymmetric signal:
guest's `currentTime` updates in coarse ~5 s chunks, while host's
position is extrapolated linearly via `(nowMs - posAt)`. Even on a
perfectly-synced session the diff swings ±5 s every tick, so the
button flickered in and out continuously. Show only after drift has
stayed over the 3 s threshold for ≥ 3 s of wall clock — measurement
noise is filtered out, real sustained drift still surfaces in time.
2. **Match neighbour height.** The button was 32 px tall against 26 px
for the other action buttons (.orbit-bar__settings) so every flicker
shifted the entire bar height. Set `height: 26 px` and tighten the
padding/font so the layout is stable regardless of visibility.
* fix(orbit): tighten queue-extension lockout + reliable initial-sync seek
Two follow-on fixes after the 4-bug umbrella:
**1. Local queue-extension paths fully off during Orbit.**
Phase check broadened from `active` to cover `starting` / `joining` /
`active` so a fetch-then-join race can't pop the bulk-add modal *after*
the join. The proactive infinite-queue topper inside `next()` (which
fires when ≤ 2 auto-tracks remain ahead) is now also gated, plus each
async `.then()` callback in the radio + infinite-queue paths re-checks
at resolution time. A `playTrack(... 6-track queue ...)` after the user
joined Orbit was the path that re-triggered the "Add 5 tracks?" modal
on a freshly-joined guest.
**2. `syncToHost` only seeks once the engine reports playing.**
The previous 2 s deadline-fallback applied the seek even when the
engine hadn't started, where the seek silently no-ops and the track
plays from 0:00. Symptom: clicking Catch Up makes the song "jump 50 %
forward" — that's the seek finally landing because the engine is now
ready, the initial-sync seek had already failed silently. New deadline
is 5 s, and on timeout we return `false` so the outer pull tick keeps
`lastAppliedRef` null and the 500 ms fast-poll retries.
* fix(orbit): double-click play button + hide preview during session
Two cucadmuh-flagged gaps:
**1. Double-click on the inline play button now reaches the orbit-add
path.** The album-track row's onDoubleClick already routes to
`addTrackToOrbit` when in Orbit, but the inline play button stopped
propagation on click — so clicking it twice just fired the "double-
click to add" hint toast and never touched the orbit queue. Add an
onDoubleClick on the button itself that delegates to the parent's
`onDoubleClickSong`.
**2. Track preview is suppressed during an Orbit session.** Preview
shares the Rust audio engine with the shared playback, so starting
one as a guest yanks the host's track out from under everyone. A new
`[data-orbit-active]` attribute on `<html>` (set whenever role is
host/guest and phase is starting/joining/active) hides every
preview button via a single CSS rule, and `previewStore.startPreview`
short-circuits as a defensive guard for keyboard shortcuts and any
programmatic callers.
|
||
|
|
a702a5dd5b |
feat(orbit): in-app diagnostics popover with copyable event log (#524)
* feat(orbit): in-app diagnostics popover with copyable event log Multiple users on Discord report Orbit guests stopping after the first song with no errors anywhere — Settings → Debug → Export Logs is too buried for non-technical reporters, and the relevant code branches have no logging at all (silent fail). This adds a one-click "Copy log" path right inside the Orbit session bar. The new Activity-icon button next to Help opens a popover with: - Live mini-display: role, host vs. guest track id + position, drift, age of the host's last state write — all updating once a second. - Scrolling event log textarea fed by an in-memory ring (200 events). - Copy + Clear buttons. Copy formats `[ISO] [scope] body` lines and drops them on the clipboard — paste straight into a Discord report. Instrumentation lands at the previously-silent decision points: - Guest pull tick: full snapshot of host vs. guest state on every read. - Each branch of the divergence detection in `useOrbitGuest.ts` logs which path it took and why (initial / track-change-followed / track-change-diverged / play-pause-flip), making the "stuck after first song" symptom diagnosable from the buffer alone. - Host pushes log track id, isPlaying, queue length, guest count. Events are also bridged to the existing `frontend_debug_log` Tauri command when Settings → Logging is on Debug, so power users still get the same data in `psysonic-logs-*.log` for offline triage. i18n: full `orbit.diag.*` namespace in all eight locales. EN + DE are native; ES / FR / NB / NL / RU / ZH are first-pass and may want a polish from native speakers later. * docs(changelog): add orbit diagnostics popover entry |
||
|
|
acadc1be34 | Merge remote-tracking branch 'origin/main' into refactor/backend-pilot | ||
|
|
874b0c67ae |
fix(context-menu): drop inline z-index that hid menus under floating player (#522)
* fix(context-menu): drop inline z-index that hid menus under floating player
The main context menu wrapper carried an inline `zIndex: 999` that
overrode the `.context-menu { z-index: 10000 }` stylesheet rule. The
floating player bar sits at z-index 1000, so when a menu opened near
the bottom of the screen — long enough or anchored low — the player
bar covered it.
Removing the inline override lets the stylesheet rule (10000) win, so
the menu always paints on top of the floating bar. Submenus inherit
the same stacking context so they follow the parent menu.
Closes #521.
* docs(changelog): add context-menu floating-player z-index fix entry
|
||
|
|
0d29a09455 | Merge remote-tracking branch 'origin/main' into refactor/backend-pilot | ||
|
|
fec513b629 |
fix(home): swap Because-you-listened rail to AlbumRow under 696 px (#520)
* fix(home): swap Because-you-listened rail to AlbumRow under 696 px The hero-style BecauseCards are tuned for full-rail widths (3 cards at 1052 px+, 2 cards at 696-1051 px). Below that the cards stretched full-width with a fixed 160 px cover stuck on the left and centred text floating in a wide empty area — looked like three over-sized banners stacked vertically instead of a compact recommendation rail. A `ResizeObserver` on the rail wrapper now watches the container width and below 696 px renders a standard `AlbumRow` (which is already perf-tuned for narrow rails: artwork budget, viewport windowing, scroll paging). Wide layouts keep the unchanged hero card layout, so the mainstage view at full width is identical to before. * docs(changelog): add Because-you-listened narrow-layout fix entry |
||
|
|
b8fb1ca8fc | Merge remote-tracking branch 'origin/main' into refactor/backend-pilot | ||
|
|
268086ac74 |
docs(issue-template): label Nix install option as "flakes" (#519)
The bug report form's install-source dropdown listed "Cachix / Nix", but Cachix is just a binary cache — users actually install the app via the Nix flake. Relabel to "flakes" so the dropdown reflects the mechanism people pick. |
||
|
|
37c19237f3 |
fix(refactor): restore Windows build on backend-pilot
- Re-export `build_mini_player_window` from `lib_commands::ui` so the Windows-only pre-create call in `lib.rs` resolves through the `use lib_commands::*` glob (was lost during the M5 split). - Gate the `is_tiling_wm` import in `ui::mini` and its re-export in `lib_commands::sync` behind `cfg(target_os = "linux")`, the only platform that actually consults it. |