Files
ersatztv/web/src/mediaSources/paths.test.ts
T
timothyandClaude Opus 4.8 6ce448d265 feat(spa): media-source SPA foundation — shared client, helpers, App-owned popstate wrapper (#202 slice S5)
Owns the shared single-files so the S6a (Local) / S6b (Remote) editor slices touch
disjoint files. Editor screens are stubbed (MediaSourceEditorPlaceholder) for S6.

- web/src/api/mediaSources.ts (+test): client module over the new media-source write
  endpoints (local CRUD + move/path-exists; Plex pin-flow/sign-out; shared remote
  state/connection/libraries/path-replacements/refresh; family = only URL variance),
  DTOs re-exported from generated v1, messageFromMediaSourcesError; barrel export.
- web/src/mediaSources/{familyMeta,paths,pinFlowPoll}.ts (+tests): family labels/routes/
  remote-path column naming (owns RemoteFamily); client-side NormalizePath mirror for
  in-draft dup detection; pure §C1 pin-flow poll state machine (waiting/finalizing/
  success/timeout/budget-exhausted), timer-free and fully unit-tested.
- routing.ts parseLibrariesSubRoute + LibrariesSubRoute union split Local vs Remote.
- App.tsx: libraries route allowSubPaths; LibrariesRouteScreen wrapper dispatching a
  flat switch to placeholders; App-owned popstate (finding 4) — App is the single
  popstate owner, consults canLeaveCurrentScreen() and only on approval updates
  librariesSubPath passed DOWN to the wrapper (wrapper never self-listens); state write
  scoped to the libraries route so Playouts/Media pops stay byte-identical (nit 3).
- LibrariesScreen hub wiring: Add-Source menu (Local/Plex/Jellyfin/Emby), remote source
  gear -> family screen, local library row gear -> edit route; removed the disabled
  Scan-All button + the deferred-sources card (§C7/§D.1).
- Tests: App-owned-popstate dirty-guard case (confirm false keeps URL+sub-screen; true
  navigates); mediaSources client URL/verb mapping; pinFlowPoll transitions; familyMeta/
  paths units; hub-wiring navigation.
- docs/spa-conventions.md §8 (resolved sub-path+dirty-guard caveat -> App-owned popstate)
  + §2 exemplar list (guarded-route exception).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:11:16 +02:00

44 lines
1.5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { isDuplicatePath, normalizePath, pathsEqual } from './paths';
describe('normalizePath', () => {
it('trims trailing slashes and backslashes', () => {
expect(normalizePath('/media/movies/')).toBe('/MEDIA/MOVIES');
expect(normalizePath('/media/movies///')).toBe('/MEDIA/MOVIES');
expect(normalizePath('C:\\Media\\Movies\\')).toBe('C:\\MEDIA\\MOVIES');
});
it('upper-cases for case-insensitive comparison', () => {
expect(normalizePath('/Media/Movies')).toBe('/MEDIA/MOVIES');
});
it('leaves a bare separator-free path (minus case) alone', () => {
expect(normalizePath('movies')).toBe('MOVIES');
});
});
describe('pathsEqual', () => {
it('treats trailing-separator and case variants as equal', () => {
expect(pathsEqual('/media/movies', '/media/movies/')).toBe(true);
expect(pathsEqual('/Media/Movies', '/media/movies')).toBe(true);
expect(pathsEqual('/media/movies', '/media/shows')).toBe(false);
});
});
describe('isDuplicatePath', () => {
const existing = ['/media/movies', '/media/shows/'];
it('detects a duplicate ignoring case + trailing separators', () => {
expect(isDuplicatePath(existing, '/media/movies/')).toBe(true);
expect(isDuplicatePath(existing, '/MEDIA/SHOWS')).toBe(true);
});
it('accepts a genuinely new path', () => {
expect(isDuplicatePath(existing, '/media/music')).toBe(false);
});
it('handles an empty existing list', () => {
expect(isDuplicatePath([], '/media/movies')).toBe(false);
});
});