Files
readest/apps/readest-app/src/__tests__/styles/zIndexScale.test.ts
T
Huang Xin 99b9adfe85 refactor(sync): provider-agnostic file-sync engine with incremental WebDAV sync (#4784)
* refactor(sync): extract provider-agnostic layout paths

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): extract wire envelope module

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): extract pure merge module with law tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): add FileSyncProvider and LocalStore interfaces

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): FileSyncEngine orchestration over a provider

Port WebDAVSync's per-book + library-wide sync onto FileSyncProvider +
LocalStore. Behavior preserved; the #4756 metadata-reconciliation test is
retargeted to drive the engine through a fake provider + store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): move WebDAV client + connect settings under providers/webdav

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): WebDAVProvider implementing FileSyncProvider

Wraps the WebDAV transport client, maps WebDAVRequestError to the neutral
FileSyncError, and owns Tauri streaming upload/download. Adds a
provider-conformance suite future backends can run against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): shared appService-backed LocalStore bridge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reader): drive WebDAV sync through FileSyncEngine

Construct a WebDAVProvider + shared LocalStore + engine once per hook; the
inline buffered/streaming book-file loader collapses into the provider +
store, so the hook no longer imports tauriUpload or the file path helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(settings): drive WebDAV library sync + browse through the provider

WebDAVForm now builds a WebDAVProvider + shared LocalStore + engine and calls
engine.syncLibrary; the ~170-line inline callback block (buffered/streaming
loaders, URL+auth construction) is gone. WebDAVBrowsePane builds a provider for
the engine-level deleteRemoteBookDir cleanup helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): remove WebDAV-specific sync module, WebDAV is now a provider

Delete src/services/webdav (WebDAVSync/WebDAVPaths + the transitional client
and connect-settings shims). The superseded webdav-metadata-sync test is
replaced by engine-metadata-sync; webdav-delete now drives deleteRemoteBookDir
through a WebDAVProvider and asserts FileSyncError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): hydrate library before WebDAV Sync now to prevent clobber

Sync now while the library store was unloaded (app launched into reader/
settings without mounting the Library view) merged the engine's
addBookToLibrary / updateBookMetadata against an empty in-memory library,
persisting a downloaded book or a metadata update as the entire library and
wiping what was on disk. Pre-existing bug surfaced during the file-sync
review. Hydrate the store in handleSyncNow and harden the store bridge with a
load-if-unloaded guard (mirrors useLibraryStore.updateBooks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): make listDirectory honor the FileSyncError contract

listDirectory threw a plain Error (and let raw fetch failures escape), so
WebDAVProvider flattened every list() failure to FileSyncError(UNKNOWN). Throw
the same WebDAVRequestError taxonomy as the file-level helpers (AUTH_FAILED /
NOT_FOUND / NETWORK) so the provider maps them correctly. Add list() cases to
the provider-conformance suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sync): cover streaming upload, discovery/download, and receive paths

The metadata-sync gate only exercised the buffered metadata + config-merge
paths. Add engine tests for streaming uploadStream (+ HEAD short-circuit +
one-shot retry), remote-only discovery -> streaming download -> addBook, and
the receive strategy (pull-only, no config or index writes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): incremental WebDAV Sync now + bounded concurrency

Sync now was a full walk of every book each run (675 round-trips even when
nothing changed). Default to incremental: diff the local library against the
shared library.json index per hash and only process books whose local copy is
newer (or absent). book.updatedAt bumps on every progress/notes/metadata save
(bookDataStore.saveConfig), so the index is a reliable per-book change marker.
Remote-newer books pull their config in the reconcile pass so peer progress
still propagates. A new 'Full Sync' toggle (default off) re-checks everything.

Also run the reconcile / download / push phases over a bounded worker pool
(default concurrency 4) instead of one book at a time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): simplify Sync now toast to a single book count

The completion toast built a multi-line success bullet list (downloaded /
pulled / pushed / uploaded). Replace it with the same single-line info toast
the native cloud sync uses: '{{count}} book(s) synced'. Add a booksSynced
counter to the engine result (a Set of distinct hashes touched in any
direction, since the per-action counters overlap under Full Sync). Failures
still surface as a warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): raise toasts above modals so they aren't hidden by open dialogs

Toasts rendered at z-50, below the Settings dialog (z-110) and ModalPortal
(z-120), so a toast dispatched from an open dialog (e.g. WebDAV 'Sync now')
was buried. The documented overlay scale already places toast at 130; the
component just hadn't followed it. Move the toast to z-[130] and extend the
zIndexScale invariant test to guard TOAST > MODAL/SETTINGS and APP_LOCK > TOAST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:57:52 +02:00

85 lines
3.3 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
/**
* Coordinated overlay z-index scale.
*
* Body-portaled / full-screen overlays share a single global stacking order.
* They must all clear the desktop rounded-window page frame (`.window-border`,
* z-index 99 in globals.css) and then layer in this order (low -> high):
*
* 100 RSVP immersive reading overlay
* 101 RSVP immersive controls (start dialog / hint chip)
* 110 Settings app dialog (raised above RSVP for dictionary management)
* 120 modal / command-palette layer (ModalPortal, CommandPalette)
* 130 toast / alert
* 200 security lock screen (AppLockScreen)
*
* This test reads the values straight from source so a future change that
* re-orders the layers fails loudly. It encodes the regression that buried the
* "Add OPDS Catalog" dialog behind Settings on mobile: Settings had been raised
* to z-[10050] (PR #3235) above the ModalPortal at z-[100], so any modal opened
* from inside Settings rendered behind it.
*/
const PAGE_FRAME = 99; // .window-border in src/styles/globals.css
const read = (rel: string) => fs.readFileSync(path.join(process.cwd(), rel), 'utf8');
const firstZ = (src: string, re: RegExp): number => {
const m = src.match(re);
expect(m, `expected to find z-index via ${re} in source`).not.toBeNull();
return Number(m![1]);
};
const MODAL = firstZ(read('src/components/ModalPortal.tsx'), /z-\[(\d+)\]/);
const SETTINGS = firstZ(read('src/components/settings/SettingsDialog.tsx'), /!z-\[(\d+)\]/);
const RSVP_OVERLAY = firstZ(
read('src/app/reader/components/rsvp/RSVPOverlay.tsx'),
/fixed inset-0 z-\[(\d+)\] flex select-none/,
);
const RSVP_CONTROLS = firstZ(
read('src/app/reader/components/rsvp/RSVPStartDialog.tsx'),
/z-\[(\d+)\]/,
);
const TOAST = firstZ(read('src/components/Toast.tsx'), /toast z-\[(\d+)\]/);
const APP_LOCK = firstZ(read('src/components/AppLockScreen.tsx'), /z-\[(\d+)\]/);
describe('overlay z-index scale', () => {
it('renders a modal (e.g. Add OPDS Catalog) above the Settings dialog', () => {
// Regression: ModalPortal opened from inside Settings was buried (#add-catalog).
expect(MODAL).toBeGreaterThan(SETTINGS);
});
it('raises the Settings dialog above the RSVP immersive overlay', () => {
expect(SETTINGS).toBeGreaterThan(RSVP_OVERLAY);
});
it('keeps RSVP controls above the RSVP overlay', () => {
expect(RSVP_CONTROLS).toBeGreaterThan(RSVP_OVERLAY);
});
it('keeps the RSVP overlay above the desktop window-border page frame', () => {
expect(RSVP_OVERLAY).toBeGreaterThan(PAGE_FRAME);
});
it('raises toasts above every modal so they show over open dialogs', () => {
// Regression: a sync-complete toast dispatched from the open Settings
// dialog was buried because the toast sat at z-50, below Settings (110)
// and ModalPortal (120).
expect(TOAST).toBeGreaterThan(MODAL);
expect(TOAST).toBeGreaterThan(SETTINGS);
});
it('keeps the security lock screen on top of every modal and toast', () => {
expect(APP_LOCK).toBeGreaterThan(MODAL);
expect(APP_LOCK).toBeGreaterThan(TOAST);
});
it('uses a compact scale with no four-digit z-index', () => {
for (const value of [RSVP_OVERLAY, RSVP_CONTROLS, SETTINGS, MODAL, TOAST, APP_LOCK]) {
expect(value).toBeLessThan(1000);
}
});
});