图库: 单层浏览 + 懒判定与 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

+269
View File
@@ -0,0 +1,269 @@
/* test_browse_ui_host.c — 书库浏览页宿主集成测试(可选,需 SDK 源码树)。
*
* 目的(M13):
* U1 列表用 src2 模式(每帧重取行):扫描后行副标题是占位「…」,后台探测完成、
* on_tick 补位后同一行立即显示页数——验证懒算结果能上屏(固定池 vsource 做不到);
* U2 行语义:书行显示 "N pages"(无进入箭头),空目录「空目录」,无图目录「无图片」;
* U3 导航:可逐级上溯父目录;文件系统 "/" 处 library_go_up 返回 0(app.c 据此退出);
* 「TF卡」回根动作回到书库根;
* U4 不可读目录:load 失败 → 停留原目录 + 顶部提示横幅。
*
* 编译:见 test/run_tests.sh(与 test_settings_lock_host 同口径:链 SDK 源码树 + 打桩;
* 只发布 meow_ui.a 的交付环境自动 SKIP)。ui/library/library.c 以
* -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;本测试另打桩 mr_reader_open_dir/mr_nav_report。
*/
#include "tuku_internal.h"
#include "logic/library.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <dirent.h>
/* ---- ui/library/library.c 的测试接缝(仅 -DMR_LIBRARY_UI_TEST_HOOKS 时定义) ---- */
int library_test_load(const char *dir);
void library_test_set_root(const char *root);
const char *library_test_root(void);
const char *library_test_dir(void);
void library_test_set_dir(const char *dir);
int library_test_rows(void);
int library_test_src(int idx, meow_list_item *item);
int library_test_hint_active(void);
const char *library_test_hint(void);
int library_test_go_root(void);
int library_test_go_up(void);
static int g_fail;
#define CHECK(cond, ...) do { \
if (cond) { printf(" PASS\n"); } \
else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \
} while (0)
/* app.c 的导航上报在测试里不需要网络侧实现。 */
void mr_nav_report(void) {}
/* reader 打桩:只记录被打开的册路径(本测试不建 reader 页)。 */
static char g_opened[MR_PATH_MAX];
static int g_opened_n;
void mr_reader_open_dir(const char *path)
{
snprintf(g_opened, sizeof g_opened, "%s", path ? path : "");
g_opened_n++;
}
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)) { chmod(path, 0700); return unlink(path); }
chmod(path, 0700);
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);
}
static int row_index_for(const char *name)
{
int rows = library_test_rows();
for (int i = 0; i < rows; i++) {
meow_list_item it;
if (!library_test_src(i, &it)) continue;
if (it.title && strcmp(it.title, name) == 0) return i;
}
return -1;
}
static void wait_ready(int ms)
{
for (int t = 0; t < ms / 10; t++) {
library_on_tick();
int ready = 1;
int rows = library_test_rows();
for (int i = 0; i < rows; i++) {
meow_list_item it;
if (!library_test_src(i, &it)) continue;
if (it.subtitle && strcmp(it.subtitle, "…") == 0) { ready = 0; break; }
}
if (ready) return;
usleep(10000);
}
}
int main(void)
{
const char *base = getenv("TMPDIR");
if (!base || !base[0]) base = ".";
char tmpl[512];
snprintf(tmpl, sizeof tmpl, "%s/mr_browse_ui_XXXXXX", base);
char *data = mkdtemp(tmpl);
if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; }
setenv("MEOW_DATA_DIR", data, 1);
char root[MR_PATH_MAX];
snprintf(root, sizeof root, "%s/library", data);
char p[MR_PATH_MAX];
const char *dirs[] = { "book", "empty", "noimg", "deep/sub", "MeowOS/sub" };
for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) {
joinp(p, sizeof p, root, dirs[i]);
if (mkdir_p(p) != 0) { printf("SKIP: mkdir %s failed\n", p); return 0; }
}
joinp(p, sizeof p, root, "book/page2.png"); touch_file(p);
joinp(p, sizeof p, root, "book/page10.jpg"); touch_file(p);
joinp(p, sizeof p, root, "noimg/readme.txt"); touch_file(p);
joinp(p, sizeof p, root, "deep/sub/001.png"); touch_file(p);
joinp(p, sizeof p, root, "MeowOS/sub/m.png"); touch_file(p);
meow_ui_opts o = { 480, 720 };
if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; }
meow_view *lib = library_view_create();
library_test_set_root(root);
CHECK(strcmp(library_test_root(), root) == 0, "test root installed");
meow_view_page_push(ACT_LIBRARY);
CHECK(meow_ui_activity_active() == ACT_LIBRARY, "library active");
/* ---- U1:src2 行 + 懒算补位 ---- */
CHECK(library_test_load(root) == 1, "load fixture");
CHECK(library_test_rows() == 5, "5 rows (got %d)", library_test_rows());
static const char *want[5] = { "book", "deep", "empty", "MeowOS", "noimg" };
for (int i = 0; i < 5; i++) {
meow_list_item it;
CHECK(library_test_src(i, &it) == 1, "src row %d", i);
CHECK(it.title && strcmp(it.title, want[i]) == 0, "row[%d]=%s (want %s)",
i, it.title ? it.title : "(null)", want[i]);
CHECK(it.subtitle && strcmp(it.subtitle, "…") == 0,
"row[%d] starts with placeholder (got %s)", i, it.subtitle ? it.subtitle : "");
CHECK(it.right == MEOW_LIST_RIGHT_ARROW, "row[%d] has enter arrow while pending", i);
}
wait_ready(3000);
/* U2:补位后的行语义 */
for (int i = 0; i < 5; i++) {
meow_list_item it;
library_test_src(i, &it);
CHECK(it.subtitle && strcmp(it.subtitle, "…") != 0,
"row[%d] subtitle filled (got %s)", i, it.subtitle ? it.subtitle : "");
}
int ib = row_index_for("book");
int ie = row_index_for("empty");
int in = row_index_for("noimg");
int id = row_index_for("deep");
meow_list_item it;
CHECK(ib >= 0 && library_test_src(ib, &it) && strcmp(it.subtitle, "2 pages") == 0,
"book subtitle=%s", it.subtitle ? it.subtitle : "");
CHECK(it.right == MEOW_LIST_RIGHT_NONE, "book row has no enter arrow (opens reader)");
CHECK(ie >= 0 && library_test_src(ie, &it) && strcmp(it.subtitle, "空目录") == 0,
"empty subtitle=%s", it.subtitle ? it.subtitle : "");
CHECK(in >= 0 && library_test_src(in, &it) && strcmp(it.subtitle, "无图片") == 0,
"noimg subtitle=%s", it.subtitle ? it.subtitle : "");
CHECK(id >= 0 && library_test_src(id, &it) && strcmp(it.subtitle, "无图片") == 0,
"deep (images only in subdir) is not a book: %s", it.subtitle ? it.subtitle : "");
/* ---- 行点击:书 → reader;空目录 → 进入 ---- */
int list_top = meow_ui_statusbar_height() + 56;
g_opened_n = 0;
library_on_tap(120, list_top + ib * 56 + 28);
CHECK(g_opened_n == 1 && strstr(g_opened, "/book") != NULL,
"tap book opens reader (%s)", g_opened);
CHECK(strcmp(library_test_dir(), root) == 0, "tap book does not navigate");
library_on_tap(120, list_top + ie * 56 + 28);
char empty_path[MR_PATH_MAX];
joinp(empty_path, sizeof empty_path, root, "empty");
CHECK(strcmp(library_test_dir(), empty_path) == 0,
"tap empty dir enters it (%s)", library_test_dir());
CHECK(library_test_rows() == 0, "empty dir shows empty list");
/* ---- U3:导航(逐级上溯 / 到 / 退出 / 回 TF 根) ---- */
CHECK(library_test_go_up() == 1, "go_up from empty consumed");
CHECK(strcmp(library_test_dir(), root) == 0, "go_up to fixture root");
char deep[MR_PATH_MAX];
joinp(deep, sizeof deep, root, "deep/sub");
CHECK(library_test_load(deep) == 1, "load deep/sub");
CHECK(library_test_go_up() == 1, "go_up from deep/sub");
joinp(p, sizeof p, root, "deep");
CHECK(strcmp(library_test_dir(), p) == 0, "now at deep");
/* 「TF卡」回根 */
CHECK(library_test_load(deep) == 1, "reload deep/sub");
CHECK(library_test_go_root() == 1 && strcmp(library_test_dir(), root) == 0,
"TF卡 action returns to library root");
/* 上溯到文件系统 /:go_up 在 / 返回 0(app.c 据此退出)。
* 宿主(Termux)读不了 "/":从 /usr 返回时 load("/") 失败,仍应切到 "/" 的空列表
* (否则会卡在 /usr 无法退出),下一次返回=退出 App。 */
library_test_set_dir("/usr");
CHECK(library_test_go_up() == 1, "go_up toward / consumed");
CHECK(strcmp(library_test_dir(), "/") == 0, "reached / (unreadable root fallback)");
CHECK(library_test_go_up() == 0, "go_up at / returns 0 (app exits)");
CHECK(strcmp(library_test_dir(), "/") == 0, "still at / after exit gesture");
/* ---- U4:不可读目录 → 停留 + 提示 ---- */
CHECK(library_test_load(root) == 1, "back to fixture root");
char noread[MR_PATH_MAX];
joinp(noread, sizeof noread, root, "noread");
if (geteuid() != 0) {
CHECK(mkdir(noread, 0755) == 0, "mkdir noread");
chmod(noread, 0000);
int ok = library_test_load(noread);
CHECK(ok == 0, "unreadable dir load fails");
CHECK(strcmp(library_test_dir(), root) == 0, "stays in previous dir (%s)",
library_test_dir());
CHECK(library_test_hint_active(), "hint banner shown: %s", library_test_hint());
CHECK(strstr(library_test_hint(), "无法读取") != NULL,
"hint text mentions unreadable: %s", library_test_hint());
chmod(noread, 0700);
} else {
printf(" SKIP unreadable-dir case (running as root)\n");
}
meow_view_free(lib);
meow_ui_shutdown();
rm_rf(data);
printf("%s: %s\n", g_fail ? "FAIL" : "PASS",
g_fail ? "see failures above" : "browse UI checks passed");
return g_fail ? 1 : 0;
}