- logic/reader/archive_source.c:档案册页源实现 M6 mr_book 契约 (mz_open/mz_count/mz_entry_at/mz_read_alloc),条目自然序 + 图片后缀 + 路径安全(`..`/绝对路径/反斜杠/隐藏段/内嵌 NUL)+ 单页尺寸上限过滤; meta 按需解压只读图片头(不落缓存,结果按页记忆);mr_book_open_auto 统一目录册/档案册开册,翻页与条漫两套消费代码零改动。 - logic/reader/page_cache.c/h:解压页落 MeowData/<pkg>/cache,LRU 64 页或 32MiB,启动清 mrpage_* 孤儿,写入 .part + rename;与缩略图缓存同目录、 前缀隔离;互斥量保护,解码/缩略图线程可并发。 - logic/library.c/h:扫描直接子文件里的 .cbz/.zip 成册(行=文件名+页数, 点入直接阅读);档案探测由调用方注入(logic 不依赖 zip 容器),不注入时 与 M7 行为逐字一致;册间衔接兄弟表=含图目录∪含页档案册,同列自然序。 条目构造/副标题/角标/兄弟判据拆成小函数,便于 M13 书库重写拼接。 - ui/library:档案册行 + 封面缩略图(解压档内首图)、扫描注入探测、缓存 目录统一由 page_cache 推导(一份规则)。 - ui/reader:open_book 走 mr_book_open_auto;跨册衔接纳入档案册。 - build_tuku.sh 补 zip/archive/cache 源与 sysroot zlib 头(zip.c include <zlib.h>);run_tests.sh 纳入 zip+cbz 用例;run_webtoon_tests.sh 的宿主 集成构建补新源与 -lz;.gitignore 同步测试产物。 - test/test_cbz.c(267 项断言)+ make_cbz_samples.py 真实 cbz 夹具: 列档/自然序/解压字节 CRC/路径安全/坏档与 zip 炸弹容错/LRU 与 32MiB 上限/ 启动清孤儿/续读角标/封面首图。
848 lines
34 KiB
C
848 lines
34 KiB
C
/* test_cbz.c — M11 档案册(CBZ/zip)集成宿主测试(纯 C + -lz,无 SDK/硬件)。
|
||
*
|
||
* 夹具由 test/make_cbz_samples.py 用 python3 zipfile 现场生成(真实 zip 产物 +
|
||
* 定点字节修补),放在 $TMPDIR 下临时目录,结束后删除。
|
||
*
|
||
* 覆盖:
|
||
* T1 扩展名判据:cbz/zip 大小写不敏感;cbr/gz/无扩展名不算档案。
|
||
* T2 页枚举:只收图片页(目录项/文本/0 字节/加密/超限过滤),跨子目录自然序;
|
||
* 中文条目名按原始 UTF-8 字节透传。
|
||
* T3 路径安全:绝对路径、'..' 段、反斜杠、内嵌隐藏段一律不收;解压字节与
|
||
* manifest 的 CRC/尺寸一致,缓存文件落在 MeowData/<pkg>/cache。
|
||
* T4 meta:PNG/BMP/JPEG 原始尺寸经「解压到内存只读头」探测(翻页/条漫排版共用)。
|
||
* T5 解压缓存纪律:64 页 LRU 淘汰最旧、命中不重写(inode 不变)、32MiB 字节上限、
|
||
* 启动清孤儿(只删 mrpage_*,不动缩略图 *.bmp)、缓存不可用时优雅失败。
|
||
* T6 坏档容错:非 zip/截断/无图片页/声明 96MiB 的 zip 炸弹 → 打开失败或 0 页,不崩。
|
||
* T7 统一开册契约:目录册与档案册走同一个 mr_book(count/meta/open/close/destroy),
|
||
* 越界 open 返回 NULL,close_auto 幂等。
|
||
* T8 书库列档:.cbz 与文件夹册同列自然序(bookA < box < vol2.cbz < Vol10.cbz),
|
||
* 行=文件名+页数;坏档/无图档案不列;只含档案册的目录写 "N books";
|
||
* 不注入探测回调时行为与 M7 完全一致(纯目录书库)。
|
||
* T9 册间衔接:同父目录自然序混排(bookA → vol2.cbz → Vol10.cbz),
|
||
* 首/末册返回 0;不注入探测回调时档案册不参与(M7 语义)。
|
||
* T10 续读角标:档案路径作为册标识,副标题后缀「· 续读 Pn」。
|
||
*
|
||
* 编译(在 tuku/ 下):
|
||
* gcc -Wall -Wextra -Werror -O1 -I. test/test_cbz.c logic/library.c logic/reader.c \
|
||
* logic/reader/page_source.c logic/reader/archive_source.c \
|
||
* logic/reader/page_cache.c logic/zip/zip.c test/settings_reader_stub.c \
|
||
* -lz -o test/test_cbz && ./test/test_cbz
|
||
* 或 test/run_cbz_tests.sh。
|
||
*/
|
||
#define _POSIX_C_SOURCE 200809L
|
||
|
||
#include "logic/library.h"
|
||
#include "logic/reader.h"
|
||
#include "logic/reader/page_cache.h"
|
||
#include "logic/reader/page_source.h"
|
||
#include "test/settings_reader_stub.h"
|
||
|
||
#include <dirent.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
#include <sys/wait.h>
|
||
#include <unistd.h>
|
||
#include <zlib.h>
|
||
|
||
#define PATH_CAP 1024
|
||
#define CJK_PAGE "\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.png" /* 中文名.png */
|
||
|
||
static int g_pass;
|
||
static int g_fail;
|
||
static char g_root[PATH_CAP]; /* 夹具根 */
|
||
static char g_lib[PATH_CAP]; /* <root>/library */
|
||
static char g_feat[PATH_CAP]; /* <root>/features */
|
||
static char g_data[PATH_CAP]; /* <root>/app/os.meow.picobrowser/data */
|
||
static const char *g_argv0;
|
||
|
||
#define CHECK(cond, ...) do { \
|
||
if (cond) { \
|
||
g_pass++; \
|
||
} else { \
|
||
g_fail++; \
|
||
printf("FAIL %s:%d ", __FILE__, __LINE__); \
|
||
printf(__VA_ARGS__); \
|
||
printf("\n"); \
|
||
} \
|
||
} while (0)
|
||
|
||
/* ============================================================ */
|
||
/* 夹具/文件辅助 */
|
||
/* ============================================================ */
|
||
static void joinp(char *out, size_t cap, const char *a, const char *b)
|
||
{
|
||
snprintf(out, cap, "%s/%s", a, b);
|
||
}
|
||
|
||
static int rm_rf(const char *path)
|
||
{
|
||
struct stat st;
|
||
if (lstat(path, &st) != 0) return -1;
|
||
if (!S_ISDIR(st.st_mode)) return unlink(path);
|
||
DIR *d = opendir(path);
|
||
if (!d) return -1;
|
||
struct dirent *de;
|
||
while ((de = readdir(d)) != NULL) {
|
||
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue;
|
||
char full[PATH_CAP];
|
||
joinp(full, sizeof full, path, de->d_name);
|
||
rm_rf(full);
|
||
}
|
||
closedir(d);
|
||
return rmdir(path);
|
||
}
|
||
|
||
static int make_temp_dir(void)
|
||
{
|
||
const char *bases[3];
|
||
int nb = 0;
|
||
const char *env = getenv("TMPDIR");
|
||
if (env && env[0]) bases[nb++] = env;
|
||
bases[nb++] = "/data/data/com.termux/files/usr/tmp";
|
||
bases[nb++] = "/tmp";
|
||
|
||
for (int i = 0; i < nb; i++) {
|
||
char tmpl[PATH_CAP];
|
||
snprintf(tmpl, sizeof tmpl, "%s/tuku_cbz_XXXXXX", bases[i]);
|
||
if (mkdtemp(tmpl)) {
|
||
snprintf(g_root, sizeof g_root, "%s", tmpl);
|
||
return 1;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static const char *find_generator(char *buf, size_t cap)
|
||
{
|
||
static const char *fallbacks[] = {
|
||
"test/make_cbz_samples.py", "make_cbz_samples.py",
|
||
};
|
||
const char *a0 = g_argv0 ? g_argv0 : "";
|
||
const char *slash = strrchr(a0, '/');
|
||
if (a0[0] && strcmp(a0, "-") != 0 && slash) {
|
||
snprintf(buf, cap, "%.*s/make_cbz_samples.py", (int)(slash - a0), a0);
|
||
if (access(buf, R_OK) == 0) return buf;
|
||
}
|
||
for (size_t i = 0; i < sizeof fallbacks / sizeof fallbacks[0]; i++)
|
||
if (access(fallbacks[i], R_OK) == 0) return fallbacks[i];
|
||
return fallbacks[0];
|
||
}
|
||
|
||
static int run_generator(const char *gen)
|
||
{
|
||
char cmd[PATH_CAP * 2 + 32];
|
||
snprintf(cmd, sizeof cmd, "python3 '%s' '%s'", gen, g_root);
|
||
int st = system(cmd);
|
||
return st != -1 && WIFEXITED(st) && WEXITSTATUS(st) == 0;
|
||
}
|
||
|
||
static int file_crc(const char *path, unsigned long *crc, long *size)
|
||
{
|
||
FILE *f = fopen(path, "rb");
|
||
if (!f) return 0;
|
||
unsigned char buf[8192];
|
||
uLong c = crc32(0L, Z_NULL, 0);
|
||
long total = 0;
|
||
size_t n;
|
||
while ((n = fread(buf, 1, sizeof buf, f)) > 0) {
|
||
c = crc32(c, buf, (uInt)n);
|
||
total += (long)n;
|
||
}
|
||
fclose(f);
|
||
if (crc) *crc = (unsigned long)c;
|
||
if (size) *size = total;
|
||
return 1;
|
||
}
|
||
|
||
/* manifest.txt 行:<档案相对路径>\t<条目名>\t<crc32>\t<size> */
|
||
static int manifest_lookup(const char *rel, const char *entry,
|
||
unsigned long *crc, long *size)
|
||
{
|
||
char path[PATH_CAP];
|
||
joinp(path, sizeof path, g_root, "manifest.txt");
|
||
FILE *f = fopen(path, "r");
|
||
if (!f) return 0;
|
||
char line[PATH_CAP * 2];
|
||
char m_rel[PATH_CAP], m_ent[512];
|
||
unsigned long m_crc;
|
||
long m_size;
|
||
int found = 0;
|
||
while (fgets(line, sizeof line, f) != NULL) {
|
||
if (sscanf(line, "%1023[^\t]\t%511[^\t]\t%lx\t%ld",
|
||
m_rel, m_ent, &m_crc, &m_size) != 4)
|
||
continue;
|
||
if (strcmp(m_rel, rel) == 0 && strcmp(m_ent, entry) == 0) {
|
||
if (crc) *crc = m_crc;
|
||
if (size) *size = m_size;
|
||
found = 1;
|
||
break;
|
||
}
|
||
}
|
||
fclose(f);
|
||
return found;
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* 假解码回调(契约边界:logic 不链 SDK) */
|
||
/* ============================================================ */
|
||
static int g_dummy;
|
||
static int g_load_n;
|
||
static int g_unload_n;
|
||
static char g_load_path[MR_CACHE_FILE_MAX];
|
||
|
||
static meow_img *fake_load(const char *path, void *ud, int maxdim)
|
||
{
|
||
(void)ud;
|
||
(void)maxdim;
|
||
g_load_n++;
|
||
snprintf(g_load_path, sizeof g_load_path, "%s", path ? path : "");
|
||
return (meow_img *)&g_dummy;
|
||
}
|
||
|
||
static void fake_unload(meow_img *img)
|
||
{
|
||
if (img) g_unload_n++;
|
||
}
|
||
|
||
static int archive_pages_probe(const char *path, void *ud)
|
||
{
|
||
(void)ud;
|
||
return mr_zip_count_pages(path);
|
||
}
|
||
|
||
static int count_cache_files(void)
|
||
{
|
||
const char *dir = mr_cache_dir();
|
||
if (!dir || !dir[0]) return -1;
|
||
DIR *d = opendir(dir);
|
||
if (!d) return -1;
|
||
int n = 0;
|
||
size_t plen = strlen(MR_CACHE_PREFIX);
|
||
struct dirent *de;
|
||
while ((de = readdir(d)) != NULL) {
|
||
if (strncmp(de->d_name, MR_CACHE_PREFIX, plen) == 0) n++;
|
||
}
|
||
closedir(d);
|
||
return n;
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* T1 扩展名 */
|
||
/* ============================================================ */
|
||
static void test_ext(void)
|
||
{
|
||
CHECK(mr_path_is_archive("a.cbz") == 1, "cbz");
|
||
CHECK(mr_path_is_archive("A.CBZ") == 1, "CBZ");
|
||
CHECK(mr_path_is_archive("x.zip") == 1, "zip");
|
||
CHECK(mr_path_is_archive("X.ZiP") == 1, "ZiP");
|
||
CHECK(mr_path_is_archive("a.cbr") == 0, "cbr not archive");
|
||
CHECK(mr_path_is_archive("a.gz") == 0, "gz not archive");
|
||
CHECK(mr_path_is_archive("cbz") == 0, "no extension");
|
||
CHECK(mr_path_is_archive(".cbz") == 0, "dot name");
|
||
CHECK(mr_path_is_archive(NULL) == 0, "NULL safe");
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* T2 页枚举 / 排序 / 名字透传 */
|
||
/* ============================================================ */
|
||
static void test_enum(void)
|
||
{
|
||
char path[PATH_CAP];
|
||
joinp(path, sizeof path, g_lib, "vol2.cbz");
|
||
|
||
mr_zip_book zb;
|
||
int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL);
|
||
CHECK(n == 3, "vol2 pages=%d (want 3)", n);
|
||
CHECK(zb.count == 3, "zb.count=%d", zb.count);
|
||
if (n != 3) {
|
||
mr_zip_book_free(&zb);
|
||
return;
|
||
}
|
||
|
||
char name[512];
|
||
mr_zip_book_page_name(&zb, 0, name, sizeof name);
|
||
CHECK(strcmp(name, "ch01/p2.png") == 0, "page0=%s", name);
|
||
mr_zip_book_page_name(&zb, 1, name, sizeof name);
|
||
CHECK(strcmp(name, "ch01/p10.png") == 0, "page1=%s", name);
|
||
mr_zip_book_page_name(&zb, 2, name, sizeof name);
|
||
CHECK(strcmp(name, CJK_PAGE) == 0, "page2=%s", name);
|
||
|
||
mr_zip_book_page_name(&zb, -1, name, sizeof name);
|
||
CHECK(name[0] == '\0', "page_name(-1) not empty");
|
||
mr_zip_book_page_name(&zb, 3, name, sizeof name);
|
||
CHECK(name[0] == '\0', "page_name(count) not empty");
|
||
mr_zip_book_page_name(&zb, 0, NULL, 10);
|
||
mr_zip_book_page_name(&zb, 0, name, 0);
|
||
|
||
/* 目录项/文本/0 字节条目都被过滤(count==3 已证明)。 */
|
||
|
||
mr_book b;
|
||
memset(&b, 0, sizeof b);
|
||
mr_zip_book_bind(&b, NULL);
|
||
CHECK(!mr_book_valid(&b), "bind NULL must leave book invalid");
|
||
CHECK(mr_book_count(&b) == 0 && mr_book_open(&b, 0) == NULL, "invalid book safe");
|
||
|
||
mr_zip_book_bind(&b, &zb);
|
||
CHECK(mr_book_valid(&b), "valid after bind");
|
||
CHECK(mr_book_count(&b) == 3, "book count=%d", mr_book_count(&b));
|
||
CHECK(mr_book_open(&b, 3) == NULL, "open(count) != NULL");
|
||
CHECK(mr_book_open(&b, -1) == NULL, "open(-1) != NULL");
|
||
|
||
int before = g_load_n;
|
||
meow_img *img = mr_book_open(&b, 0);
|
||
CHECK(img != NULL, "open(0) returned NULL");
|
||
CHECK(g_load_n == before + 1, "load callback not called");
|
||
CHECK(strncmp(g_load_path, mr_cache_dir(), strlen(mr_cache_dir())) == 0,
|
||
"load path not in cache dir: %s", g_load_path);
|
||
CHECK(access(g_load_path, R_OK) == 0, "extracted file missing: %s", g_load_path);
|
||
CHECK(strstr(g_load_path, ".png") != NULL, "extension not preserved: %s", g_load_path);
|
||
mr_book_close(&b, img);
|
||
CHECK(g_unload_n >= 1, "unload not called");
|
||
mr_book_close(&b, NULL); /* 容忍 NULL */
|
||
|
||
mr_book_destroy(&b); /* 走 destroy→zip_free */
|
||
CHECK(!mr_book_valid(&b), "destroy left book valid");
|
||
mr_zip_book_free(&zb); /* 幂等 */
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* T3 路径安全 + 解压内容 */
|
||
/* ============================================================ */
|
||
static void test_pathsafe(void)
|
||
{
|
||
char path[PATH_CAP];
|
||
joinp(path, sizeof path, g_feat, "pathsafe.cbz");
|
||
|
||
mr_zip_book zb;
|
||
int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL);
|
||
CHECK(n == 2, "pathsafe pages=%d (want 2: ok1.png, sub/ok2.jpg)", n);
|
||
if (n != 2) {
|
||
mr_zip_book_free(&zb);
|
||
return;
|
||
}
|
||
|
||
char name[512];
|
||
mr_zip_book_page_name(&zb, 0, name, sizeof name);
|
||
CHECK(strcmp(name, "ok1.png") == 0, "page0=%s", name);
|
||
mr_zip_book_page_name(&zb, 1, name, sizeof name);
|
||
CHECK(strcmp(name, "sub/ok2.jpg") == 0, "page1=%s", name);
|
||
|
||
const char *bad[] = { "../evil.png", "/abs.png", "a/../../b.png", "sub/../ok3.png",
|
||
".hidden/x.png", "._mac.png", "sub\\win.png" };
|
||
for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) {
|
||
CHECK(mr_zip_entry_is_page(bad[i], (unsigned)strlen(bad[i])) == 0,
|
||
"unsafe entry accepted: %s", bad[i]);
|
||
}
|
||
|
||
for (int i = 0; i < n; i++) {
|
||
char out[MR_CACHE_FILE_MAX];
|
||
CHECK(mr_zip_book_page_path(&zb, i, out, (int)sizeof out) == 0,
|
||
"page_path(%d) failed", i);
|
||
if (!out[0]) continue;
|
||
CHECK(strncmp(out, mr_cache_dir(), strlen(mr_cache_dir())) == 0,
|
||
"cache path escaped dir: %s", out);
|
||
mr_zip_book_page_name(&zb, i, name, sizeof name);
|
||
unsigned long crc = 0;
|
||
long size = 0;
|
||
CHECK(manifest_lookup("features/pathsafe.cbz", name, &crc, &size),
|
||
"manifest miss %s", name);
|
||
unsigned long got_crc = 0;
|
||
long got_size = 0;
|
||
CHECK(file_crc(out, &got_crc, &got_size), "crc read failed: %s", out);
|
||
CHECK(got_crc == crc && got_size == size,
|
||
"extracted bytes differ for %s (crc %lx/%lx size %ld/%ld)",
|
||
name, got_crc, crc, got_size, size);
|
||
}
|
||
mr_zip_book_free(&zb);
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* T4 meta(原始像素尺寸) */
|
||
/* ============================================================ */
|
||
static void test_meta(void)
|
||
{
|
||
char path[PATH_CAP];
|
||
joinp(path, sizeof path, g_lib, "vol2.cbz");
|
||
|
||
mr_zip_book zb;
|
||
CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 3,
|
||
"vol2 init for meta");
|
||
mr_book b;
|
||
mr_zip_book_bind(&b, &zb);
|
||
|
||
int w = 0, h = 0;
|
||
CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 41 && h == 31,
|
||
"meta0=%dx%d (want 41x31)", w, h);
|
||
CHECK(mr_book_meta(&b, 1, &w, &h) == 1 && w == 40 && h == 30,
|
||
"meta1=%dx%d (want 40x30)", w, h);
|
||
CHECK(mr_book_meta(&b, 2, &w, &h) == 1 && w == 12 && h == 34,
|
||
"meta2=%dx%d (want 12x34)", w, h);
|
||
CHECK(mr_book_meta(&b, 3, &w, &h) == 0 && w == 0 && h == 0, "meta(count)");
|
||
mr_book_destroy(&b);
|
||
|
||
joinp(path, sizeof path, g_lib, "Vol10.cbz");
|
||
CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 1,
|
||
"Vol10 init");
|
||
mr_zip_book_bind(&b, &zb);
|
||
CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 7 && h == 9,
|
||
"bmp meta=%dx%d (want 7x9)", w, h);
|
||
mr_book_destroy(&b);
|
||
|
||
joinp(path, sizeof path, g_lib, "box/box2.cbz");
|
||
CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 1,
|
||
"box2 init");
|
||
mr_zip_book_bind(&b, &zb);
|
||
CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 12 && h == 34,
|
||
"jpeg meta=%dx%d (want 12x34)", w, h);
|
||
mr_book_destroy(&b);
|
||
|
||
/* 内存探测边界 */
|
||
CHECK(mr_probe_size_mem(NULL, 100, &w, &h) == 0, "probe NULL");
|
||
CHECK(mr_probe_size_mem("ABCDEFGH", 8, &w, &h) == 0, "probe junk");
|
||
|
||
/* meta 只解压到内存读头,不落解压缓存(box1 从未被 open 过)。 */
|
||
joinp(path, sizeof path, g_lib, "box/box1.cbz");
|
||
CHECK(mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL) == 2,
|
||
"box1 init");
|
||
mr_zip_book_bind(&b, &zb);
|
||
CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 8 && h == 8,
|
||
"box1 meta=%dx%d (want 8x8)", w, h);
|
||
struct stat st;
|
||
char pname[256], cpath[MR_CACHE_FILE_MAX];
|
||
CHECK(stat(path, &st) == 0, "stat box1");
|
||
mr_zip_book_page_name(&zb, 0, pname, (int)sizeof pname);
|
||
CHECK(mr_cache_path(path, (long long)st.st_mtime, (long long)st.st_size,
|
||
pname, 0, cpath, (int)sizeof cpath) == 0, "meta cache path");
|
||
CHECK(access(cpath, F_OK) != 0, "meta wrote the page cache");
|
||
mr_book_destroy(&b);
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* T5 解压缓存纪律 */
|
||
/* ============================================================ */
|
||
static void test_cache(void)
|
||
{
|
||
char path[PATH_CAP];
|
||
char data_dir[PATH_CAP];
|
||
char cache_copy[MR_CACHE_DIR_MAX];
|
||
joinp(path, sizeof path, g_feat, "big.cbz");
|
||
|
||
/* ---- (a) 64 页 LRU ---- */
|
||
joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picobrowser/data");
|
||
CHECK(mr_cache_init(data_dir) == 0, "cache init c1");
|
||
snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir());
|
||
CHECK(cache_copy[0] && strstr(cache_copy, "/cache") != NULL,
|
||
"cache dir=%s", cache_copy);
|
||
|
||
mr_zip_book zb;
|
||
int n = mr_zip_book_init(&zb, path, 720, fake_load, fake_unload, NULL);
|
||
CHECK(n == 80, "big pages=%d (want 80)", n);
|
||
char p[MR_CACHE_FILE_MAX];
|
||
for (int i = 0; i < n; i++)
|
||
CHECK(mr_zip_book_page_path(&zb, i, p, (int)sizeof p) == 0,
|
||
"big page %d path", i);
|
||
CHECK(mr_cache_count() == MR_CACHE_MAX_PAGES,
|
||
"cache count=%d (want 64)", mr_cache_count());
|
||
CHECK(mr_cache_bytes() <= MR_CACHE_MAX_BYTES, "cache bytes over cap");
|
||
CHECK(count_cache_files() == mr_cache_count(),
|
||
"disk files=%d index=%d", count_cache_files(), mr_cache_count());
|
||
|
||
/* 最旧的 16 页被淘汰,最新的仍在 */
|
||
struct stat st;
|
||
char arc[PATH_CAP];
|
||
char name[512];
|
||
snprintf(arc, sizeof arc, "%s", path);
|
||
CHECK(stat(arc, &st) == 0, "stat big.cbz");
|
||
char p0[MR_CACHE_FILE_MAX], p79[MR_CACHE_FILE_MAX];
|
||
mr_zip_book_page_name(&zb, 0, name, sizeof name);
|
||
CHECK(mr_cache_path(arc, (long long)st.st_mtime, (long long)st.st_size,
|
||
name, 0, p0, (int)sizeof p0) == 0, "cache_path p0");
|
||
mr_zip_book_page_name(&zb, 79, name, sizeof name);
|
||
CHECK(mr_cache_path(arc, (long long)st.st_mtime, (long long)st.st_size,
|
||
name, 79, p79, (int)sizeof p79) == 0, "cache_path p79");
|
||
CHECK(access(p0, F_OK) != 0, "LRU kept the oldest page");
|
||
CHECK(access(p79, F_OK) == 0, "LRU dropped the newest page");
|
||
|
||
/* 命中不重写:同一 inode */
|
||
struct stat s1, s2;
|
||
CHECK(stat(p79, &s1) == 0, "stat p79");
|
||
CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 re-get");
|
||
CHECK(stat(p, &s2) == 0 && s1.st_ino == s2.st_ino, "cache hit rewrote the file");
|
||
mr_zip_book_free(&zb);
|
||
|
||
/* ---- (b) 32MiB 字节上限 + 启动清孤儿 ---- */
|
||
joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picobrowser/data");
|
||
mr_cache_shutdown();
|
||
CHECK(mr_cache_init(data_dir) == 0, "cache init c2");
|
||
snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir());
|
||
|
||
char orphan[MR_CACHE_FILE_MAX], keep[MR_CACHE_FILE_MAX];
|
||
snprintf(orphan, sizeof orphan, "%s/%sdeadbeef.png", cache_copy, MR_CACHE_PREFIX);
|
||
snprintf(keep, sizeof keep, "%s/thumb.bmp", cache_copy);
|
||
FILE *f = fopen(orphan, "wb");
|
||
CHECK(f != NULL, "create orphan");
|
||
if (f) { fputs("orphan", f); fclose(f); }
|
||
f = fopen(keep, "wb");
|
||
CHECK(f != NULL, "create thumb");
|
||
if (f) { fputs("thumb", f); fclose(f); }
|
||
|
||
mr_cache_shutdown();
|
||
CHECK(mr_cache_init(data_dir) == 0, "cache re-init c2");
|
||
CHECK(access(orphan, F_OK) != 0, "startup did not clean orphan mrpage_*");
|
||
CHECK(access(keep, F_OK) == 0, "startup deleted non-cache file");
|
||
|
||
size_t big_len = 20u * 1024u * 1024u;
|
||
unsigned char *big = (unsigned char *)malloc(big_len);
|
||
CHECK(big != NULL, "malloc 20MiB");
|
||
if (big) {
|
||
memset(big, 0xA5, big_len);
|
||
char b1[MR_CACHE_FILE_MAX], b2[MR_CACHE_FILE_MAX];
|
||
snprintf(b1, sizeof b1, "%s/%sbig1.img", cache_copy, MR_CACHE_PREFIX);
|
||
snprintf(b2, sizeof b2, "%s/%sbig2.img", cache_copy, MR_CACHE_PREFIX);
|
||
mr_cache_shutdown();
|
||
mr_cache_init(data_dir);
|
||
CHECK(mr_cache_store(b1, big, big_len) == 1, "store 20MiB #1");
|
||
CHECK(mr_cache_count() == 1 && mr_cache_bytes() == (long long)big_len,
|
||
"count=%d bytes=%lld", mr_cache_count(), mr_cache_bytes());
|
||
CHECK(mr_cache_store(b2, big, big_len) == 1, "store 20MiB #2");
|
||
CHECK(mr_cache_count() == 1 && mr_cache_bytes() == (long long)big_len,
|
||
"byte cap: count=%d bytes=%lld", mr_cache_count(), mr_cache_bytes());
|
||
CHECK(access(b1, F_OK) != 0, "byte cap did not evict the oldest");
|
||
free(big);
|
||
}
|
||
unlink(keep);
|
||
|
||
/* ---- (c) 缓存不可用时优雅失败(不崩、不写文件) ---- */
|
||
mr_cache_shutdown();
|
||
CHECK(mr_cache_ready() == 0, "shutdown left cache ready");
|
||
joinp(path, sizeof path, g_lib, "vol2.cbz");
|
||
mr_zip_book zb2;
|
||
CHECK(mr_zip_book_init(&zb2, path, 720, fake_load, fake_unload, NULL) == 3,
|
||
"init without cache");
|
||
CHECK(mr_zip_book_page_path(&zb2, 0, p, (int)sizeof p) == -1,
|
||
"page_path without cache should fail");
|
||
int before = g_load_n;
|
||
mr_book b;
|
||
mr_zip_book_bind(&b, &zb2);
|
||
CHECK(mr_book_open(&b, 0) == NULL, "open without cache should fail");
|
||
CHECK(g_load_n == before, "decode callback called without cache");
|
||
mr_book_destroy(&b);
|
||
|
||
joinp(data_dir, sizeof data_dir, g_root, "app/os.meow.picobrowser/data");
|
||
CHECK(mr_cache_init(data_dir) == 0, "cache init app");
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* T6 坏档容错 */
|
||
/* ============================================================ */
|
||
static void expect_bad(const char *path, const char *tag)
|
||
{
|
||
mr_book_handle h;
|
||
mr_book b;
|
||
int n = mr_book_open_auto(&b, &h, path, 720, fake_load, fake_unload, NULL);
|
||
CHECK(n == -1, "%s: open_auto=%d (want -1)", tag, n);
|
||
CHECK(!mr_book_valid(&b), "%s: book valid after failure", tag);
|
||
CHECK(mr_book_open(&b, 0) == NULL, "%s: open after failure", tag);
|
||
mr_book_close_auto(&b, &h);
|
||
mr_book_close_auto(&b, &h); /* 幂等 */
|
||
CHECK(mr_zip_count_pages(path) == 0, "%s: count_pages != 0", tag);
|
||
}
|
||
|
||
static void test_bad(void)
|
||
{
|
||
char p[PATH_CAP];
|
||
|
||
joinp(p, sizeof p, g_lib, "broken.cbz");
|
||
expect_bad(p, "broken");
|
||
joinp(p, sizeof p, g_lib, "truncated.cbz");
|
||
expect_bad(p, "truncated");
|
||
joinp(p, sizeof p, g_feat, "truncated.cbz");
|
||
expect_bad(p, "truncated(feat)");
|
||
|
||
joinp(p, sizeof p, g_lib, "missing.cbz");
|
||
expect_bad(p, "missing");
|
||
|
||
mr_book_handle h;
|
||
mr_book b;
|
||
CHECK(mr_book_open_auto(&b, &h, NULL, 720, fake_load, fake_unload, NULL) == -1,
|
||
"open_auto(NULL)");
|
||
CHECK(mr_book_open_auto(NULL, &h, p, 720, fake_load, fake_unload, NULL) == -1,
|
||
"open_auto(NULL book)");
|
||
CHECK(mr_book_open_auto(&b, NULL, p, 720, fake_load, fake_unload, NULL) == -1,
|
||
"open_auto(NULL handle)");
|
||
|
||
/* 合法 zip 但没有图片页 / 声明尺寸超上限(zip 炸弹) */
|
||
const char *zero[] = { "notabook.zip", "emptypages.cbz" };
|
||
for (size_t i = 0; i < sizeof zero / sizeof zero[0]; i++) {
|
||
joinp(p, sizeof p, g_lib, zero[i]);
|
||
int n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL);
|
||
CHECK(n == 0, "%s: open_auto=%d (want 0)", zero[i], n);
|
||
CHECK(!mr_book_valid(&b), "%s: book bound with 0 pages", zero[i]);
|
||
CHECK(mr_book_open(&b, 0) == NULL, "%s: open on empty book", zero[i]);
|
||
mr_book_close_auto(&b, &h);
|
||
CHECK(mr_zip_count_pages(p) == 0, "%s: count_pages != 0", zero[i]);
|
||
}
|
||
|
||
joinp(p, sizeof p, g_feat, "bomb.cbz");
|
||
CHECK(mr_zip_count_pages(p) == 0, "bomb.cbz should have no page (96MiB)");
|
||
CHECK(mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL) == 0,
|
||
"bomb: open_auto should be 0 pages");
|
||
mr_book_close_auto(&b, &h);
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* T7 统一开册契约 */
|
||
/* ============================================================ */
|
||
static void test_open_auto(void)
|
||
{
|
||
char p[PATH_CAP];
|
||
joinp(p, sizeof p, g_lib, "bookA");
|
||
|
||
mr_book_handle h;
|
||
mr_book b;
|
||
int n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL);
|
||
CHECK(n == 2 && h.kind == MR_BOOK_KIND_DIR, "dir open n=%d kind=%d", n, h.kind);
|
||
CHECK(mr_book_valid(&b) && mr_book_count(&b) == 2, "dir book contract");
|
||
int before = g_load_n;
|
||
meow_img *img = mr_book_open(&b, 1);
|
||
CHECK(img != NULL && g_load_n == before + 1, "dir open(1)");
|
||
CHECK(strstr(g_load_path, "002.png") != NULL, "dir page path=%s", g_load_path);
|
||
mr_book_close(&b, img);
|
||
int mw = 0, mh = 0;
|
||
CHECK(mr_book_meta(&b, 0, &mw, &mh) == 1 && mw == 30 && mh == 20,
|
||
"dir meta=%dx%d (want 30x20)", mw, mh);
|
||
mr_book_close_auto(&b, &h);
|
||
CHECK(h.kind == MR_BOOK_KIND_NONE && !mr_book_valid(&b), "dir close_auto");
|
||
mr_book_close_auto(&b, &h); /* 幂等 */
|
||
|
||
joinp(p, sizeof p, g_lib, "vol2.cbz");
|
||
n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL);
|
||
CHECK(n == 3 && h.kind == MR_BOOK_KIND_ZIP, "zip open n=%d kind=%d", n, h.kind);
|
||
CHECK(mr_book_valid(&b) && mr_book_count(&b) == 3, "zip book contract");
|
||
CHECK(mr_book_open(&b, 99) == NULL, "zip out of range");
|
||
mr_book_close_auto(&b, &h);
|
||
CHECK(!mr_book_valid(&b), "zip close_auto");
|
||
|
||
joinp(p, sizeof p, g_lib, "emptypages.cbz");
|
||
n = mr_book_open_auto(&b, &h, p, 720, fake_load, fake_unload, NULL);
|
||
CHECK(n == 0 && h.kind == MR_BOOK_KIND_ZIP, "empty zip n=%d kind=%d", n, h.kind);
|
||
CHECK(!mr_book_valid(&b), "empty zip bound");
|
||
mr_book_close_auto(&b, &h);
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* T8 书库列档 */
|
||
/* ============================================================ */
|
||
static const mr_library_entry *lib_find(const mr_library *lib, const char *name)
|
||
{
|
||
for (int i = 0; i < lib->count; i++)
|
||
if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i];
|
||
return NULL;
|
||
}
|
||
|
||
static void test_library(void)
|
||
{
|
||
mr_test_cfg_clear();
|
||
|
||
mr_library lib;
|
||
memset(&lib, 0, sizeof lib);
|
||
int n = mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL);
|
||
CHECK(n == 4, "library rows=%d (want bookA, box, vol2.cbz, Vol10.cbz)", n);
|
||
if (n == 4) {
|
||
CHECK(strcmp(lib.items[0].name, "bookA") == 0, "row0=%s", lib.items[0].name);
|
||
CHECK(strcmp(lib.items[1].name, "box") == 0, "row1=%s", lib.items[1].name);
|
||
CHECK(strcmp(lib.items[2].name, "vol2.cbz") == 0, "row2=%s", lib.items[2].name);
|
||
CHECK(strcmp(lib.items[3].name, "Vol10.cbz") == 0, "row3=%s", lib.items[3].name);
|
||
}
|
||
|
||
const mr_library_entry *e = lib_find(&lib, "bookA");
|
||
CHECK(e && e->pages == 2 && e->has_images == 1 && e->subdirs == 0, "bookA fields");
|
||
CHECK(e && strcmp(e->subtitle, "2 pages") == 0, "bookA subtitle=%s",
|
||
e ? e->subtitle : "");
|
||
CHECK(e && strstr(e->cover, "001.png") != NULL, "bookA cover=%s",
|
||
e ? e->cover : "");
|
||
|
||
e = lib_find(&lib, "box");
|
||
CHECK(e && e->has_images == 0 && e->subdirs == 0, "box fields");
|
||
CHECK(e && strcmp(e->subtitle, "2 books") == 0, "box subtitle=%s",
|
||
e ? e->subtitle : "");
|
||
CHECK(e && e->cover[0] == '\0', "box cover should be empty");
|
||
|
||
e = lib_find(&lib, "vol2.cbz");
|
||
CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0,
|
||
"vol2.cbz fields");
|
||
CHECK(e && strcmp(e->subtitle, "3 pages") == 0, "vol2.cbz subtitle=%s",
|
||
e ? e->subtitle : "");
|
||
CHECK(e && e->cover[0] == '\0', "archive cover should be empty (UI extracts)");
|
||
CHECK(e && strstr(e->path, "vol2.cbz") != NULL, "vol2.cbz path=%s",
|
||
e ? e->path : "");
|
||
|
||
e = lib_find(&lib, "Vol10.cbz");
|
||
CHECK(e && e->pages == 1 && strcmp(e->subtitle, "1 page") == 0,
|
||
"Vol10.cbz pages/subtitle");
|
||
|
||
CHECK(lib_find(&lib, "notabook.zip") == NULL, "text-only zip listed");
|
||
CHECK(lib_find(&lib, "emptypages.cbz") == NULL, "no-image cbz listed");
|
||
CHECK(lib_find(&lib, "broken.cbz") == NULL, "broken cbz listed");
|
||
CHECK(lib_find(&lib, "truncated.cbz") == NULL, "truncated cbz listed");
|
||
CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz listed");
|
||
mr_library_free(&lib);
|
||
|
||
/* 不注入探测回调:与 M7 完全一致(档案册不参与,只含档案册的目录也不 nav)。 */
|
||
memset(&lib, 0, sizeof lib);
|
||
CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 1,
|
||
"legacy scan rows=%d (want 1: bookA)", lib.count);
|
||
CHECK(lib.count >= 1 && strcmp(lib.items[0].name, "bookA") == 0,
|
||
"legacy scan row0");
|
||
mr_library_free(&lib);
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* T9 册间衔接 */
|
||
/* ============================================================ */
|
||
static void expect_adj(const char *from, int delta, const char *want, const char *tag)
|
||
{
|
||
char out[MR_PATH_MAX];
|
||
int r = mr_book_adjacent_ex(from, delta, out, (int)sizeof out,
|
||
archive_pages_probe, NULL);
|
||
if (!want) {
|
||
CHECK(r == 0 && out[0] == '\0', "%s: r=%d out=%s", tag, r, out);
|
||
} else {
|
||
CHECK(r == 1 && strcmp(out, want) == 0, "%s: got %s (want %s)", tag, out, want);
|
||
}
|
||
}
|
||
|
||
static void test_adjacent(void)
|
||
{
|
||
char bookA[MR_PATH_MAX], vol2[MR_PATH_MAX], vol10[MR_PATH_MAX];
|
||
char broken[MR_PATH_MAX];
|
||
joinp(bookA, sizeof bookA, g_lib, "bookA");
|
||
joinp(vol2, sizeof vol2, g_lib, "vol2.cbz");
|
||
joinp(vol10, sizeof vol10, g_lib, "Vol10.cbz");
|
||
joinp(broken, sizeof broken, g_lib, "broken.cbz");
|
||
|
||
/* 兄弟表 = bookA + vol2.cbz + Vol10.cbz(box 无直接图、坏档不算书)。 */
|
||
expect_adj(bookA, 1, vol2, "bookA next");
|
||
expect_adj(vol2, 1, vol10, "vol2 next");
|
||
expect_adj(vol10, 1, NULL, "vol10 next (end)");
|
||
expect_adj(bookA, -1, NULL, "bookA prev (start)");
|
||
expect_adj(vol2, -1, bookA, "vol2 prev");
|
||
expect_adj(vol10, -1, vol2, "vol10 prev");
|
||
expect_adj(broken, 1, NULL, "broken not a book");
|
||
expect_adj(broken, -1, NULL, "broken not a book prev");
|
||
|
||
/* 不注入探测回调:档案册不参与(M7 语义) */
|
||
char out[MR_PATH_MAX];
|
||
CHECK(mr_book_adjacent(vol2, 1, out, (int)sizeof out) == 0,
|
||
"legacy adjacency should ignore archives (r=%d)", 0);
|
||
CHECK(mr_book_adjacent(bookA, 1, out, (int)sizeof out) == 0,
|
||
"legacy adjacency bookA next should be none");
|
||
|
||
/* 参数边界 */
|
||
CHECK(mr_book_adjacent_ex(vol2, 0, out, (int)sizeof out, archive_pages_probe,
|
||
NULL) == -1, "delta 0");
|
||
CHECK(mr_book_adjacent_ex(NULL, 1, out, (int)sizeof out, archive_pages_probe,
|
||
NULL) == -1, "NULL dir");
|
||
CHECK(mr_book_adjacent_ex(vol2, 1, NULL, 32, archive_pages_probe, NULL) == -1,
|
||
"NULL out");
|
||
CHECK(mr_book_adjacent_ex("/", 1, out, (int)sizeof out, archive_pages_probe,
|
||
NULL) == -1, "root");
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* T10 续读角标 + 封面 */
|
||
/* ============================================================ */
|
||
static void test_badge_cover(void)
|
||
{
|
||
char vol2[MR_PATH_MAX];
|
||
joinp(vol2, sizeof vol2, g_lib, "vol2.cbz");
|
||
|
||
mr_test_cfg_clear();
|
||
mr_cont_save(vol2, 2, MR_MODE_WEBTOON);
|
||
|
||
mr_library lib;
|
||
memset(&lib, 0, sizeof lib);
|
||
CHECK(mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe,
|
||
NULL) == 4, "rescan for badge");
|
||
const mr_library_entry *e = lib_find(&lib, "vol2.cbz");
|
||
CHECK(e && strstr(e->subtitle, "续读 P3") != NULL, "badge subtitle=%s",
|
||
e ? e->subtitle : "");
|
||
CHECK(e && strstr(e->subtitle, "3 pages") != NULL, "badge keeps page text: %s",
|
||
e ? e->subtitle : "");
|
||
e = lib_find(&lib, "Vol10.cbz");
|
||
CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 should have no badge");
|
||
mr_library_free(&lib);
|
||
|
||
/* 清除记录后角标消失 */
|
||
mr_cont_save(vol2, 0, MR_MODE_WEBTOON);
|
||
memset(&lib, 0, sizeof lib);
|
||
mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL);
|
||
e = lib_find(&lib, "vol2.cbz");
|
||
CHECK(e && strstr(e->subtitle, "续读") == NULL, "cleared badge gone");
|
||
mr_library_free(&lib);
|
||
|
||
/* 封面 = 档内自然序首图(ch01/p2.png),解压到缓存 */
|
||
char cover[MR_CACHE_FILE_MAX];
|
||
CHECK(mr_zip_cover_path(vol2, cover, (int)sizeof cover) == 1, "cover path");
|
||
CHECK(cover[0] && access(cover, R_OK) == 0, "cover file missing: %s", cover);
|
||
unsigned long crc = 0;
|
||
long size = 0;
|
||
CHECK(file_crc(cover, &crc, &size), "cover crc");
|
||
unsigned long want_crc = 0;
|
||
long want_size = 0;
|
||
CHECK(manifest_lookup("library/vol2.cbz", "ch01/p2.png", &want_crc, &want_size),
|
||
"cover manifest");
|
||
CHECK(crc == want_crc && size == want_size, "cover bytes differ");
|
||
|
||
char broken[MR_PATH_MAX];
|
||
joinp(broken, sizeof broken, g_lib, "broken.cbz");
|
||
CHECK(mr_zip_cover_path(broken, cover, (int)sizeof cover) == 0, "broken cover");
|
||
CHECK(mr_zip_cover_path(vol2, NULL, 16) == 0, "cover NULL out");
|
||
}
|
||
|
||
/* ============================================================ */
|
||
int main(int argc, char **argv)
|
||
{
|
||
g_argv0 = argv[0];
|
||
char gen_buf[PATH_CAP];
|
||
const char *gen = argc > 1 ? argv[1] : find_generator(gen_buf, sizeof gen_buf);
|
||
|
||
if (!make_temp_dir()) {
|
||
perror("mkdtemp");
|
||
return 2;
|
||
}
|
||
joinp(g_lib, sizeof g_lib, g_root, "library");
|
||
joinp(g_feat, sizeof g_feat, g_root, "features");
|
||
joinp(g_data, sizeof g_data, g_root, "app/os.meow.picobrowser/data");
|
||
|
||
if (!run_generator(gen)) {
|
||
printf("generator failed: %s\n", gen);
|
||
rm_rf(g_root);
|
||
return 2;
|
||
}
|
||
if (mr_cache_init(g_data) != 0) {
|
||
printf("cache init failed: %s\n", g_data);
|
||
rm_rf(g_root);
|
||
return 2;
|
||
}
|
||
|
||
test_ext();
|
||
test_enum();
|
||
test_pathsafe();
|
||
test_meta();
|
||
test_cache();
|
||
test_bad();
|
||
test_open_auto();
|
||
test_library();
|
||
test_adjacent();
|
||
test_badge_cover();
|
||
|
||
mr_cache_shutdown();
|
||
rm_rf(g_root);
|
||
|
||
printf("cbz tests: %d passed, %d failed\n", g_pass, g_fail);
|
||
return g_fail == 0 ? 0 : 1;
|
||
}
|