Files
MeowOS-SDK/tuku/test/test_cbz.c
T
AskaEth 9f34417c49 图库: 单层浏览 + 懒判定与 M7/M11 整合;探测缓存 P2 修复(M13)
rebase 到 picbrowser(含 M7 条漫/续读、M11 CBZ 档案册)后的语义拼接:
- 保留单层扫描重写:一次 opendir/readdir+lstat 只列当前层(目录 + 注入 archive_pages
  时的 .cbz/.zip 档案文件),自然升序、隐藏项/符号链接跳过、2000 条截断标注;
  系统目录/空目录/无图目录一律列出;彻底删除递归「子树含图」探测
- 保留并复用 M11 档案册分支:add_dir_entry / add_archive_entry / entry_subtitle /
  entry_badge / archive_pages_at / sibling_is_book / mr_library_scan_ex 注入;
  档案册行同样先成行、页数由后台 probe(注入 zip 页数回调)懒算
- 保留 M7:mr_library_dir_has_images / mr_book_adjacent(_ex) 语义不变;为 adjacency
  恢复 ignored_dir_name(仅册间衔接跳过系统/隐藏目录,浏览本身不隐藏);
  mr_library_format_subtitle 在补位后拼「· 续读 Pn」角标
- mr_library_probe_path 统一探测目录/档案册:mtime(秒+纳秒)+size 失效的 256 项 LRU +
  互斥锁;评审 P2:只缓存「探测前后 stat 完全一致」的结果,期间变化则不写缓存
  (消除「不完整结果绑定新 mtime」的假新鲜;下次调用重新探测)
- UI:列表用 src2 每帧取行;后台线程探测优先于缩略图;档案册封面解压档内首图(M11);
  行语义=书页数/缩略图、空目录「空目录」、无图/坏档「无图片」;可上溯至文件系统 /、
  不可读目录提示并停留;TopAppBar「TF卡」回根;启动时根目录不可读也建空列表;
  顶栏回根命中加 g_tb_home_idx>=0 防御
- tuku_internal.h:library_go_up 注释校正(0=到达文件系统 /,app.c 据此退出)
- 测试:test_library 改单层语义;新增 browse_nav / browse_lazy / browse_perf /
  browse_ui_host(含评审 P2 的「探测期间变化不写缓存」用例);test_cont_adjacent A6
  改两段式;test_cbz T8/T10 改两段式(坏档/无图档案列出但非书);
  run_tests.sh 保留双方全部条目(browse 4 套 + run_zip_tests/run_cbz_tests)
- 复验:run_tests.sh 全 PASS(含 M11 zip/cbz)、run_webtoon_tests.sh 全 PASS;
  worktree 内 sdk/build_sdk.sh + build_tuku.sh 零警告静态 MIPS32r2 ELF
2026-11-05 12:23:31 +08:00

965 lines
40 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 书库列档(M13 单层 + 懒判定):.cbz 与文件夹册同列自然序(bookA < box < ... <
* Vol10.cbz);扫描期只成行、副标题占位,探测补位后才有页数/书标记;
* 坏档/无图片页档案仍列出但为非书(访问放开,不再隐藏);
* 不注入探测回调时档案文件不成行(M7 语义),目录仍按单层列出。
* T9 册间衔接:同父目录自然序混排(bookA → vol2.cbz → Vol10.cbz),
* 首/末册返回 0;不注入探测回调时档案册不参与(M7 语义)。
* T10 续读角标(M13 两段式):档案路径作为册标识,probe 补位后副标题后缀「· 续读 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 */
#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]; /* <root>/library */
static char g_feat[PATH_CAP]; /* <root>/features */
static char g_data[PATH_CAP]; /* <root>/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<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);
/* 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;
}
/* M13:模拟 UI 后台探测补位(目录单层探测 / 档案册注入页数回调)。 */
static void lib_probe_all(mr_library *lib)
{
for (int i = 0; i < lib->count; i++) {
mr_library_probe p;
if (mr_library_probe_path(lib->items[i].path, lib->items[i].is_archive,
archive_pages_probe, NULL, &p) == 0)
mr_library_apply_probe(lib, i, &p);
else
mr_library_mark_error(lib, i);
}
}
static void test_library(void)
{
mr_test_cfg_clear();
/* M13:扫描只做单层枚举——目录(全部列出,含无图的 box)与档案文件(.cbz/.zip)
* 都先成行、副标题占位;书/非书与页数由后台 probe 补位。 */
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 == 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]);
CHECK(lib.items[i].probe_state == MR_LIB_PENDING && lib.items[i].pages == -1,
"%s pending after scan", lib.items[i].name);
CHECK(strcmp(lib.items[i].subtitle, "…") == 0,
"%s placeholder subtitle=%s", lib.items[i].name, lib.items[i].subtitle);
}
/* 后台补位(等价 UI 的 on_tick):目录走单层目录探测,档案走注入的 zip 页数回调。 */
for (int i = 0; i < lib.count; i++) {
mr_library_probe p;
int ok = (mr_library_probe_path(lib.items[i].path, lib.items[i].is_archive,
archive_pages_probe, NULL, &p) == 0);
CHECK(ok, "%s probed", lib.items[i].name);
if (ok) mr_library_apply_probe(&lib, i, &p);
}
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 : "");
/* box 只含档案册:目录本身不直接含图 → 不是书(M13 不做递归/子表统计)。 */
e = lib_find(&lib, "box");
CHECK(e && e->has_images == 0 && e->subdirs == 0 && e->is_archive == 0,
"box fields");
CHECK(e && strcmp(e->subtitle, "无图片") == 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 &&
e->is_archive == 1, "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");
/* 坏档/无图片页档案:列出但探测为非书(M13 访问放开,不再隐藏)。 */
static const char *not_book[] = { "notabook.zip", "emptypages.cbz",
"broken.cbz", "truncated.cbz" };
for (unsigned i = 0; i < sizeof not_book / sizeof not_book[0]; i++) {
e = lib_find(&lib, not_book[i]);
CHECK(e && e->is_archive == 1 && e->has_images == 0 && e->pages == 0 &&
strcmp(e->subtitle, "无图片") == 0,
"%s: not a book (subtitle=%s)", not_book[i], e ? e->subtitle : "(missing)");
}
CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz not listed");
mr_library_free(&lib);
/* 不注入探测回调:M7 纯目录书库(档案文件不成行,目录仍按 M13 单层列出)。 */
memset(&lib, 0, sizeof lib);
CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 2,
"legacy scan rows=%d (want 2: bookA, box)", lib.count);
CHECK(lib.count >= 2 && strcmp(lib.items[0].name, "bookA") == 0 &&
strcmp(lib.items[1].name, "box") == 0, "legacy scan rows");
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);
/* M13 两段式:扫描期只有占位副标题;后台 probe 补位后才出现页数与续读角标。 */
mr_library lib;
memset(&lib, 0, sizeof lib);
CHECK(mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe,
NULL) == 8, "rescan for badge (single-layer rows)");
const mr_library_entry *e = lib_find(&lib, "vol2.cbz");
CHECK(e && strcmp(e->subtitle, "…") == 0, "vol2 placeholder before probe: %s",
e ? e->subtitle : "");
lib_probe_all(&lib);
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);
lib_probe_all(&lib);
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.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;
}