mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 22:11:03 +08:00
首页(书架)此前是纯命令式拼出来的,而整个客户端没有任何路由:URL 从头到尾不变, 返回键、刷新、分享、收藏一概不成立。 ## 路由 `ui/router.ts` 把 fragment 解析成 route,shell 渲染 router 报上来的那个: #/shelf 书架 #/book/<id> 阅读器 #/library/<路径> 书库里的目录 用 fragment 而不是路径,是因为同一份产物要被两个宿主加载,而两个都服务不了任意路径: Android 从 appassets.androidplatform.net 加载(那里没有服务端),H5 由 reader 服务端 托管(没有客户端路由回落)。路径式路由会在 vite dev 里正常、在两个生产宿主里 404。 返回走应用自己的屏幕轨迹,不走 history.back():应用里的「返回」是「上一个屏幕」, 而浏览器历史里可能有登录表单或别的站点。轨迹为空时(新标签页里粘贴的链接)替换成父级, 而不是压入——否则那个链接会永远压在书架后面。 走进书库子目录用 replace:书库是一个带面包屑的屏幕,走目录是同一个屏幕改自己的参数。 压栈的话,读者走过四个文件夹再按返回会逐层退出来。 带着链接进来而尚未登录时,route 存在 App.pending 里等在登录页后面,登录后直接落到 目标屏幕,而不是落到一个他从未见过的书架。 Android 外壳的返回手势不需要改动:它看的是 WebView 的 canGoBack(),而路由现在真的 往 WebView 历史里写——之前那段注释描述的行为,从这次起才成立。 ## 首页 - 先看到书,再看到控件:标题和封面在最上面,搜索在后,排序与数量贴在它们作用的那一节上。 旧版把搜索框、数量、四个排序 chip 依次排在封面之前,三行控件才轮到第一本书。 - 排序栏是唯一吸顶的东西,它承载的是读者会滚回去改的两件事。 - 空状态给一条出路(打开书库管理 / 刷新 / 清除搜索),而不是一句「书库还是空的」。 - 冷启动画骨架屏,且按当前密度画——密度不对的骨架屏会让读者看到布局动两次。 - 封面缺图时把书名画成封面,而不是一个灰色方块。 ## 顺手修掉的 5 个真缺陷 1. mountUI 在 unmount 之后仍会渲染。屏幕是异步加载的,离开后那条迟到的响应会把 整棵树画进一个刚被清空的容器;书架迟到的响应会把自己一屏封面画进书库管理器。 2. --safe-left/-right/-top/-bottom 从未在生效的样式表里定义过,只有一份没人 import 的 死样式表定义过。未定义的自定义属性会让整个 calc() 失效并静默丢弃声明。 3. 封面在 flex 列里没有显式宽度,aspect-ratio 盒子因此收缩到内容宽度:只有书名的封面 塌成 42px、有图的占满卡片,整排看起来参差不齐。 4. 阅读列在宽屏上被拉伸到 9 列 45px 的封面。现在内容列封顶 68rem,封面随断点变大。 5. 删掉 web/src/ui/styles.css——414 行从未被引用的旧样式,以及 .book-highlight 等 已经没有任何代码产出的规则。 ## 验证 - web 全量 284 项测试通过(新增 router 19 项 + 链接契约 4 项)。 - typecheck / build / check-shell.sh / build-web-assets.sh 均通过。 - 真浏览器实测:深链直开、刷新不丢、返回离开屏幕、登录后落回目标、书库走目录、 空搜索、粘性工具栏、深浅色、390px 与 1440px 两档宽度。 - 体积:251KB → 252KB(gzip 79.1KB)。
529 lines
23 KiB
TypeScript
529 lines
23 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { ManagerScreen } from '../src/ui/manager-screen.tsx';
|
||
import { ReaderApi, type SessionStore } from '../src/api/client.ts';
|
||
import { ApiError } from '../src/api/errors.ts';
|
||
import { FakeTransport, makePlatform } from './helpers/env.ts';
|
||
import type { BrowseListing } from '../src/api/types.ts';
|
||
|
||
/**
|
||
* The file manager.
|
||
*
|
||
* jsdom has no layout engine, so nothing here is about how the screen *looks*.
|
||
* What it covers is the part a regression would be silent about: which entries
|
||
* are reachable in which mode, and — the one that actually costs data — that a
|
||
* destructive call is never sent without an explicit selection and a confirmation.
|
||
*/
|
||
|
||
function entry(partial: Partial<BrowseListing['entries'][number]> & { name: string }): BrowseListing['entries'][number] {
|
||
return {
|
||
path: partial.name,
|
||
type: 'file',
|
||
size: 0,
|
||
mtime: Date.now(),
|
||
mode: 0o644,
|
||
hidden: false,
|
||
hiddenByRule: false,
|
||
scanned: false,
|
||
ext: '',
|
||
indexed: false,
|
||
...partial,
|
||
};
|
||
}
|
||
|
||
function listing(overrides: Partial<BrowseListing> = {}): BrowseListing {
|
||
return {
|
||
path: '',
|
||
crumbs: [{ name: '书库', path: '' }],
|
||
parent: null,
|
||
entries: [],
|
||
total: 0,
|
||
dirs: 0,
|
||
files: 0,
|
||
size: 0,
|
||
writable: true,
|
||
name: '',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function makeScreen(transport: FakeTransport, calls: string[] = []): { screen: ManagerScreen; calls: string[] } {
|
||
const platform = makePlatform(transport);
|
||
const sessions: SessionStore = {
|
||
load: async () => null,
|
||
save: async () => undefined,
|
||
clear: async () => undefined,
|
||
};
|
||
const api = new ReaderApi(platform, sessions);
|
||
api.setBaseUrl('http://nas:8080');
|
||
const screen = new ManagerScreen({
|
||
api,
|
||
onClose: () => calls.push('close'),
|
||
onSignedOut: () => calls.push('signed-out'),
|
||
onNavigate: (path) => calls.push(`navigate:${path}`),
|
||
});
|
||
document.body.append(screen.element);
|
||
return { screen, calls };
|
||
}
|
||
|
||
beforeEach(() => {
|
||
document.body.replaceChildren();
|
||
});
|
||
|
||
describe('library manager', () => {
|
||
it('lists directories and files, and shows the entries the scanner skips', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.json(
|
||
listing({
|
||
entries: [
|
||
entry({ name: '科幻', type: 'dir', path: '科幻' }),
|
||
entry({ name: '.trash', type: 'dir', path: '.trash', hidden: true, hiddenByRule: true }),
|
||
entry({ name: '三体.epub', path: '三体.epub', scanned: true, ext: 'epub' }),
|
||
],
|
||
dirs: 2,
|
||
files: 1,
|
||
total: 3,
|
||
}),
|
||
);
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
const rows = screen.element.querySelectorAll('.manager-row');
|
||
expect(rows).toHaveLength(3);
|
||
// The rule-skipped folder is the answer to "my book is on disk but not on
|
||
// the shelf", so it must be visible rather than filtered.
|
||
expect(screen.element.textContent).toContain('.trash');
|
||
expect(screen.element.querySelector('.manager-badge.warn')?.textContent).toBe('扫描忽略');
|
||
});
|
||
|
||
it('reports the directory counts after a successful listing', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.json(
|
||
listing({ dirs: 2, files: 3, size: 4096, total: 5, entries: [entry({ name: 'a.epub' })] }),
|
||
);
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
// The counts are the answer to "is my file in here at all", so a successful
|
||
// listing must leave them on screen rather than blank.
|
||
expect(screen.element.querySelector('.manager-status')?.textContent).toContain('3 个文件');
|
||
});
|
||
|
||
it('hides the write controls when the mount is read-only', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.json(listing({ writable: false, entries: [entry({ name: '三体.epub', scanned: true })] }));
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
const matches = transport.requests.filter((request) => request.url.startsWith('/api/v1/library/browse'));
|
||
expect(matches).toHaveLength(1);
|
||
expect(matches[0]!.method).toBe('GET');
|
||
// The mkdir button is the only write affordance outside selection mode.
|
||
const buttons = [...screen.element.querySelectorAll<HTMLButtonElement>('.icon-button')];
|
||
const mkdir = buttons.find((button) => button.getAttribute('aria-label') === '新建文件夹');
|
||
expect(mkdir?.hidden).toBe(true);
|
||
});
|
||
|
||
it('reports a folder tap as navigation instead of doing it itself', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.respondWith((request) => {
|
||
const path = new URL(`http://x${request.url}`).searchParams.get('path') ?? '';
|
||
return path === '科幻'
|
||
? {
|
||
status: 200,
|
||
headers: {},
|
||
json: listing({
|
||
path: '科幻',
|
||
name: '科幻',
|
||
parent: '',
|
||
crumbs: [
|
||
{ name: '书库', path: '' },
|
||
{ name: '科幻', path: '科幻' },
|
||
],
|
||
entries: [entry({ name: '三体.epub', path: '科幻/三体.epub', scanned: true })],
|
||
files: 1,
|
||
}),
|
||
}
|
||
: {
|
||
status: 200,
|
||
headers: {},
|
||
json: listing({ entries: [entry({ name: '科幻', type: 'dir', path: '科幻' })] }),
|
||
};
|
||
});
|
||
const calls: string[] = [];
|
||
const { screen } = makeScreen(transport, calls);
|
||
await screen.open();
|
||
(screen.element.querySelector('.manager-row') as HTMLElement).click();
|
||
// The screen does not navigate itself any more: a folder is a route
|
||
// (`#/library/科幻`), so the tap reports the intent and the router writes the
|
||
// URL. That round trip is what makes a folder link shareable and Back leave
|
||
// the manager rather than walk out of it one folder at a time.
|
||
expect(calls).toEqual(['navigate:科幻']);
|
||
expect(transport.requests.filter((request) => request.method !== 'GET')).toHaveLength(0);
|
||
});
|
||
|
||
it('shows the folder the route asked for, without navigating itself', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.respondWith(() => ({
|
||
status: 200,
|
||
headers: {},
|
||
json: listing({
|
||
path: '科幻',
|
||
name: '科幻',
|
||
parent: '',
|
||
crumbs: [
|
||
{ name: '书库', path: '' },
|
||
{ name: '科幻', path: '科幻' },
|
||
],
|
||
entries: [entry({ name: '三体.epub', path: '科幻/三体.epub', scanned: true })],
|
||
files: 1,
|
||
}),
|
||
}));
|
||
const { screen } = makeScreen(transport);
|
||
// What a deep link, a Back and a forward walk all look like from here.
|
||
await screen.open('科幻');
|
||
expect(screen.element.textContent).toContain('三体.epub');
|
||
expect(transport.requests.at(-1)?.url).toBe('/api/v1/library/browse?path=%E7%A7%91%E5%B9%BB');
|
||
});
|
||
|
||
it('does not send a delete for a selection that was never made', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.json(listing({ entries: [entry({ name: '三体.epub', scanned: true })] }));
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
// Tapping a row is navigation, not selection — and on a file there is nowhere
|
||
// to navigate, so a tap must be a no-op rather than a deletion.
|
||
(screen.element.querySelector('.manager-row') as HTMLElement).click();
|
||
expect(transport.requests.filter((request) => request.method !== 'GET')).toHaveLength(0);
|
||
expect(screen.element.querySelector('.manager-actions')?.hasAttribute('hidden')).toBe(true);
|
||
});
|
||
|
||
it('sends the selected paths, and only those, on a confirmed delete', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.respondWith((request) =>
|
||
request.method === 'GET'
|
||
? {
|
||
status: 200,
|
||
headers: {},
|
||
json: listing({
|
||
entries: [entry({ name: 'a.epub', path: 'a.epub' }), entry({ name: 'b.epub', path: 'b.epub' })],
|
||
files: 2,
|
||
}),
|
||
}
|
||
: { status: 200, headers: {}, json: { removed: 1 } },
|
||
);
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
|
||
// Selection through the row's own action sheet, which is the keyboard-free
|
||
// path a test can drive without a touch device.
|
||
(screen.element.querySelector('.manager-more') as HTMLElement).click();
|
||
// The action sheet is created inside an awaited promise, so it appears a
|
||
// microtask after the click rather than synchronously.
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-list')).not.toBeNull());
|
||
const choose = [...screen.element.querySelectorAll('.dialog-list .button')].find(
|
||
(button) => button.textContent === '选择',
|
||
) as HTMLElement;
|
||
choose.click();
|
||
// The sheet closes through a promise too, so the action bar is one tick away.
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.manager-selection')?.hasAttribute('hidden')).toBe(false));
|
||
const deleteButton = [...screen.element.querySelectorAll('.manager-actions .button')].find(
|
||
(button) => button.textContent === '删除',
|
||
) as HTMLElement;
|
||
deleteButton.click();
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-actions')).not.toBeNull());
|
||
const confirmButton = [...screen.element.querySelectorAll('.dialog-actions .button')].find(
|
||
(button) => button.textContent === '删除',
|
||
) as HTMLElement;
|
||
confirmButton.click();
|
||
|
||
await vi.waitFor(() => {
|
||
expect(transport.requests.some((request) => request.url.endsWith('/browse/delete'))).toBe(true);
|
||
});
|
||
const del = transport.requests.find((request) => request.url.endsWith('/browse/delete'));
|
||
expect(JSON.parse(String(del?.body))).toEqual({ paths: ['a.epub'] });
|
||
});
|
||
|
||
it('reports a refused write instead of pretending it worked', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.respondWith((request) =>
|
||
request.method === 'GET'
|
||
? { status: 200, headers: {}, json: listing({ entries: [entry({ name: 'a.epub', path: 'a.epub' })] }) }
|
||
: {
|
||
status: 403,
|
||
headers: {},
|
||
json: { error: { code: 'READ_ONLY_MOUNT', message: 'the library mount is read-only' } },
|
||
},
|
||
);
|
||
const { screen, calls } = makeScreen(transport);
|
||
await screen.open();
|
||
(screen.element.querySelector('.manager-more') as HTMLElement).click();
|
||
const rename = [...screen.element.querySelectorAll('.dialog-list .button')].find(
|
||
(button) => button.textContent === '重命名',
|
||
) as HTMLElement;
|
||
rename.click();
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog input')).not.toBeNull());
|
||
const input = screen.element.querySelector<HTMLInputElement>('.dialog input')!;
|
||
input.value = 'b.epub';
|
||
const form = screen.element.querySelector<HTMLFormElement>('form.dialog')!;
|
||
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||
await vi.waitFor(() => {
|
||
expect(transport.requests.some((request) => request.url.endsWith('/browse/rename'))).toBe(true);
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
|
||
await vi.waitFor(() => {
|
||
expect(screen.element.querySelector('.manager-status')?.textContent).toContain('read-only');
|
||
});
|
||
// A refused operation must not sign the reader out: the 403 here is about the
|
||
// mount, not about their session.
|
||
expect(calls).not.toContain('signed-out');
|
||
});
|
||
});
|
||
|
||
describe('uploading from the manager', () => {
|
||
/** A `File` the jsdom environment will accept. */
|
||
const file = (name: string, body = 'bytes'): File => new File([body], name, { type: 'application/epub+zip' });
|
||
|
||
const pick = async (screen: ManagerScreen, files: File[]): Promise<void> => {
|
||
const input = screen.element.querySelector<HTMLInputElement>('.manager-upload-input')!;
|
||
Object.defineProperty(input, 'files', { value: files, configurable: true });
|
||
input.dispatchEvent(new Event('change'));
|
||
};
|
||
|
||
it('asks what to do about a clash before sending any bytes', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.json(listing({ entries: [entry({ name: '三体.epub', scanned: true })] }));
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
void pick(screen, [file('三体.epub')]);
|
||
|
||
// The policy dialog appears first, so a user who has waited for a 400MB
|
||
// upload is never told afterwards that it was a name clash.
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-list')).not.toBeNull());
|
||
const uploads = transport.requests.filter((request) => request.url.includes('upload'));
|
||
expect(uploads).toHaveLength(0);
|
||
});
|
||
|
||
it('sends the file, the destination and the policy, and reports what landed', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.respondWith((request) =>
|
||
request.url.includes('upload')
|
||
? {
|
||
status: 200,
|
||
headers: {},
|
||
json: {
|
||
uploaded: [{ path: '科幻/三体.epub', originalName: '三体.epub', name: '三体.epub', size: 5, kind: 'file', bookId: 'b1' }],
|
||
skipped: [{ name: '坏.zip', reason: '无法解压' }],
|
||
scan: { added: 1, updated: 0, removed: 0, failed: 0, startedAt: 1, finishedAt: 2 },
|
||
},
|
||
}
|
||
: {
|
||
status: 200,
|
||
headers: {},
|
||
json:
|
||
new URL(`http://x${request.url}`).searchParams.get('path') === '科幻'
|
||
? listing({
|
||
path: '科幻',
|
||
name: '科幻',
|
||
parent: '',
|
||
crumbs: [
|
||
{ name: '书库', path: '' },
|
||
{ name: '科幻', path: '科幻' },
|
||
],
|
||
entries: [],
|
||
})
|
||
: listing({ entries: [entry({ name: '科幻', type: 'dir', path: '科幻' })] }),
|
||
},
|
||
);
|
||
const { screen } = makeScreen(transport);
|
||
// Open the folder first: the upload goes into the directory being viewed,
|
||
// which is the whole contract of the feature. It is the *route* that opens it,
|
||
// which is also why the destination survives a reload.
|
||
await screen.open('科幻');
|
||
expect(screen.element.querySelector('.manager-crumb[aria-current="true"]')?.textContent).toBe('科幻');
|
||
|
||
void pick(screen, [file('三体.epub')]);
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-list')).not.toBeNull());
|
||
const rename = [...screen.element.querySelectorAll('.dialog-list .button')].find(
|
||
(button) => button.textContent === '两份都留(加 (2))',
|
||
) as HTMLElement;
|
||
rename.click();
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-list')).toBeNull());
|
||
|
||
await vi.waitFor(() => {
|
||
expect(transport.requests.some((request) => request.url.includes('upload'))).toBe(true);
|
||
});
|
||
const request = transport.requests.find((r) => r.url.includes('upload'))!;
|
||
const form = request.body as FormData;
|
||
expect(form.get('path')).toBe('科幻');
|
||
expect(form.get('onConflict')).toBe('rename');
|
||
expect((form.get('file') as File).name).toBe('三体.epub');
|
||
|
||
// The status line names the file that was *skipped*: a count alone leaves the
|
||
// user with nothing to act on.
|
||
await vi.waitFor(() => {
|
||
expect(screen.element.querySelector('.manager-status')?.textContent).toContain('坏.zip');
|
||
});
|
||
});
|
||
|
||
it('does not offer an upload at all on a read-only mount', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.json(listing({ writable: false, entries: [] }));
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
const buttons = [...screen.element.querySelectorAll<HTMLButtonElement>('.icon-button')];
|
||
expect(buttons.find((button) => button.getAttribute('aria-label') === '上传书籍')?.hidden).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('batch management', () => {
|
||
const twoBooks = (): BrowseListing =>
|
||
listing({
|
||
entries: [
|
||
entry({ name: '卷一.epub', path: '卷一.epub', scanned: true, indexed: true }),
|
||
entry({ name: '卷二.epub', path: '卷二.epub', scanned: true, indexed: true }),
|
||
],
|
||
files: 2,
|
||
});
|
||
|
||
/** Selects every row through the action sheet, as a keyboard-less test can. */
|
||
const selectAll = async (screen: ManagerScreen): Promise<void> => {
|
||
for (const more of [...screen.element.querySelectorAll<HTMLElement>('.manager-more')]) {
|
||
more.click();
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-list')).not.toBeNull());
|
||
const choose = [...screen.element.querySelectorAll('.dialog-list .button')].find(
|
||
(button) => button.textContent === '选择',
|
||
) as HTMLElement;
|
||
choose.click();
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-list')).toBeNull());
|
||
}
|
||
};
|
||
|
||
it('sends one metadata patch for the whole selection', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.respondWith((request) =>
|
||
request.url.includes('/browse/metadata')
|
||
? { status: 200, headers: {}, json: { applied: 2, books: ['a', 'b'], failed: [] } }
|
||
: { status: 200, headers: {}, json: twoBooks() },
|
||
);
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
await selectAll(screen);
|
||
|
||
const metadata = [...screen.element.querySelectorAll('.manager-actions .button')].find(
|
||
(button) => button.textContent === '改资料',
|
||
) as HTMLElement;
|
||
metadata.click();
|
||
await vi.waitFor(() => expect(screen.element.querySelector('[placeholder="留空则不改"]')).not.toBeNull());
|
||
const author = screen.element.querySelector<HTMLInputElement>('.dialog input')!;
|
||
author.value = '某某';
|
||
screen.element.querySelector<HTMLFormElement>('form.dialog')!.dispatchEvent(
|
||
new Event('submit', { bubbles: true, cancelable: true }),
|
||
);
|
||
|
||
await vi.waitFor(() => {
|
||
expect(transport.requests.some((request) => request.url.endsWith('/browse/metadata'))).toBe(true);
|
||
});
|
||
const request = transport.requests.find((r) => r.url.endsWith('/browse/metadata'))!;
|
||
// Bottom to top, because the *last* row was appended second and the list is
|
||
// built in document order — what matters is that both were sent.
|
||
expect(JSON.parse(String(request.body))).toEqual({
|
||
paths: ['卷一.epub', '卷二.epub'],
|
||
fields: { author: '某某' },
|
||
});
|
||
// The server's count, not a generic "完成".
|
||
await vi.waitFor(() => {
|
||
expect(screen.element.querySelector('.manager-status')?.textContent).toContain('已更新 2 本');
|
||
});
|
||
});
|
||
|
||
it('takes books off the shelf only after saying the files are untouched', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.respondWith((request) =>
|
||
request.url.includes('/browse/shelf')
|
||
? { status: 200, headers: {}, json: { applied: 2, books: ['a', 'b'], failed: [] } }
|
||
: { status: 200, headers: {}, json: twoBooks() },
|
||
);
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
await selectAll(screen);
|
||
const shelve = [...screen.element.querySelectorAll('.manager-actions .button')].find(
|
||
(button) => button.textContent === '下架',
|
||
) as HTMLElement;
|
||
shelve.click();
|
||
|
||
// The confirmation has to say the files survive: this is one word away from a
|
||
// delete in the same bar, and the two mean opposite things.
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog p')?.textContent).toContain('文件一个都不会动'));
|
||
const confirm = [...screen.element.querySelectorAll('.dialog-actions .button')].find(
|
||
(button) => button.textContent === '删除',
|
||
) as HTMLElement;
|
||
confirm.click();
|
||
|
||
await vi.waitFor(() => {
|
||
expect(transport.requests.some((request) => request.url.endsWith('/browse/shelf'))).toBe(true);
|
||
});
|
||
expect(JSON.parse(String(transport.requests.find((r) => r.url.endsWith('/browse/shelf'))!.body))).toEqual({
|
||
paths: ['卷一.epub', '卷二.epub'],
|
||
action: 'remove',
|
||
});
|
||
// And no delete was ever sent.
|
||
expect(transport.requests.some((request) => request.url.endsWith('/browse/delete'))).toBe(false);
|
||
});
|
||
|
||
it('keeps the form actions out of the fields-scan scroller', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.json(twoBooks());
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
await selectAll(screen);
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.manager-actions .button')).not.toBeNull());
|
||
([...screen.element.querySelectorAll('.manager-actions .button')].find(
|
||
(button) => button.textContent === '改资料',
|
||
) as HTMLElement).click();
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-fields')).not.toBeNull());
|
||
|
||
// Seven optional fields do not fit on a phone, so the *fields* scroll and the
|
||
// actions do not. A dialog that scrolled whole put 保存 below the fold, which
|
||
// reads as a form with no way to commit it.
|
||
const fields = screen.element.querySelector('.dialog-fields')!;
|
||
const actions = screen.element.querySelector('.dialog-actions')!;
|
||
expect(fields.contains(actions)).toBe(false);
|
||
expect(fields.querySelectorAll('.dialog-field')).toHaveLength(7);
|
||
});
|
||
|
||
it('would rather say nothing than guess when a path is not a book', async () => {
|
||
const transport = new FakeTransport();
|
||
transport.respondWith((request) =>
|
||
request.url.includes('/browse/shelf')
|
||
? { status: 200, headers: {}, json: { applied: 0, books: [], failed: [{ path: '空目录', reason: 'NOT_A_BOOK' }] } }
|
||
: { status: 200, headers: {}, json: listing({ entries: [entry({ name: '空目录', type: 'dir', path: '空目录' })], writable: true }) },
|
||
);
|
||
const { screen } = makeScreen(transport);
|
||
await screen.open();
|
||
await selectAll(screen);
|
||
const shelve = [...screen.element.querySelectorAll('.manager-actions .button')].find(
|
||
(button) => button.textContent === '下架',
|
||
) as HTMLElement;
|
||
shelve.click();
|
||
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-actions')).not.toBeNull());
|
||
([...screen.element.querySelectorAll('.dialog-actions .button')].find(
|
||
(button) => button.textContent === '删除',
|
||
) as HTMLElement).click();
|
||
|
||
await vi.waitFor(() => {
|
||
expect(screen.element.querySelector('.manager-status')?.textContent).toContain('不是书');
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('error classification', () => {
|
||
it('does not treat a refused operation as a dead session', () => {
|
||
// Both are 403 on this API and only one is about the credentials. Classifying
|
||
// them together signed a reader out — and discarded their tokens — for trying
|
||
// to rename a file on a read-only mount.
|
||
expect(new ApiError('forbidden', 'the library mount is read-only', 'READ_ONLY_MOUNT', 403).isAuthFailure).toBe(false);
|
||
expect(new ApiError('forbidden', 'admin role required', 'ADMIN_REQUIRED', 403).isAuthFailure).toBe(false);
|
||
expect(new ApiError('forbidden', 'account disabled', 'ACCOUNT_DISABLED', 403).isAuthFailure).toBe(true);
|
||
expect(new ApiError('unauthorized', 'token expired', 'TOKEN_EXPIRED', 401).isAuthFailure).toBe(true);
|
||
});
|
||
});
|