Files
MeowOS-SDK/tuku/test/test_browse_lazy.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

444 lines
17 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_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 <dirent.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
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 = &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;
}