const test = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const h = require('./helpers'); h.installFetchStub(); h.setHandler(() => h.makeResponse({ status: 404 })); const store = require('../library/store'); function tmpDir(tag) { const d = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-${tag}-`)); return d; } const created = []; function freshRoot(tag) { const d = tmpDir(tag); created.push(d); store.init(d); return d; } test.after(() => { for (const d of created) { try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ } } }); test('init 建出目录结构', () => { const root = freshRoot('init'); assert.ok(fs.existsSync(path.join(root, 'files'))); assert.ok(fs.existsSync(path.join(root, 'covers'))); assert.strictEqual(store.getRoot(), path.resolve(root)); }); test('add / get / list 往返', () => { freshRoot('crud'); const it = store.add({ title: '测试书', authors: ['作者'], sourceId: 's', sourcePostId: 1 }); assert.ok(it.id); const got = store.get(it.id); assert.strictEqual(got.title, '测试书'); assert.strictEqual(got.sourcePostId, '1', 'sourcePostId 应统一为字符串'); assert.strictEqual(store.list().length, 1); assert.ok(store.findBySource('s', 1), '数字 postId 应能匹配'); assert.ok(store.findBySource('s', '1')); }); test('批量导入本地文件可按上一级目录创建并复用书架', () => { const root = freshRoot('local-import-shelves'); const source = path.join(root, 'source'); for (const folder of ['文学', '技术']) fs.mkdirSync(path.join(source, folder), { recursive: true }); const files = [ path.join(source, '文学', '小说.epub'), path.join(source, '文学', '诗集.pdf'), path.join(source, '技术', '手册.txt') ]; files.forEach((file, index) => fs.writeFileSync(file, `fixture-${index}`)); const existingLiterature = store.addShelf('文学'); const records = files.map((file) => ({ path: file, name: path.basename(file), format: path.extname(file).slice(1).toUpperCase(), parentName: path.basename(path.dirname(file)) })); const imported = store.importLocal(records, 'shelf'); assert.strictEqual(imported.added, 3); assert.strictEqual(imported.skipped, 0); assert.deepStrictEqual(store.listShelves().map((shelf) => shelf.name).sort(), ['技术', '文学']); const literature = store.listShelves().find((shelf) => shelf.name === '文学'); assert.strictEqual(literature.id, existingLiterature.id); assert.strictEqual( store.list().filter((item) => item.shelfId === literature.id).length, 2 ); const repeated = store.importLocal(records, 'shelf'); assert.deepStrictEqual( { added: repeated.added, skipped: repeated.skipped, skippedDuplicates: repeated.skippedDuplicates }, { added: 0, skipped: 3, skippedDuplicates: 3 } ); assert.strictEqual(store.list().length, 3); }); test('本地导入按规范路径跳过书库中已有的同一文件', () => { const root = freshRoot('local-import-same-path'); const source = path.join(root, 'source', 'same.pdf'); fs.mkdirSync(path.dirname(source), { recursive: true }); fs.writeFileSync(source, 'same-path-content'); const record = { path: source, name: 'same.pdf', parentName: 'source' }; assert.strictEqual(store.importLocal([record]).added, 1); const repeated = store.importLocal([record]); assert.deepStrictEqual( { added: repeated.added, skipped: repeated.skipped, skippedDuplicates: repeated.skippedDuplicates }, { added: 0, skipped: 1, skippedDuplicates: 1 } ); assert.strictEqual(store.list().length, 1); }); test('本地导入按文件字节跳过不同路径下的副本', () => { const root = freshRoot('local-import-copy'); const original = path.join(root, 'original', 'first.pdf'); const copy = path.join(root, 'copy', 'renamed.pdf'); fs.mkdirSync(path.dirname(original), { recursive: true }); fs.mkdirSync(path.dirname(copy), { recursive: true }); fs.writeFileSync(original, 'identical-file-bytes'); fs.copyFileSync(original, copy); assert.strictEqual(store.importLocal([{ path: original }]).added, 1); const copied = store.importLocal([{ path: copy, name: 'Completely Different Title.pdf', title: 'Completely Different Title' }]); assert.deepStrictEqual( { added: copied.added, skipped: copied.skipped, skippedDuplicates: copied.skippedDuplicates }, { added: 0, skipped: 1, skippedDuplicates: 1 } ); assert.strictEqual(store.list().length, 1); }); test('本地导入保留同名但字节不同的版本', () => { const root = freshRoot('local-import-editions'); const first = path.join(root, 'edition-one', 'Shared Title.pdf'); const second = path.join(root, 'edition-two', 'Shared Title.pdf'); fs.mkdirSync(path.dirname(first), { recursive: true }); fs.mkdirSync(path.dirname(second), { recursive: true }); fs.writeFileSync(first, 'edition-A'); fs.writeFileSync(second, 'edition-B'); const imported = store.importLocal([ { path: first, title: 'Shared Title' }, { path: second, title: 'Shared Title' } ]); assert.deepStrictEqual( { added: imported.added, skipped: imported.skipped, skippedDuplicates: imported.skippedDuplicates }, { added: 2, skipped: 0, skippedDuplicates: 0 } ); assert.deepStrictEqual(store.list().map((item) => item.title), ['Shared Title', 'Shared Title']); }); test('混合批量导入同时跳过已有路径、已有副本和批内副本', () => { const root = freshRoot('local-import-mixed'); const existing = path.join(root, 'existing', 'book.epub'); const existingCopy = path.join(root, 'incoming', 'existing-copy.epub'); const fresh = path.join(root, 'incoming', 'fresh.epub'); const freshCopy = path.join(root, 'incoming-copy', 'fresh-copy.epub'); for (const file of [existing, existingCopy, fresh, freshCopy]) { fs.mkdirSync(path.dirname(file), { recursive: true }); } fs.writeFileSync(existing, 'existing-content'); fs.copyFileSync(existing, existingCopy); fs.writeFileSync(fresh, 'brand-new-content'); fs.copyFileSync(fresh, freshCopy); store.importLocal([{ path: existing }]); const imported = store.importLocal([ { path: existing }, { path: existingCopy }, { path: fresh }, { path: freshCopy } ]); assert.deepStrictEqual( { added: imported.added, skipped: imported.skipped, skippedDuplicates: imported.skippedDuplicates }, { added: 1, skipped: 3, skippedDuplicates: 3 } ); assert.strictEqual(imported.items[0].files[0].path, fs.realpathSync(fresh)); assert.strictEqual(store.list().length, 2); }); test('本地批量导入写入失败时回滚条目、分类和去重状态', () => { const root = freshRoot('local-import-rollback'); const source = path.join(root, '回滚分类'); const first = path.join(source, 'first.pdf'); const duplicate = path.join(source, 'first-copy.pdf'); const second = path.join(source, 'second.pdf'); fs.mkdirSync(source, { recursive: true }); fs.writeFileSync(first, 'duplicate-content'); fs.copyFileSync(first, duplicate); fs.writeFileSync(second, 'distinct-content'); const records = [first, duplicate, second].map((file) => ({ path: file, parentName: '回滚分类' })); const file = path.join(root, 'library.json'); const originalRename = fs.renameSync; let failed = false; fs.renameSync = function renameWithFailure(sourcePath, destination) { if (!failed && sourcePath === `${file}.tmp` && destination === file) { failed = true; throw new Error('simulated replace failure'); } return originalRename.apply(this, arguments); }; try { assert.throws( () => store.importLocal(records, 'shelf'), /书库索引写入失败/ ); } finally { fs.renameSync = originalRename; } assert.ok(failed); assert.deepStrictEqual(store.list(), []); assert.deepStrictEqual(store.listShelves(), []); assert.ok(!fs.existsSync(file)); assert.ok(!fs.existsSync(`${file}.tmp`)); const retried = store.importLocal(records, 'shelf'); assert.deepStrictEqual( { added: retried.added, skipped: retried.skipped, skippedDuplicates: retried.skippedDuplicates }, { added: 2, skipped: 1, skippedDuplicates: 1 } ); assert.strictEqual(store.list().length, 2); assert.deepStrictEqual(store.listShelves().map((shelf) => shelf.name), ['回滚分类']); }); test('批量导入本地文件可按上一级目录创建标签或保持不分类', () => { const root = freshRoot('local-import-tags'); const folder = path.join(root, '旧分类'); fs.mkdirSync(folder, { recursive: true }); const taggedFile = path.join(folder, '标签书.pdf'); const plainFile = path.join(folder, '普通书.epub'); fs.writeFileSync(taggedFile, 'tagged'); fs.writeFileSync(plainFile, 'plain'); const existingTag = store.addTag('旧分类'); const tagged = store.importLocal([{ path: taggedFile, name: '标签书.pdf', parentName: '旧分类' }], 'tag'); assert.strictEqual(tagged.added, 1); assert.deepStrictEqual(store.get(tagged.items[0].id).tags, ['旧分类']); const taggedCatalog = store.listTags().find((tag) => tag.name === '旧分类'); assert.strictEqual(taggedCatalog.id, existingTag.id); assert.strictEqual(taggedCatalog.count, 1); const plain = store.importLocal([{ path: plainFile, name: '普通书.epub', parentName: '旧分类' }], 'none'); assert.strictEqual(plain.added, 1); assert.deepStrictEqual(store.get(plain.items[0].id).tags, []); assert.strictEqual(store.get(plain.items[0].id).shelfId, null); assert.throws(() => store.importLocal([], 'invalid'), /分类方式无效/); }); test('书库内文件存相对路径,外部文件存绝对路径', () => { const root = freshRoot('paths'); const inside = path.join(root, 'files', 'a.pdf'); fs.writeFileSync(inside, 'x'); const outsideDir = tmpDir('outside'); created.push(outsideDir); const outside = path.join(outsideDir, 'b.pdf'); fs.writeFileSync(outside, 'y'); const it = store.add({ title: 'T', files: [{ path: inside }, { path: outside }] }); const raw = JSON.parse(fs.readFileSync(path.join(root, 'library.json'), 'utf8')); const stored = raw.items[0].files.map((f) => f.path); assert.ok(stored.includes('files/a.pdf'), '库内文件未转相对路径: ' + stored); assert.ok(stored.some((p) => path.isAbsolute(p)), '库外文件不应转相对路径'); // 对外一律给绝对路径 for (const f of it.files) assert.ok(path.isAbsolute(f.path), f.path); assert.ok(it.files.every((f) => f.exists)); }); test('expand 如实反映磁盘状态', () => { const root = freshRoot('missing'); const p = path.join(root, 'files', 'gone.pdf'); fs.writeFileSync(p, 'x'); const it = store.add({ title: 'T', files: [{ path: p }] }); assert.strictEqual(store.get(it.id).missing, false); fs.unlinkSync(p); const after = store.get(it.id); assert.strictEqual(after.files[0].exists, false); assert.strictEqual(after.missing, true); }); test('allocFilePath 避免覆盖同名文件', () => { const root = freshRoot('alloc'); const first = store.allocFilePath('book.pdf'); fs.writeFileSync(first, 'a'); const second = store.allocFilePath('book.pdf'); assert.notStrictEqual(first, second); assert.ok(second.includes('(1)'), second); }); test('sanitize 去掉非法字符', () => { assert.strictEqual(store.sanitize('a/b:c*d?.pdf'), 'a_b_c_d_.pdf'); assert.strictEqual(store.sanitize(''), 'download'); assert.strictEqual(store.sanitize(' '), 'download'); }); test('remove 默认保留文件,deleteFiles 才删', () => { const root = freshRoot('remove'); const p = path.join(root, 'files', 'keep.pdf'); fs.writeFileSync(p, 'x'); const a = store.add({ title: 'A', files: [{ path: p }] }); store.remove(a.id, false); assert.ok(fs.existsSync(p), '未勾选删除时不该删文件'); const b = store.add({ title: 'B', files: [{ path: p }] }); store.remove(b.id, true); assert.ok(!fs.existsSync(p), '勾选删除后文件应被删除'); }); test('remove 不删书库目录外的用户文件', () => { freshRoot('remove-outside'); const outDir = tmpDir('user'); created.push(outDir); const p = path.join(outDir, 'mine.pdf'); fs.writeFileSync(p, 'x'); const it = store.add({ title: 'T', files: [{ path: p }] }); store.remove(it.id, true); assert.ok(fs.existsSync(p), '原地引用的外部文件被误删了'); }); test('scan 导入孤立文件并跳过非书籍扩展名', () => { const root = freshRoot('scan'); fs.writeFileSync(path.join(root, 'files', 'novel.epub'), 'x'); fs.writeFileSync(path.join(root, 'files', 'notes.exe'), 'x'); const r = store.scan(); assert.strictEqual(r.added, 1, '应只导入 epub'); assert.strictEqual(store.list()[0].title, 'novel'); const again = store.scan(); assert.strictEqual(again.added, 0, '重复扫描不应重复导入'); }); test('attachFile 幂等,不产生重复条目文件', () => { const root = freshRoot('attach'); const it = store.add({ title: 'T' }); const p = path.join(root, 'files', 'x.pdf'); fs.writeFileSync(p, 'x'); store.attachFile(it.id, p); const after = store.attachFile(it.id, p); assert.strictEqual(after.files.length, 1, '重复挂载产生了重复记录'); }); test('生成封面写入 covers 并随条目删除', () => { const root = freshRoot('generated-cover'); const it = store.add({ title: 'T' }); const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]); const cover = store.setGeneratedCover(it.id, jpeg); assert.ok(cover.startsWith(path.join(root, 'covers')), cover); assert.ok(fs.existsSync(cover)); assert.strictEqual(store.get(it.id).cover, cover); store.remove(it.id, false); assert.ok(!fs.existsSync(cover), '移除条目后遗留了生成封面'); }); test('生成封面不覆盖更新后的来源封面', () => { const root = freshRoot('generated-priority'); const it = store.add({ title: 'T', cover: 'https://old.example/cover.jpg' }); store.update(it.id, { cover: 'https://new.example/cover.jpg' }); const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]); assert.strictEqual(store.setGeneratedCover(it.id, jpeg, it.cover), ''); assert.strictEqual(store.get(it.id).cover, 'https://new.example/cover.jpg'); const blank = store.add({ title: 'Blank' }); const manual = path.join(root, 'manual.jpg'); fs.writeFileSync(manual, jpeg); store.update(blank.id, { cover: manual }); assert.strictEqual(store.setGeneratedCover(blank.id, jpeg, ''), ''); assert.strictEqual(store.get(blank.id).cover, manual); }); test('生成封面拒绝非 JPEG 和过大数据', () => { freshRoot('generated-validation'); const it = store.add({ title: 'T' }); assert.throws(() => store.setGeneratedCover(it.id, Buffer.from('not an image')), /JPEG/); const large = Buffer.alloc(2 * 1024 * 1024 + 1); large[0] = 0xff; large[1] = 0xd8; large[2] = 0xff; assert.throws(() => store.setGeneratedCover(it.id, large), /JPEG/); }); test('远程封面下载完成后不覆盖期间更新的封面', async () => { const root = freshRoot('remote-cover-race'); let release; let startedResolve; const started = new Promise((resolve) => { startedResolve = resolve; }); h.setHandler(() => { startedResolve(); return new Promise((resolve) => { release = () => resolve({ ok: true, arrayBuffer: async () => Uint8Array.from([0xff, 0xd8, 0xff, 0xe0]).buffer }); }); }); const it = store.add({ title: 'T', cover: 'https://old.example/cover.jpg' }); const job = store.ensureCoverCached(it.id); await started; const manual = path.join(root, 'manual.jpg'); fs.writeFileSync(manual, Buffer.from([0xff, 0xd8, 0xff, 0xe0])); store.update(it.id, { cover: manual }); release(); assert.strictEqual(await job, ''); assert.strictEqual(store.get(it.id).cover, manual); h.setHandler(() => h.makeResponse({ status: 404 })); }); test('远程封面缓存拒绝网页响应', async () => { const root = freshRoot('remote-cover-html'); h.setHandler(() => ({ ok: true, arrayBuffer: async () => Uint8Array.from(Buffer.from('not an image')).buffer })); const it = store.add({ title: 'T', cover: 'https://example.com/cover.jpg' }); assert.strictEqual(await store.ensureCoverCached(it.id), ''); assert.strictEqual(store.get(it.id).cover, 'https://example.com/cover.jpg'); assert.deepStrictEqual(fs.readdirSync(path.join(root, 'covers')), []); h.setHandler(() => h.makeResponse({ status: 404 })); }); test('索引损坏时报错而不是静默清空书库', () => { const root = freshRoot('corrupt'); store.add({ title: '重要的书' }); fs.writeFileSync(path.join(root, 'library.json'), '{ 坏掉的 json'); store.init(root); assert.throws(() => store.list(), /书库索引读取失败/); }); test('写入后可从 .bak 恢复', () => { const root = freshRoot('bak'); store.add({ title: '书' }); const idx = path.join(root, 'library.json'); fs.copyFileSync(idx, idx + '.bak'); fs.unlinkSync(idx); store.init(root); assert.strictEqual(store.list().length, 1, '未从 .bak 恢复'); }); test('migrateTo 搬运文件并保持条目可用', () => { const src = freshRoot('mig-src'); const shelf = store.addShelf({ name: '迁移书架' }); const p = path.join(src, 'files', 'm.pdf'); fs.writeFileSync(p, 'data'); const added = store.add({ title: 'M', shelfId: shelf.id, tags: ['迁移'], files: [{ path: p }] }); const oldCover = store.setGeneratedCover( added.id, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 0xff, 0xd9]) ); const dest = tmpDir('mig-dest'); created.push(dest); store.migrateTo(dest); store.finalizeMigration(); assert.strictEqual(store.getRoot(), path.resolve(dest)); const items = store.list(); assert.strictEqual(items.length, 1); assert.ok(items[0].files[0].exists, '迁移后文件丢失'); assert.ok(items[0].files[0].path.startsWith(path.resolve(dest)), items[0].files[0].path); assert.ok(items[0].cover.startsWith(path.resolve(dest)), items[0].cover); assert.ok(fs.existsSync(items[0].cover), '迁移后生成封面丢失'); assert.strictEqual(items[0].shelfId, shelf.id); assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['迁移书架']); assert.ok(!fs.existsSync(p), '旧文件未清理'); assert.ok(!fs.existsSync(oldCover), '旧生成封面未清理'); }); test('migrateTo 拒绝互相包含的目录', () => { const src = freshRoot('mig-nest'); assert.throws(() => store.migrateTo(path.join(src, 'sub')), /不能互相包含/); }); test('migrateTo 拒绝已有书库的目标目录', () => { freshRoot('mig-occupied'); store.add({ title: 'A' }); const dest = tmpDir('mig-taken'); created.push(dest); fs.writeFileSync(path.join(dest, 'library.json'), '{}'); assert.throws(() => store.migrateTo(dest), /已包含书库索引/); }); test('rollbackMigration 回到原目录且不留残File', () => { const src = freshRoot('mig-rb'); const shelf = store.addShelf('回滚书架'); const p = path.join(src, 'files', 'r.pdf'); fs.writeFileSync(p, 'data'); store.add({ title: 'R', shelfId: shelf.id, files: [{ path: p }] }); const dest = tmpDir('mig-rb-dest'); created.push(dest); store.migrateTo(dest); store.rollbackMigration(); assert.strictEqual(store.getRoot(), path.resolve(src)); assert.ok(fs.existsSync(p), '回滚后源文件应还在'); assert.ok(!fs.existsSync(path.join(dest, 'library.json')), '目标目录索引未清理'); assert.strictEqual(store.list().length, 1); assert.strictEqual(store.list()[0].shelfId, shelf.id); assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['回滚书架']); }); test('importLegacy 正确复制相对路径封面', () => { const legacy = tmpDir('legacy-relative-cover'); created.push(legacy); fs.mkdirSync(path.join(legacy, 'covers'), { recursive: true }); fs.writeFileSync(path.join(legacy, 'covers', 'old.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0])); fs.writeFileSync(path.join(legacy, 'library.json'), JSON.stringify([{ id: 'legacy-book', title: 'Legacy', cover: 'covers/old.jpg', files: [] }])); const root = freshRoot('legacy-relative-dest'); assert.strictEqual(store.importLegacy(legacy).imported, 1); const imported = store.get('legacy-book'); assert.ok(imported.cover.startsWith(path.join(root, 'covers')), imported.cover); assert.ok(fs.existsSync(imported.cover)); }); test('update 修改字段并刷新 updatedAt', () => { freshRoot('update'); const it = store.add({ title: '旧' }); const out = store.update(it.id, { title: '新', tags: ['t'] }); assert.strictEqual(out.title, '新'); assert.deepStrictEqual(out.tags, ['t']); assert.throws(() => store.update('nope', {}), /条目不存在/); }); test('v1 和 v2 索引透明迁移到 v4 并保留条目与标签目录', () => { for (const fixture of [ { tag: 'schema-v1', data: [{ id: 'v1', title: '旧数组', custom: { kept: true }, tags: [' A ', 'a', ''] }] }, { tag: 'schema-v2', data: { version: 2, items: [{ id: 'v2', title: '旧对象', custom: { kept: true }, tags: ['B'], shelfId: 'missing' }] } } ]) { const root = freshRoot(fixture.tag); fs.writeFileSync(path.join(root, 'library.json'), JSON.stringify(fixture.data)); store.init(root); const item = store.list()[0]; assert.deepStrictEqual(item.custom, { kept: true }); assert.strictEqual(item.shelfId, null); assert.strictEqual(item.tags.length, 1); store.update(item.id, { title: item.title }); const persisted = JSON.parse(fs.readFileSync(path.join(root, 'library.json'), 'utf8')); assert.strictEqual(persisted.version, 4); assert.deepStrictEqual(persisted.shelves, []); assert.strictEqual(persisted.tags.length, 1); assert.strictEqual(persisted.tags[0].name, item.tags[0]); assert.deepStrictEqual(persisted.items[0].custom, { kept: true }); } }); test('书架 CRUD 强制唯一非空名称并返回深拷贝', () => { freshRoot('shelf-crud'); const shelf = store.addShelf({ name: ' 技术 ' }); assert.match(shelf.id, /^shelf_[a-f0-9]{24}$/); assert.strictEqual(shelf.name, '技术'); assert.ok(Number.isFinite(shelf.createdAt)); assert.ok(Number.isFinite(shelf.updatedAt)); assert.throws(() => store.addShelf(' '), /不能为空/); assert.throws(() => store.addShelf('技术'), /已存在/); const listed = store.listShelves(); listed[0].name = '被外部修改'; listed.push({ id: 'fake', name: '假的' }); assert.deepStrictEqual(store.listShelves().map((entry) => entry.name), ['技术']); const updated = store.updateShelf(shelf.id, { name: ' 文学 ' }); assert.strictEqual(updated.name, '文学'); assert.strictEqual(updated.createdAt, shelf.createdAt); assert.ok(updated.updatedAt >= shelf.updatedAt); updated.name = '再次外部修改'; assert.strictEqual(store.listShelves()[0].name, '文学'); assert.throws(() => store.updateShelf('missing', { name: 'X' }), /不存在/); const other = store.addShelf('Research'); assert.throws(() => store.addShelf(' research '), /已存在/); assert.throws(() => store.updateShelf(other.id, { name: ' 文学 ' }), /已存在/); }); test('书架列表返回每个分类当前包含的书籍数量', () => { freshRoot('shelf-counts'); const research = store.addShelf('研究'); const archive = store.addShelf('归档'); const first = store.add({ title: 'A', shelfId: research.id }); store.add({ title: 'B', shelfId: research.id }); store.add({ title: '未分类' }); assert.deepStrictEqual( store.listShelves().map((shelf) => [shelf.name, shelf.count]), [['研究', 2], ['归档', 0]] ); store.update(first.id, { shelfId: archive.id }); assert.deepStrictEqual( store.listShelves().map((shelf) => [shelf.name, shelf.count]), [['研究', 1], ['归档', 1]] ); }); test('删除书架只清空条目 shelfId 且组织变更触发通知', () => { freshRoot('shelf-remove'); let changes = 0; store.setChangeListener(() => { changes++; }); try { const shelf = store.addShelf('待整理'); const book = store.add({ title: '保留我' }); store.update(book.id, { shelfId: shelf.id, tags: [' A ', 'a', 'B'] }); assert.strictEqual(changes, 2, '书架添加和组织更新均应通知'); assert.strictEqual(store.get(book.id).shelfId, shelf.id); assert.deepStrictEqual(store.removeShelf(shelf.id), { removed: true }); assert.strictEqual(changes, 3); assert.strictEqual(store.list().length, 1, '删除书架不应删除书籍'); assert.strictEqual(store.get(book.id).shelfId, null); assert.deepStrictEqual(store.get(book.id).tags, ['A', 'B']); assert.deepStrictEqual(store.removeShelf(shelf.id), { removed: false }); assert.strictEqual(changes, 3, '重复删除不存在的书架不应通知'); } finally { store.setChangeListener(null); } }); test('条目组织字段归一化并限制标签数量和长度', () => { freshRoot('organization-normalize'); const shelf = store.addShelf('有效书架'); const manyTags = Array.from({ length: 60 }, (_, i) => ` tag-${i} `); const book = store.add({ title: '组织', shelfId: shelf.id, tags: [' Foo ', 'foo', null, '', 'x'.repeat(80), ...manyTags] }); assert.strictEqual(book.shelfId, shelf.id); assert.strictEqual(book.tags[0], 'Foo'); assert.strictEqual(book.tags[1].length, 64); assert.strictEqual(book.tags.length, 50); const invalid = store.update(book.id, { shelfId: 'not-a-shelf', tags: 'not-an-array' }); assert.strictEqual(invalid.shelfId, null); assert.deepStrictEqual(invalid.tags, []); }); test('listTags 合并大小写、保留稳定 ID 并按数量和中文名称排序', () => { freshRoot('tag-catalog'); store.add({ title: '一', tags: [' 科学 ', 'SCIENCE', '历史'] }); store.add({ title: '二', tags: ['科学', 'science', '文学'] }); store.add({ title: '三', tags: ['Science'] }); const actual = store.listTags(); assert.ok(actual.every((tag) => /^tag_[a-f0-9]{24}$/.test(tag.id))); assert.deepStrictEqual(actual.slice(0, 2).map(({ name, count }) => ({ name, count })), [ { name: 'SCIENCE', count: 3 }, { name: '科学', count: 2 } ]); const tied = actual.slice(2).map(({ name, count }) => ({ name, count })); assert.deepStrictEqual( tied, [{ name: '历史', count: 1 }, { name: '文学', count: 1 }] .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'base' })) ); }); test('importLegacy 保留书架、标签并将同名书架映射到现有书架', () => { const legacy = tmpDir('legacy-shelves'); created.push(legacy); fs.writeFileSync(path.join(legacy, 'library.json'), JSON.stringify({ version: 3, shelves: [ { id: 'old-shared', name: ' 已有 ', createdAt: 10, updatedAt: 20 }, { id: 'old-new', name: '新书架', createdAt: 30, updatedAt: 40 } ], items: [ { id: 'legacy-shared-book', title: '共享', shelfId: 'old-shared', tags: [' A ', 'a'] }, { id: 'legacy-new-book', title: '新增', shelfId: 'old-new', tags: [' B '] } ] })); freshRoot('legacy-shelves-dest'); const existing = store.addShelf('已有'); assert.strictEqual(store.importLegacy(legacy).imported, 2); const importedShelves = store.listShelves(); assert.deepStrictEqual(importedShelves.map((entry) => entry.name), ['已有', '新书架']); assert.strictEqual(importedShelves[1].createdAt, 30); assert.strictEqual(store.get('legacy-shared-book').shelfId, existing.id); assert.strictEqual(store.get('legacy-new-book').shelfId, importedShelves[1].id); assert.deepStrictEqual(store.get('legacy-shared-book').tags, ['A']); const raw = JSON.parse(fs.readFileSync(path.join(store.getRoot(), 'library.json'), 'utf8')); assert.strictEqual(raw.version, 4); assert.strictEqual(raw.shelves.length, 2); assert.deepStrictEqual(raw.tags.map((tag) => tag.name).sort(), ['A', 'B']); });