feat: 集成漫画源并支持在线阅读
This commit is contained in:
@@ -89,3 +89,36 @@ test('目录 fsync 失败不影响写入结果', () => {
|
||||
}
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { ok: true });
|
||||
});
|
||||
|
||||
test('writeBytes 原子覆盖二进制文件且不残留临时文件', () => {
|
||||
const dir = fresh('bytes');
|
||||
const dest = path.join(dir, 'book.epub');
|
||||
fs.writeFileSync(dest, Buffer.from([1, 2, 3]));
|
||||
atomic.writeBytes(dest, Buffer.from([4, 5, 0, 255]));
|
||||
assert.deepStrictEqual([...fs.readFileSync(dest)], [4, 5, 0, 255]);
|
||||
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
|
||||
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
|
||||
});
|
||||
|
||||
test('writeBytes 替换失败时恢复旧二进制内容', () => {
|
||||
const dir = fresh('bytes-rollback');
|
||||
const dest = path.join(dir, 'book.epub');
|
||||
fs.writeFileSync(dest, Buffer.from([1, 2, 3]));
|
||||
const realRename = fs.renameSync;
|
||||
let failed = false;
|
||||
fs.renameSync = function failing(from, to) {
|
||||
if (!failed && String(from) === `${dest}.tmp` && String(to) === dest) {
|
||||
failed = true;
|
||||
throw new Error('模拟二进制替换失败');
|
||||
}
|
||||
return realRename.apply(this, arguments);
|
||||
};
|
||||
try {
|
||||
assert.throws(() => atomic.writeBytes(dest, Buffer.from([9, 9])), /模拟二进制替换失败/);
|
||||
} finally {
|
||||
fs.renameSync = realRename;
|
||||
}
|
||||
assert.deepStrictEqual([...fs.readFileSync(dest)], [1, 2, 3]);
|
||||
assert.strictEqual(fs.existsSync(`${dest}.tmp`), false);
|
||||
assert.strictEqual(fs.existsSync(`${dest}.bak`), false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
@@ -9,6 +10,7 @@ app.setPath('appData', TMP);
|
||||
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
|
||||
|
||||
const results = [];
|
||||
let imageServer = null;
|
||||
function check(name, pass, detail) {
|
||||
results.push([pass ? 'OK' : 'FAIL', name, detail || '']);
|
||||
}
|
||||
@@ -36,6 +38,31 @@ function printSummary() {
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
imageServer = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': png.length });
|
||||
response.end(png);
|
||||
});
|
||||
await new Promise((resolve) => imageServer.listen(0, '127.0.0.1', resolve));
|
||||
const imageUrl = `http://127.0.0.1:${imageServer.address().port}/page.png`;
|
||||
const mangaDex = require(path.join(ROOT, 'src', 'sources', 'mangadex'));
|
||||
mangaDex.chapters = async () => ({
|
||||
items: [
|
||||
{ chapterId: 'online-c1', label: '在线第 1 话', pages: 1 },
|
||||
{ chapterId: 'online-c2', label: '在线第 2 话', pages: 1 }
|
||||
],
|
||||
page: 1,
|
||||
maxPage: 1
|
||||
});
|
||||
mangaDex.chapterImageUrls = async () => ({
|
||||
urls: [imageUrl],
|
||||
mustReport: false,
|
||||
quality: 'dataSaver'
|
||||
});
|
||||
|
||||
const library = require(path.join(ROOT, 'src', 'library', 'store'));
|
||||
let scanStartedAt = 0;
|
||||
library.scan = () => {
|
||||
@@ -63,23 +90,91 @@ app.whenReady().then(async () => {
|
||||
checked: input.checked,
|
||||
name: input.parentElement.querySelector('span').textContent
|
||||
}));
|
||||
return rows.length >= 16 ? rows : null;
|
||||
return rows.length >= 18 ? rows : null;
|
||||
})()`));
|
||||
const expectedSources = ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en'];
|
||||
const expectedSources = [
|
||||
'openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en',
|
||||
'mangadex', 'copymanga'
|
||||
];
|
||||
check('新增开放书源出现在设置页且新安装默认启用',
|
||||
expectedSources.every((id) => sourceRows.some((row) => row.id === id && row.checked)),
|
||||
sourceRows.filter((row) => expectedSources.includes(row.id)).map((row) => `${row.id}:${row.name}`).join(', '));
|
||||
|
||||
const sourceGroups = await waitUntil(() => win.webContents.executeJavaScript(`(() => {
|
||||
const labels = Array.from(document.querySelectorAll('#sourceSelect optgroup')).map((group) => group.label);
|
||||
return labels.includes('漫画') ? labels : null;
|
||||
})()`));
|
||||
check('检索书源下拉框按内容类型分组',
|
||||
['学术论文', '电子书', '开放教材与文库', '档案与特藏', '漫画']
|
||||
.every((label) => sourceGroups.includes(label)),
|
||||
sourceGroups.join(', '));
|
||||
|
||||
const mangaLanguage = await win.webContents.executeJavaScript(
|
||||
`document.getElementById('mangadexLanguageSelect').value`
|
||||
);
|
||||
check('MangaDex 新安装默认只显示简体中文章节', mangaLanguage === 'zh', mangaLanguage);
|
||||
|
||||
await waitUntil(() => scanStartedAt > 0, 7000);
|
||||
check('启动维护在首屏完成后延迟执行', scanStartedAt - startedAt >= 1400,
|
||||
`${scanStartedAt - startedAt}ms`);
|
||||
|
||||
const beforeOnline = await win.webContents.executeJavaScript(
|
||||
`window.api.library.list().then((result) => result.data.length)`
|
||||
);
|
||||
const openedOnline = await win.webContents.executeJavaScript(`window.api.sources.readOnline('mangadex', {
|
||||
mangaId: 'online-manga',
|
||||
title: '在线阅读夹具',
|
||||
authors: ['测试作者'],
|
||||
language: 'zh',
|
||||
quality: 'dataSaver'
|
||||
})`);
|
||||
check('漫画详情可以打开不入库的在线阅读器', openedOnline && openedOnline.ok,
|
||||
openedOnline && openedOnline.error);
|
||||
const onlineWindow = await waitUntil(() => BrowserWindow.getAllWindows().find(
|
||||
(item) => item !== win && /manga-online\.html/.test(item.webContents.getURL())
|
||||
));
|
||||
let firstChapter = false;
|
||||
try {
|
||||
firstChapter = await waitUntil(() => onlineWindow.webContents.executeJavaScript(
|
||||
`document.querySelectorAll('.chapter-item').length === 2
|
||||
&& document.querySelector('.manga-page img')
|
||||
&& document.querySelector('.manga-page img').naturalWidth > 0`
|
||||
));
|
||||
} catch (error) { /* 由下面的诊断状态报告 */ }
|
||||
const onlineState = await onlineWindow.webContents.executeJavaScript(`(() => ({
|
||||
status: document.getElementById('readerStatus').textContent,
|
||||
statusHidden: document.getElementById('readerStatus').classList.contains('hidden'),
|
||||
chapters: document.querySelectorAll('.chapter-item').length,
|
||||
pages: document.querySelectorAll('.manga-page').length,
|
||||
pageState: document.querySelector('.manga-page')?.dataset.state || '',
|
||||
pageText: document.querySelector('.manga-page')?.dataset.placeholder || ''
|
||||
}))()`);
|
||||
check('在线阅读器读取整部章节目录并通过主进程显示图片',
|
||||
!!firstChapter, JSON.stringify(onlineState));
|
||||
let secondChapter = false;
|
||||
if (firstChapter) {
|
||||
await onlineWindow.webContents.executeJavaScript(`document.getElementById('nextChapterBtn').click()`);
|
||||
secondChapter = await waitUntil(() => onlineWindow.webContents.executeJavaScript(`(() => {
|
||||
const image = document.querySelector('.manga-page img');
|
||||
return document.getElementById('chapterTitle').textContent === '在线第 2 话'
|
||||
&& image && image.naturalWidth > 0;
|
||||
})()`));
|
||||
}
|
||||
check('在线阅读器可连续切换到下一章', secondChapter);
|
||||
const afterOnline = await win.webContents.executeJavaScript(
|
||||
`window.api.library.list().then((result) => result.data.length)`
|
||||
);
|
||||
check('在线阅读不会创建书库条目', afterOnline === beforeOnline,
|
||||
`${beforeOnline} → ${afterOnline}`);
|
||||
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.destroy();
|
||||
}
|
||||
await new Promise((resolve) => imageServer.close(resolve));
|
||||
const failed = printSummary();
|
||||
app.exit(failed ? 1 : 0);
|
||||
}).catch((error) => {
|
||||
if (imageServer) imageServer.close();
|
||||
console.error('异常:', error);
|
||||
check('启动验证未发生异常', false, error.message || String(error));
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
|
||||
@@ -43,6 +43,32 @@ test('所有 IPC handler 都通过 thunk 调用 wrap', () => {
|
||||
assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare);
|
||||
});
|
||||
|
||||
test('章节制数据源 IPC 通过源能力下载并按请求隔离进度', () => {
|
||||
const start = mainSrc.indexOf("ipcMain.handle('source:chapters'");
|
||||
const end = mainSrc.indexOf('// 代理配置', start);
|
||||
const segment = mainSrc.slice(start, end);
|
||||
assert.ok(start >= 0 && end > start, '缺少章节制数据源 IPC');
|
||||
assert.match(segment, /source\.chapters\(postId, page, options \|\| \{\}\)/);
|
||||
assert.match(segment, /source\.downloadChapter\(library, payload \|\| \{\}, sendProgress\)/);
|
||||
assert.match(segment, /requestId:\s*progressId/);
|
||||
assert.match(segment, /event\.sender\.isDestroyed\(\)/);
|
||||
assert.match(segment, /event\.sender\.send\('source:chapterProgress'/);
|
||||
});
|
||||
|
||||
test('在线漫画使用独立窗口、sender 绑定会话和主进程图片代理', () => {
|
||||
const start = mainSrc.indexOf("ipcMain.handle('source:readOnline'");
|
||||
const end = mainSrc.indexOf('// 代理配置', start);
|
||||
const segment = mainSrc.slice(start, end);
|
||||
assert.ok(start >= 0 && end > start, '缺少在线漫画 IPC');
|
||||
assert.match(segment, /event\.sender\.id !== mainWindow\.webContents\.id/);
|
||||
assert.match(segment, /mangaOnlineWindow\.create\(__dirname, currentUiTheme\)/);
|
||||
assert.match(segment, /mangaOnlineSessions\.create\(ownerId, source, input \|\| \{\}\)/);
|
||||
assert.match(segment, /mangaOnlineWindow\.fromWebContents\(event\.sender\)/);
|
||||
for (const channel of ['meta', 'chapters', 'chapterManifest', 'image', 'close']) {
|
||||
assert.match(segment, new RegExp(`ipcMain\\.handle\\('mangaOnline:${channel}'`));
|
||||
}
|
||||
});
|
||||
|
||||
test('版本比较:预发布版本低于同号正式版', () => {
|
||||
assert.strictEqual(compareVersion('1.1.0', '1.1.0-beta'), 1);
|
||||
assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0'), -1);
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const h = require('./helpers');
|
||||
|
||||
h.installFetchStub();
|
||||
|
||||
const store = require('../library/store');
|
||||
const mangaDownload = require('../library/manga-download');
|
||||
const mangaEpub = require('../library/manga-epub');
|
||||
|
||||
const roots = [];
|
||||
|
||||
function freshRoot(tag) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-manga-${tag}-`));
|
||||
roots.push(root);
|
||||
store.init(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function installImageHandler() {
|
||||
h.setHandler((url) => {
|
||||
const name = new URL(url).pathname.split('/').at(-1);
|
||||
const byte = name.startsWith('one') ? 1 : 2;
|
||||
const data = Buffer.from([byte, byte, byte]);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
arrayBuffer: async () => data
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sourceFor(files) {
|
||||
return {
|
||||
id: 'mangadex',
|
||||
chapterImageUrls: async () => ({
|
||||
urls: files.map((name) => `https://uploads.mangadex.org/data/hash/${name}`),
|
||||
mustReport: false
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function payload(chapterId, chapter) {
|
||||
return {
|
||||
mangaId: 'manga-1',
|
||||
title: '测试漫画',
|
||||
authors: ['作者'],
|
||||
originalLanguage: 'ja',
|
||||
chapterId,
|
||||
chapter,
|
||||
label: `第 ${chapter} 话`,
|
||||
translatedLanguage: 'zh',
|
||||
quality: 'dataSaver'
|
||||
};
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
for (const root of roots) {
|
||||
try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchChapterImages: 保持页序并上报准确进度', async () => {
|
||||
installImageHandler();
|
||||
const progress = [];
|
||||
const result = await mangaDownload.fetchChapterImages(
|
||||
sourceFor(['one.jpg', 'two.png']),
|
||||
'chapter-1',
|
||||
'dataSaver',
|
||||
(done, total) => progress.push([done, total])
|
||||
);
|
||||
assert.deepStrictEqual(result.map((image) => image.ext), ['jpg', 'png']);
|
||||
assert.deepStrictEqual([...result[0].data], [1, 1, 1]);
|
||||
assert.deepStrictEqual([...result[1].data], [2, 2, 2]);
|
||||
assert.deepStrictEqual(progress, [[1, 2], [2, 2]]);
|
||||
});
|
||||
|
||||
test('fetchChapterImages: 失败页换节点重试,进度只按成功页单调增加', async () => {
|
||||
let sourceCalls = 0;
|
||||
h.setHandler((url) => {
|
||||
if (url.includes('old-one.jpg')) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
headers: { get: () => null },
|
||||
body: { cancel: async () => {} }
|
||||
};
|
||||
}
|
||||
const data = Buffer.from(url.includes('one.jpg') ? [1] : [2]);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
arrayBuffer: async () => data
|
||||
};
|
||||
});
|
||||
const source = {
|
||||
chapterImageUrls: async () => {
|
||||
sourceCalls++;
|
||||
const prefix = sourceCalls === 1 ? 'old-' : 'fresh-';
|
||||
return {
|
||||
urls: [
|
||||
`https://uploads.mangadex.org/data/hash/${prefix}one.jpg`,
|
||||
`https://uploads.mangadex.org/data/hash/${prefix}two.jpg`
|
||||
],
|
||||
mustReport: false
|
||||
};
|
||||
}
|
||||
};
|
||||
const progress = [];
|
||||
const result = await mangaDownload.fetchChapterImages(
|
||||
source,
|
||||
'chapter-1',
|
||||
'dataSaver',
|
||||
(done, total) => progress.push([done, total])
|
||||
);
|
||||
assert.strictEqual(sourceCalls, 2);
|
||||
assert.deepStrictEqual(progress, [[1, 2], [2, 2]]);
|
||||
assert.deepStrictEqual(result.map((image) => [...image.data]), [[1], [2]]);
|
||||
});
|
||||
|
||||
test('fetchChapterImages: @Home 图片上报完成后才返回,且上报体包含传输指标', async () => {
|
||||
let reportBody = null;
|
||||
let reportFinished = false;
|
||||
h.setHandler((url, options) => {
|
||||
if (url === 'https://api.mangadex.network/report') {
|
||||
reportBody = JSON.parse(options.body);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
body: { cancel: async () => { reportFinished = true; } }
|
||||
};
|
||||
}
|
||||
const data = Buffer.from([7, 8, 9]);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (name) => name.toLowerCase() === 'x-cache' ? 'HIT' : null },
|
||||
arrayBuffer: async () => data
|
||||
};
|
||||
});
|
||||
const source = {
|
||||
chapterImageUrls: async () => ({
|
||||
urls: ['https://node.example.test/data/hash/one.jpg'],
|
||||
mustReport: true
|
||||
})
|
||||
};
|
||||
await mangaDownload.fetchChapterImages(source, 'chapter-1', 'dataSaver');
|
||||
assert.strictEqual(reportFinished, true);
|
||||
assert.strictEqual(reportBody.url, 'https://node.example.test/data/hash/one.jpg');
|
||||
assert.strictEqual(reportBody.success, true);
|
||||
assert.strictEqual(reportBody.bytes, 3);
|
||||
assert.strictEqual(reportBody.cached, true);
|
||||
assert.ok(Number.isFinite(reportBody.duration));
|
||||
});
|
||||
|
||||
test('downloadChapter: 首章建库,后续章节追加到同一 EPUB', async () => {
|
||||
freshRoot('append');
|
||||
installImageHandler();
|
||||
const source = sourceFor(['one.jpg']);
|
||||
|
||||
const first = await mangaDownload.downloadChapter(source, store, payload('chapter-1', '1'));
|
||||
assert.strictEqual(first.created, true);
|
||||
assert.strictEqual(store.list().length, 1);
|
||||
|
||||
const second = await mangaDownload.downloadChapter(source, store, payload('chapter-2', '2'));
|
||||
assert.strictEqual(second.created, false);
|
||||
assert.strictEqual(second.entry.id, first.entry.id);
|
||||
assert.strictEqual(store.list().length, 1);
|
||||
|
||||
const epub = second.entry.files.find((file) => /\.epub$/i.test(file.path));
|
||||
assert.ok(epub && epub.exists);
|
||||
assert.deepStrictEqual(
|
||||
mangaEpub.listChapters(fs.readFileSync(epub.path)).map((chapter) => chapter.chapterId),
|
||||
['chapter-1', 'chapter-2']
|
||||
);
|
||||
});
|
||||
|
||||
test('downloadChapter: 同一漫画并发下载会串行合并,不产生重复书库条目', async () => {
|
||||
freshRoot('concurrent');
|
||||
h.setHandler(() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
arrayBuffer: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return Buffer.from([1, 2, 3]);
|
||||
}
|
||||
}));
|
||||
const source = sourceFor(['one.jpg']);
|
||||
const [first, second] = await Promise.all([
|
||||
mangaDownload.downloadChapter(source, store, payload('chapter-1', '1')),
|
||||
mangaDownload.downloadChapter(source, store, payload('chapter-2', '2'))
|
||||
]);
|
||||
|
||||
assert.strictEqual(first.entry.id, second.entry.id);
|
||||
assert.strictEqual(store.list().length, 1);
|
||||
const epub = store.list()[0].files.find((file) => /\.epub$/i.test(file.path));
|
||||
assert.deepStrictEqual(
|
||||
mangaEpub.listChapters(fs.readFileSync(epub.path)).map((chapter) => chapter.chapterId),
|
||||
['chapter-1', 'chapter-2']
|
||||
);
|
||||
});
|
||||
|
||||
test('downloadChapter: 已下载章节不会重复抓图', async () => {
|
||||
freshRoot('dedupe');
|
||||
installImageHandler();
|
||||
const source = sourceFor(['one.jpg']);
|
||||
await mangaDownload.downloadChapter(source, store, payload('chapter-1', '1'));
|
||||
|
||||
let calls = 0;
|
||||
const countingSource = {
|
||||
id: 'mangadex',
|
||||
chapterImageUrls: async () => {
|
||||
calls++;
|
||||
return source.chapterImageUrls();
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
mangaDownload.downloadChapter(countingSource, store, payload('chapter-1', '1')),
|
||||
/该章节已下载/
|
||||
);
|
||||
assert.strictEqual(calls, 0);
|
||||
});
|
||||
|
||||
test('downloadChapter: 已有条目只有非 EPUB 文件时新建 EPUB 而不误解析旧文件', async () => {
|
||||
const root = freshRoot('non-epub');
|
||||
installImageHandler();
|
||||
const pdf = path.join(root, 'files', 'old.pdf');
|
||||
fs.writeFileSync(pdf, '%PDF-test');
|
||||
const entry = store.add({
|
||||
title: '测试漫画',
|
||||
sourceId: 'mangadex',
|
||||
sourcePostId: 'manga-1',
|
||||
files: [{ path: pdf, name: 'old.pdf', format: 'PDF' }]
|
||||
});
|
||||
|
||||
const result = await mangaDownload.downloadChapter(
|
||||
sourceFor(['one.jpg']),
|
||||
store,
|
||||
payload('chapter-1', '1')
|
||||
);
|
||||
assert.strictEqual(result.entry.id, entry.id);
|
||||
assert.strictEqual(store.list().length, 1);
|
||||
assert.strictEqual(result.entry.files.filter((file) => /\.epub$/i.test(file.path)).length, 1);
|
||||
assert.strictEqual(result.entry.files.filter((file) => /\.pdf$/i.test(file.path)).length, 1);
|
||||
});
|
||||
|
||||
test('downloadChapter: 已有普通 EPUB 时保留原文件并另建可合并的漫画 EPUB', async () => {
|
||||
const root = freshRoot('plain-epub');
|
||||
installImageHandler();
|
||||
const plain = path.join(root, 'files', 'plain.epub');
|
||||
fs.writeFileSync(plain, 'not a manga epub');
|
||||
const entry = store.add({
|
||||
title: '测试漫画',
|
||||
sourceId: 'mangadex',
|
||||
sourcePostId: 'manga-1',
|
||||
files: [{ path: plain, name: 'plain.epub', format: 'EPUB' }]
|
||||
});
|
||||
|
||||
const result = await mangaDownload.downloadChapter(
|
||||
sourceFor(['one.jpg']),
|
||||
store,
|
||||
payload('chapter-1', '1')
|
||||
);
|
||||
assert.strictEqual(result.entry.id, entry.id);
|
||||
const epubs = result.entry.files.filter((file) => /\.epub$/i.test(file.path));
|
||||
assert.strictEqual(epubs.length, 2);
|
||||
const generated = epubs.find((file) => file.path !== plain);
|
||||
assert.strictEqual(mangaEpub.isMangaEpub(fs.readFileSync(generated.path), 'manga-1'), true);
|
||||
});
|
||||
|
||||
test('extFromUrl: 忽略查询参数并为无扩展名地址回退到 jpg', () => {
|
||||
assert.strictEqual(mangaDownload.extFromUrl('https://x.test/p.webp?token=1'), 'webp');
|
||||
assert.strictEqual(mangaDownload.extFromUrl('https://x.test/image'), 'jpg');
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const mangaEpub = require('../library/manga-epub');
|
||||
const zip = require('../zip');
|
||||
|
||||
function img(byte, ext) {
|
||||
return { ext: ext || 'jpg', data: Buffer.from([byte, byte, byte]) };
|
||||
}
|
||||
|
||||
test('createMangaEpub: 产出的 zip 含标准 EPUB 骨架与章节旁路索引', () => {
|
||||
const { bytes, chapterCount, pageCount } = mangaEpub.createMangaEpub({
|
||||
title: '测试漫画',
|
||||
mangaId: 'manga-1',
|
||||
originalLanguage: 'ja',
|
||||
chapterId: 'ch-1',
|
||||
label: '第 1 话',
|
||||
volume: '1',
|
||||
chapter: '1',
|
||||
translatedLanguage: 'zh',
|
||||
group: '汉化组',
|
||||
images: [img(1), img(2), img(3)]
|
||||
});
|
||||
assert.strictEqual(chapterCount, 1);
|
||||
assert.strictEqual(pageCount, 3);
|
||||
|
||||
const entries = zip.readZip(bytes);
|
||||
assert.ok(entries.has('mimetype'));
|
||||
assert.strictEqual(entries.get('mimetype').toString('utf8'), 'application/epub+zip');
|
||||
assert.ok(entries.has('META-INF/container.xml'));
|
||||
assert.ok(entries.has('content.opf'));
|
||||
assert.ok(entries.has('nav.xhtml'));
|
||||
assert.ok(entries.has('images/ch-1/0001.jpg'));
|
||||
assert.ok(entries.has('chapters/ch-1/p0001.xhtml'));
|
||||
|
||||
const meta = JSON.parse(entries.get('peoplelib/chapters.json').toString('utf8'));
|
||||
assert.strictEqual(meta.mangaId, 'manga-1');
|
||||
assert.strictEqual(meta.chapters.length, 1);
|
||||
assert.strictEqual(meta.chapters[0].chapterId, 'ch-1');
|
||||
assert.strictEqual(meta.chapters[0].pageCount, 3);
|
||||
});
|
||||
|
||||
test('appendMangaChapter: 合并新章节,保留旧章节图片字节不变,按卷/话排序', () => {
|
||||
const first = mangaEpub.createMangaEpub({
|
||||
title: '测试漫画',
|
||||
mangaId: 'manga-1',
|
||||
originalLanguage: 'ja',
|
||||
chapterId: 'ch-2',
|
||||
label: '第 2 话',
|
||||
volume: '1',
|
||||
chapter: '2',
|
||||
images: [img(10)]
|
||||
});
|
||||
const { bytes } = mangaEpub.appendMangaChapter(first.bytes, {
|
||||
title: '测试漫画',
|
||||
mangaId: 'manga-1',
|
||||
originalLanguage: 'ja',
|
||||
chapterId: 'ch-1',
|
||||
label: '第 1 话',
|
||||
volume: '1',
|
||||
chapter: '1',
|
||||
images: [img(20), img(21)]
|
||||
});
|
||||
|
||||
const entries = zip.readZip(bytes);
|
||||
assert.ok(entries.has('images/ch-1/0001.jpg'));
|
||||
assert.ok(entries.has('images/ch-2/0001.jpg'));
|
||||
// 旧章节图片字节原样保留,未被重新编码
|
||||
assert.deepStrictEqual([...entries.get('images/ch-2/0001.jpg')], [10, 10, 10]);
|
||||
|
||||
const list = mangaEpub.listChapters(bytes);
|
||||
assert.strictEqual(list.length, 2);
|
||||
// 按 volume/chapter 排序:话 1 应排在话 2 前面,尽管是后追加的
|
||||
assert.strictEqual(list[0].chapterId, 'ch-1');
|
||||
assert.strictEqual(list[1].chapterId, 'ch-2');
|
||||
});
|
||||
|
||||
test('appendMangaChapter: 追加同一章节 ID 时抛出可读错误', () => {
|
||||
const first = mangaEpub.createMangaEpub({
|
||||
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
|
||||
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
|
||||
});
|
||||
assert.throws(
|
||||
() => mangaEpub.appendMangaChapter(first.bytes, {
|
||||
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
|
||||
chapterId: 'ch-1', label: '重复章节', images: [img(2)]
|
||||
}),
|
||||
/该章节已存在/
|
||||
);
|
||||
});
|
||||
|
||||
test('appendMangaChapter: 追加到不同漫画的 EPUB 会被拒绝', () => {
|
||||
const first = mangaEpub.createMangaEpub({
|
||||
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
|
||||
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
|
||||
});
|
||||
assert.throws(
|
||||
() => mangaEpub.appendMangaChapter(first.bytes, {
|
||||
title: '另一部漫画', mangaId: 'manga-2', originalLanguage: 'ja',
|
||||
chapterId: 'ch-9', label: '第 9 话', images: [img(2)]
|
||||
}),
|
||||
/不是同一部漫画/
|
||||
);
|
||||
});
|
||||
|
||||
test('appendMangaChapter: 目标不是漫画 EPUB(缺少旁路索引)时给出可操作提示', () => {
|
||||
const plain = zip.writeZip([{ name: 'mimetype', data: Buffer.from('application/epub+zip') }]);
|
||||
assert.throws(
|
||||
() => mangaEpub.appendMangaChapter(plain, {
|
||||
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
|
||||
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
|
||||
}),
|
||||
/请改为新建条目/
|
||||
);
|
||||
});
|
||||
|
||||
test('hasChapter: 命中已收录章节,未收录或非法数据都返回 false', () => {
|
||||
const { bytes } = mangaEpub.createMangaEpub({
|
||||
title: '测试漫画', mangaId: 'manga-1', originalLanguage: 'ja',
|
||||
chapterId: 'ch-1', label: '第 1 话', images: [img(1)]
|
||||
});
|
||||
assert.strictEqual(mangaEpub.hasChapter(bytes, 'ch-1'), true);
|
||||
assert.strictEqual(mangaEpub.hasChapter(bytes, 'ch-missing'), false);
|
||||
assert.strictEqual(mangaEpub.hasChapter(Buffer.from('not a zip'), 'ch-1'), false);
|
||||
});
|
||||
|
||||
test('compareChapters: 缺省卷号/话号排到最后', () => {
|
||||
const withNum = { volume: '1', chapter: '1' };
|
||||
const withoutNum = { volume: '', chapter: '' };
|
||||
assert.ok(mangaEpub.compareChapters(withNum, withoutNum) < 0);
|
||||
assert.ok(mangaEpub.compareChapters(withoutNum, withNum) > 0);
|
||||
});
|
||||
|
||||
test('漫画 EPUB 用 sourceId 隔离不同站点的同名 ID', () => {
|
||||
const created = mangaEpub.createMangaEpub({
|
||||
title: '中文漫画',
|
||||
sourceId: 'copymanga',
|
||||
mangaId: 'same-id',
|
||||
originalLanguage: 'zh',
|
||||
chapterId: 'chapter-1',
|
||||
label: '第 1 话',
|
||||
images: [img(1)]
|
||||
});
|
||||
const parsed = mangaEpub.parseMangaEpub(created.bytes);
|
||||
assert.strictEqual(parsed.sourceId, 'copymanga');
|
||||
assert.strictEqual(mangaEpub.isMangaEpub(created.bytes, 'same-id', 'copymanga'), true);
|
||||
assert.strictEqual(mangaEpub.isMangaEpub(created.bytes, 'same-id', 'mangadex'), false);
|
||||
assert.throws(() => mangaEpub.appendMangaChapter(parsed, {
|
||||
title: '另一来源',
|
||||
sourceId: 'mangadex',
|
||||
mangaId: 'same-id',
|
||||
chapterId: 'chapter-2',
|
||||
label: '第 2 话',
|
||||
images: [img(2)]
|
||||
}), /不是同一部漫画/);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
const assert = require('node:assert');
|
||||
const { test, afterEach } = require('node:test');
|
||||
const mangaDownload = require('../library/manga-download');
|
||||
const sessions = require('../manga-online/session');
|
||||
const { responseBytes } = require('../sources/http');
|
||||
|
||||
const originalFetchOnlineImage = mangaDownload.fetchOnlineImage;
|
||||
|
||||
afterEach(() => {
|
||||
sessions.reset();
|
||||
mangaDownload.fetchOnlineImage = originalFetchOnlineImage;
|
||||
});
|
||||
|
||||
function source(overrides) {
|
||||
return {
|
||||
id: 'fixture-manga',
|
||||
chapters: async (_mangaId, page, options) => ({
|
||||
page,
|
||||
maxPage: 2,
|
||||
items: [
|
||||
{ chapterId: 'c1', label: '第 1 话', pages: 2, group: options.language },
|
||||
{ chapterId: 'external', label: '站外章节', external: true },
|
||||
{ chapterId: 'missing', label: '不可用章节', unavailable: true }
|
||||
]
|
||||
}),
|
||||
chapterImageUrls: async () => ({
|
||||
urls: ['https://image.test/1.png', 'https://image.test/2.png'],
|
||||
headers: { Referer: 'https://reader.test/' },
|
||||
mustReport: false
|
||||
}),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('在线漫画会话与窗口 sender 绑定,并只授权目录里可用的章节', async () => {
|
||||
const created = sessions.create(11, source(), {
|
||||
mangaId: 'manga-1',
|
||||
title: '在线漫画',
|
||||
language: 'zh-hk',
|
||||
quality: 'data'
|
||||
});
|
||||
assert.strictEqual(created.title, '在线漫画');
|
||||
assert.strictEqual(created.quality, 'data');
|
||||
await assert.rejects(
|
||||
Promise.resolve().then(() => sessions.meta(12, created.sessionId)),
|
||||
/会话无效/
|
||||
);
|
||||
|
||||
const chapters = await sessions.chapters(11, created.sessionId, 1);
|
||||
assert.deepStrictEqual(chapters.items.map((item) => item.chapterId), ['c1']);
|
||||
assert.strictEqual(chapters.items[0].group, 'zh-hk');
|
||||
await assert.rejects(
|
||||
sessions.chapterManifest(11, created.sessionId, 'external'),
|
||||
/不属于当前在线漫画会话/
|
||||
);
|
||||
const manifest = await sessions.chapterManifest(11, created.sessionId, 'c1');
|
||||
assert.strictEqual(manifest.pages, 2);
|
||||
});
|
||||
|
||||
test('在线图片由主进程代理,节点失败时刷新地址后重试并校验图片格式', async () => {
|
||||
let sourceCalls = 0;
|
||||
let imageCalls = 0;
|
||||
const mangaSource = source({
|
||||
chapterImageUrls: async () => {
|
||||
sourceCalls++;
|
||||
return {
|
||||
urls: [
|
||||
`https://image.test/${sourceCalls}/1.png`,
|
||||
`https://image.test/${sourceCalls}/2.png`
|
||||
],
|
||||
headers: { Referer: 'https://reader.test/' },
|
||||
mustReport: true
|
||||
};
|
||||
}
|
||||
});
|
||||
mangaDownload.fetchOnlineImage = async (url, headers, mustReport) => {
|
||||
imageCalls++;
|
||||
assert.strictEqual(headers.Referer, 'https://reader.test/');
|
||||
assert.strictEqual(mustReport, true);
|
||||
if (/\/1\//.test(url)) throw new Error('节点失败');
|
||||
assert.match(url, /\/2\/[12]\.png$/);
|
||||
return Buffer.from('89504e470d0a1a0a00000000', 'hex');
|
||||
};
|
||||
|
||||
const created = sessions.create(21, mangaSource, { mangaId: 'manga-2' });
|
||||
await sessions.chapters(21, created.sessionId, 1);
|
||||
const manifest = await sessions.chapterManifest(21, created.sessionId, 'c1');
|
||||
const images = await Promise.all([
|
||||
sessions.image(21, created.sessionId, manifest.manifestId, 0),
|
||||
sessions.image(21, created.sessionId, manifest.manifestId, 1)
|
||||
]);
|
||||
assert.ok(images.every((image) => image.mimeType === 'image/png'));
|
||||
assert.strictEqual(imageCalls, 4);
|
||||
assert.strictEqual(sourceCalls, 2);
|
||||
});
|
||||
|
||||
test('关闭窗口会回收其全部在线漫画会话', () => {
|
||||
const first = sessions.create(31, source(), { mangaId: 'one' });
|
||||
const second = sessions.create(31, source(), { mangaId: 'two' });
|
||||
sessions.closeOwner(31);
|
||||
assert.throws(() => sessions.meta(31, first.sessionId), /会话无效/);
|
||||
assert.throws(() => sessions.meta(31, second.sessionId), /会话无效/);
|
||||
});
|
||||
|
||||
test('快速切章时较慢的旧清单不会覆盖当前章节', async () => {
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
|
||||
const mangaSource = source({
|
||||
chapters: async () => ({
|
||||
items: [
|
||||
{ chapterId: 'c1', label: '第 1 话' },
|
||||
{ chapterId: 'c2', label: '第 2 话' }
|
||||
],
|
||||
page: 1,
|
||||
maxPage: 1
|
||||
}),
|
||||
chapterImageUrls: async (chapterId) => {
|
||||
if (chapterId === 'c1') await firstGate;
|
||||
return { urls: [`https://image.test/${chapterId}.png`], mustReport: false };
|
||||
}
|
||||
});
|
||||
const created = sessions.create(41, mangaSource, { mangaId: 'switching' });
|
||||
await sessions.chapters(41, created.sessionId, 1);
|
||||
const first = sessions.chapterManifest(41, created.sessionId, 'c1');
|
||||
const second = await sessions.chapterManifest(41, created.sessionId, 'c2');
|
||||
releaseFirst();
|
||||
await assert.rejects(first, /章节加载已取消/);
|
||||
assert.strictEqual(second.pages, 1);
|
||||
});
|
||||
|
||||
test('有界响应读取会在分块图片超限时立即取消', async () => {
|
||||
let pulls = 0;
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({
|
||||
pull(controller) {
|
||||
pulls++;
|
||||
controller.enqueue(new Uint8Array(4));
|
||||
if (pulls >= 10) controller.close();
|
||||
},
|
||||
cancel() { cancelled = true; }
|
||||
});
|
||||
const result = await responseBytes(new Response(body), 6);
|
||||
assert.strictEqual(result, null);
|
||||
assert.strictEqual(cancelled, true);
|
||||
assert.ok(pulls < 10);
|
||||
});
|
||||
@@ -23,6 +23,19 @@ test('注册表:每个源都实现完整接口', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('注册表:两个可用漫画源都提供章节制下载能力与分类', () => {
|
||||
const list = sources.listSources();
|
||||
for (const id of ['mangadex', 'copymanga']) {
|
||||
const entry = list.find((source) => source.id === id);
|
||||
assert.strictEqual(entry.chapterBased, true);
|
||||
assert.strictEqual(entry.category, 'manga');
|
||||
const source = sources.getSource(id);
|
||||
assert.strictEqual(typeof source.chapters, 'function');
|
||||
assert.strictEqual(typeof source.downloadChapter, 'function');
|
||||
}
|
||||
assert.strictEqual(list.find((source) => source.id === 'copymanga').experimental, true);
|
||||
});
|
||||
|
||||
test('注册表:开放教材与中英文维基文库已启用', () => {
|
||||
const ids = sources.listSources().map((source) => source.id);
|
||||
for (const id of ['openstax', 'opentextbook', 'wikisource-zh', 'wikisource-en']) {
|
||||
@@ -710,3 +723,252 @@ test('gutenberg: 解析格式与封面', async () => {
|
||||
assert.strictEqual(r.items[0].cover, 'https://x/c.jpg');
|
||||
assert.strictEqual(r.maxPage, 2);
|
||||
});
|
||||
|
||||
// --- MangaDex ---
|
||||
|
||||
test('mangadex: 列表映射本地化标题、作者与封面,并传递安全分级', async () => {
|
||||
h.resetCalls();
|
||||
h.setHandler(h.routes([['api.mangadex.org/manga?', {
|
||||
body: {
|
||||
total: 41,
|
||||
data: [{
|
||||
id: 'manga-1',
|
||||
attributes: {
|
||||
title: { ja: '原题', en: 'English title' },
|
||||
year: 2024
|
||||
},
|
||||
relationships: [
|
||||
{ type: 'cover_art', attributes: { fileName: 'cover.jpg' } },
|
||||
{ type: 'author', attributes: { name: '作者' } },
|
||||
{ type: 'artist', attributes: { name: '画师' } }
|
||||
]
|
||||
}]
|
||||
}
|
||||
}]]));
|
||||
|
||||
const result = await sources.getSource('mangadex').list(2);
|
||||
assert.strictEqual(result.page, 2);
|
||||
assert.strictEqual(result.maxPage, 3);
|
||||
assert.deepStrictEqual(result.items[0], {
|
||||
postId: 'manga-1',
|
||||
title: 'English title',
|
||||
cover: 'https://uploads.mangadex.org/covers/manga-1/cover.jpg.512.jpg',
|
||||
date: '2024',
|
||||
url: 'https://mangadex.org/title/manga-1',
|
||||
subtitle: '作者, 画师'
|
||||
});
|
||||
|
||||
const request = new URL(h.getCalls().at(-1).url);
|
||||
assert.strictEqual(request.searchParams.get('offset'), '20');
|
||||
assert.deepStrictEqual(request.searchParams.getAll('contentRating[]'), ['safe', 'suggestive']);
|
||||
assert.deepStrictEqual(request.searchParams.getAll('includes[]'), ['cover_art', 'author', 'artist']);
|
||||
});
|
||||
|
||||
test('mangadex: 空搜索词直接返回空结果,不发起网络请求', async () => {
|
||||
h.resetCalls();
|
||||
const result = await sources.getSource('mangadex').search(' ', 3);
|
||||
assert.deepStrictEqual(result, { items: [], maxPage: 1, page: 3 });
|
||||
assert.strictEqual(h.getCalls().length, 0);
|
||||
});
|
||||
|
||||
test('mangadex: 详情映射状态、分级、标签与原始语言', async () => {
|
||||
h.setHandler(h.routes([['api.mangadex.org/manga/manga-1?', {
|
||||
body: {
|
||||
data: {
|
||||
id: 'manga-1',
|
||||
attributes: {
|
||||
title: { zh: '中文标题' },
|
||||
description: { zh: '简介' },
|
||||
status: 'ongoing',
|
||||
publicationDemographic: 'shounen',
|
||||
originalLanguage: 'ja',
|
||||
year: 2023,
|
||||
tags: [{ attributes: { name: { en: 'Action' } } }]
|
||||
},
|
||||
relationships: [{ type: 'author', attributes: { name: '作者' } }]
|
||||
}
|
||||
}
|
||||
}]]));
|
||||
|
||||
const result = await sources.getSource('mangadex').detail('manga-1');
|
||||
assert.strictEqual(result.title, '中文标题');
|
||||
assert.strictEqual(result.brief, '简介');
|
||||
assert.deepStrictEqual(result.tags, ['状态:连载中', '分级:少年', '标签:Action']);
|
||||
assert.strictEqual(result.originalLanguage, 'ja');
|
||||
assert.deepStrictEqual(result.authors, ['作者']);
|
||||
});
|
||||
|
||||
test('mangadex: 章节列表映射翻译组、站外与不可用状态', async () => {
|
||||
h.resetCalls();
|
||||
h.setHandler(h.routes([['/manga/manga-1/feed?', {
|
||||
body: {
|
||||
total: 101,
|
||||
data: [{
|
||||
id: 'chapter-1',
|
||||
attributes: {
|
||||
volume: '2',
|
||||
chapter: '3.5',
|
||||
title: '番外',
|
||||
translatedLanguage: 'zh-hk',
|
||||
pages: 18,
|
||||
publishAt: '2026-01-02T00:00:00Z',
|
||||
externalUrl: 'https://example.test/read',
|
||||
isUnavailable: true
|
||||
},
|
||||
relationships: [{ type: 'scanlation_group', attributes: { name: '翻译组' } }]
|
||||
}]
|
||||
}
|
||||
}]]));
|
||||
|
||||
const result = await sources.getSource('mangadex').chapters('manga-1', 1, { language: 'zh-hk' });
|
||||
assert.strictEqual(result.maxPage, 2);
|
||||
assert.deepStrictEqual(result.items[0], {
|
||||
chapterId: 'chapter-1',
|
||||
volume: '2',
|
||||
chapter: '3.5',
|
||||
title: '番外',
|
||||
label: '第 2 卷 第 3.5 话 番外',
|
||||
translatedLanguage: 'zh-hk',
|
||||
pages: 18,
|
||||
publishAt: '2026-01-02T00:00:00Z',
|
||||
group: '翻译组',
|
||||
external: true,
|
||||
unavailable: true
|
||||
});
|
||||
const request = new URL(h.getCalls().at(-1).url);
|
||||
assert.deepStrictEqual(request.searchParams.getAll('translatedLanguage[]'), ['zh-hk']);
|
||||
});
|
||||
|
||||
test('mangadex: 原图与压缩图使用正确的 MangaDex@Home 路径', async () => {
|
||||
const source = sources.getSource('mangadex');
|
||||
const originalAtHome = source.atHome;
|
||||
source.atHome = async () => ({
|
||||
baseUrl: 'https://node.example.test',
|
||||
chapter: {
|
||||
hash: 'hash-1',
|
||||
data: ['one.jpg'],
|
||||
dataSaver: ['one-small.jpg']
|
||||
}
|
||||
});
|
||||
try {
|
||||
const original = await source.chapterImageUrls('chapter-1', 'data');
|
||||
assert.deepStrictEqual(original.urls, ['https://node.example.test/data/hash-1/one.jpg']);
|
||||
assert.strictEqual(original.mustReport, true);
|
||||
|
||||
const saver = await source.chapterImageUrls('chapter-1', 'dataSaver');
|
||||
assert.deepStrictEqual(saver.urls, ['https://node.example.test/data-saver/hash-1/one-small.jpg']);
|
||||
assert.strictEqual(saver.mustReport, true);
|
||||
} finally {
|
||||
source.atHome = originalAtHome;
|
||||
}
|
||||
});
|
||||
|
||||
test('mangadex: 官方图片域名无需上报,下载整部时提示先选章节', async () => {
|
||||
const source = sources.getSource('mangadex');
|
||||
const originalAtHome = source.atHome;
|
||||
source.atHome = async () => ({
|
||||
baseUrl: 'https://uploads.mangadex.org',
|
||||
chapter: { hash: 'hash-1', data: ['one.png'], dataSaver: ['one.png'] }
|
||||
});
|
||||
try {
|
||||
const result = await source.chapterImageUrls('chapter-1', 'data');
|
||||
assert.strictEqual(result.mustReport, false);
|
||||
} finally {
|
||||
source.atHome = originalAtHome;
|
||||
}
|
||||
await assert.rejects(source.download('manga-1'), /选择要下载的具体章节/);
|
||||
});
|
||||
|
||||
// --- 拷贝漫画 ---
|
||||
|
||||
test('copymanga: 列表与搜索使用移动端 API 的分页结构', async () => {
|
||||
h.setHandler(h.routes([
|
||||
['/api/v3/comics?', {
|
||||
body: {
|
||||
results: {
|
||||
total: 43,
|
||||
list: [{
|
||||
name: '漫画甲',
|
||||
path_word: 'comic-a',
|
||||
cover: 'https://img/a.jpg',
|
||||
author: [{ name: '作者甲' }],
|
||||
datetime_updated: '2026-08-08'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}],
|
||||
['/api/v3/search/comic?', {
|
||||
body: {
|
||||
results: {
|
||||
total: 1,
|
||||
list: [{ name: '漫画乙', path_word: 'comic-b', author: [] }]
|
||||
}
|
||||
}
|
||||
}]
|
||||
]));
|
||||
const source = sources.getSource('copymanga');
|
||||
const listed = await source.list(2);
|
||||
assert.strictEqual(listed.maxPage, 3);
|
||||
assert.strictEqual(listed.items[0].subtitle, '作者甲');
|
||||
const searched = await source.search('漫画', 1);
|
||||
assert.strictEqual(searched.items[0].postId, 'comic-b');
|
||||
});
|
||||
|
||||
test('copymanga: 详情、分组章节和章节图片按当前 v3 API 映射', async () => {
|
||||
h.resetCalls();
|
||||
h.setHandler(h.routes([
|
||||
['/api/v3/comic2/comic-a?', {
|
||||
body: {
|
||||
results: {
|
||||
comic: {
|
||||
uuid: 'book-1',
|
||||
name: '漫画甲',
|
||||
path_word: 'comic-a',
|
||||
cover: 'https://img/cover.jpg',
|
||||
reclass: { value: 1, display: '漫画' },
|
||||
region: { display: '日本' },
|
||||
status: { display: '连载中' },
|
||||
author: [{ name: '作者' }],
|
||||
theme: [{ name: '冒险' }],
|
||||
brief: '简介',
|
||||
datetime_updated: '2026-08-08'
|
||||
},
|
||||
groups: { default: { path_word: 'default', count: 2, name: '默认' } }
|
||||
}
|
||||
}
|
||||
}],
|
||||
['/group/default/chapters?', {
|
||||
body: {
|
||||
results: {
|
||||
list: [
|
||||
{ uuid: 'c1', name: '第 1 话', ordered: 10, size: 12, datetime_created: '2026-01-01' },
|
||||
{ uuid: 'c2', name: '第 2 话', ordered: 20, size: 13, datetime_created: '2026-01-02' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}],
|
||||
['/chapter/c1?', {
|
||||
body: {
|
||||
results: {
|
||||
chapter: {
|
||||
contents: [{ url: 'https://img/1.jpg' }, { url: 'https://img/2.webp' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
]));
|
||||
const source = sources.getSource('copymanga');
|
||||
const detail = await source.detail('comic-a');
|
||||
assert.deepStrictEqual(detail.tags, ['状态:连载中', '地区:日本', '标签:冒险']);
|
||||
const chapters = await source.chapters('comic-a', 1);
|
||||
assert.deepStrictEqual(chapters.items.map((item) => item.chapterId), ['comic-a||c1', 'comic-a||c2']);
|
||||
assert.strictEqual(chapters.items[0].chapter, '1');
|
||||
await source.chapters('comic-a', 1);
|
||||
assert.strictEqual(
|
||||
h.getCalls().filter((call) => call.url.includes('/group/default/chapters?')).length,
|
||||
1,
|
||||
'重复翻页不应重新抓取完整章节目录'
|
||||
);
|
||||
const images = await source.chapterImageUrls('comic-a||c1');
|
||||
assert.deepStrictEqual(images.urls, ['https://img/1.jpg', 'https://img/2.webp']);
|
||||
});
|
||||
|
||||
@@ -240,6 +240,54 @@ test('Z-Library 进入详情不消耗下载额度,点击下载后才解析并
|
||||
assert.match(onDemand, /每日免费下载额度有限,仅在点击后获取下载地址/);
|
||||
});
|
||||
|
||||
test('漫画源按分类显示,MangaDex 可筛选中文章节并设置图片画质', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
|
||||
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
|
||||
assert.match(html, /id="mangadexQualitySelect"[\s\S]*value="dataSaver"[\s\S]*value="data"/);
|
||||
assert.match(html, /id="mangadexLanguageSelect"[\s\S]*value="zh"[\s\S]*value="zh-hk"[\s\S]*value="all"/);
|
||||
assert.match(app, /settings\.get\('mangadex\.imageQuality', 'dataSaver'\)/);
|
||||
assert.match(app, /settings\.set\('mangadex\.imageQuality'/);
|
||||
assert.match(app, /settings\.get\('mangadex\.chapterLanguage', 'zh'\)/);
|
||||
assert.match(app, /settings\.set\('mangadex\.chapterLanguage'/);
|
||||
assert.match(browse, /\['academic', '学术论文'\]/);
|
||||
assert.match(browse, /\['manga', '漫画'\]/);
|
||||
assert.match(browse, /<optgroup label="\$\{label\}">/);
|
||||
assert.match(browse, /sourceIsChapterBased/);
|
||||
assert.match(browse, /window\.api\.sources\.chapters\(sourceId, mangaId, page, options\)/);
|
||||
assert.match(browse, /window\.api\.sources\.downloadChapter\(sourceId, payload/);
|
||||
assert.match(browse, /settings\.get\('mangadex\.chapterLanguage', 'zh'\)/);
|
||||
assert.match(browse, /settings\.get\('mangadex\.imageQuality', 'dataSaver'\)/);
|
||||
assert.match(browse, /createDownloadProgress\(row\)/);
|
||||
assert.match(preload, /chapters:\s*\(sourceId, postId, page, options\)[\s\S]*source:chapters/);
|
||||
assert.match(preload, /downloadChapter:\s*\(sourceId, payload, onProgress\).*runChapterDownload/);
|
||||
assert.match(preload, /invokeWithProgress\(\s*'source:chapterProgress',\s*'source:downloadChapter'/);
|
||||
assert.match(preload, /removeListener\(progressChannel, listener\)/);
|
||||
assert.match(css, /\.chapter-toolbar\s*\{/);
|
||||
});
|
||||
|
||||
test('漫画详情提供不入库的整部在线阅读器,并通过独立 preload 懒加载图片', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'manga-online.html'), 'utf8');
|
||||
const script = fs.readFileSync(path.join(__dirname, '..', 'ui', 'manga-online.js'), 'utf8');
|
||||
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'manga-online-preload.js'), 'utf8');
|
||||
|
||||
assert.match(browse, /id="onlineReadBtn">在线阅读整部漫画/);
|
||||
assert.match(browse, /window\.api\.sources\.readOnline\(state\.activeSourceId/);
|
||||
assert.doesNotMatch(browse.slice(browse.indexOf('async function readOnline'), browse.indexOf('async function loadChapters')), /library\.add/);
|
||||
assert.match(html, /Content-Security-Policy" content="default-src 'self'; img-src 'self' blob: data:/);
|
||||
assert.match(html, /id="chapterList"/);
|
||||
assert.match(html, /id="pageStack"/);
|
||||
assert.match(script, /new IntersectionObserver/);
|
||||
assert.match(script, /URL\.createObjectURL\(new Blob/);
|
||||
assert.match(script, /ensureNextChapter/);
|
||||
assert.match(preload, /mangaOnline:image/);
|
||||
assert.doesNotMatch(preload, /library:|reader:bytes|shell:openPath/);
|
||||
});
|
||||
|
||||
test('主窗口在设置旁提供持久化明暗主题切换', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const zip = require('../zip');
|
||||
|
||||
test('writeZip/readZip: 基本往返,文本与二进制内容不失真', () => {
|
||||
const entries = [
|
||||
{ name: 'mimetype', data: Buffer.from('application/epub+zip') },
|
||||
{ name: 'a/b.txt', data: Buffer.from('hello 你好', 'utf8') },
|
||||
{ name: 'c.bin', data: Buffer.from([0, 1, 2, 255, 254, 128]) }
|
||||
];
|
||||
const buf = zip.writeZip(entries);
|
||||
const parsed = zip.readZip(buf);
|
||||
assert.deepStrictEqual([...parsed.keys()], ['mimetype', 'a/b.txt', 'c.bin']);
|
||||
assert.strictEqual(parsed.get('a/b.txt').toString('utf8'), 'hello 你好');
|
||||
assert.deepStrictEqual([...parsed.get('c.bin')], [0, 1, 2, 255, 254, 128]);
|
||||
});
|
||||
|
||||
test('writeZip/readZip: 大文件(跨多个 chunk)内容比特级一致', () => {
|
||||
const big = Buffer.alloc(500000);
|
||||
for (let i = 0; i < big.length; i++) big[i] = i % 256;
|
||||
const buf = zip.writeZip([{ name: 'big.bin', data: big }]);
|
||||
const parsed = zip.readZip(buf);
|
||||
assert.strictEqual(Buffer.compare(parsed.get('big.bin'), big), 0);
|
||||
});
|
||||
|
||||
test('readZip: 能解析第三方(JSZip)产出的 DEFLATE 压缩包', async () => {
|
||||
const JSZip = require('../ui/vendor/jszip.min.js');
|
||||
const jz = new JSZip();
|
||||
jz.file('x.txt', 'DEFLATE 测试内容');
|
||||
const buf = await jz.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
||||
const parsed = zip.readZip(buf);
|
||||
assert.strictEqual(parsed.get('x.txt').toString('utf8'), 'DEFLATE 测试内容');
|
||||
});
|
||||
|
||||
test('writeZip: 产出的包能被第三方(JSZip)正确打开', async () => {
|
||||
const JSZip = require('../ui/vendor/jszip.min.js');
|
||||
const buf = zip.writeZip([
|
||||
{ name: 'mimetype', data: Buffer.from('application/epub+zip') },
|
||||
{ name: 'nested/dir/file.txt', data: Buffer.from('nested content') }
|
||||
]);
|
||||
const jz = await JSZip.loadAsync(buf);
|
||||
assert.ok(jz.file('mimetype'));
|
||||
const text = await jz.file('nested/dir/file.txt').async('string');
|
||||
assert.strictEqual(text, 'nested content');
|
||||
});
|
||||
|
||||
test('readZip: 空 ZIP(EOCD 但无条目)不抛错,返回空 Map', () => {
|
||||
const buf = zip.writeZip([]);
|
||||
const parsed = zip.readZip(buf);
|
||||
assert.strictEqual(parsed.size, 0);
|
||||
});
|
||||
|
||||
test('readZip: 非 ZIP 数据抛出可读错误而不是崩溃', () => {
|
||||
assert.throws(() => zip.readZip(Buffer.from('not a zip file')), /不是有效的 ZIP 文件/);
|
||||
});
|
||||
|
||||
test('readZip: 不把未知压缩方式误当作 STORE 内容', () => {
|
||||
const buf = zip.writeZip([{ name: 'x.bin', data: Buffer.from([1, 2, 3]) }]);
|
||||
const central = buf.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
|
||||
buf.writeUInt16LE(99, central + 10);
|
||||
assert.throws(() => zip.readZip(buf), /压缩方式不受支持/);
|
||||
});
|
||||
|
||||
test('readZip: 条目内容损坏时通过 CRC 拒绝继续读取', () => {
|
||||
const buf = zip.writeZip([{ name: 'x.bin', data: Buffer.from([1, 2, 3]) }]);
|
||||
const dataStart = 30 + Buffer.byteLength('x.bin');
|
||||
buf[dataStart] ^= 0xff;
|
||||
assert.throws(() => zip.readZip(buf), /条目校验失败/);
|
||||
});
|
||||
Reference in New Issue
Block a user