Files
MeowOS-SDK/tuku/test/test_browse_ui_host.c
T
AskaEth 66e598cb62 图库: 恢复缩略图 LRU 跳过解压页缓存(M11 P2-1 回归),补启动空列表/prune 回归测试(M13 评审 P2)
- ui/library/library.c cache_prune:rebase 时丢失了 M11 c775f85 的必修保护,
  恢复 `if (mr_cache_is_page_name(de->d_name)) continue;`(在 .bmp 过滤之前)。
  解压页缓存与缩略图同目录,mrpage_* 归 page_cache 自己的 64 页/32MiB LRU 管理;
  否则档案页被缩略图 LRU 统计并删除 → 每翻页重解压,且「touch 命中→解码」窗口内
  可能被删导致解码失败。
- 回归测试(MR_LIBRARY_UI_TEST_HOOKS 接缝):test_browse_ui_host 新增 U6——
  预置 1 个最旧 mrpage_*.bmp + 65 个较新缩略图,调用 library_test_cache_prune(),
  断言 mrpage_* 保留且不计入 64 张额度、缩略图被裁到 64;已验证去掉修复时该用例
  会失败(防止再次在 rebase 中丢失)。
- 顺手补齐/校正:
  ① test_browse_ui_host 的启动路径改为 create 前就把根指向不存在的目录,真实覆盖
     「启动根不可读仍建空列表」(断言 has_list/rows==0/横幅/当前目录),不再只是声明;
  ② tuku_internal.h 的 library_at_root 注释改为「书库根(生产=MR_ROOT_DIR,测试可改根)」。
- 复验:run_tests.sh 全 PASS(73/69/90/18/108/19/85 + settings 集成 + browse UI 集成
  + zip 106 + cbz 325);run_webtoon_tests.sh 全 PASS(76/36/71/57 + 集成);
  sdk/build_sdk.sh + build_tuku.sh 零警告 MIPS32r2 静态 ELF;UI host 测试 ASan/UBSan 全绿。
2026-11-05 12:31:46 +08:00

367 lines
15 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_ui_host.c — 书库浏览页宿主集成测试(可选,需 SDK 源码树)。
*
* 目的(M13):
* U1 列表用 src2 模式(每帧重取行):扫描后行副标题是占位「…」,后台探测完成、
* on_tick 补位后同一行立即显示页数——验证懒算结果能上屏(固定池 vsource 做不到);
* U2 行语义:书行显示 "N pages"(无进入箭头),空目录「空目录」,无图目录「无图片」;
* U3 导航:可逐级上溯父目录;文件系统 "/" 处 library_go_up 返回 0(app.c 据此退出);
* 「TF卡」回根动作回到书库根;
* U4 不可读目录:load 失败 → 停留原目录 + 顶部提示横幅;
* U5 启动根不可读:create 时仍建空列表(不是只有横幅);
* U6 缩略图 LRU 跳过解压页缓存(M11 P2-1 回归):cache_prune 只统计/删除缩略图,
* mrpage_*(page_cache 的解压页)必须保留且不计入 64 张额度。
*
* 编译:见 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 <fcntl.h>
#include <time.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);
int library_test_has_list(void);
const char *library_test_cache_dir(void);
int library_test_cache_on(void);
void library_test_cache_prune(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 set_mtime(const char *path, time_t t)
{
struct timespec ts[2];
ts[0].tv_sec = t;
ts[0].tv_nsec = 0;
ts[1] = ts[0];
utimensat(AT_FDCWD, path, ts, 0);
}
/* 统计缓存目录里的缩略图(*.bmp 且非 mrpage_*)与解压页(mrpage_*.bmp)。 */
static int count_bmp(const char *dir, int *pages)
{
DIR *d = opendir(dir);
if (!d) return -1;
int thumbs = 0, pg = 0;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
size_t len = strlen(de->d_name);
if (len <= 4 || strcmp(de->d_name + len - 4, ".bmp") != 0) continue;
if (strncmp(de->d_name, "mrpage_", 7) == 0) pg++;
else thumbs++;
}
closedir(d);
if (pages) *pages = pg;
return thumbs;
}
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);
}
}
/* U6:缩略图 LRU 必须跳过解压页缓存(M11 P2-1 回归;MR_CACHE_MAX_FILES=64)。 */
static void test_cache_prune_skips_pages(void)
{
const char *dir = library_test_cache_dir();
CHECK(library_test_cache_on() && dir && dir[0], "thumbnail cache enabled: %s",
dir ? dir : "");
if (!dir || !dir[0]) return;
/* 清空之前可能写入的 bmp(本测试独占 MEOW_DATA_DIR)。 */
DIR *d = opendir(dir);
if (d) {
struct dirent *de;
while ((de = readdir(d)) != NULL) {
size_t len = strlen(de->d_name);
if (len > 4 && strcmp(de->d_name + len - 4, ".bmp") == 0) {
char full[MR_PATH_MAX + 96];
snprintf(full, sizeof full, "%s/%s", dir, de->d_name);
unlink(full);
}
}
closedir(d);
}
char page[MR_PATH_MAX + 96], thumb[MR_PATH_MAX + 96];
snprintf(page, sizeof page, "%s/mrpage_0123456789abcdef.bmp", dir);
CHECK(touch_file(page) == 0, "create page cache file");
set_mtime(page, time(NULL) - 7200); /* 最旧:若被误删一定先删它 */
time_t base = time(NULL) - 3600;
for (int i = 0; i < 65; i++) { /* 65 张缩略图 > MR_CACHE_MAX_FILES */
snprintf(thumb, sizeof thumb, "%s/%016x_%ld_%d.bmp", dir,
(unsigned)i, (long)(base + i), i);
CHECK(touch_file(thumb) == 0, "create thumbnail %d", i);
set_mtime(thumb, base + i);
}
int pages_before = 0;
CHECK(count_bmp(dir, &pages_before) == 65, "precondition 65 thumbnails");
CHECK(pages_before == 1, "precondition 1 page (got %d)", pages_before);
library_test_cache_prune();
int pages_after = 0;
int thumbs_after = count_bmp(dir, &pages_after);
CHECK(access(page, R_OK) == 0, "mrpage_* survives thumbnail LRU");
CHECK(pages_after == 1, "mrpage_* not counted as thumbnail (got %d)", pages_after);
CHECK(thumbs_after == 64, "thumbnails trimmed to 64 (got %d)", thumbs_after);
}
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; }
/* U5:先指向一个不存在的根,再 create —— 真实走「启动时根目录不可读」分支。 */
const char *bad_root = "/no/such/tuku/m13_root";
library_test_set_root(bad_root);
CHECK(strcmp(library_test_root(), bad_root) == 0, "bad root installed");
meow_view *lib = library_view_create();
CHECK(library_test_hint_active(), "startup unreadable root shows hint: %s",
library_test_hint());
CHECK(library_test_rows() == 0, "startup empty list rows=%d", library_test_rows());
CHECK(library_test_has_list(), "startup built listview for empty list");
CHECK(strcmp(library_test_dir(), bad_root) == 0, "startup dir=%s", library_test_dir());
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");
}
test_cache_prune_skips_pages();
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;
}