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>
44 lines
1.5 KiB
TypeScript
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);
|
|
});
|
|
});
|