/* test_browse_lazy.c — 单层探测 / 懒判定 / mtime 失效缓存 / 行合成(纯 C,无 SDK)。 * * 覆盖: * L1 单层探测:直接图片数、自然序首图、空目录标记;不递归(只有子目录含图的目录不算书); * L2 懒判定接线:scan 后行全部 PENDING(占位副标题)→ apply_probe 后页数/首图/副标题/ * 书标记;空目录「空目录」;无图目录「无图片」;探测失败「无法读取」且视为非书; * L3 缓存:mtime 失效(同 mtime 命中不读盘;目录变化后重扫并刷新结果);并发调用线程安全; * L3b 评审 P2:探测期间 mtime/size 变化的结果不写缓存(避免「不完整结果绑定新 mtime」); * L4 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行; * L5 M7 兼容语义:「直接含图兄弟册」+ 自然序册间顺序(M7 mr_book_adjacent 的口径)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ * test/test_browse_lazy.c logic/library.c logic/reader.c test/settings_reader_stub.c \ * -pthread -o build/host/test_browse_lazy */ #include "logic/library.h" #include #include #include #include #include #include #include #include #include static int g_pass, g_fail; #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 int mkdir_p(const char *path) { char tmp[MR_PATH_MAX]; snprintf(tmp, sizeof tmp, "%s", path); for (size_t i = 1; i <= strlen(tmp); i++) { if (tmp[i] == '/' || tmp[i] == '\0') { char ch = tmp[i]; tmp[i] = '\0'; if (mkdir(tmp, 0755) != 0) { struct stat st; if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; } tmp[i] = ch; } } return 0; } static int touch_file(const char *path) { FILE *f = fopen(path, "wb"); if (!f) return -1; fputs("x", f); return fclose(f); } 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[MR_PATH_MAX]; snprintf(full, sizeof full, "%s/%s", path, de->d_name); rm_rf(full); } closedir(d); return rmdir(path); } static void joinp(char *out, int outsz, const char *a, const char *b) { snprintf(out, (size_t)outsz, "%s/%s", a, b); } /* 把目录 mtime 显式推后 3 秒(避免同秒内变化被缓存误命中)。 */ static void bump_mtime(const char *path) { struct timespec ts[2]; ts[0].tv_sec = time(NULL) + 3; ts[0].tv_nsec = 0; ts[1] = ts[0]; utimensat(AT_FDCWD, path, ts, 0); } static const mr_library_entry *find_entry(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 int find_index(const mr_library *lib, const char *name, int *out) { for (int i = 0; i < lib->count; i++) if (strcmp(lib->items[i].name, name) == 0) { *out = i; return 1; } *out = -1; return 0; } /* ---- L1 + L2 ---- */ static void test_probe_and_apply(const char *root) { char book[MR_PATH_MAX], noimg[MR_PATH_MAX], empty[MR_PATH_MAX], dirs_only[MR_PATH_MAX]; char deep[MR_PATH_MAX]; joinp(book, sizeof book, root, "book"); joinp(noimg, sizeof noimg, root, "noimg"); joinp(empty, sizeof empty, root, "empty"); joinp(dirs_only, sizeof dirs_only, root, "dirs_only"); joinp(deep, sizeof deep, root, "dirs_only/deep"); mr_library_probe p; CHECK(mr_library_probe_dir(book, &p) == 0, "probe book"); CHECK(p.pages == 2, "book pages=%d", p.pages); CHECK(p.empty == 0, "book not empty"); CHECK(strstr(p.cover, "page2.png") != NULL, "cover natural-first: %s", p.cover); CHECK(mr_library_probe_dir(noimg, &p) == 0 && p.pages == 0 && p.empty == 0, "noimg: 0 pages, not empty"); CHECK(mr_library_probe_dir(empty, &p) == 0 && p.pages == 0 && p.empty == 1, "empty dir flagged"); CHECK(mr_library_probe_dir(dirs_only, &p) == 0 && p.pages == 0 && p.empty == 0, "dirs_only has no direct images (no recursion)"); CHECK(mr_library_probe_dir(deep, &p) == 0 && p.pages == 1, "deep is a book"); char slash[MR_PATH_MAX]; snprintf(slash, sizeof slash, "%s/", book); CHECK(mr_library_probe_dir(slash, &p) == 0 && p.pages == 2, "trailing slash"); CHECK(mr_library_probe_dir("/no/such/dir", &p) == -1, "missing dir -> -1"); CHECK(mr_library_probe_dir(NULL, &p) == -1, "NULL -> -1"); mr_library lib; memset(&lib, 0, sizeof lib); CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) == 5, "scan 5 dirs (got %d)", lib.count); const mr_library_entry *e = find_entry(&lib, "book"); CHECK(e && e->probe_state == MR_LIB_PENDING && e->pages == -1, "book pending after scan"); int idx_book, idx_noimg, idx_empty, idx_dirs; CHECK(find_index(&lib, "book", &idx_book), "book index"); CHECK(find_index(&lib, "noimg", &idx_noimg), "noimg index"); CHECK(find_index(&lib, "empty", &idx_empty), "empty index"); CHECK(find_index(&lib, "dirs_only", &idx_dirs), "dirs_only index"); if (idx_book < 0 || idx_noimg < 0 || idx_empty < 0 || idx_dirs < 0) { mr_library_free(&lib); return; } CHECK(mr_library_probe_dir(lib.items[idx_book].path, &p) == 0, "probe row book"); CHECK(mr_library_apply_probe(&lib, idx_book, &p) == 1, "apply book"); e = &lib.items[idx_book]; CHECK(e->has_images == 1 && e->pages == 2 && e->probe_state == MR_LIB_READY, "book ready"); CHECK(strcmp(e->subtitle, "2 pages") == 0, "book subtitle=%s", e->subtitle); CHECK(strstr(e->cover, "page2.png") != NULL, "book row cover=%s", e->cover); CHECK(mr_library_probe_dir(lib.items[idx_noimg].path, &p) == 0 && mr_library_apply_probe(&lib, idx_noimg, &p) == 1, "apply noimg"); e = &lib.items[idx_noimg]; CHECK(e->has_images == 0 && strcmp(e->subtitle, "无图片") == 0, "noimg subtitle=%s", e->subtitle); CHECK(mr_library_probe_dir(lib.items[idx_empty].path, &p) == 0 && mr_library_apply_probe(&lib, idx_empty, &p) == 1, "apply empty"); e = &lib.items[idx_empty]; CHECK(e->has_images == 0 && e->empty == 1 && strcmp(e->subtitle, "空目录") == 0, "empty subtitle=%s", e->subtitle); CHECK(mr_library_mark_error(&lib, idx_dirs) == 1, "mark error"); e = &lib.items[idx_dirs]; CHECK(e->probe_state == MR_LIB_FAILED && e->has_images == 0 && strcmp(e->subtitle, "无法读取") == 0, "failed subtitle=%s", e->subtitle); CHECK(mr_library_apply_probe(&lib, 999, &p) == 0, "apply out of range"); CHECK(mr_library_mark_error(&lib, -1) == 0, "mark_error out of range"); mr_library_free(&lib); } /* 档案册探测回调(P2 用例):可注入「探测期间文件变化」。 */ typedef struct arch_probe_ctx { int calls; int bump_times; /* 前 N 次调用里改动文件 mtime(模拟探测期间变化) */ } arch_probe_ctx; static int arch_probe(const char *path, void *ud) { arch_probe_ctx *c = (arch_probe_ctx *)ud; c->calls++; if (c->calls <= c->bump_times) bump_mtime(path); return 1; } /* ---- L3b:评审 P2——探测期间 mtime 变化不得写入缓存(消除假新鲜) ---- */ static void test_cache_race(const char *root) { char arc[MR_PATH_MAX]; joinp(arc, sizeof arc, root, "race.cbz"); CHECK(touch_file(arc) == 0, "create race.cbz"); mr_library_probe_cache_clear(); mr_library_probe p; /* 稳定文件:第二次走缓存,回调只调一次。 */ arch_probe_ctx stable = { 0, 0 }; CHECK(mr_library_probe_path(arc, 1, arch_probe, &stable, &p) == 0 && p.pages == 1, "archive probe"); CHECK(mr_library_probe_path(arc, 1, arch_probe, &stable, &p) == 0 && p.pages == 1, "archive probe (cached)"); CHECK(stable.calls == 1, "stable archive cached (calls=%d)", stable.calls); /* 探测期间文件变化:本次结果不得入缓存 → 下一次仍重新探测。 */ mr_library_probe_cache_clear(); arch_probe_ctx race = { 0, 1 }; CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, "racing archive probe"); CHECK(race.calls == 1, "racing probe called once (calls=%d)", race.calls); CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, "racing archive probe again"); CHECK(race.calls == 2, "changed-during-probe result NOT cached (calls=%d)", race.calls); /* 第二次稳定后落缓存,第三次命中。 */ CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, "racing archive probe third"); CHECK(race.calls == 2, "stable result cached after third call (calls=%d)", race.calls); mr_library_probe_cache_clear(); } /* ---- L3:mtime 失效缓存 + 并发 ---- */ #define THREADS 6 typedef struct th_arg { const char *dir; int want; int *fail; pthread_mutex_t *mu; } th_arg; static void *probe_thread(void *arg) { th_arg *a = (th_arg *)arg; for (int i = 0; i < 200; i++) { mr_library_probe p; int ok = (mr_library_probe_cached(a->dir, &p) == 0 && p.pages == a->want); if (!ok) { pthread_mutex_lock(a->mu); (*a->fail)++; pthread_mutex_unlock(a->mu); } } return NULL; } static void test_cache(const char *root) { char book[MR_PATH_MAX]; joinp(book, sizeof book, root, "book"); mr_library_probe_cache_clear(); mr_library_io_stats st; mr_library_probe p; mr_library_test_io_reset(); CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 2, "cache miss probe"); mr_library_test_io_get(&st); CHECK(st.opendirs == 1, "cache miss opens once (got %ld)", st.opendirs); mr_library_test_io_reset(); CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 2, "cache hit"); mr_library_test_io_get(&st); CHECK(st.opendirs == 0 && st.lstats == 0, "cache hit does no IO (open=%ld stat=%ld)", st.opendirs, st.lstats); char img[MR_PATH_MAX]; joinp(img, sizeof img, book, "page30.png"); CHECK(touch_file(img) == 0, "add image"); bump_mtime(book); mr_library_test_io_reset(); CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 3, "cache invalidated by mtime (pages=%d)", p.pages); mr_library_test_io_get(&st); CHECK(st.opendirs == 1, "invalidation re-opens (got %ld)", st.opendirs); CHECK(mr_library_probe_cached("/no/such/dir", &p) == -1, "missing dir -> -1"); pthread_t th[THREADS]; th_arg args[THREADS]; int failures = 0; pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER; int started = 0; for (int t = 0; t < THREADS; t++) { args[t].dir = book; args[t].want = 3; args[t].fail = &failures; args[t].mu = μ if (pthread_create(&th[t], NULL, probe_thread, &args[t]) == 0) started++; } for (int t = 0; t < started; t++) pthread_join(th[t], NULL); CHECK(started == THREADS, "threads started (%d)", started); CHECK(failures == 0, "concurrent probe failures=%d", failures); pthread_mutex_destroy(&mu); } /* ---- L4:行合成 O(1) + 截断提示行 ---- */ static void test_rows(const char *root) { mr_library lib; memset(&lib, 0, sizeof lib); mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); mr_library_io_stats st; mr_library_test_io_reset(); struct timespec t0, t1; clock_gettime(CLOCK_MONOTONIC, &t0); int rows = 0; for (int r = 0; r < 2000; r++) for (int i = 0; i < lib.count; i++) { mr_library_row row; if (mr_library_row_at(&lib, i, &row)) rows++; } clock_gettime(CLOCK_MONOTONIC, &t1); mr_library_test_io_get(&st); long long us = (long long)(t1.tv_sec - t0.tv_sec) * 1000000LL + (t1.tv_nsec - t0.tv_nsec) / 1000LL; CHECK(rows == 2000 * lib.count, "rows synthesized (%d)", rows); CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0, "row synth is pure memory (open=%ld rd=%ld stat=%ld)", st.opendirs, st.readdirs, st.lstats); CHECK(us < 2000000, "row synth bounded (%lldus)", us); mr_library_row row; CHECK(mr_library_row_at(&lib, lib.count, &row) == 0, "no extra row when not truncated"); lib.truncated = 1; CHECK(mr_library_row_at(&lib, lib.count, &row) == 1 && row.disabled == 1, "truncation info row"); CHECK(strcmp(row.title, "已截断") == 0, "truncation title=%s", row.title); CHECK(strstr(row.subtitle, "2000") != NULL, "truncation subtitle=%s", row.subtitle); mr_library_free(&lib); } /* ---- L5:M7 兼容语义(直接含图兄弟册 + 自然序) ---- */ static void collect_books(const char *parent, char names[][MR_NAME_MAX], int max, int *out_n) { mr_library lib; memset(&lib, 0, sizeof lib); int n = 0; if (mr_library_scan(&lib, parent, MR_LIB_MAX_DEPTH) > 0) { for (int i = 0; i < lib.count && n < max; i++) { mr_library_probe p; if (mr_library_probe_cached(lib.items[i].path, &p) == 0 && p.pages > 0) { snprintf(names[n], MR_NAME_MAX, "%s", lib.items[i].name); n++; } } } mr_library_free(&lib); *out_n = n; } static int book_index(char names[][MR_NAME_MAX], int n, const char *cur) { for (int i = 0; i < n; i++) if (strcmp(names[i], cur) == 0) return i; return -1; } static void test_m7_semantics(const char *root) { char parent[MR_PATH_MAX]; joinp(parent, sizeof parent, root, "series"); char names[16][MR_NAME_MAX]; int n = 0; collect_books(parent, names, 16, &n); CHECK(n == 3, "series books=%d (vol1/vol2/vol10; vol6 is not a book)", n); if (n == 3) { CHECK(strcmp(names[0], "vol1") == 0, "book[0]=%s", names[0]); CHECK(strcmp(names[1], "vol2") == 0, "book[1]=%s", names[1]); CHECK(strcmp(names[2], "vol10") == 0, "book[2]=%s", names[2]); } CHECK(book_index(names, n, "vol6") < 0, "dirs-only vol6 skipped (direct images only)"); CHECK(book_index(names, n, ".hidden") < 0, "hidden skipped"); CHECK(book_index(names, n, "MeowOS") < 0, "system dir without direct images skipped"); int i2 = book_index(names, n, "vol2"); CHECK(i2 >= 0 && i2 + 1 < n && strcmp(names[i2 + 1], "vol10") == 0, "vol2 next = vol10 (natural order, vol6 skipped)"); CHECK(book_index(names, n, "vol10") == n - 1, "vol10 is last (no next)"); CHECK(book_index(names, n, "vol1") == 0, "vol1 is first (no prev)"); } int main(void) { char tmpl[] = "./mr_lazy_XXXXXX"; char *root = mkdtemp(tmpl); CHECK(root != NULL, "mkdtemp"); if (!root) return 1; char p[MR_PATH_MAX]; const char *dirs[] = { "book", "noimg", "empty", "dirs_only/deep", "series" }; for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { joinp(p, sizeof p, root, dirs[i]); CHECK(mkdir_p(p) == 0, "mkdir %s", p); } joinp(p, sizeof p, root, "book/page2.png"); CHECK(touch_file(p) == 0, "page2"); joinp(p, sizeof p, root, "book/page10.jpg"); CHECK(touch_file(p) == 0, "page10"); joinp(p, sizeof p, root, "book/notes.txt"); CHECK(touch_file(p) == 0, "notes"); joinp(p, sizeof p, root, "noimg/readme.txt"); CHECK(touch_file(p) == 0, "readme"); joinp(p, sizeof p, root, "dirs_only/deep/a.png"); CHECK(touch_file(p) == 0, "deep a"); /* M7 语义夹具:vol1/vol2/vol10 直接含图;vol6 只有子目录含图;.hidden / MeowOS 干扰项 */ const char *mk[] = { "series/vol1/001.png", "series/vol2/001.png", "series/vol10/001.png", "series/vol6/sub/001.png", "series/.hidden/001.png", "series/MeowOS/deep/001.png" }; for (unsigned i = 0; i < sizeof mk / sizeof mk[0]; i++) { joinp(p, sizeof p, root, mk[i]); /* 先建父目录,再落文件 */ char dir[MR_PATH_MAX]; snprintf(dir, sizeof dir, "%s", p); char *slash = strrchr(dir, '/'); if (slash) { *slash = '\0'; CHECK(mkdir_p(dir) == 0, "mkdir %s", dir); } CHECK(touch_file(p) == 0, "image %s", p); } test_probe_and_apply(root); test_cache(root); test_cache_race(root); test_rows(root); test_m7_semantics(root); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); return g_fail ? 1 : 0; }