图库: 单层浏览 + 懒判定与 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
This commit is contained in:
AskaEth committed 2026-11-05 12:23:31 +08:00
1 parent c9cff99af3
commit 9f34417c49
12 files changed
+2299 -358

No files matched your search

+443
View File
@@ -0,0 +1,443 @@
/* 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;
}