/* 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//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 书库列档(M18 文件管理器模型):目录 + .cbz/.zip 同列(目录优先、组内自然序, * bookA < box < … < Vol10.cbz);扫描期一次成行,副标题是「文件夹/档案册 · 大小」, * 不再探测页数/封面/续读角标;坏档/无图片页档案也照常列出(点开时才由 reader 判)。 * 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 #include #include #include #include #include #include #include #define PATH_CAP 1024 #define CJK_PAGE "\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.png" /* 中文名.png */ #define HUGE_PAGE_LEN (33 * 1024 * 1024) /* 与 make_cbz_samples.py 的 HUGE_PAGE_LEN 一致 */ static int g_pass; static int g_fail; static char g_root[PATH_CAP]; /* 夹具根 */ static char g_lib[PATH_CAP]; /* /library */ static char g_feat[PATH_CAP]; /* /features */ static char g_data[PATH_CAP]; /* /app/os.meow.picbrowser/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\t */ 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); /* P2-1:BMP 页的缓存文件名也是 *.bmp,但必须能被缩略图 LRU 判据识别为解压页 */ char vp[MR_CACHE_FILE_MAX]; CHECK(mr_zip_book_page_path(&zb, 0, vp, (int)sizeof vp) == 0, "bmp page path"); const char *vbase = strrchr(vp, '/'); CHECK(vbase && mr_cache_is_page_name(vbase + 1) == 1, "bmp page cache looks like a thumbnail: %s", vp); CHECK(vbase && strstr(vbase, ".bmp") != NULL, "bmp page lost its extension: %s", vp); 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]; /* 解压页缓存与缩略图的文件名判据(P2-1):缩略图 LRU 依赖它跳过 mrpage_*。 */ CHECK(mr_cache_is_page_name("mrpage_0123456789abcdef.bmp") == 1, "page name bmp"); CHECK(mr_cache_is_page_name("mrpage_0123456789abcdef.png") == 1, "page name png"); CHECK(mr_cache_is_page_name("0123456789abcdef_1700000000_1234.bmp") == 0, "thumb name"); CHECK(mr_cache_is_page_name("thumb.bmp") == 0, "plain thumb"); CHECK(mr_cache_is_page_name("") == 0 && mr_cache_is_page_name(NULL) == 0, "page name edges"); joinp(path, sizeof path, g_feat, "big.cbz"); /* ---- (a) 64 页 LRU ---- */ joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picbrowser/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"); /* 假命中自愈(P2-1):外部删掉缓存文件后再取同页,应重新解压而不是返回死路径 */ unlink(p79); CHECK(access(p79, F_OK) != 0, "unlink p79"); CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 after unlink"); CHECK(access(p79, F_OK) == 0, "page79 not re-extracted after external unlink"); CHECK(mr_cache_count() == MR_CACHE_MAX_PAGES, "count after self-heal=%d", mr_cache_count()); mr_zip_book_free(&zb); /* ---- (b) 32MiB 字节上限 + 启动清孤儿 ---- */ joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picbrowser/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) 单页 > 32MiB:文档化的一次性溢出(大页可读,其它条目按预算淘汰) ---- */ joinp(data_dir, sizeof data_dir, g_root, "c3/os.meow.picbrowser/data"); mr_cache_shutdown(); CHECK(mr_cache_init(data_dir) == 0, "cache init c3"); joinp(arc, sizeof arc, g_lib, "box/box1.cbz"); mr_zip_book small; CHECK(mr_zip_book_init(&small, arc, 720, fake_load, fake_unload, NULL) == 2, "small book init"); char sp[MR_CACHE_FILE_MAX]; CHECK(mr_zip_book_page_path(&small, 0, sp, (int)sizeof sp) == 0, "small page"); CHECK(mr_cache_count() == 1, "small cache count=%d", mr_cache_count()); mr_zip_book_free(&small); joinp(arc, sizeof arc, g_feat, "hugepage.cbz"); mr_zip_book huge; CHECK(mr_zip_book_init(&huge, arc, 720, fake_load, fake_unload, NULL) == 1, "hugepage init"); char hp[MR_CACHE_FILE_MAX]; CHECK(mr_zip_book_page_path(&huge, 0, hp, (int)sizeof hp) == 0, "huge page path"); CHECK(mr_cache_count() == 1, "overflow count=%d", mr_cache_count()); CHECK(mr_cache_bytes() == (long long)HUGE_PAGE_LEN, "overflow bytes=%lld (want %d)", mr_cache_bytes(), HUGE_PAGE_LEN); CHECK(mr_cache_bytes() > MR_CACHE_MAX_BYTES, "huge page should exceed the budget"); CHECK(access(sp, F_OK) != 0, "huge page should evict the small page"); CHECK(access(hp, F_OK) == 0, "huge page file missing"); CHECK(count_cache_files() == 1, "overflow left extra cache files"); mr_book hb; mr_zip_book_bind(&hb, &huge); int before_load = g_load_n; meow_img *him = mr_book_open(&hb, 0); CHECK(him != NULL && g_load_n == before_load + 1, "huge page not readable"); mr_book_close(&hb, him); mr_book_destroy(&hb); /* 再来一个小页:整体预算仍然生效,大页被淘汰 */ joinp(arc, sizeof arc, g_lib, "box/box1.cbz"); mr_zip_book small2; CHECK(mr_zip_book_init(&small2, arc, 720, fake_load, fake_unload, NULL) == 2, "small2 init"); CHECK(mr_zip_book_page_path(&small2, 0, sp, (int)sizeof sp) == 0, "small2 page"); CHECK(mr_cache_count() == 1 && mr_cache_bytes() < MR_CACHE_MAX_BYTES, "budget not restored: count=%d bytes=%lld", mr_cache_count(), mr_cache_bytes()); CHECK(access(hp, F_OK) != 0, "huge page should be evicted by the budget"); mr_zip_book_free(&small2); /* ---- (d) 缓存不可用时优雅失败(不崩、不写文件) ---- */ 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.picbrowser/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(); /* 文件管理器模型:目录 + 档案册同列(目录优先、组内自然序),扫描期一次成行; * 档案册只按扩展名成行,不探测页数/封面(打开时才由 reader 解档)。 */ mr_library lib; memset(&lib, 0, sizeof lib); int n = mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH); CHECK(n == 8, "library rows=%d (want 2 dirs + 6 archives)", n); static const char *want[8] = { "bookA", "box", "broken.cbz", "emptypages.cbz", "notabook.zip", "truncated.cbz", "vol2.cbz", "Vol10.cbz" }; for (int i = 0; i < 8 && i < lib.count; i++) { CHECK(strcmp(lib.items[i].name, want[i]) == 0, "row[%d]=%s (want %s)", i, lib.items[i].name, want[i]); } for (int i = 0; i < 2 && i < lib.count; i++) CHECK(lib.items[i].kind == MR_LIB_KIND_DIR && strcmp(lib.items[i].subtitle, "文件夹") == 0, "%s dir row subtitle=%s", lib.items[i].name, lib.items[i].subtitle); for (int i = 2; i < 8 && i < lib.count; i++) CHECK(lib.items[i].kind == MR_LIB_KIND_ARCHIVE && strncmp(lib.items[i].subtitle, "档案册 · ", strlen("档案册 · ")) == 0, "%s archive row subtitle=%s", lib.items[i].name, lib.items[i].subtitle); const mr_library_entry *e = lib_find(&lib, "bookA"); CHECK(e && e->kind == MR_LIB_KIND_DIR && e->size == 0, "bookA dir entry"); e = lib_find(&lib, "vol2.cbz"); CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE && e->size > 0 && strstr(e->path, "vol2.cbz") != NULL, "vol2.cbz archive entry (size=%lld)", e ? e->size : -1); /* 坏档/无图片页档案也照常列出(书库不预判,点开时 reader 才判可读性)。 */ static const char *all_arch[] = { "notabook.zip", "emptypages.cbz", "broken.cbz", "truncated.cbz" }; for (unsigned i = 0; i < sizeof all_arch / sizeof all_arch[0]; i++) { e = lib_find(&lib, all_arch[i]); CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE, "%s listed as archive", all_arch[i]); } CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz not listed"); 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); /* 文件管理器模型:书库行只有「档案册 · 大小」,不再有「· 续读 Pn」角标 * (续读仍由 reader 打开该册时恢复,见 test_mode_switch_host)。 */ mr_library lib; memset(&lib, 0, sizeof lib); CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 8, "rescan rows"); const mr_library_entry *e = lib_find(&lib, "vol2.cbz"); CHECK(e && strstr(e->subtitle, "续读") == NULL, "no continuation badge in file-manager rows: %s", e ? e->subtitle : ""); CHECK(e && strstr(e->subtitle, "档案册") != NULL, "archive subtitle=%s", e ? e->subtitle : ""); e = lib_find(&lib, "Vol10.cbz"); CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 no badge either"); 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.picbrowser/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; }