From 64336f95d92fd480c58913db3c18b12b1e980443 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 19:12:32 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=B9=A6=E5=BA=93?= =?UTF-8?q?=E6=94=B9=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E5=99=A8=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20+=20=E5=85=B3=E9=97=AD=E7=BC=A9=E7=95=A5=E5=9B=BE?= =?UTF-8?q?=20+=20=E6=A0=B9=E9=94=81=E5=AE=9A/=E6=97=A0=E5=8D=A1=E5=AE=B9?= =?UTF-8?q?=E9=94=99=EF=BC=88M18=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈四件事一起改: - 「目录里有一张图就被当成最终目录」→ 取消「目录含图=一本书」全部链路 (探测/页数/封面/续读角标/懒算 worker/sr2 补位),列表 = 目录 + 图片 + .cbz/.zip(目录优先、组内自然序)。点目录=进入(空目录也进),点图片= mr_reader_open_dir_at(当前目录, 图片自然序下标),点档案册=打开档案册。 - 「文件夹浏览特别卡」→ 删除缩略图线程/磁盘 bmp 缓存/LRU/解码路径与行内 缩略图;首帧只做一次 opendir/readdir+lstat,副标题(类型·大小)扫描期 生成一次,行合成/滚动/点击零 IO 零格式化;去掉逐行 snap(自由滚动手感)。 2000 项目录扫描 ~2.8ms(opendir=1, lstat=2000),行合成 2.78ms/200 万次 (宿主实测;旧实现 2000 项 entry 含 512B cover,内存 2.7MB→1.7MB)。 - 「不要访问 SD 卡上级目录」→ 浏览根锁定 MR_ROOT_DIR:删除越根的顶栏 「上级」/library_go_parent,根隐藏顶栏返回,左缘返回到根=退出 App。 - 「没有 SD 卡不要报错」→ 根不可访问(未插卡/挂载失败/根是文件)时友好空态 「未检测到 SD 卡」+ 顶栏刷新/重进页面重试;子目录不可读才弹提示横幅。 集成陷阱:书库图片下标与阅读器目录册共享同一套过滤器(隐藏项/图片扩展名/ 普通文件/符号链接)与 mr_natural_cmp,test_browse_lazy 直接对比 mr_dir_book_init 顺序,test_open_book_host 断言「点 img10 就开 img10」。 reader 侧 M17 遗留:mr_reader_open_dir_at 在阅读页已激活时先 pop 再 push, 走完整 leave/enter(SDK 对同 view 重复 push 是 no-op 且会压重复 activity)。 回归:run_tests.sh / run_webtoon_tests.sh 全 PASS;交叉构建零警告。 --- tuku/README.md | 13 + tuku/app.c | 3 +- tuku/logic/library.c | 386 +++------------ tuku/logic/library.h | 152 ++---- tuku/test/run_tests.sh | 14 +- tuku/test/test_browse_lazy.c | 396 ++++------------ tuku/test/test_browse_nav.c | 67 ++- tuku/test/test_browse_perf.c | 110 +++-- tuku/test/test_browse_ui_host.c | 417 ++++++++-------- tuku/test/test_cbz.c | 126 ++--- tuku/test/test_cont_adjacent.c | 85 +--- tuku/test/test_library.c | 111 +++-- tuku/test/test_open_book_host.c | 151 +++--- tuku/tuku_internal.h | 13 +- tuku/ui/library/library.c | 814 ++++++-------------------------- tuku/ui/reader/reader.c | 9 + 16 files changed, 940 insertions(+), 1927 deletions(-) diff --git a/tuku/README.md b/tuku/README.md index 5361e54..e3dc6a2 100644 --- a/tuku/README.md +++ b/tuku/README.md @@ -7,6 +7,19 @@ ./pack_tuku.sh # 打包 → os.meow.picbrowser.mpk(根布局;未签名) ``` +## 浏览交互(文件管理器模型,M18) + +- 列表 = 当前目录一层的**子目录 + 受支持图片(png/jpg/jpeg/bmp)+ 档案册(.cbz/.zip)**; + 目录优先,组内自然序(大小写不敏感,同键按原始字节兜底,`A.jpg`/`a.jpg` 不抖动)。 +- 点目录 = 进入(空目录也进);点图片 = 从该图开始读(下标与阅读器目录册同一套过滤器/比较器); + 点档案册 = 打开档案册(第 1 页/续读)。 +- 缩略图功能已关闭:行内只有类型图标 + 文件名 + 类型·大小;首帧只做单层 + `opendir/readdir + lstat`,滚动/点击路径不解码、不访问文件、不重排字符串。 +- 浏览根锁定 TF 卡根(`/usr/data/mnt/sd_0`):根目录不显示返回箭头,左缘返回 = 退出 App; + **不允许上溯到 SD 卡的上级目录**。 +- 根目录不可访问(未插卡/挂载失败)→ 友好空态「未检测到 SD 卡」;点顶栏刷新或重进页面即重扫, + 不弹错、不崩溃。 + ## 一键签名出包 ```sh diff --git a/tuku/app.c b/tuku/app.c index f4dfb68..1e56c25 100644 --- a/tuku/app.c +++ b/tuku/app.c @@ -5,8 +5,7 @@ * - 页面切换统一走 meow_view_page_push/pop(activity 栈 + on_enter/on_leave 唯一入口); * - 物理键不映射返回/确认(on_key 仅日志);屏底上滑由 SDK 系统手势接管,App 不拦截; * - 左缘返回:reader/settings=pop;lock 页自行消费;书库逐级上移, - * **TF 根=退出 App**(系统返回语义,不再上溯系统目录);TF 根以外的上级浏览 - * 走顶栏显式「上级」动作。 + * **到 TF 卡根=退出 App**(浏览根锁定,不再上溯系统目录;顶栏也没有越根的「上级」)。 * * 主题同步:注册 meow_theme_add_listener;明暗/风格族切换时更新页面底色并整帧 invalidate, * 各页 on_draw / SDK 控件下一帧即用新语义色重绘(不缓存颜色)。 diff --git a/tuku/logic/library.c b/tuku/logic/library.c index 7ab94c0..be142d7 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -1,28 +1,19 @@ -/* logic/library.c — 书库目录浏览:单层枚举 + 后台懒判定(纯 C)。 +/* logic/library.c — 书库文件管理器模型:单层枚举 + 纯内存行合成(纯 C)。 * - * 扫描(mr_library_scan / mr_library_scan_ex)与 os.meow.files 的 fsx_scan_dir 同构: - * - 一次 opendir/readdir,逐项 lstat(只认目录与档案文件;符号链接跳过防环); - * - 隐藏项("." 开头,含 . ..)跳过;系统目录/空目录/无图目录一律列出; - * - 目录名/档案文件名按 mr_natural_cmp 自然升序;条目到 MR_LIB_SCAN_MAX 后仍有条目 → - * 截断标注。不再对子目录做任何递归/探测——首帧成本只与本层条目数线性相关。 + * 扫描(mr_library_scan)与 os.meow.files 的 fsx_scan_dir 同构: + * - 一次 opendir/readdir,逐项 lstat(只认目录、受支持图片、.cbz/.zip;符号链接跳过防环); + * - 隐藏项("." 开头,含 . ..)跳过;空目录/无图目录/系统目录照常列出; + * - 排序:目录优先,组内按 mr_natural_cmp 自然升序; + * - 副标题(类型 · 大小)在扫描期生成一次,渲染期不再做任何格式化/IO; + * - 条目到 MR_LIB_SCAN_MAX 后仍有条目 → 截断标注。 * - * 懒判定(probe): - * - mr_library_probe_dir 只读一层(图片项 lstat 校验普通文件),给出直接图片数与自然序首图; - * - mr_library_probe_path 按行类型探测:目录走上面的单层扫描;档案册调调用方注入的 - * archive_pages(M11,logic 不依赖 zip)。两者共用 mtime(秒+纳秒)+size 失效的 256 项 - * LRU 缓存 + 互斥锁,可被后台线程并发调用;探测期间目录/档案发生变化则不写缓存。 - * - mr_library_apply_probe 把结果补回行;副标题由 mr_library_format_subtitle 统一生成, - * 书行再拼 M7 的「· 续读 Pn」角标。 - * - * M11 档案册:注入 archive_pages 时 .cbz/.zip 直接子文件也成行(懒探测页数); - * 未注入时只列目录(M7 书库语义)。册间衔接的兄弟表 = 含图目录 ∪ 含图片页的档案册。 + * 阅读器辅助(与列表模型解耦):mr_library_dir_has_images 单层判据 + + * mr_book_adjacent(_ex) 兄弟册衔接(M7/M11 阅读器翻到相邻册用)。 */ #include "library.h" -#include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */ #include #include -#include #include #include #include @@ -138,244 +129,39 @@ int mr_natural_cmp(const char *a, const char *b) return strcmp(a, b) < 0 ? -1 : (strcmp(a, b) > 0 ? 1 : 0); } -/* 档案册探测(UI 注入;NULL=纯目录书库,档案文件不成行)。 */ -static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path) -{ - if (!probe || !path || !path[0]) return 0; - int n = probe(path, ud); - return n > 0 ? n : 0; -} - /* ============================================================ */ -/* 单层探测 + mtime/size 失效缓存 */ +/* 扫描 / 排序 / 行合成 */ /* ============================================================ */ -int mr_library_probe_dir(const char *dir, mr_library_probe *out) +static void fmt_size(long long size, char *out, int outsz) { - if (!out) return -1; - memset(out, 0, sizeof *out); - if (!dir || !dir[0]) return -1; - - IO_OPEN(); - DIR *d = opendir(dir); - if (!d) return -1; - - char best[MR_NAME_MAX]; - best[0] = '\0'; - out->empty = 1; - struct dirent *de; - while ((de = readdir(d)) != NULL) { - IO_READDIR(); - const char *name = de->d_name; - if (name[0] == '.') continue; /* 隐藏项不算内容 */ - out->empty = 0; - if (!mr_path_is_image(name)) continue; - - char full[MR_PATH_MAX]; - if (join_path(full, (int)sizeof full, dir, name) != 0) continue; - IO_LSTAT(); - struct stat st; - if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; - - out->pages++; - if (best[0] == '\0' || mr_natural_cmp(name, best) < 0) { - copy_str(best, (int)sizeof best, name); - copy_str(out->cover, (int)sizeof out->cover, full); - } - } - closedir(d); - return 0; + if (size < 0) size = 0; + static const char *const unit[] = { "B", "KB", "MB", "GB" }; + double v = (double)size; + int i = 0; + while (v >= 1024.0 && i < 3) { v /= 1024.0; i++; } + if (i == 0) snprintf(out, (size_t)outsz, "%lld B", size); + else snprintf(out, (size_t)outsz, "%.1f %s", v, unit[i]); } -#define MR_PROBE_CACHE_MAX 256 - -typedef struct probe_cache_entry { - char path[MR_PATH_MAX]; - long long mtime_ns; - long long size; - mr_library_probe probe; - unsigned long long last_use; -} probe_cache_entry; - -static probe_cache_entry *g_cache[MR_PROBE_CACHE_MAX]; -static unsigned long long g_cache_clock; -static pthread_mutex_t g_cache_lock = PTHREAD_MUTEX_INITIALIZER; - -static long long stat_mtime_ns(const struct stat *st) +/* 副标题只在扫描期生成一次(渲染期零格式化)。 */ +static void format_subtitle(mr_library_entry *e) { -#if defined(__APPLE__) - return (long long)st->st_mtimespec.tv_sec * 1000000000LL + st->st_mtimespec.tv_nsec; -#else - return (long long)st->st_mtim.tv_sec * 1000000000LL + st->st_mtim.tv_nsec; -#endif -} - -static probe_cache_entry *cache_find_locked(const char *path) -{ - for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) { - if (!g_cache[i]) continue; - if (strcmp(g_cache[i]->path, path) == 0) return g_cache[i]; - } - return NULL; -} - -static void cache_put_locked(const char *path, long long mtime_ns, long long size, - const mr_library_probe *p) -{ - probe_cache_entry *e = cache_find_locked(path); - if (!e) { - int slot = -1; - for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) - if (!g_cache[i]) { slot = i; break; } - if (slot < 0) { /* 满:淘汰最久未用项 */ - int lru = 0; - unsigned long long oldest = g_cache[0]->last_use; - for (int i = 1; i < MR_PROBE_CACHE_MAX; i++) { - if (g_cache[i]->last_use < oldest) { - oldest = g_cache[i]->last_use; - lru = i; - } - } - free(g_cache[lru]); - g_cache[lru] = NULL; - slot = lru; - } - e = (probe_cache_entry *)malloc(sizeof *e); - if (!e) return; /* 缓存只是加速:失败就每次现算 */ - if (copy_str(e->path, (int)sizeof e->path, path) != 0) { - free(e); - return; - } - g_cache[slot] = e; - } - e->mtime_ns = mtime_ns; - e->size = size; - e->probe = *p; - e->last_use = ++g_cache_clock; -} - -void mr_library_probe_cache_clear(void) -{ - pthread_mutex_lock(&g_cache_lock); - for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) { - free(g_cache[i]); - g_cache[i] = NULL; - } - g_cache_clock = 0; - pthread_mutex_unlock(&g_cache_lock); -} - -/* 无缓存探测:目录=单层目录扫描;档案册=注入回调取页数。 */ -static int probe_path_uncached(const char *path, int is_archive, - mr_lib_archive_fn archive_pages, void *ud, - mr_library_probe *out) -{ - struct stat st; - if (stat(path, &st) != 0) return -1; - if (is_archive) { - if (!archive_pages || !S_ISREG(st.st_mode)) return -1; - out->pages = archive_pages_at(archive_pages, ud, path); - out->empty = 0; - out->cover[0] = '\0'; - return 0; - } - if (!S_ISDIR(st.st_mode)) return -1; - return mr_library_probe_dir(path, out); -} - -int mr_library_probe_path(const char *path, int is_archive, - mr_lib_archive_fn archive_pages, void *ud, - mr_library_probe *out) -{ - if (!out) return -1; - memset(out, 0, sizeof *out); - if (!path || !path[0]) return -1; - - struct stat st; - if (stat(path, &st) != 0) return -1; - if (is_archive ? !S_ISREG(st.st_mode) : !S_ISDIR(st.st_mode)) return -1; - - long long mtime_ns = stat_mtime_ns(&st); - long long size = (long long)st.st_size; - - pthread_mutex_lock(&g_cache_lock); - probe_cache_entry *hit = cache_find_locked(path); - if (hit && hit->mtime_ns == mtime_ns && hit->size == size) { - *out = hit->probe; - hit->last_use = ++g_cache_clock; - pthread_mutex_unlock(&g_cache_lock); - return 0; - } - pthread_mutex_unlock(&g_cache_lock); - - mr_library_probe p; - memset(&p, 0, sizeof p); - if (probe_path_uncached(path, is_archive, archive_pages, ud, &p) != 0) return -1; - - /* 评审 P2:只把「探测前后完全一致」的结果写入缓存。 - * 探测期间目录/档案的 mtime 或 size 变了(新增/替换文件),说明结果可能不完整, - * 不能绑定到新 mtime 上当「新鲜」——本次直接返回,下次调用重新探测。 */ - struct stat st2; - if (stat(path, &st2) == 0 && stat_mtime_ns(&st2) == mtime_ns && - (long long)st2.st_size == size) { - pthread_mutex_lock(&g_cache_lock); - cache_put_locked(path, mtime_ns, size, &p); - pthread_mutex_unlock(&g_cache_lock); - } - - *out = p; - return 0; -} - -int mr_library_probe_cached(const char *dir, mr_library_probe *out) -{ - return mr_library_probe_path(dir, 0, NULL, NULL, out); -} - -/* ============================================================ */ -/* 行状态:副标题 / 补位 / 行合成 */ -/* ============================================================ */ -/* 基础副标题(不含 M7 续读角标)。 */ -static void entry_subtitle(mr_library_entry *e) -{ - switch (e->probe_state) { - case MR_LIB_READY: - if (e->pages > 0) - snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages, - e->pages == 1 ? "" : "s"); - else if (!e->is_archive && e->empty) - snprintf(e->subtitle, sizeof e->subtitle, "空目录"); - else - snprintf(e->subtitle, sizeof e->subtitle, "无图片"); + char sz[32]; + switch (e->kind) { + case MR_LIB_KIND_DIR: + snprintf(e->subtitle, sizeof e->subtitle, "文件夹"); break; - case MR_LIB_FAILED: - snprintf(e->subtitle, sizeof e->subtitle, "无法读取"); + case MR_LIB_KIND_ARCHIVE: + fmt_size(e->size, sz, (int)sizeof sz); + snprintf(e->subtitle, sizeof e->subtitle, "档案册 · %s", sz); break; default: - snprintf(e->subtitle, sizeof e->subtitle, "…"); + fmt_size(e->size, sz, (int)sizeof sz); + snprintf(e->subtitle, sizeof e->subtitle, "图片 · %s", sz); break; } } -/* 续读角标(M7):有 last page 记录的书行,在副标题后缀「· 续读 Pn」。 - * 档案册以档案路径为册标识,同一套逻辑复用。 - * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ -static void entry_badge(mr_library_entry *e) -{ - if (e->has_images != 1) return; - char badge[24]; - if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return; - int used = (int)strlen(e->subtitle); - if (used < (int)sizeof e->subtitle - 2) - snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge); -} - -void mr_library_format_subtitle(mr_library_entry *e) -{ - if (!e) return; - entry_subtitle(e); - entry_badge(e); -} - void mr_library_free(mr_library *lib) { if (!lib) return; @@ -400,59 +186,32 @@ static int lib_add(mr_library *lib, const mr_library_entry *e) return 0; } +/* 目录优先;组内自然序(大小写不敏感 + 原始字节兜底,结果确定)。 */ static int entry_cmp(const void *pa, const void *pb) { const mr_library_entry *a = (const mr_library_entry *)pa; const mr_library_entry *b = (const mr_library_entry *)pb; + int ad = (a->kind == MR_LIB_KIND_DIR), bd = (b->kind == MR_LIB_KIND_DIR); + if (ad != bd) return ad ? -1 : 1; return mr_natural_cmp(a->name, b->name); } -/* 文件夹册行(M13):一律列出、初始待探测;是否「书」由后台 probe 决定。 */ -static int add_dir_entry(mr_library *lib, const char *name, const char *full) +static int add_entry(mr_library *lib, const char *name, const char *full, + int kind, const struct stat *st) { mr_library_entry e; memset(&e, 0, sizeof e); if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; - e.pages = -1; - e.has_images = -1; - e.probe_state = MR_LIB_PENDING; - mr_library_format_subtitle(&e); + e.kind = kind; + e.size = (kind == MR_LIB_KIND_DIR) ? 0 : (long long)st->st_size; + format_subtitle(&e); return lib_add(lib, &e); } -/* 档案册(.cbz/.zip)行:文件即一本书,点入直接阅读。 - * pages<0=待后台探测(M13 懒算);>=0=已知页数(扫描期注入的旧路径/测试用)。 - * cover 留空——缩略图由 UI 解压档内首图(mr_zip_cover_path)生成。 */ -static int add_archive_entry(mr_library *lib, const char *name, const char *full, - int pages) +int mr_library_scan(mr_library *lib, const char *dir, int max_depth) { - mr_library_entry e; - memset(&e, 0, sizeof e); - if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; - if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; - e.is_archive = 1; - if (pages < 0) { - e.pages = -1; - e.has_images = -1; - e.probe_state = MR_LIB_PENDING; - } else { - e.pages = pages; - e.has_images = (pages > 0) ? 1 : 0; - e.probe_state = MR_LIB_READY; - } - mr_library_format_subtitle(&e); - return lib_add(lib, &e); -} - -/* ============================================================ */ -/* 单层扫描 */ -/* ============================================================ */ -int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, - mr_lib_archive_fn archive_pages, void *ud) -{ - (void)max_depth; /* 兼容旧签名:扫描不再递归 */ - (void)ud; + (void)max_depth; /* 兼容旧签名:扫描不递归 */ if (!lib) return -1; mr_library_free(lib); if (!dir || !dir[0]) return -1; @@ -475,15 +234,15 @@ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, if (lstat(full, &st) != 0) continue; if (S_ISLNK(st.st_mode)) continue; - int is_dir = S_ISDIR(st.st_mode); - int is_arch = (!is_dir && archive_pages && S_ISREG(st.st_mode) && - mr_path_is_archive(name)); - if (!is_dir && !is_arch) continue; - if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; } + int kind; + if (S_ISDIR(st.st_mode)) kind = MR_LIB_KIND_DIR; + else if (!S_ISREG(st.st_mode)) continue; + else if (mr_path_is_image(name)) kind = MR_LIB_KIND_IMAGE; + else if (mr_path_is_archive(name)) kind = MR_LIB_KIND_ARCHIVE; + else continue; - int rc = is_dir ? add_dir_entry(lib, name, full) - : add_archive_entry(lib, name, full, -1); - if (rc != 0) { + if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; } + if (add_entry(lib, name, full, kind, &st) != 0) { closedir(d); mr_library_free(lib); return -1; @@ -496,37 +255,6 @@ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, return lib->count; } -int mr_library_scan(mr_library *lib, const char *dir, int max_depth) -{ - return mr_library_scan_ex(lib, dir, max_depth, NULL, NULL); -} - -int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p) -{ - if (!lib || !p || idx < 0 || idx >= lib->count) return 0; - mr_library_entry *e = &lib->items[idx]; - e->pages = p->pages; - e->empty = p->empty; - e->has_images = (p->pages > 0) ? 1 : 0; - e->probe_state = MR_LIB_READY; - copy_str(e->cover, (int)sizeof e->cover, p->cover); - mr_library_format_subtitle(e); - return 1; -} - -int mr_library_mark_error(mr_library *lib, int idx) -{ - if (!lib || idx < 0 || idx >= lib->count) return 0; - mr_library_entry *e = &lib->items[idx]; - e->pages = -1; - e->empty = 0; - e->has_images = 0; - e->probe_state = MR_LIB_FAILED; - e->cover[0] = '\0'; - mr_library_format_subtitle(e); - return 1; -} - int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) { static char trunc_sub[48]; @@ -551,12 +279,21 @@ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) memset(out, 0, sizeof *out); out->title = e->name; out->subtitle = e->subtitle; - out->is_book = (e->has_images == 1); - out->is_archive = e->is_archive; + out->kind = e->kind; out->index = idx; return 1; } +int mr_library_image_index(const mr_library *lib, int idx) +{ + if (!lib || idx < 0 || idx >= lib->count) return -1; + if (lib->items[idx].kind != MR_LIB_KIND_IMAGE) return -1; + int n = 0; + for (int i = 0; i < idx; i++) + if (lib->items[i].kind == MR_LIB_KIND_IMAGE) n++; + return n; +} + int mr_path_parent(const char *path, char *out, int outsz) { if (!path || !out || outsz <= 0) return -1; @@ -582,16 +319,19 @@ int mr_path_parent(const char *path, char *out, int outsz) int mr_library_dir_has_images(const char *dir) { if (!dir || !dir[0]) return 0; + IO_OPEN(); DIR *d = opendir(dir); if (!d) return 0; int found = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { + IO_READDIR(); const char *name = de->d_name; if (name[0] == '.' || !mr_path_is_image(name)) continue; char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, dir, name) != 0) continue; + IO_LSTAT(); struct stat st; if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; found = 1; @@ -618,7 +358,9 @@ static int sibling_is_book(const char *full, const struct stat *st, const char *slash = strrchr(full, '/'); const char *name = slash ? slash + 1 : full; if (!mr_path_is_archive(name)) return 0; - return archive_pages_at(probe, ud, full) > 0; + if (!probe) return 0; + int n = probe(full, ud); + return n > 0; } return 0; } diff --git a/tuku/logic/library.h b/tuku/logic/library.h index 4149c88..a4fd7d0 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -1,25 +1,18 @@ -/* logic/library.h — 书库目录浏览(纯 C,不依赖 meow_ui.h)。 +/* logic/library.h — 书库文件管理器模型(纯 C,不依赖 meow_ui.h)。 * * 分层契约同 examples/hello:logic 层只含文件系统与纯数据,宿主 gcc 可直接裸测。 * - * M13 浏览语义(用户要求:性能 + 访问放开;参考 os.meow.files 的 fsx_scan_dir): - * - mr_library_scan / mr_library_scan_ex 只枚举**当前目录一层**:一次 opendir/readdir + - * 逐项 lstat,列出全部子目录(自然升序),不做任何递归「子树含图」探测; - * - 访问放开:系统目录(MeowAPPs/MeowData/MeowOS/lost+found)不再隐藏, - * 空目录/无图目录一律列出并可进入;仅跳过 "."/".." 与其它 "." 开头的隐藏项、 - * 符号链接(防环); - * - 「直接含图目录=一本书」的判定改为**后台懒算**:mr_library_probe_dir 只读一层, - * mr_library_probe_path 按行类型(目录/档案册)走同一套 mtime 失效缓存(线程安全), - * 结果经 mr_library_apply_probe 补回行(页数/首图/副标题)。首帧/滚动不做子目录 IO; - * - 单目录条目上限 MR_LIB_SCAN_MAX(默认 2000):到上限后若还有条目则截断并置 - * lib->truncated=1(UI 用 mr_library_row_at 末尾的提示行标注)。 + * M18 浏览语义(用户要求「完全像文件管理器」): + * - 列表 = 当前目录一层的「子目录 + 受支持图片(png/jpg/jpeg/bmp) + 档案册(.cbz/.zip)」; + * 目录优先,组内自然序;其它普通文件不列;隐藏项("." 开头)与符号链接跳过; + * - **没有**「目录含图=一本书」的判定:不探测、不算页数、不取封面、不拼续读角标; + * 点目录就是进目录(空目录也进),点图片从该图开始读,点档案册打开档案册; + * - 扫描只做一次 opendir/readdir + 逐项 lstat;副标题(类型 · 大小)在扫描时生成一次, + * 渲染期零 IO、零字符串格式化(UI row 回调直接取指针); + * - 单目录条目上限 MR_LIB_SCAN_MAX:到上限后若还有条目则截断并置 lib->truncated=1。 * - * M11 档案册(.cbz/.zip):注入 archive_pages 回调时,当前层的档案文件也成行(与目录同列 - * 自然序混排);档案册的页数同样走懒探测(mr_library_probe_path + 注入回调),logic 层 - * 不依赖 zip 容器。未注入时(archive_pages==NULL)只列目录,等价 M7 书库。 - * - * M7 续读角标 / 册间衔接:mr_library_format_subtitle 在探测完成后拼「· 续读 Pn」 - * (mr_cont_badge);mr_library_dir_has_images / mr_book_adjacent(_ex) 语义不变。 + * 仍保留的阅读器辅助:mr_library_dir_has_images / mr_book_adjacent(_ex) + * (阅读到册末时翻到同父目录的相邻册,与书库列表模型无关)。 */ #ifndef MR_LIBRARY_H #define MR_LIBRARY_H @@ -30,149 +23,96 @@ extern "C" { #define MR_PATH_MAX 512 #define MR_NAME_MAX 256 -#define MR_LIB_MAX_DEPTH 8 /* 兼容旧调用:扫描已不递归,该参数被忽略 */ +#define MR_LIB_MAX_DEPTH 8 /* 兼容旧调用:扫描不递归,该参数被忽略 */ /* 单目录列出的条目上限(超大目录截断 + UI 提示;可用 -D 覆写便于宿主测试)。 */ #ifndef MR_LIB_SCAN_MAX #define MR_LIB_SCAN_MAX 2000 #endif -/* 行懒判定状态:0=待探测(副标题占位「…」)/1=已探测/2=探测失败(不可读)。 */ -#define MR_LIB_PENDING 0 -#define MR_LIB_READY 1 -#define MR_LIB_FAILED 2 +/* 行类型(列表图标/点击行为由 UI 按此分派)。 */ +#define MR_LIB_KIND_DIR 0 /* 子目录:点=进入 */ +#define MR_LIB_KIND_IMAGE 1 /* 受支持图片:点=从该图开始阅读 */ +#define MR_LIB_KIND_ARCHIVE 2 /* .cbz/.zip 档案册:点=打开档案册 */ typedef struct mr_library_entry { - char name[MR_NAME_MAX]; /* 目录名/档案文件名(行主标题) */ - char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader/探测用) */ - char cover[MR_PATH_MAX]; /* 后台探测填:自然序第一张直接子图(档案册留空,UI 解压首图) */ - char subtitle[48]; /* 占位「…」;探测后 "N pages"/"空目录"/"无图片"/"无法读取" */ - int pages; /* -1=未判定;>=0=直接图片数(档案册=档内图片页数) */ - int subdirs; /* 兼容字段(M11 及旧读者):新扫描不统计,恒 0 */ - int has_images; /* 1=书;0=非书;-1=未判定 */ - int probe_state; /* MR_LIB_PENDING / MR_LIB_READY / MR_LIB_FAILED */ - int empty; /* 1=空目录(目录且 probe_state==READY 后有效) */ - int is_archive; /* 1=.cbz/.zip 档案册行 */ + char name[MR_NAME_MAX]; /* 文件名/目录名(行主标题) */ + char path[MR_PATH_MAX]; /* 完整路径 */ + char subtitle[64]; /* 扫描时预生成一次:「文件夹」/「图片 · 1.2 MB」/「档案册 · 1.2 MB」 */ + long long size; /* 普通文件字节数;目录=0 */ + int kind; /* MR_LIB_KIND_* */ } mr_library_entry; typedef struct mr_library { mr_library_entry *items; - int count; /* 当前层列出的条目数 */ + int count; /* 当前层列出的条目数 */ int cap; - int truncated; /* 1=达到 MR_LIB_SCAN_MAX 被截断 */ + int truncated; /* 1=达到 MR_LIB_SCAN_MAX 被截断 */ } mr_library; -/* 单行探测结果(目录=本层直接图片数;档案册=档内图片页数;均不递归)。 */ -typedef struct mr_library_probe { - int pages; /* 受支持图片数(png/jpg/jpeg/bmp;档案册=档内页数) */ - int empty; /* 1=目录没有可见子项(档案册恒 0) */ - char cover[MR_PATH_MAX]; /* 目录的自然序第一张图(档案册留空) */ -} mr_library_probe; - -/* 行数据(纯内存合成;UI 的每帧 src 回调直接取用,零 IO)。 */ +/* 行数据(纯内存合成;UI 的每帧 src 回调直接取用,零 IO、零格式化)。 */ typedef struct mr_library_row { - const char *title; /* 主标题 */ - const char *subtitle; /* 副标题(可空串) */ - int is_book; /* 1=书(显示页数/缩略图,点入阅读,无进入箭头) */ - int is_archive; /* 1=档案册行 */ - int disabled; /* 1=提示行(不可点,如截断标注) */ - int index; /* 对应 mr_library.items 下标;提示行=-1 */ + const char *title; /* 主标题 */ + const char *subtitle; /* 副标题(预生成,永不为 NULL) */ + int kind; /* MR_LIB_KIND_* */ + int disabled; /* 1=提示行(不可点,如截断标注) */ + int index; /* 对应 mr_library.items 下标;提示行=-1 */ } mr_library_row; /* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */ int mr_path_is_image(const char *name); -/* 档案册扩展名判断:cbz / zip(大小写不敏感)。1=档案册。 - * 注意这只是路径判据;能不能当书读还要看档案里有没有图片页(探测回调)。 */ +/* 档案册扩展名判断:cbz / zip(大小写不敏感)。1=档案册。 */ int mr_path_is_archive(const char *name); /* 自然排序比较:数字段按数值比(page2 < page10),数字相等时前导零少的在前; - * 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序。 */ + * 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序(结果确定,无抖动)。 */ int mr_natural_cmp(const char *a, const char *b); -/* 扫描 dir 的**直接子目录**(单层;不读子目录内容),写入 lib(先释放 lib 旧内容)。 +/* 扫描 dir 的**直接子项**(单层):子目录 + 受支持图片 + 档案册,写入 lib(先释放旧内容)。 * 返回条目数(>=0);dir 不可读/不存在/不是目录返回 -1(lib 仍为空且可安全 free)。 * max_depth 仅为兼容旧调用保留,已忽略(不再递归)。 */ int mr_library_scan(mr_library *lib, const char *dir, int max_depth); -/* ============================================================ */ -/* 档案册(M11):.cbz/.zip 当一本书,与文件夹册同列 */ -/* ------------------------------------------------------------ - * logic 层不依赖 zip 容器(保持宿主裸测/链接面干净):档案册探测由 UI 注入。 - * archive_pages(path, ud) 返回档内图片页数(>0=是一本书),0=不是/坏档。 - * 传 NULL 时行为与 M7 完全一致(纯目录书库,档案文件不成行)。 - * 档案册行:name=文件名、path=档案路径、is_archive=1、cover 留空(UI 解压首图)、 - * 页数/书标记由后台 probe 经同一注入回调懒算。 - * ============================================================ */ -typedef int (*mr_lib_archive_fn)(const char *path, void *ud); - -int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, - mr_lib_archive_fn archive_pages, void *ud); - /* 释放条目数组并清零(可重复调用)。 */ void mr_library_free(mr_library *lib); /* 取父目录路径(去掉末尾 '/' 后截断最后一段)。根 "/" 返回 "/"。 - * 成功 0;参数非法/输出缓冲不足 -1。可一路向上到文件系统 '/'。 */ + * 成功 0;参数非法/输出缓冲不足 -1。纯路径工具,不做根目录限制(限制在 UI 导航层)。 */ int mr_path_parent(const char *path, char *out, int outsz); -/* ---- 后台懒判定(单层探测;可由工作线程调用) ---- */ - -/* 单层探测目录 dir:直接图片数 + 自然序首图 + 是否空目录。不递归、不跟随符号链接。 - * 0=成功;-1=参数非法/不可读(out 清零)。 */ -int mr_library_probe_dir(const char *dir, mr_library_probe *out); - -/* 带缓存探测目录:按目录 mtime(秒+纳秒)+ size 失效;内部互斥锁,可多线程并发调用。 - * 0=成功(可能来自缓存);-1=不可读(失败不缓存)。 */ -int mr_library_probe_cached(const char *dir, mr_library_probe *out); - -/* 按行探测(目录或档案册),带同一套缓存: - * is_archive=0 → 目录(等价 mr_library_probe_cached); - * is_archive=1 → 调 archive_pages(path,ud) 取档内页数(cover 留空、empty=0); - * archive_pages 为 NULL 时返回 -1。 - * 0=成功;-1=参数非法/不可读。探测期间 mtime/size 变化则不写入缓存(避免假新鲜)。 */ -int mr_library_probe_path(const char *path, int is_archive, - mr_lib_archive_fn archive_pages, void *ud, - mr_library_probe *out); - -/* 清空探测缓存(目录整体切换/测试用)。 */ -void mr_library_probe_cache_clear(void); - -/* 把探测结果补回第 idx 行(pages/has_images/cover/subtitle/empty)。 - * 1=已应用;0=参数非法/越界。 */ -int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p); - -/* 标记第 idx 行探测失败(不可读):副标题「无法读取」,视为非书。1=已应用;0=非法。 */ -int mr_library_mark_error(mr_library *lib, int idx); - -/* 按行当前状态重算副标题(占位/页数/空目录/无图片/无法读取), - * 并在书行(has_images==1)后缀「· 续读 Pn」(M7 角标)。 */ -void mr_library_format_subtitle(mr_library_entry *e); - /* 行合成(纯内存、零 IO、O(1);UI 线程每帧调用):idx 越界返回 0。 * idx==count 且 lib->truncated 时输出末尾提示行(disabled=1)。 * 返回的 title/subtitle 指向 lib 内部或函数内静态缓冲(截断提示行),调用方不要长期持有。 */ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out); -/* 目录是否**直接**含受支持图片(= 书库语义里的「一本书」,可直接进 reader)。 - * 1=有;0=没有/不可读。 */ +/* 图片行在该目录图片自然序中的下标(0 基);idx 不是图片行/越界返回 -1。 + * + * ⚠️ 这是书库→阅读器的契约:阅读器的目录册(logic/reader/page_source.c)只枚举 + * 「非隐藏 + mr_path_is_image + 普通文件」并按 mr_natural_cmp 排序。书库扫描用的是 + * 同一套过滤器与比较器,因此本函数数出的下标就是 mr_reader_open_dir_at() 的起始页。 */ +int mr_library_image_index(const mr_library *lib, int idx); + +/* 目录是否**直接**含受支持图片。1=有;0=没有/不可读。 + * 仅用于阅读器的「相邻册」判定,不再是书库行语义。 */ int mr_library_dir_has_images(const char *dir); -/* 册间衔接(M7):同父目录下按书库自然序排列的「直接含图」兄弟册。 +/* 册间衔接(M7):同父目录下按自然序排列的「直接含图」兄弟册。 * delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径; * 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。 - * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 + * 语义:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 * 只算文件夹册(直接含图目录)——M7 口径不变。 */ int mr_book_adjacent(const char *dir, int delta, char *out, int outsz); /* 册间衔接 + 档案册(M11):兄弟表 = 「直接含图目录」∪「含图片页的 .cbz/.zip」, * 两者同列按名称自然序混排(跨册衔接按同父目录自然序)。 * archive_pages 传 NULL 时与 mr_book_adjacent 完全等价。 */ +typedef int (*mr_lib_archive_fn)(const char *path, void *ud); int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, mr_lib_archive_fn archive_pages, void *ud); #ifdef MR_LIBRARY_TEST_HOOKS -/* 宿主测试用 IO 计数(scan/probe_dir 的 opendir/readdir/lstat 次数;线程安全不保证)。 */ +/* 宿主测试用 IO 计数(scan/dir_has_images 的 opendir/readdir/lstat 次数;线程安全不保证)。 */ typedef struct mr_library_io_stats { long opendirs; long readdirs; diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index f55bec8..dbdde77 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -1,9 +1,9 @@ #!/bin/bash # run_tests.sh — 宿主 gcc 构建并运行图库测试(不链 SDK 静态库、不需要设备)。 -# - test_library:单层目录扫描/自然排序/父目录/阅读方向 -# - test_browse_nav:访问放开(系统/空/无图目录可进入)、上溯到 /、不可读目录、截断 -# - test_browse_lazy:单层探测/懒判定补位/mtime 失效缓存/行合成 O(1)/M7 册间语义 -# - test_browse_perf:500 目录×8 层嵌套+2000 文件夹具,断言扫描单层且有界 +# - test_library:单层目录扫描(目录+图片+档案册)/自然排序/图片下标/父目录/阅读方向 +# - test_browse_nav:访问放开(系统/空/无图目录可进入)、图片与档案册成行、截断、不可读 +# - test_browse_lazy:行合成零 IO/副标题预生成/图片下标 == 阅读器目录册下标(集成陷阱) +# - test_browse_perf:500 目录×8 层嵌套 + 2000 项大目录,断言单层扫描与行合成成本 # - test_settings_reader_persist:阅读方向 meow_config 持久化(默认/落盘/重启恢复/ # 坏值/非法值/只读一次/写失败只告警) # - test_pin:启动 PIN 格式/设置/校验/修改/禁用/残缺配置/落盘失败(logic/pin.c 为纯 C, @@ -33,7 +33,8 @@ gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \ "$OUT"/test_browse_nav gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \ - test/test_browse_lazy.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + test/test_browse_lazy.c logic/library.c logic/reader.c \ + logic/reader/page_source.c test/settings_reader_stub.c \ -pthread -o "$OUT/test_browse_lazy" "$OUT"/test_browse_lazy @@ -73,8 +74,9 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then -pthread -lm -o "$OUT"/test_settings_lock_host "$OUT"/test_settings_lock_host - # 书库浏览页集成(src2 懒算补位/行语义/上溯至 / /不可读提示);同样需要 SDK 源码树。 + # 书库浏览页集成(文件管理器行模型/点击语义/根锁定/无 SD 卡空态/零 IO 行合成);同样需要 SDK 源码树。 gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_LIBRARY_UI_TEST_HOOKS \ + -DMR_LIBRARY_TEST_HOOKS \ -I. -I"$SDK" -I"$SDK/include" \ test/test_browse_ui_host.c ui/library/library.c logic/library.c \ logic/reader.c logic/reader/page_source.c logic/reader/archive_source.c \ diff --git a/tuku/test/test_browse_lazy.c b/tuku/test/test_browse_lazy.c index b6843c8..731048a 100644 --- a/tuku/test/test_browse_lazy.c +++ b/tuku/test/test_browse_lazy.c @@ -1,24 +1,22 @@ -/* test_browse_lazy.c — 单层探测 / 懒判定 / mtime 失效缓存 / 行合成(纯 C,无 SDK)。 +/* test_browse_lazy.c — 浏览行模型 / 零 IO 行合成 / 图片下标一致性(纯 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 的口径)。 + * 文件名沿用历史(旧的「懒判定」已随 M18 文件管理器模型删除);本测试现在覆盖: + * L1 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行; + * L2 **书库→阅读器下标记号契约**:mr_library_image_index 数出的下标 == 阅读器目录册 + * (logic/reader/page_source.c: mr_dir_book_init)里同一张图的下标。 + * 夹具含大小写混排(A.jpg/a.jpg)、数字序号(img2/img10)、大写扩展名(IMG3.JPG)、 + * 前导零(page01/page1)、夹在图片之间的档案册与普通文件; + * L3 扫描期副标题:每行都有内容(不再是占位「…」,渲染期不做格式化)。 * * 编译(在 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 \ + * test/test_browse_lazy.c logic/library.c logic/reader/page_source.c \ * -pthread -o build/host/test_browse_lazy */ #include "logic/library.h" +#include "logic/reader/page_source.h" #include -#include -#include #include #include #include @@ -81,255 +79,47 @@ 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) +static long long now_us(void) { - 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); + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long)ts.tv_sec * 1000000LL + ts.tv_nsec / 1000LL; } -static const mr_library_entry *find_entry(const mr_library *lib, const char *name) +/* 阅读器目录册用的假解码回调(本测试不打开任何页)。 */ +static meow_img *fake_load(const char *path, void *ud, int maxdim) { - for (int i = 0; i < lib->count; i++) - if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i]; + (void)path; (void)ud; (void)maxdim; return NULL; } +static void fake_unload(meow_img *img) { (void)img; } -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) +/* ---- L1 + L3:行合成零 IO / 副标题预生成 ---- */ +static void test_rows_zero_io(const char *root) { mr_library lib; memset(&lib, 0, sizeof lib); - mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) > 0, "scan fixture"); + + for (int i = 0; i < lib.count; i++) { + mr_library_row row; + CHECK(mr_library_row_at(&lib, i, &row) == 1, "row %d", i); + CHECK(row.title && row.title[0], "row %d title", i); + CHECK(row.subtitle && row.subtitle[0] && strcmp(row.subtitle, "…") != 0, + "row %d subtitle pre-generated (%s)", i, row.subtitle ? row.subtitle : ""); + } mr_library_io_stats st; mr_library_test_io_reset(); - struct timespec t0, t1; - clock_gettime(CLOCK_MONOTONIC, &t0); + long long t0 = now_us(); 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); + long long us = now_us() - t0; 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)", @@ -343,99 +133,93 @@ static void test_rows(const char *root) "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) +/* ---- L2:书库图片下标 == 阅读器目录册下标 ---- */ +static void test_image_index_matches_reader(const char *dir) { 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++; - } - } + CHECK(mr_library_scan(&lib, dir, MR_LIB_MAX_DEPTH) > 0, "scan mixed dir"); + + mr_dir_book db; + memset(&db, 0, sizeof db); + int pages = mr_dir_book_init(&db, dir, 720, fake_load, fake_unload, NULL); + CHECK(pages > 0, "reader dir book pages=%d", pages); + + /* 书库里的图片行按出现顺序,其下标必须依次是 0..n-1(无空洞/无重排)。 */ + int expect = 0; + for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; + int idx = mr_library_image_index(&lib, i); + CHECK(idx == expect, "image row %s index=%d (want %d)", + lib.items[i].name, idx, expect); + expect++; } + CHECK(expect == pages, "library images=%d vs reader pages=%d", expect, pages); + if (expect != pages) { + mr_library_free(&lib); + mr_dir_book_free(&db); + return; + } + + /* 逐张对齐:书库第 k 张图 == 阅读器目录册第 k 页(下标即起始页)。 */ + int k = 0; + for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; + char page_path[MR_PATH_MAX]; + mr_dir_book_path(&db, k, page_path, (int)sizeof page_path); + CHECK(strcmp(page_path, lib.items[i].path) == 0, + "page[%d]: library=%s reader=%s", k, lib.items[i].path, page_path); + k++; + } + + /* 档案册/目录不占图片下标。 */ + for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind == MR_LIB_KIND_IMAGE) continue; + CHECK(mr_library_image_index(&lib, i) == -1, + "non-image %s has no image index", lib.items[i].name); + } + + mr_dir_book_free(&db); 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 tmpl[] = "./mr_rows_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" }; + const char *dirs[] = { "book", "mixed", "mixed/sub", "empty" }; 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"); + joinp(p, sizeof p, root, "mixed/sub/zz.png");CHECK(touch_file(p) == 0, "nested"); - /* 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); } + /* 大小写混排 + 数字序号 + 大写扩展名 + 前导零 + 夹档案/普通文件。 */ + const char *mixed[] = { + "A.jpg", "a.jpg", "img2.png", "IMG3.JPG", "img10.png", + "page01.bmp", "page1.jpeg", "cover.cbz", "notes.txt", ".hidden.png" + }; + for (unsigned i = 0; i < sizeof mixed / sizeof mixed[0]; i++) { + joinp(p, sizeof p, root, "mixed"); + size_t dl = strlen(p); + snprintf(p + dl, sizeof p - dl, "/%s", mixed[i]); 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); + test_rows_zero_io(root); + joinp(p, sizeof p, root, "mixed"); + test_image_index_matches_reader(p); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); diff --git a/tuku/test/test_browse_nav.c b/tuku/test/test_browse_nav.c index 58240a6..93d8221 100644 --- a/tuku/test/test_browse_nav.c +++ b/tuku/test/test_browse_nav.c @@ -1,17 +1,16 @@ -/* test_browse_nav.c — 浏览访问放开 / 上溯父目录 / 截断标注(纯 C,无 SDK)。 +/* test_browse_nav.c — 浏览模型 / 进入目录 / 截断(纯 C,无 SDK)。 * - * 覆盖(M13 用户要求②): - * N1 访问放开:系统目录(MeowAPPs/MeowData/MeowOS/lost+found)不再隐藏, - * 空目录/无图目录都列出且可进入(scan 返回 0 而不是 -1); - * N2 隐藏项/普通文件/符号链接仍不列(防环、防噪声); - * N3 不可读目录/不存在/不是目录:scan 返回 -1,调用方据此提示并停留; - * N4 上溯父目录:mr_path_parent 可一路走到文件系统 "/"(不再以 TF 卡根为界); - * N5 单目录条目上限:达到 MR_LIB_SCAN_MAX 后截断并置 truncated(行合成给出提示行)。 + * 覆盖(M18 文件管理器模型): + * N1 单层列表:目录 + 受支持图片 + 档案册(cbz/zip)都成行,目录优先+自然序; + * 系统目录/空目录/无图目录都列出且可进入;隐藏项/普通文件/符号链接不列; + * N2 不可读目录/不存在/不是目录:scan 返回 -1,调用方据此显示空态或提示; + * N3 父目录路径推导:mr_path_parent 是纯路径工具(可算到文件系统 "/"; + * 浏览越界的限制在 UI 导航层,见 test_browse_ui_host 的根锁定用例); + * N4 单目录条目上限:达到 MR_LIB_SCAN_MAX 后截断并置 truncated(行合成给出提示行)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ - * test/test_browse_nav.c logic/library.c logic/reader.c test/settings_reader_stub.c \ - * -pthread -o build/host/test_browse_nav + * test/test_browse_nav.c logic/library.c -pthread -o build/host/test_browse_nav */ #include "logic/library.h" @@ -85,19 +84,20 @@ static const mr_library_entry *find_entry(const mr_library *lib, const char *nam return NULL; } -/* ---- N1/N2 ---- */ +/* ---- N1 ---- */ static void test_open_access(const char *root) { mr_library lib; memset(&lib, 0, sizeof lib); int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); - CHECK(n == 7, "root lists 7 dirs (got %d)", n); + CHECK(n == 9, "root lists 9 rows (got %d)", n); CHECK(lib.truncated == 0, "root not truncated"); - static const char *want[7] = { - "book", "empty", "lost+found", "MeowAPPs", "MeowData", "MeowOS", "noimg" + static const char *want[9] = { + "book", "empty", "lost+found", "MeowAPPs", "MeowData", "MeowOS", "noimg", + "album.cbz", "loose.png" }; - for (int i = 0; i < 7 && i < lib.count; i++) + for (int i = 0; i < 9 && i < lib.count; i++) CHECK(strcmp(lib.items[i].name, want[i]) == 0, "sort[%d]=%s got %s", i, want[i], lib.items[i].name); @@ -105,11 +105,13 @@ static void test_open_access(const char *root) CHECK(find_entry(&lib, want[i]) != NULL, "system/empty/noimg dir listed: %s", want[i]); CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded"); CHECK(find_entry(&lib, "linkdir") == NULL, "symlink dir excluded"); - CHECK(find_entry(&lib, "loose.png") == NULL, "file excluded"); + CHECK(find_entry(&lib, "loose.png") != NULL, "loose image listed (clickable)"); + CHECK(find_entry(&lib, "album.cbz") != NULL, "archive listed"); + CHECK(find_entry(&lib, "readme.txt") == NULL, "plain file excluded"); /* 可进入:每个列出的目录都能被 scan(0 项也算成功,不等于 -1)。 */ - char p[MR_PATH_MAX]; for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind != MR_LIB_KIND_DIR) continue; mr_library child; memset(&child, 0, sizeof child); CHECK(mr_library_scan(&child, lib.items[i].path, MR_LIB_MAX_DEPTH) >= 0, @@ -117,11 +119,20 @@ static void test_open_access(const char *root) mr_library_free(&child); } + char p[MR_PATH_MAX]; + joinp(p, sizeof p, root, "book"); + mr_library book; + memset(&book, 0, sizeof book); + CHECK(mr_library_scan(&book, p, MR_LIB_MAX_DEPTH) == 1 && + find_entry(&book, "001.png") != NULL, "dir with images lists the image"); + mr_library_free(&book); + joinp(p, sizeof p, root, "MeowOS"); mr_library syslib; memset(&syslib, 0, sizeof syslib); - CHECK(mr_library_scan(&syslib, p, MR_LIB_MAX_DEPTH) == 1 && - find_entry(&syslib, "sub") != NULL, "system dir browsable"); + CHECK(mr_library_scan(&syslib, p, MR_LIB_MAX_DEPTH) == 2 && + find_entry(&syslib, "sub") != NULL && + find_entry(&syslib, "001.png") != NULL, "system dir browsable"); mr_library_free(&syslib); joinp(p, sizeof p, root, "empty"); @@ -133,13 +144,13 @@ static void test_open_access(const char *root) mr_library_free(&lib); } -/* ---- N3 ---- */ +/* ---- N2 ---- */ static void test_unreadable(const char *root) { mr_library lib; char p[MR_PATH_MAX]; memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan(&lib, "/no/such/dir/m13", MR_LIB_MAX_DEPTH) == -1, "missing -> -1"); + CHECK(mr_library_scan(&lib, "/no/such/dir/m18", MR_LIB_MAX_DEPTH) == -1, "missing -> -1"); CHECK(lib.count == 0 && lib.truncated == 0, "failed scan leaves empty lib"); mr_library_free(&lib); @@ -160,8 +171,8 @@ static void test_unreadable(const char *root) } } -/* ---- N4 ---- */ -static void test_up_to_root(void) +/* ---- N3 ---- */ +static void test_parent_path(void) { char cur[MR_PATH_MAX]; snprintf(cur, sizeof cur, "/usr/data/mnt/sd_0/a/b/c"); @@ -183,10 +194,10 @@ static void test_up_to_root(void) "root parent stays /"); CHECK(mr_path_parent("/usr/data/mnt/sd_0", up, (int)sizeof up) == 0 && strcmp(up, "/usr/data/mnt") == 0, - "TF root can go above mount point: %s", up); + "path helper is root-agnostic: %s", up); } -/* ---- N5 ---- */ +/* ---- N4 ---- */ static void test_truncation(void) { CHECK(MR_LIB_SCAN_MAX == 2000, "default cap is 2000 (got %d)", MR_LIB_SCAN_MAX); @@ -241,6 +252,10 @@ int main(void) touch_file(p); joinp(p, sizeof p, root, "loose.png"); touch_file(p); + joinp(p, sizeof p, root, "album.cbz"); + touch_file(p); + joinp(p, sizeof p, root, "readme.txt"); + touch_file(p); /* 符号链接目录不参与浏览(有权限才建;建不了不影响其余断言)。 */ char target[MR_PATH_MAX], link[MR_PATH_MAX]; joinp(target, sizeof target, root, "book"); @@ -249,7 +264,7 @@ int main(void) test_open_access(root); test_unreadable(root); - test_up_to_root(); + test_parent_path(); rm_rf(root); test_truncation(); diff --git a/tuku/test/test_browse_perf.c b/tuku/test/test_browse_perf.c index 596f9d4..6c52e6b 100644 --- a/tuku/test/test_browse_perf.c +++ b/tuku/test/test_browse_perf.c @@ -1,17 +1,16 @@ -/* test_browse_perf.c — 单层扫描性能/有界性验收(纯 C,无 SDK)。 +/* test_browse_perf.c — 单层扫描 / 行合成性能与有界性验收(纯 C,无 SDK)。 * - * 夹具:500 个顶层目录 × 8 层嵌套(4000 个子目录)+ 根目录 2000 个文件。 - * 断言(对应 M13 任务⑤): - * P1 首帧扫描只做单层:opendir 次数 == 1(旧实现会递归打开数千个目录); - * lstat 次数 == 本层条目数;耗时有界; - * P2 后台探测同样只读一层:每个目录一次 opendir,不进入其嵌套子树; - * P3 行合成(UI src/bind 的数据面)纯内存、零 IO、O(1); - * P4 深目录直接扫描也只打开它自己。 + * 夹具:500 个顶层目录 × 8 层嵌套(4000 个子目录)+ 根目录 2000 个普通文件 + * + 一个 big/ 目录(2000 张图片,验证 2000 项大目录)。 + * 断言(对应 M18 任务③): + * P1 首帧扫描只做单层:opendir 次数 == 1,lstat 次数 == 本层条目数,耗时有界; + * P2 2000 项大目录:一次扫描出全部 2000 行,opendir=1、lstat=2000,耗时有界; + * P3 行合成(UI src 的数据面)纯内存、零 IO、耗时有界(2000 行 × 1000 遍); + * P4 进入深层/普通子目录同样只打开它自己(进入目录成本 = 该目录一层)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ - * test/test_browse_perf.c logic/library.c logic/reader.c test/settings_reader_stub.c \ - * -pthread -o build/host/test_browse_perf + * test/test_browse_perf.c logic/library.c -pthread -o build/host/test_browse_perf */ #include "logic/library.h" @@ -26,6 +25,7 @@ #define PERF_DIRS 500 #define PERF_DEPTH 8 #define PERF_FILES 2000 +#define PERF_BIG 2000 static int g_pass, g_fail; #define CHECK(cond, ...) do { \ @@ -92,7 +92,7 @@ int main(void) if (!root) return 1; char p[MR_PATH_MAX]; - /* 根目录:2000 个文件 + 500 个顶层目录(每个顶层目录内是 8 层嵌套 + 1 张图)。 */ + /* 根目录:2000 个普通文件(不成行)+ 500 个顶层目录(每个顶层目录内是 8 层嵌套 + 1 张图)。 */ for (int f = 0; f < PERF_FILES; f++) { snprintf(p, sizeof p, "%s/f%04d.txt", root, f); if (touch_file(p) != 0) { CHECK(0, "file %s", p); break; } @@ -101,7 +101,7 @@ int main(void) char deep[MR_PATH_MAX]; snprintf(deep, sizeof deep, "%s/d%04d", root, d); if (mkdir_p(deep) != 0) { CHECK(0, "mkdir %s", deep); break; } - snprintf(p, sizeof p, "%s/001.png", deep); /* 顶层目录自身直接含图(=一本书) */ + snprintf(p, sizeof p, "%s/001.png", deep); if (touch_file(p) != 0) { CHECK(0, "touched top image"); break; } for (int l = 1; l <= PERF_DEPTH; l++) { char sub[MR_PATH_MAX]; @@ -113,6 +113,15 @@ int main(void) if (touch_file(p) != 0) { CHECK(0, "touched deep image"); break; } } + /* 2000 项大目录(图片)。 */ + char big[MR_PATH_MAX]; + snprintf(big, sizeof big, "%s/big", root); + CHECK(mkdir_p(big) == 0, "mkdir big"); + for (int i = 0; i < PERF_BIG; i++) { + snprintf(p, sizeof p, "%s/img%04d.png", big, i); + if (touch_file(p) != 0) { CHECK(0, "big image %s", p); break; } + } + /* ---- P1:首帧扫描只做单层 ---- */ mr_library lib; memset(&lib, 0, sizeof lib); @@ -123,61 +132,67 @@ int main(void) mr_library_io_stats st; mr_library_test_io_get(&st); - CHECK(n == PERF_DIRS, "scan lists %d dirs (got %d)", PERF_DIRS, n); + CHECK(n == PERF_DIRS + 1, "scan lists %d dirs (got %d)", PERF_DIRS + 1, n); CHECK(lib.truncated == 0, "not truncated"); CHECK(st.opendirs == 1, "single-layer scan: opendir=%ld (want 1)", st.opendirs); - CHECK(st.lstats == PERF_FILES + PERF_DIRS, - "lstat only current level: %ld (want %d)", st.lstats, PERF_FILES + PERF_DIRS); + CHECK(st.lstats == PERF_FILES + PERF_DIRS + 1, + "lstat only current level: %ld (want %d)", st.lstats, PERF_FILES + PERF_DIRS + 1); CHECK(scan_us < 1500000LL, "scan bounded: %lldus", scan_us); - if (lib.count > 1) { - CHECK(strcmp(lib.items[0].name, "d0000") == 0, "first=%s", lib.items[0].name); + if (lib.count == PERF_DIRS + 1) { + CHECK(strcmp(lib.items[0].name, "big") == 0, "first=%s", lib.items[0].name); CHECK(strcmp(lib.items[lib.count - 1].name, "d0499") == 0, "last=%s", lib.items[lib.count - 1].name); } - printf(" scan: %lldus, %d dirs, opendir=%ld lstat=%ld\n", + printf(" root scan: %lldus, %d rows, opendir=%ld lstat=%ld\n", scan_us, n, st.opendirs, st.lstats); + mr_library_free(&lib); - /* ---- P2:后台探测每目录只开一次,不进入嵌套子树 ---- */ + /* ---- P2:2000 项大目录(进入目录 = 单层扫描) ---- */ + mr_library biglib; + memset(&biglib, 0, sizeof biglib); mr_library_test_io_reset(); - long long p0 = now_us(); - int bad = 0; - for (int i = 0; i < lib.count; i++) { - mr_library_probe probe; - if (mr_library_probe_cached(lib.items[i].path, &probe) != 0 || probe.pages != 1) - bad++; - } - long long probe_us = now_us() - p0; + long long b0 = now_us(); + int bn = mr_library_scan(&biglib, big, MR_LIB_MAX_DEPTH); + long long big_us = now_us() - b0; mr_library_test_io_get(&st); - CHECK(bad == 0, "all top dirs have 1 direct image (bad=%d)", bad); - CHECK(st.opendirs == PERF_DIRS, - "probe stays shallow: opendir=%ld (want %d, not %d)", - st.opendirs, PERF_DIRS, PERF_DIRS * (PERF_DEPTH + 1)); - CHECK(st.lstats == PERF_DIRS, - "probe lstats only image candidates: %ld (want %d)", st.lstats, PERF_DIRS); - CHECK(probe_us < 5000000LL, "probe bounded: %lldus", probe_us); - printf(" probe: %lldus, opendir=%ld lstat=%ld\n", probe_us, st.opendirs, st.lstats); + CHECK(bn == PERF_BIG, "big dir rows=%d (want %d)", bn, PERF_BIG); + CHECK(biglib.truncated == 0, "big dir not truncated at cap"); + CHECK(st.opendirs == 1, "big dir scan: opendir=%ld", st.opendirs); + CHECK(st.lstats == PERF_BIG, "big dir scan: lstat=%ld (want %d)", st.lstats, PERF_BIG); + CHECK(big_us < 1500000LL, "big dir scan bounded: %lldus", big_us); + if (biglib.count == PERF_BIG) { + CHECK(strcmp(biglib.items[0].name, "img0000.png") == 0, "first=%s", biglib.items[0].name); + CHECK(strcmp(biglib.items[PERF_BIG - 1].name, "img1999.png") == 0, + "last=%s", biglib.items[PERF_BIG - 1].name); + CHECK(biglib.items[PERF_BIG - 1].kind == MR_LIB_KIND_IMAGE, "last is image"); + CHECK(mr_library_image_index(&biglib, PERF_BIG - 1) == PERF_BIG - 1, + "image index at end"); + } + printf(" big dir scan: %lldus, %d rows, opendir=%ld lstat=%ld\n", + big_us, bn, st.opendirs, st.lstats); - /* ---- P3:行合成(bind/src 数据面)纯内存 O(1) ---- */ + /* ---- P3:行合成(UI src 数据面)纯内存、2000 行 × 1000 遍 ---- */ mr_library_test_io_reset(); long long r0 = now_us(); int rows = 0; for (int r = 0; r < 1000; r++) - for (int i = 0; i < lib.count; i++) { + for (int i = 0; i < biglib.count; i++) { mr_library_row row; - if (mr_library_row_at(&lib, i, &row)) rows++; + if (mr_library_row_at(&biglib, i, &row)) rows++; } long long row_us = now_us() - r0; mr_library_test_io_get(&st); - CHECK(rows == 1000 * lib.count, "rows synthesized (%d)", rows); + CHECK(rows == 1000 * biglib.count, "rows synthesized (%d)", rows); CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0, "row synthesis does no IO (open=%ld rd=%ld stat=%ld)", st.opendirs, st.readdirs, st.lstats); CHECK(row_us < 1000000LL, "row synthesis bounded: %lldus", row_us); printf(" rows: %lldus for %d rows, zero IO\n", row_us, rows); + mr_library_free(&biglib); - /* ---- P4:直接扫描深层目录同样只打开它自己 ---- */ + /* ---- P4:进入子目录只打开它自己(不递归子树) ---- */ char deep[MR_PATH_MAX]; - snprintf(deep, sizeof deep, "%s", lib.items[0].path); + snprintf(deep, sizeof deep, "%s/d0000", root); for (int l = 1; l <= PERF_DEPTH; l++) { char sub[MR_PATH_MAX]; snprintf(sub, sizeof sub, "%s/l%d", deep, l); @@ -186,13 +201,22 @@ int main(void) mr_library_test_io_reset(); mr_library deep_lib; memset(&deep_lib, 0, sizeof deep_lib); - CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 0, "deep leaf has no subdir"); + CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 1, "deep leaf has one image"); mr_library_test_io_get(&st); CHECK(st.opendirs == 1, "deep scan opens once (got %ld)", st.opendirs); CHECK(st.lstats == 1, "deep scan lstats only its image (got %ld)", st.lstats); mr_library_free(&deep_lib); - mr_library_free(&lib); + /* 顶层目录:一个子目录 + 一张图 = 2 行,只打开它自己。 */ + snprintf(deep, sizeof deep, "%s/d0000", root); + memset(&deep_lib, 0, sizeof deep_lib); + mr_library_test_io_reset(); + CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 2, "top dir rows"); + mr_library_test_io_get(&st); + CHECK(st.opendirs == 1 && st.lstats == 2, "top dir scan: open=%ld stat=%ld", + st.opendirs, st.lstats); + mr_library_free(&deep_lib); + rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); return g_fail ? 1 : 0; diff --git a/tuku/test/test_browse_ui_host.c b/tuku/test/test_browse_ui_host.c index 6ac52f6..57f64e4 100644 --- a/tuku/test/test_browse_ui_host.c +++ b/tuku/test/test_browse_ui_host.c @@ -1,33 +1,35 @@ /* 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 张额度。 + * 目的(M18 文件管理器模型): + * U1 行模型:目录 + 图片 + 档案册混排(目录优先+自然序),行副标题是扫描期生成的 + * 类型·大小(不再有占位/后台补位),图标/右侧箭头按类型区分; + * U2 行合成零 IO:反复取可见行不触发 opendir/readdir/lstat(渲染期无文件访问); + * U3 点击语义:点目录=进入(空目录也进);点图片=从该图开始读 + * (mr_reader_open_dir_at(当前目录, 图片下标));点档案册=打开档案册; + * U4 根目录锁定:根不显示顶栏返回、library_go_up 在根返回 0(app.c 退出); + * 子目录返回=回根;不存在越过浏览根的导航; + * U5 无 SD 卡容错:浏览根不可访问(不存在/不是目录)→ 友好空态「未检测到 SD 卡」, + * 不弹错误横幅;顶栏刷新/重扫可重试成功; + * U6 子目录不可读:停留原目录 + 顶部提示横幅; + * U7 书库不再碰缓存目录:CBZ 解压页缓存 mrpage_* 原样保留,且不写任何 bmp。 * - * 编译:见 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。 + * 编译:见 test/run_tests.sh(链 SDK 源码树 + 打桩;只发布 meow_ui.a 的环境自动 SKIP)。 + * ui/library/library.c 以 -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;logic/library.c + * 以 -DMR_LIBRARY_TEST_HOOKS 编译出 IO 计数。 */ #include "tuku_internal.h" #include "logic/library.h" +#include "logic/reader/page_cache.h" #include #include #include #include #include -#include #include #include -/* ---- ui/library/library.c 的测试接缝(仅 -DMR_LIBRARY_UI_TEST_HOOKS 时定义) ---- */ +/* 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); @@ -37,14 +39,26 @@ 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_topbar_back(void); +const char *library_test_empty_text(void); +int library_test_sd_missing(void); +int library_test_retry(void); int library_test_go_up(void); -int library_test_go_parent(void); int library_test_drag_active(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); + +/* 阅读器入口打桩:只记录被打开的册路径与起始页(本测试不建 reader 页)。 */ +#define MR_READER_START_RESUME (-1) +void mr_reader_open_dir_at(const char *dir, int start_index); +static char g_opened[MR_PATH_MAX]; +static int g_opened_start; +static int g_opened_n; +void mr_reader_open_dir_at(const char *dir, int start_index) +{ + snprintf(g_opened, sizeof g_opened, "%s", dir ? dir : ""); + g_opened_start = start_index; + g_opened_n++; +} static int g_fail; #define CHECK(cond, ...) do { \ @@ -55,15 +69,6 @@ static int g_fail; /* 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]; @@ -125,16 +130,15 @@ static int row_index_for(const char *name) return -1; } -static void set_mtime(const char *path, time_t t) +static void tap_row(const char *name) { - struct timespec ts[2]; - ts[0].tv_sec = t; - ts[0].tv_nsec = 0; - ts[1] = ts[0]; - utimensat(AT_FDCWD, path, ts, 0); + int idx = row_index_for(name); + if (idx < 0) { CHECK(0, "row %s not found", name); return; } + int list_top = meow_ui_statusbar_height() + 56; + library_on_tap(120, list_top + idx * 56 + 28); } -/* 统计缓存目录里的缩略图(*.bmp 且非 mrpage_*)与解压页(mrpage_*.bmp)。 */ +/* 统计缓存目录里的 bmp(缩略图/解压页)。 */ static int count_bmp(const char *dir, int *pages) { DIR *d = opendir(dir); @@ -144,7 +148,7 @@ static int count_bmp(const char *dir, int *pages) 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++; + if (mr_cache_is_page_name(de->d_name)) pg++; else thumbs++; } closedir(d); @@ -152,71 +156,6 @@ static int count_bmp(const char *dir, int *pages) 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"); @@ -227,129 +166,192 @@ int main(void) if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } setenv("MEOW_DATA_DIR", data, 1); - char root[MR_PATH_MAX]; + char root[MR_PATH_MAX], p[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" }; + const char *dirs[] = { "album", "empty", "noimg", "deep/sub", "Pictures", "Pictures/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, "album/001.png"); touch_file(p); + joinp(p, sizeof p, root, "album/002.png"); 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); + joinp(p, sizeof p, root, "root.png"); touch_file(p); + joinp(p, sizeof p, root, "book.cbz"); touch_file(p); + + /* Pictures:大小写混排 + 数字序号 + 大写扩展名 + 档案册(图片排序契约夹具)。 */ + const char *pics[] = { "A.jpg", "a.jpg", "img2.png", "IMG3.JPG", "img10.png", + "page01.bmp", "page1.jpeg", "cover.cbz", "notes.txt" }; + for (unsigned i = 0; i < sizeof pics / sizeof pics[0]; i++) { + joinp(p, sizeof p, root, "Pictures"); + size_t dl = strlen(p); + snprintf(p + dl, sizeof p - dl, "/%s", pics[i]); + 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"; + /* ---- U5:浏览根不可访问 —— 先指向不存在的根,再 create ---- */ + const char *bad_root = "/no/such/tuku/m18_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(library_test_sd_missing() == 1, "unreadable root -> SD missing state"); + CHECK(library_test_rows() == 0, "empty list rows=%d", library_test_rows()); + CHECK(library_test_has_list(), "startup still builds a listview"); CHECK(strcmp(library_test_dir(), bad_root) == 0, "startup dir=%s", library_test_dir()); + CHECK(strstr(library_test_empty_text(), "SD") != NULL, + "friendly empty state: %s", library_test_empty_text()); + CHECK(library_test_hint_active() == 0, "no error banner for missing SD"); + CHECK(library_test_topbar_back() == 0, "no back arrow at storage root"); + CHECK(library_test_retry() == 0 && library_test_sd_missing() == 1, + "retry while still missing stays in empty state (no crash)"); + /* 根路径不是目录(普通文件)同样走 SD 空态。 */ + char notadir[MR_PATH_MAX]; + joinp(notadir, sizeof notadir, data, "notadir"); + touch_file(notadir); + library_test_set_root(notadir); + library_test_set_dir(notadir); + CHECK(library_test_retry() == 0 && library_test_sd_missing() == 1, + "root pointing at a file -> SD missing state"); + + /* 插卡/挂载恢复:同一路径变成可读目录后,刷新即恢复列表。 */ library_test_set_root(root); - CHECK(strcmp(library_test_root(), root) == 0, "test root installed"); + library_test_set_dir(root); + CHECK(library_test_retry() > 0, "retry after storage available loads rows"); + CHECK(library_test_sd_missing() == 0, "SD missing state cleared"); + CHECK(strcmp(library_test_dir(), root) == 0, "now browsing root"); + CHECK(library_test_topbar_back() == 0, "still no back arrow at root"); + 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++) { + /* ---- U1:行模型(目录优先 + 自然序 + 类型图标/箭头) ---- */ + CHECK(library_test_load(root) == 1, "load fixture root"); + CHECK(library_test_rows() == 7, "7 rows (got %d)", library_test_rows()); + static const char *want[7] = { + "album", "deep", "empty", "noimg", "Pictures", "book.cbz", "root.png" + }; + for (int i = 0; i < 7; 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); + CHECK(it.subtitle && it.subtitle[0] && strcmp(it.subtitle, "…") != 0, + "row[%d] subtitle pre-generated (%s)", i, it.subtitle ? it.subtitle : ""); + if (i < 5) { + CHECK(it.icon_kind == MEOW_LIST_ICON_FOLDER && it.right == MEOW_LIST_RIGHT_ARROW, + "row[%d] is a folder row with enter arrow", i); + } else { + CHECK(it.icon_kind == MEOW_LIST_ICON_FILE && it.right == MEOW_LIST_RIGHT_NONE, + "row[%d] is a file row without child arrow", 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"); + int ic = row_index_for("book.cbz"); 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 : ""); + CHECK(ic >= 0 && library_test_src(ic, &it) && + strncmp(it.subtitle, "档案册 · ", strlen("档案册 · ")) == 0, + "archive subtitle=%s", it.subtitle ? it.subtitle : ""); + int ir = row_index_for("root.png"); + CHECK(ir >= 0 && library_test_src(ir, &it) && + strncmp(it.subtitle, "图片 · ", strlen("图片 · ")) == 0, + "image subtitle=%s", it.subtitle ? it.subtitle : ""); - /* ---- 行点击:书 → reader;空目录 → 进入 ---- */ - int list_top = meow_ui_statusbar_height() + 56; + /* ---- U2:行合成零 IO(渲染/滚动路径不访问文件系统) ---- */ + mr_library_test_io_reset(); + for (int pass = 0; pass < 500; pass++) + for (int i = 0; i < library_test_rows(); i++) { + meow_list_item tmp; + library_test_src(i, &tmp); + } + mr_library_io_stats io; + mr_library_test_io_get(&io); + CHECK(io.opendirs == 0 && io.readdirs == 0 && io.lstats == 0, + "row source does no IO (open=%ld rd=%ld stat=%ld)", + io.opendirs, io.readdirs, io.lstats); + + /* ---- U3:点击语义 ---- */ + char pics_dir[MR_PATH_MAX]; + joinp(pics_dir, sizeof pics_dir, root, "Pictures"); + CHECK(library_test_load(pics_dir) == 1, "entered Pictures"); + CHECK(strcmp(library_test_dir(), pics_dir) == 0, "current dir=%s", library_test_dir()); + + /* 图片下标 = 该目录里排在该图之前的图片行数(阅读器目录册同序)。 */ + const char *target = "img10.png"; + int ti = row_index_for(target); + CHECK(ti >= 0, "target image row present"); + int expect_idx = 0; + for (int i = 0; i < ti; i++) { + meow_list_item tmp; + if (!library_test_src(i, &tmp)) continue; + if (tmp.subtitle && strncmp(tmp.subtitle, "图片 · ", strlen("图片 · ")) == 0) + expect_idx++; + } 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"); + tap_row(target); + CHECK(g_opened_n == 1 && strcmp(g_opened, pics_dir) == 0, + "tap image opens reader for current dir (%s)", g_opened); + CHECK(g_opened_start == expect_idx, "start index=%d (want %d)", g_opened_start, expect_idx); + CHECK(strcmp(library_test_dir(), pics_dir) == 0, "tap image 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()); + g_opened_n = 0; + tap_row("cover.cbz"); + char archive[MR_PATH_MAX]; + joinp(archive, sizeof archive, pics_dir, "cover.cbz"); + CHECK(g_opened_n == 1 && strcmp(g_opened, archive) == 0, + "tap archive opens archive book (%s)", g_opened); + CHECK(g_opened_start == MR_READER_START_RESUME, "archive uses resume start (%d)", + g_opened_start); + + /* 点目录 = 进入;空目录也进(不打开阅读器)。 */ + char sub_dir[MR_PATH_MAX]; + joinp(sub_dir, sizeof sub_dir, pics_dir, "sub"); + g_opened_n = 0; + tap_row("sub"); + CHECK(g_opened_n == 0, "tap dir does not open reader"); + CHECK(strcmp(library_test_dir(), sub_dir) == 0, "entered empty sub dir (%s)", + library_test_dir()); CHECK(library_test_rows() == 0, "empty dir shows empty list"); + CHECK(strcmp(library_test_empty_text(), "空文件夹") == 0, + "empty dir state: %s", library_test_empty_text()); - /* ---- 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"); + /* ---- U4:根目录锁定 ---- */ + char album_dir[MR_PATH_MAX]; + joinp(album_dir, sizeof album_dir, root, "album"); + CHECK(library_test_load(album_dir) == 1, "load album"); + CHECK(library_test_topbar_back() == 1, "back arrow shown in subdir"); + CHECK(library_test_go_up() == 1, "go_up from subdir consumed"); + CHECK(strcmp(library_test_dir(), root) == 0, "back to 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"); + /* 根:系统返回 = 退出(返回 0),并保持列表不变。 */ + CHECK(library_test_go_up() == 0, "go_up at root returns 0 (app exits)"); + CHECK(strcmp(library_test_dir(), root) == 0, "still at root after exit gesture"); + CHECK(library_test_topbar_back() == 0, "no back arrow at root"); - /* 「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"); + /* 顶栏返回:子目录点击返回箭头 = 回根;根上该区域不响应。 */ + CHECK(library_test_load(album_dir) == 1, "load album again"); + library_on_tap(20, meow_ui_statusbar_height() + 28); + CHECK(strcmp(library_test_dir(), root) == 0, "topbar back returns to root"); + int root_rows_before = library_test_rows(); + library_on_tap(20, meow_ui_statusbar_height() + 28); + CHECK(strcmp(library_test_dir(), root) == 0 && library_test_rows() == root_rows_before, + "topbar back is inert at 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"); + /* 嵌套:deep/sub 两级返回。 */ + tap_row("deep"); + tap_row("sub"); + char deep_sub[MR_PATH_MAX], deep_dir[MR_PATH_MAX]; + joinp(deep_dir, sizeof deep_dir, root, "deep"); + joinp(deep_sub, sizeof deep_sub, root, "deep/sub"); + CHECK(strcmp(library_test_dir(), deep_sub) == 0, "entered deep/sub (%s)", library_test_dir()); + CHECK(library_test_go_up() == 1 && strcmp(library_test_dir(), deep_dir) == 0, "up to deep"); + CHECK(library_test_go_up() == 1 && strcmp(library_test_dir(), root) == 0, "up to root"); - /* ---- U3b:系统返回语义 = TF 根退出 App;顶栏「上级」保留越界浏览 ---- */ - CHECK(library_test_load(root) == 1, "load fixture root"); - CHECK(library_test_go_up() == 0, "go_up at TF root returns 0 (app exits)"); - CHECK(strcmp(library_test_dir(), root) == 0, "still at TF root after exit gesture"); - - /* 顶栏显式「上级」:访问放开,TF 根 → 其父目录(fixture root 的 parent = data)。 */ - CHECK(library_test_go_parent() == 1, "explicit up from TF root consumed"); - CHECK(strcmp(library_test_dir(), data) == 0, - "explicit up goes above TF root (%s)", library_test_dir()); - CHECK(library_test_load(root) == 1, "back to fixture root"); - - /* ---- U3c:左缘 24px 系统返回手势带不启动列表拖拽 ---- */ + /* ---- U3c:左缘 24px / 屏底热区不启动列表拖拽 ---- */ + int list_top = meow_ui_statusbar_height() + 56; int row_y = list_top + 28; library_on_touch_down(120, row_y); CHECK(library_test_drag_active() == 1, "touch inside list starts drag"); @@ -362,11 +364,10 @@ int main(void) library_on_touch_down(120, meow_ui_height() - 4); CHECK(library_test_drag_active() == 0, "bottom hotzone does not start drag"); - /* ---- U4:不可读目录 → 停留 + 提示 ---- */ - CHECK(library_test_load(root) == 1, "back to fixture root"); - char noread[MR_PATH_MAX]; - joinp(noread, sizeof noread, root, "noread"); + /* ---- U6:不可读子目录 → 停留 + 提示 ---- */ if (geteuid() != 0) { + char noread[MR_PATH_MAX]; + joinp(noread, sizeof noread, root, "noread"); CHECK(mkdir(noread, 0755) == 0, "mkdir noread"); chmod(noread, 0000); int ok = library_test_load(noread); @@ -381,7 +382,33 @@ int main(void) printf(" SKIP unreadable-dir case (running as root)\n"); } - test_cache_prune_skips_pages(); + /* ---- U7:书库不碰缓存目录(CBZ 解压页缓存原样保留,不写 bmp) ---- */ + CHECK(mr_cache_init(data) == 0, "page cache init"); + const char *cdir = mr_cache_dir(); + CHECK(cdir && cdir[0], "page cache dir: %s", cdir ? cdir : ""); + if (cdir && cdir[0]) { + char page[MR_PATH_MAX + 96]; + snprintf(page, sizeof page, "%s/mrpage_0123456789abcdef.bmp", cdir); + CHECK(touch_file(page) == 0, "create page cache file"); + int pages_before = 0; + count_bmp(cdir, &pages_before); + CHECK(pages_before == 1, "precondition 1 page (got %d)", pages_before); + + CHECK(library_test_load(root) == 1, "reload root"); + for (int i = 0; i < library_test_rows(); i++) { + meow_list_item tmp; + library_test_src(i, &tmp); + } + tap_row("album"); + tap_row("001.png"); /* 打开阅读器入口(打桩) */ + library_test_go_up(); + + int pages_after = 0; + int thumbs_after = count_bmp(cdir, &pages_after); + CHECK(access(page, R_OK) == 0, "mrpage_* survives book browsing"); + CHECK(pages_after == 1 && thumbs_after == 0, + "library writes no bmp to cache (pages=%d thumbs=%d)", pages_after, thumbs_after); + } meow_view_free(lib); meow_ui_shutdown(); diff --git a/tuku/test/test_cbz.c b/tuku/test/test_cbz.c index 97cc465..3173cee 100644 --- a/tuku/test/test_cbz.c +++ b/tuku/test/test_cbz.c @@ -15,13 +15,12 @@ * T6 坏档容错:非 zip/截断/无图片页/声明 96MiB 的 zip 炸弹 → 打开失败或 0 页,不崩。 * T7 统一开册契约:目录册与档案册走同一个 mr_book(count/meta/open/close/destroy), * 越界 open 返回 NULL,close_auto 幂等。 - * T8 书库列档(M13 单层 + 懒判定):.cbz 与文件夹册同列自然序(bookA < box < ... < - * Vol10.cbz);扫描期只成行、副标题占位,探测补位后才有页数/书标记; - * 坏档/无图片页档案仍列出但为非书(访问放开,不再隐藏); - * 不注入探测回调时档案文件不成行(M7 语义),目录仍按单层列出。 + * T8 书库列档(M18 文件管理器模型):目录 + .cbz/.zip 同列(目录优先、组内自然序, + * bookA < box < … < Vol10.cbz);扫描期一次成行,副标题是「文件夹/档案册 · 大小」, + * 不再探测页数/封面/续读角标;坏档/无图片页档案也照常列出(点开时才由 reader 判)。 * T9 册间衔接:同父目录自然序混排(bookA → vol2.cbz → Vol10.cbz), * 首/末册返回 0;不注入探测回调时档案册不参与(M7 语义)。 - * T10 续读角标(M13 两段式):档案路径作为册标识,probe 补位后副标题后缀「· 续读 Pn」。 + * T10 封面解压 + 无续读角标:档案首图仍可解到缓存;书库行不再带「· 续读 Pn」。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -Werror -O1 -I. test/test_cbz.c logic/library.c logic/reader.c \ @@ -716,28 +715,15 @@ static const mr_library_entry *lib_find(const mr_library *lib, const char *name) return NULL; } -/* M13:模拟 UI 后台探测补位(目录单层探测 / 档案册注入页数回调)。 */ -static void lib_probe_all(mr_library *lib) -{ - for (int i = 0; i < lib->count; i++) { - mr_library_probe p; - if (mr_library_probe_path(lib->items[i].path, lib->items[i].is_archive, - archive_pages_probe, NULL, &p) == 0) - mr_library_apply_probe(lib, i, &p); - else - mr_library_mark_error(lib, i); - } -} - static void test_library(void) { mr_test_cfg_clear(); - /* M13:扫描只做单层枚举——目录(全部列出,含无图的 box)与档案文件(.cbz/.zip) - * 都先成行、副标题占位;书/非书与页数由后台 probe 补位。 */ + /* 文件管理器模型:目录 + 档案册同列(目录优先、组内自然序),扫描期一次成行; + * 档案册只按扩展名成行,不探测页数/封面(打开时才由 reader 解档)。 */ mr_library lib; memset(&lib, 0, sizeof lib); - int n = mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); + int n = mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH); CHECK(n == 8, "library rows=%d (want 2 dirs + 6 archives)", n); static const char *want[8] = { "bookA", "box", "broken.cbz", "emptypages.cbz", @@ -746,68 +732,33 @@ static void test_library(void) for (int i = 0; i < 8 && i < lib.count; i++) { CHECK(strcmp(lib.items[i].name, want[i]) == 0, "row[%d]=%s (want %s)", i, lib.items[i].name, want[i]); - CHECK(lib.items[i].probe_state == MR_LIB_PENDING && lib.items[i].pages == -1, - "%s pending after scan", lib.items[i].name); - CHECK(strcmp(lib.items[i].subtitle, "…") == 0, - "%s placeholder subtitle=%s", lib.items[i].name, lib.items[i].subtitle); - } - - /* 后台补位(等价 UI 的 on_tick):目录走单层目录探测,档案走注入的 zip 页数回调。 */ - for (int i = 0; i < lib.count; i++) { - mr_library_probe p; - int ok = (mr_library_probe_path(lib.items[i].path, lib.items[i].is_archive, - archive_pages_probe, NULL, &p) == 0); - CHECK(ok, "%s probed", lib.items[i].name); - if (ok) mr_library_apply_probe(&lib, i, &p); } + for (int i = 0; i < 2 && i < lib.count; i++) + CHECK(lib.items[i].kind == MR_LIB_KIND_DIR && + strcmp(lib.items[i].subtitle, "文件夹") == 0, + "%s dir row subtitle=%s", lib.items[i].name, lib.items[i].subtitle); + for (int i = 2; i < 8 && i < lib.count; i++) + CHECK(lib.items[i].kind == MR_LIB_KIND_ARCHIVE && + strncmp(lib.items[i].subtitle, "档案册 · ", strlen("档案册 · ")) == 0, + "%s archive row subtitle=%s", lib.items[i].name, lib.items[i].subtitle); const mr_library_entry *e = lib_find(&lib, "bookA"); - CHECK(e && e->pages == 2 && e->has_images == 1 && e->subdirs == 0, "bookA fields"); - CHECK(e && strcmp(e->subtitle, "2 pages") == 0, "bookA subtitle=%s", - e ? e->subtitle : ""); - CHECK(e && strstr(e->cover, "001.png") != NULL, "bookA cover=%s", - e ? e->cover : ""); - - /* box 只含档案册:目录本身不直接含图 → 不是书(M13 不做递归/子表统计)。 */ - e = lib_find(&lib, "box"); - CHECK(e && e->has_images == 0 && e->subdirs == 0 && e->is_archive == 0, - "box fields"); - CHECK(e && strcmp(e->subtitle, "无图片") == 0, "box subtitle=%s", - e ? e->subtitle : ""); - CHECK(e && e->cover[0] == '\0', "box cover should be empty"); + CHECK(e && e->kind == MR_LIB_KIND_DIR && e->size == 0, "bookA dir entry"); e = lib_find(&lib, "vol2.cbz"); - CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0 && - e->is_archive == 1, "vol2.cbz fields"); - CHECK(e && strcmp(e->subtitle, "3 pages") == 0, "vol2.cbz subtitle=%s", - e ? e->subtitle : ""); - CHECK(e && e->cover[0] == '\0', "archive cover should be empty (UI extracts)"); - CHECK(e && strstr(e->path, "vol2.cbz") != NULL, "vol2.cbz path=%s", - e ? e->path : ""); + CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE && e->size > 0 && + strstr(e->path, "vol2.cbz") != NULL, + "vol2.cbz archive entry (size=%lld)", e ? e->size : -1); - e = lib_find(&lib, "Vol10.cbz"); - CHECK(e && e->pages == 1 && strcmp(e->subtitle, "1 page") == 0, - "Vol10.cbz pages/subtitle"); - - /* 坏档/无图片页档案:列出但探测为非书(M13 访问放开,不再隐藏)。 */ - static const char *not_book[] = { "notabook.zip", "emptypages.cbz", + /* 坏档/无图片页档案也照常列出(书库不预判,点开时 reader 才判可读性)。 */ + static const char *all_arch[] = { "notabook.zip", "emptypages.cbz", "broken.cbz", "truncated.cbz" }; - for (unsigned i = 0; i < sizeof not_book / sizeof not_book[0]; i++) { - e = lib_find(&lib, not_book[i]); - CHECK(e && e->is_archive == 1 && e->has_images == 0 && e->pages == 0 && - strcmp(e->subtitle, "无图片") == 0, - "%s: not a book (subtitle=%s)", not_book[i], e ? e->subtitle : "(missing)"); + for (unsigned i = 0; i < sizeof all_arch / sizeof all_arch[0]; i++) { + e = lib_find(&lib, all_arch[i]); + CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE, "%s listed as archive", all_arch[i]); } CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz not listed"); mr_library_free(&lib); - - /* 不注入探测回调:M7 纯目录书库(档案文件不成行,目录仍按 M13 单层列出)。 */ - memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 2, - "legacy scan rows=%d (want 2: bookA, box)", lib.count); - CHECK(lib.count >= 2 && strcmp(lib.items[0].name, "bookA") == 0 && - strcmp(lib.items[1].name, "box") == 0, "legacy scan rows"); - mr_library_free(&lib); } /* ============================================================ */ @@ -863,7 +814,7 @@ static void test_adjacent(void) } /* ============================================================ */ -/* T10 续读角标 + 封面 */ +/* T10 封面解压 + 无续读角标 */ /* ============================================================ */ static void test_badge_cover(void) { @@ -873,31 +824,18 @@ static void test_badge_cover(void) mr_test_cfg_clear(); mr_cont_save(vol2, 2, MR_MODE_WEBTOON); - /* M13 两段式:扫描期只有占位副标题;后台 probe 补位后才出现页数与续读角标。 */ + /* 文件管理器模型:书库行只有「档案册 · 大小」,不再有「· 续读 Pn」角标 + * (续读仍由 reader 打开该册时恢复,见 test_mode_switch_host)。 */ mr_library lib; memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, - NULL) == 8, "rescan for badge (single-layer rows)"); + CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 8, "rescan rows"); const mr_library_entry *e = lib_find(&lib, "vol2.cbz"); - CHECK(e && strcmp(e->subtitle, "…") == 0, "vol2 placeholder before probe: %s", - e ? e->subtitle : ""); - lib_probe_all(&lib); - e = lib_find(&lib, "vol2.cbz"); - CHECK(e && strstr(e->subtitle, "续读 P3") != NULL, "badge subtitle=%s", - e ? e->subtitle : ""); - CHECK(e && strstr(e->subtitle, "3 pages") != NULL, "badge keeps page text: %s", + CHECK(e && strstr(e->subtitle, "续读") == NULL, + "no continuation badge in file-manager rows: %s", e ? e->subtitle : ""); + CHECK(e && strstr(e->subtitle, "档案册") != NULL, "archive subtitle=%s", e ? e->subtitle : ""); e = lib_find(&lib, "Vol10.cbz"); - CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 should have no badge"); - mr_library_free(&lib); - - /* 清除记录后角标消失(重扫 + 再探测;探测缓存只存页数,角标按当前配置重算)。 */ - mr_cont_save(vol2, 0, MR_MODE_WEBTOON); - memset(&lib, 0, sizeof lib); - mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); - lib_probe_all(&lib); - e = lib_find(&lib, "vol2.cbz"); - CHECK(e && strstr(e->subtitle, "续读") == NULL, "cleared badge gone"); + CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 no badge either"); mr_library_free(&lib); /* 封面 = 档内自然序首图(ch01/p2.png),解压到缓存 */ diff --git a/tuku/test/test_cont_adjacent.c b/tuku/test/test_cont_adjacent.c index d2b682f..170c7d5 100644 --- a/tuku/test/test_cont_adjacent.c +++ b/tuku/test/test_cont_adjacent.c @@ -6,8 +6,8 @@ * A3 边界:末页前进→下一册、首页后退→上一册(调用方语义);路径末尾斜杠等价; * A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册; * A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录; - * A6 书库扫描接线(M13 两段式):scan 只列单层、副标题占位「…」; - * probe/on_tick 补位后出现页数与「· 续读 Pn」角标。 + * A6 书库行模型(M18):目录+图片+档案册同列、目录优先;副标题扫描期生成, + * 不再有「· 续读 Pn」角标(角标随「目录自动当书」链路删除,续读仍由 reader 恢复)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. test/test_cont_adjacent.c logic/library.c logic/reader.c \ @@ -159,84 +159,43 @@ static void test_adjacent(const char *root) CHECK(mr_book_adjacent("/", 1, out, (int)sizeof out) == -1, "root path invalid"); } -/* 模拟 UI 后台探测:逐行单层 probe 并补位(page>0 的书行由 format_subtitle 拼续读角标)。 */ -static void probe_all_rows(mr_library *lib) -{ - for (int i = 0; i < lib->count; i++) { - mr_library_probe p; - if (mr_library_probe_cached(lib->items[i].path, &p) == 0) - mr_library_apply_probe(lib, i, &p); - else - mr_library_mark_error(lib, i); - } -} - -/* A6 两段式(M13):scan 只做单层枚举、行副标题是占位「…」;后台 probe/on_tick 补位后 - * 才有页数与「· 续读 Pn」角标。 */ -static void test_library_badge(const char *root) +/* A6 文件管理器行模型:扫描即出最终行(无占位/无后台补位/无续读角标); + * 目录优先;当前层的图片行带图片下标(点图 → reader 起始页)。 */ +static void test_library_rows(const char *root) { mr_test_cfg_clear(); - char vol1[MR_PATH_MAX], vol2[MR_PATH_MAX]; + char vol1[MR_PATH_MAX]; joinp(vol1, sizeof vol1, root, "vol1"); - joinp(vol2, sizeof vol2, root, "vol2"); - /* vol1 有续读记录(第 5 页),vol2 没有 */ + /* vol1 有续读记录(第 5 页)——书库行不应出现角标。 */ mr_cont_save(vol1, 4, MR_MODE_WEBTOON); mr_library lib; memset(&lib, 0, sizeof lib); int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); CHECK(n > 0, "library scan n=%d", n); + if (n > 0) + CHECK(lib.items[0].kind == MR_LIB_KIND_DIR, "dirs first: %s", lib.items[0].name); - /* 第一段:扫描期不判定,占位副标题。 */ - int seen1 = 0, seen2 = 0; + int seen1 = 0, seen_img = 0; for (int i = 0; i < lib.count; i++) { - mr_library_entry *e = &lib.items[i]; + const mr_library_entry *e = &lib.items[i]; + CHECK(e->subtitle[0] && strcmp(e->subtitle, "…") != 0, + "%s subtitle pre-generated: %s", e->name, e->subtitle); + CHECK(strstr(e->subtitle, "续读") == NULL, "%s has no badge: %s", + e->name, e->subtitle); if (strcmp(e->name, "vol1") == 0) { seen1 = 1; - CHECK(e->probe_state == MR_LIB_PENDING && e->pages == -1, - "vol1 pending after scan"); - CHECK(strcmp(e->subtitle, "…") == 0, "vol1 placeholder subtitle=%s", e->subtitle); + CHECK(e->kind == MR_LIB_KIND_DIR, "vol1 is a dir row"); } - if (strcmp(e->name, "vol2") == 0) { - seen2 = 1; - CHECK(e->probe_state == MR_LIB_PENDING && strcmp(e->subtitle, "…") == 0, - "vol2 placeholder subtitle=%s", e->subtitle); + if (strcmp(e->name, "loose.png") == 0) { + seen_img = 1; + CHECK(e->kind == MR_LIB_KIND_IMAGE, "loose.png is an image row"); + CHECK(mr_library_image_index(&lib, i) == 0, "loose.png is image #0"); } } CHECK(seen1 == 1, "vol1 listed"); - CHECK(seen2 == 1, "vol2 listed"); - - /* 第二段:后台探测补位后,页数与续读角标出现。 */ - probe_all_rows(&lib); - seen1 = seen2 = 0; - for (int i = 0; i < lib.count; i++) { - mr_library_entry *e = &lib.items[i]; - if (strcmp(e->name, "vol1") == 0) { - seen1 = 1; - CHECK(e->has_images == 1 && e->pages == 1, "vol1 probed pages=%d", e->pages); - CHECK(strstr(e->subtitle, "page") != NULL, "vol1 keeps pages text: %s", e->subtitle); - CHECK(strstr(e->subtitle, "续读 P5") != NULL, "vol1 subtitle=%s", e->subtitle); - } - if (strcmp(e->name, "vol2") == 0) { - seen2 = 1; - CHECK(e->has_images == 1 && e->pages == 1, "vol2 probed pages=%d", e->pages); - CHECK(strstr(e->subtitle, "续读") == NULL, "vol2 no badge: %s", e->subtitle); - CHECK(strcmp(e->subtitle, "1 page") == 0, "vol2 subtitle=%s", e->subtitle); - } - } - CHECK(seen1 == 1, "vol1 listed after probe"); - CHECK(seen2 == 1, "vol2 listed after probe"); - mr_library_free(&lib); - - /* 记录清除后角标消失(重扫 + 再探测;探测缓存只存页数,角标按当前配置重算)。 */ - mr_cont_save(vol1, 0, MR_MODE_WEBTOON); - memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) > 0, "rescan"); - probe_all_rows(&lib); - for (int i = 0; i < lib.count; i++) - if (strcmp(lib.items[i].name, "vol1") == 0) - CHECK(strstr(lib.items[i].subtitle, "续读") == NULL, "cleared badge gone"); + CHECK(seen_img == 1, "loose image listed"); mr_library_free(&lib); } @@ -280,7 +239,7 @@ int main(void) test_has_images(root); test_adjacent(root); - test_library_badge(root); + test_library_rows(root); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); diff --git a/tuku/test/test_library.c b/tuku/test/test_library.c index 2b4e2a6..1fcb9a3 100644 --- a/tuku/test/test_library.c +++ b/tuku/test/test_library.c @@ -1,11 +1,11 @@ /* test_library.c — 图库 logic 层宿主测试(纯 C,无 SDK/硬件)。 * - * 覆盖: - * T1 自然排序:数字段数值序(page2 -1;max_depth 忽略; * T4 阅读方向状态(logic/reader.c)。 * * 编译(在 tuku/ 下): @@ -80,6 +80,13 @@ static const mr_library_entry *find_entry(const mr_library *lib, const char *nam return NULL; } +static int find_index(const mr_library *lib, const char *name) +{ + for (int i = 0; i < lib->count; i++) + if (strcmp(lib->items[i].name, name) == 0) return i; + return -1; +} + static void test_natural(void) { CHECK(mr_natural_cmp("page2", "page10") < 0, "page2 < page10"); @@ -91,6 +98,7 @@ static void test_natural(void) CHECK(mr_natural_cmp("a", "a1") < 0, "prefix shorter first"); CHECK(mr_natural_cmp("abc", "abc") == 0, "identical equal"); CHECK(mr_natural_cmp("ABC", "abc") != 0, "case-only tie is deterministic"); + CHECK(mr_natural_cmp("a.jpg", "A.jpg") > 0, "case tie falls back to raw bytes"); CHECK(mr_natural_cmp("A2", "a10") < 0, "case-insensitive primary then numeric"); } @@ -104,6 +112,10 @@ static void test_ext(void) CHECK(mr_path_is_image("a.txt") == 0, "txt unsupported"); CHECK(mr_path_is_image("png") == 0, "no extension"); CHECK(mr_path_is_image(".png") == 0, "dotfile-ish no stem"); + CHECK(mr_path_is_archive("a.cbz") == 1, "cbz"); + CHECK(mr_path_is_archive("a.ZIP") == 1, "ZIP uppercase"); + CHECK(mr_path_is_archive("a.7z") == 0, "7z unsupported"); + CHECK(mr_path_is_archive("cbz") == 0, "no extension archive"); } static void test_parent(void) @@ -150,54 +162,99 @@ static void test_scan(void) snprintf(p, sizeof p, "%s/MeowAPPs", root); mkdir_p(p); snprintf(p, sizeof p, "%s/MeowAPPs/m.png", root); touch_file(p); - snprintf(p, sizeof p, "%s/root.png", root); touch_file(p); /* 普通文件不成为行 */ + /* 当前层文件:图片与档案册都成行;txt/隐藏文件不成行。 */ + snprintf(p, sizeof p, "%s/root.png", root); touch_file(p); + snprintf(p, sizeof p, "%s/cover.JPG", root); touch_file(p); + snprintf(p, sizeof p, "%s/book.cbz", root); touch_file(p); + snprintf(p, sizeof p, "%s/bundle.zip", root);touch_file(p); + snprintf(p, sizeof p, "%s/notes.txt", root); touch_file(p); + snprintf(p, sizeof p, "%s/.hidden.png", root); touch_file(p); + + char link_path[MR_PATH_MAX], link_target[MR_PATH_MAX]; + snprintf(link_target, sizeof link_target, "%s/root.png", root); + snprintf(link_path, sizeof link_path, "%s/link.png", root); + symlink(link_target, link_path); /* 有权限才建;不列 */ mr_library lib; memset(&lib, 0, sizeof lib); int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); - CHECK(n == 7, "scan count=%d (want 7: all direct subdirs)", n); + CHECK(n == 11, "scan count=%d (want 11: 7 dirs + 4 files)", n); CHECK(lib.count == n, "count matches"); CHECK(lib.truncated == 0, "not truncated"); - if (lib.count == 7) { - static const char *want[7] = { - "Empty", "MeowAPPs", "Nested", "Series 2", "Series 02", "Series 3", "Series 10" + if (lib.count == 11) { + static const char *want[11] = { + "Empty", "MeowAPPs", "Nested", "Series 2", "Series 02", "Series 3", "Series 10", + "book.cbz", "bundle.zip", "cover.JPG", "root.png" }; - for (int i = 0; i < 7; i++) + for (int i = 0; i < 11; i++) CHECK(strcmp(lib.items[i].name, want[i]) == 0, "sort[%d]=%s got %s", i, want[i], lib.items[i].name); + for (int i = 0; i < 7; i++) + CHECK(lib.items[i].kind == MR_LIB_KIND_DIR, "dirs first: %s", lib.items[i].name); } - /* 访问放开:系统目录/空目录/无图目录都列出;隐藏项/文件不列。 */ - CHECK(find_entry(&lib, "MeowAPPs") != NULL, "system dir listed"); - CHECK(find_entry(&lib, "Empty") != NULL, "empty dir listed"); - CHECK(find_entry(&lib, "Series 3") != NULL, "no-image dir listed"); - CHECK(find_entry(&lib, "Nested") != NULL, "intermediate dir listed"); + /* 行类型与副标题(扫描期生成一次,不是占位)。 */ + const mr_library_entry *e = find_entry(&lib, "Empty"); + CHECK(e && e->kind == MR_LIB_KIND_DIR && strcmp(e->subtitle, "文件夹") == 0, + "dir subtitle=%s", e ? e->subtitle : ""); + e = find_entry(&lib, "root.png"); + CHECK(e && e->kind == MR_LIB_KIND_IMAGE && e->size == 1 && + strncmp(e->subtitle, "图片 · ", strlen("图片 · ")) == 0, + "image subtitle=%s size=%lld", e ? e->subtitle : "", e ? e->size : -1); + e = find_entry(&lib, "book.cbz"); + CHECK(e && e->kind == MR_LIB_KIND_ARCHIVE && + strncmp(e->subtitle, "档案册 · ", strlen("档案册 · ")) == 0, + "archive subtitle=%s", e ? e->subtitle : ""); + + /* 排除项:普通文件/隐藏项/隐藏目录/符号链接。 */ + CHECK(find_entry(&lib, "notes.txt") == NULL, "plain text file excluded"); CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded"); - CHECK(find_entry(&lib, "root.png") == NULL, "loose image file excluded"); + CHECK(find_entry(&lib, ".hidden.png") == NULL, "hidden file excluded"); + CHECK(find_entry(&lib, "link.png") == NULL, "symlink excluded"); + CHECK(find_entry(&lib, "a.png") == NULL, "no recursion into subdirs"); + CHECK(find_entry(&lib, "Deeper") == NULL, "no recursion into subdirs (deeper)"); - /* 首帧不做懒判定:所有行待探测(副标题占位)。 */ - for (int i = 0; i < lib.count; i++) { - CHECK(lib.items[i].pages == -1, "%s pages pending", lib.items[i].name); - CHECK(lib.items[i].has_images == -1, "%s book state pending", lib.items[i].name); - CHECK(lib.items[i].probe_state == MR_LIB_PENDING, "%s probe pending", lib.items[i].name); - CHECK(strcmp(lib.items[i].subtitle, "…") == 0, "%s subtitle placeholder", lib.items[i].name); - } + /* 图片下标 = 本层图片自然序(书库点图 → 阅读器起始页的契约)。 */ + int i_cover = find_index(&lib, "cover.JPG"); + int i_root = find_index(&lib, "root.png"); + CHECK(mr_library_image_index(&lib, i_cover) == 0, "cover.JPG is image #0"); + CHECK(mr_library_image_index(&lib, i_root) == 1, "root.png is image #1"); + CHECK(mr_library_image_index(&lib, find_index(&lib, "book.cbz")) == -1, + "archive has no image index"); + CHECK(mr_library_image_index(&lib, find_index(&lib, "Empty")) == -1, + "dir has no image index"); + CHECK(mr_library_image_index(&lib, -1) == -1 && mr_library_image_index(&lib, 99) == -1, + "out-of-range image index"); + + /* 行合成:纯内存,返回扫描期生成的字符串。 */ + mr_library_row row; + CHECK(mr_library_row_at(&lib, 0, &row) == 1 && row.index == 0 && + row.kind == MR_LIB_KIND_DIR && row.title && row.subtitle && + strcmp(row.subtitle, "文件夹") == 0, "row 0 = first dir"); + CHECK(mr_library_row_at(&lib, lib.count, &row) == 0, "no extra row when not truncated"); + CHECK(mr_library_row_at(&lib, -1, &row) == 0, "row -1 invalid"); /* max_depth 参数只为兼容旧调用保留:不再递归,结果一致。 */ mr_library lib1; memset(&lib1, 0, sizeof lib1); - CHECK(mr_library_scan(&lib1, root, 1) == 7, "max_depth ignored (single layer)"); + CHECK(mr_library_scan(&lib1, root, 1) == 11, "max_depth ignored (single layer)"); mr_library_free(&lib1); mr_library_free(&lib); - /* 不可读目录:返回 -1,lib 可安全 free。 */ + /* 不可读目录 / 不是目录:返回 -1,lib 可安全 free。 */ mr_library bad; memset(&bad, 0, sizeof bad); CHECK(mr_library_scan(&bad, "/no/such/dir/tuku", MR_LIB_MAX_DEPTH) == -1, "missing dir -> -1"); CHECK(bad.count == 0, "missing dir empty"); mr_library_free(&bad); + snprintf(p, sizeof p, "%s/root.png", root); + memset(&bad, 0, sizeof bad); + CHECK(mr_library_scan(&bad, p, MR_LIB_MAX_DEPTH) == -1, "file (not dir) -> -1"); + CHECK(bad.count == 0, "file scan empty"); + mr_library_free(&bad); + rm_rf(root); } diff --git a/tuku/test/test_open_book_host.c b/tuku/test/test_open_book_host.c index c2b0529..dc3a503 100644 --- a/tuku/test/test_open_book_host.c +++ b/tuku/test/test_open_book_host.c @@ -1,11 +1,14 @@ /* test_open_book_host.c — 书库点击 → 阅读器打开的宿主集成回归(需 SDK 源码树)。 * - * 复现用户报告:「父文件夹有封面图片、子文件夹也有图片,点开父文件夹报无法打开此册」。 - * 用合成夹具(真实 BMP 当封面/子图,不读用户相册)走完整链路: - * library 列表 → 行点击 → mr_reader_open_dir → reader on_enter → mr_book_open_auto - * 并断言父目录以「书」打开(count=1)且没有进入 open_err。 + * M18 文件管理器模型:点目录=进入;点图片=从该图开始读(mr_reader_open_dir_at(目录, 下标)); + * 点档案册=打开档案册。本测试用合成夹具(真实 BMP,不读用户相册)走完整链路: + * library 列表 → 点图片行 → mr_reader_open_dir_at → reader on_enter → mr_book_open_auto + * 并断言「点哪张就开哪张」:阅读器当前页 == 该书库行在目录图片自然序里的下标。 * - * 另覆盖:封面图很大时打开仍成功(打开只枚举,不解码);同目录二次打开/离开重开。 + * 另覆盖: + * - 目录行只进目录,不打开阅读器; + * - 阅读页已在本页时再次 mr_reader_open_dir_at 能换册/换页重进(SDK 忽略重复 push 的坑); + * - 离开阅读器回书库后再次点图(同目录二次打开)。 * * 编译:见 test/run_tests.sh(与 test_mode_switch_host 同口径)。 */ @@ -25,7 +28,6 @@ void library_test_set_root(const char *root); const char *library_test_dir(void); int library_test_rows(void); int library_test_src(int idx, meow_list_item *item); -int library_test_worker_on(void); /* ui/reader/reader.c 的测试接缝(-DMR_READER_HOST_TEST) */ int mr_reader_ui_mode(void); @@ -116,22 +118,6 @@ static int rm_rf(const char *path) return rmdir(path); } -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); - } -} - static int row_index_for(const char *name) { int rows = library_test_rows(); @@ -147,10 +133,25 @@ static int row_index_for(const char *name) static void tap_row(const char *name) { int idx = row_index_for(name); + if (idx < 0) { CHECK(0, "row %s not found", name); return; } int list_top = meow_ui_statusbar_height() + 56; library_on_tap(120, list_top + idx * 56 + 28); } +/* 某张图片行在本目录图片自然序里的下标 = 排在前面的「图片 · 」行数。 */ +static int expected_image_index(const char *name) +{ + int ti = row_index_for(name); + if (ti < 0) return -1; + int n = 0; + for (int i = 0; i < ti; i++) { + meow_list_item it; + if (!library_test_src(i, &it)) continue; + if (it.subtitle && strncmp(it.subtitle, "图片 · ", strlen("图片 · ")) == 0) n++; + } + return n; +} + int main(void) { const char *base = getenv("TMPDIR"); @@ -161,25 +162,27 @@ int main(void) if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } setenv("MEOW_DATA_DIR", data, 1); - char root[MR_PATH_MAX], parent[MR_PATH_MAX], sub[MR_PATH_MAX], p[MR_PATH_MAX]; + char root[MR_PATH_MAX], pics[MR_PATH_MAX], other[MR_PATH_MAX], p[MR_PATH_MAX]; snprintf(root, sizeof root, "%s/library", data); - snprintf(parent, sizeof parent, "%s/Pictures", root); - if (mkdir_p(parent) != 0) { printf("SKIP: mkdir failed\n"); return 0; } + snprintf(pics, sizeof pics, "%s/Pictures", root); + snprintf(other, sizeof other, "%s/Other", root); + if (mkdir_p(pics) != 0 || mkdir_p(other) != 0) { printf("SKIP: mkdir failed\n"); return 0; } - /* 父目录:一张封面图 + 两个含图子目录(用户报告的结构)。 */ - snprintf(p, sizeof p, "%s/cover.jpg", parent); /* 扩展名即可;打开只枚举不解码 */ - FILE *f = fopen(p, "wb"); - if (!f) { printf("SKIP: cover write failed\n"); return 0; } - fputs("x", f); - fclose(f); - snprintf(sub, sizeof sub, "%s/2023", parent); - mkdir_p(sub); - snprintf(p, sizeof p, "%s/a.bmp", sub); - write_bmp24(p, 40, 60); - snprintf(sub, sizeof sub, "%s/2024", parent); - mkdir_p(sub); - snprintf(p, sizeof p, "%s/b.bmp", sub); - write_bmp24(p, 40, 60); + /* Pictures:6 张图(数字序号/大小写/前导零混排)+ 1 个普通文件(不成行)。 */ + const char *imgs[] = { "A.bmp", "a.bmp", "img2.bmp", "img10.bmp", + "page3.bmp", "Page03.bmp" }; + for (unsigned i = 0; i < sizeof imgs / sizeof imgs[0]; i++) { + snprintf(p, sizeof p, "%s/%s", pics, imgs[i]); + if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: image write failed\n"); return 0; } + } + snprintf(p, sizeof p, "%s/notes.txt", pics); + { FILE *f = fopen(p, "wb"); if (f) { fputs("x", f); fclose(f); } } + + /* Other:4 张图,用于「已在本页时换册重进」。 */ + for (int i = 0; i < 4; i++) { + snprintf(p, sizeof p, "%s/o%d.bmp", other, i + 1); + if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: image write failed\n"); return 0; } + } meow_ui_opts o = { 480, 720 }; if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } @@ -190,46 +193,52 @@ int main(void) meow_view_page_push(ACT_LIBRARY); CHECK(library_test_load(root) == 1, "load fixture root"); - wait_ready(3000); - int ip = row_index_for("Pictures"); - meow_list_item it; - CHECK(ip >= 0 && library_test_src(ip, &it), "Pictures row present"); - CHECK(it.subtitle && strcmp(it.subtitle, "1 page") == 0, - "Pictures probed as book with direct cover (subtitle=%s)", - it.subtitle ? it.subtitle : ""); - - /* 点击父目录 → 进阅读器;cover 是唯一直接子图 → count=1,不能是 open_err。 */ + /* 点目录 = 进入目录,不打开阅读器。 */ tap_row("Pictures"); - CHECK(meow_ui_activity_active() == ACT_READER, "tap opens reader"); - CHECK(strcmp(mr_reader_dir(), parent) == 0, "reader dir=%s", mr_reader_dir()); - CHECK(mr_reader_ui_open_err() == 0, "parent folder opens without open_err"); - CHECK(mr_reader_ui_count() == 1, "parent book pages=%d (want 1)", - mr_reader_ui_count()); - CHECK(library_test_worker_on() == 0, - "library background decode stopped while reader is active"); - for (int i = 0; i < 30; i++) { usleep(10 * 1000); } + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "tap dir stays in library"); + CHECK(strcmp(library_test_dir(), pics) == 0, "entered Pictures (%s)", library_test_dir()); + CHECK(library_test_rows() == 6, "Pictures rows=%d (6 images, txt excluded)", + library_test_rows()); + + /* 点 img10.bmp:阅读器从该图开始(count=6, page=它在该目录的自然序下标)。 */ + int want = expected_image_index("img10.bmp"); + CHECK(want == 3, "img10.bmp natural image index=%d (want 3)", want); + tap_row("img10.bmp"); + CHECK(meow_ui_activity_active() == ACT_READER, "tap image opens reader"); + CHECK(strcmp(mr_reader_dir(), pics) == 0, "reader dir=%s", mr_reader_dir()); + CHECK(mr_reader_ui_open_err() == 0, "open has no error"); + CHECK(mr_reader_ui_count() == 6, "reader pages=%d (want 6)", mr_reader_ui_count()); + CHECK(mr_reader_ui_page() == want, "reader starts at selected image: page=%d (want %d)", + mr_reader_ui_page(), want); + + /* 让首帧解码 tick 跑一遍,确认不会因坏夹具进入 open_err。 */ + for (int i = 0; i < 20; i++) usleep(10 * 1000); CHECK(mr_reader_ui_open_err() == 0, "still no open_err after decode tick"); - /* 返回书库 → 后台线程恢复,再次点开(同目录二次打开 + 续读恢复)。 */ + /* 已在本页:直接换册重进(M17 遗留修复,SDK 会忽略重复 push)。 */ + mr_reader_open_dir_at(other, 2); + CHECK(meow_ui_activity_active() == ACT_READER, "switch keeps reader active"); + CHECK(strcmp(mr_reader_dir(), other) == 0, "switched reader dir=%s", mr_reader_dir()); + CHECK(mr_reader_ui_count() == 4, "switched book pages=%d (want 4)", mr_reader_ui_count()); + CHECK(mr_reader_ui_page() == 2, "switched book page=%d (want 2)", mr_reader_ui_page()); + + /* 返回书库 → 再点另一张图(同目录二次打开 + 显式起始页优先于续读)。 */ meow_view_page_pop(); CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library"); - CHECK(library_test_worker_on() == 1, "library background decode restarted on return"); - tap_row("Pictures"); - CHECK(mr_reader_ui_open_err() == 0, "reopen parent without open_err"); - CHECK(mr_reader_ui_count() == 1, "reopen pages=%d", mr_reader_ui_count()); + CHECK(strcmp(library_test_dir(), pics) == 0, "library still in Pictures"); + + tap_row("page3.bmp"); + CHECK(meow_ui_activity_active() == ACT_READER, "reopen reader"); + CHECK(mr_reader_ui_open_err() == 0, "reopen no error"); + CHECK(mr_reader_ui_count() == 6, "reopen pages=%d", mr_reader_ui_count()); + int want2 = expected_image_index("page3.bmp"); + CHECK(want2 == 4, "page3.bmp natural image index=%d (want 4)", want2); + CHECK(mr_reader_ui_page() == want2, "reopen starts at page=%d (want %d)", + mr_reader_ui_page(), want2); meow_view_page_pop(); - /* 封面是大图也不影响打开(打开只枚举文件名)。 */ - snprintf(p, sizeof p, "%s/cover.bmp", parent); - CHECK(write_bmp24(p, 400, 600) == 0, "write large cover"); - CHECK(library_test_load(root) == 1, "reload after cover change"); - wait_ready(3000); - tap_row("Pictures"); - CHECK(mr_reader_ui_open_err() == 0, "open with large cover has no open_err"); - CHECK(mr_reader_ui_count() == 2, "cover.jpg + cover.bmp pages=%d (want 2)", - mr_reader_ui_count()); - meow_view_page_pop(); + /* 档案册路径(无 zip 夹具时只验证入口不被当成目录):点 txt 不存在行;跳过。 */ meow_view_free(reader); meow_view_free(lib); diff --git a/tuku/tuku_internal.h b/tuku/tuku_internal.h index 4dcb027..9b6bac3 100644 --- a/tuku/tuku_internal.h +++ b/tuku/tuku_internal.h @@ -8,7 +8,7 @@ * 页面:ACT_LIBRARY(0) 根页(保留状态栏)、ACT_READER(1) 全屏、ACT_SETTINGS(2)、ACT_LOCK(3)。 * 并行开发接缝(后续任务只填自己的文件,不改 app.c/internal.h): * - M4 阅读器:ui/reader/reader.c 实现,方向状态用 logic/reader 的 mr_reader_get/set_direction; - * 书库点入含图目录经 mr_reader_open_dir(path) 进入本页。 + * 书库列表点图片/档案册经 mr_reader_open_dir_at()(ui/reader/reader.h)进入本页。 * - M5 锁屏/PIN:ui/lock/lock.c 实现 lock_view_should_show();本批桩返回 0(不锁)。 */ #ifndef TUKU_INTERNAL_H @@ -27,7 +27,7 @@ #define ACT_SETTINGS 2 #define ACT_LOCK 3 -#define MR_ROOT_DIR "/usr/data/mnt/sd_0" /* 书库根 = TF 卡根 */ +#define MR_ROOT_DIR "/usr/data/mnt/sd_0" /* 书库浏览根 = TF 卡根(不允许上溯到其上级) */ /* ---- 视图控制器(biz-UI 绑定点) ---- */ typedef struct mr_page_ctrl { @@ -35,16 +35,15 @@ typedef struct mr_page_ctrl { int active; } mr_page_ctrl; -/* ---- 书库页(ui/library/library.c) ---- */ +/* ---- 书库页(ui/library/library.c,文件管理器模型) ---- */ meow_view *library_view_create(void); -int library_at_root(void); /* 1=当前在书库根(生产=MR_ROOT_DIR,宿主测试可改根) */ -int library_go_up(void); /* 系统返回语义:逐级上移;TF 根/"/" 返回 0(app.c 据此退出) */ -int library_go_parent(void); /* 顶栏显式「上级」:访问放开,可越过 TF 根到 '/' */ +int library_at_root(void); /* 1=当前在浏览根(生产=MR_ROOT_DIR,宿主测试可改根) */ +int library_go_up(void); /* 系统返回语义:逐级上移;浏览根返回 0(app.c 据此退出) */ void library_on_tap(int x, int y); void library_on_swipe(int dir, int dist); /* dist=SDK on_swipe 的位移量(UP/DOWN 用) */ void library_on_touch_down(int x, int y); void library_on_touch_move(int x, int y); -void library_on_tick(void); /* 收下后台缩略图结果并局部补位 */ +void library_on_tick(void); /* 16ms tick:提示横幅到期清除 */ /* ---- 阅读器页(ui/reader/reader.c,M4 填充) ---- */ meow_view *reader_view_create(void); diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index 2514112..f7f7322 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -1,130 +1,49 @@ -/* ui/library/library.c — 图库浏览页(ACT_LIBRARY): +/* ui/library/library.c — 图库浏览页(ACT_LIBRARY):文件管理器模型。 * - * TopAppBar(标题 + 返回 + 「TF卡」回根 + ⚙)+ meow_listview(src2 行数据源)。 + * TopAppBar(标题 + 返回 + 刷新 + ⚙)+ meow_listview(src2 行数据源)。 * - * 浏览语义(M13,用户要求:性能 + 访问放开): - * - 首帧只做**单层**枚举(logic/library.c:一次 opendir/readdir+lstat), - * 不做任何递归「子树含图」探测;列表行 = 当前目录的直接子目录(M11 起还包括 - * .cbz/.zip 档案册,与目录自然序混排); - * - 系统目录/空目录/无图目录都列出且可进入;左缘返回按系统语义逐级上移, - * **到 TF 根即退出 App**(不再上溯系统目录);TF 根以外的上级浏览走顶栏「上级」动作; - * - 「直接含图目录=一本书」与档案册页数改为后台懒算:行仍在 PENDING 时副标题「…」, - * 后台线程用 mr_library_probe_path(单层 + mtime/size 失效缓存)算出页数/首图, - * on_tick 补位后只 invalidate 列表视口——首帧/滚动/命中判定都不读子目录/档案; - * - 不可读/不存在的目录:停留在原目录,顶部横幅提示 2.5s。 + * 浏览语义(M18,用户要求「完全像文件管理器」): + * - 列表 = 当前目录一层的「子目录 + 受支持图片 + .cbz/.zip 档案册」,目录优先、组内自然序; + * 点目录=进入(空目录也进),点图片=从该图开始读(mr_reader_open_dir_at(目录, 图片下标)), + * 点档案册=打开档案册(第 1 页/续读); + * - 首帧只做**单层**枚举(一次 opendir/readdir+lstat):没有「目录含图=一本书」的探测、 + * 页数、封面、角标,也没有懒算线程/缩略图解码——行副标题在扫描期生成一次; + * - 根目录锁定 TF 卡根(MR_ROOT_DIR):顶栏返回在根隐藏,library_go_up 在根返回 0 + * (app.c 据此退出 App),不存在越过根上溯的导航; + * - 根目录不可访问(未插卡/挂载失败)→ 列表空态「未检测到 SD 卡」+ 顶栏刷新重试; + * 重进本页也会自动重扫。子目录不可读 → 停留原目录 + 顶部提示横幅。 * - * 行绑定(列表 src2 模式):meow_listview_create(..., rows, lib_src, ...) 每帧/每次 - * invalidate 对可见行重新取行;lib_src 只读内存里的 g_lib + 缩略图缓存,并把「待探测/ - * 待缩略图」请求投递到有界队列(不做 IO),因此懒算结果补位后下一帧即可上屏。 - * - * 缩略图:后台线程 meow_img_downscale 到 96px,落磁盘缓存 MeowData//cache - * (BMP,文件名含源 mtime+size,源变化自动失效;≥64 张按 mtime LRU 删除)。 - * 档案册(M11)封面=档内首图:先解压到 page_cache,再按普通图片解码。 + * 行绑定(src2 模式):meow_listview_create(..., lib_src, ...) 每帧对可见行取一次行数据; + * lib_src 只读 g_lib 内存(指针直出,不做 IO/格式化),滚动与点击路径零解码零文件访问。 * * 绘制/颜色全部取 meow_theme_get() 语义色;主题切换由 app.c 的 listener 整帧 invalidate。 */ #include "tuku_internal.h" -#include "logic/reader/page_source.h" -#include "logic/reader/page_cache.h" +#include "ui/reader/reader.h" -#include #include -#include -#include #include -#include #include -#include -#include -#include -#include -#define MR_THUMB_MAX 24 -#define MR_THUMB_DIM 96 -#define MR_THUMB_DECODE_DIM 256 /* 缩略图源图预算解码边长(峰值 ~1MB;整幅解码可达数十 MB) */ -#define MR_TOPAPPBAR_H 56 -#define MR_DEFAULT_ROW_H 56 -#define MR_CACHE_MAX_FILES 64 -#define MR_HINT_H 44 -#define MR_HINT_MS 2500 - -#define MR_TREQ_MAX 64 -#define MR_TRES_MAX 16 -#define MR_PQ_MAX 128 -#define MR_PRES_MAX 48 - -typedef struct thumb_req { - int gen; - int idx; - char cover[MR_PATH_MAX]; -} thumb_req; - -typedef struct thumb_res { - int gen; - int idx; - meow_img *img; -} thumb_res; - -typedef struct probe_job { - int gen; - int idx; - int is_archive; - char path[MR_PATH_MAX]; -} probe_job; - -typedef struct probe_res { - int gen; - int idx; - int is_archive; - char path[MR_PATH_MAX]; - int ok; - mr_library_probe probe; -} probe_res; +#define MR_TOPAPPBAR_H 56 +#define MR_HINT_H 44 +#define MR_HINT_MS 2500 static mr_page_ctrl g_ctrl; static mr_library g_lib; static char g_cur_dir[MR_PATH_MAX] = MR_ROOT_DIR; -static char g_root_dir[MR_PATH_MAX] = MR_ROOT_DIR; /* 「TF卡」回根目标(宿主测试可改) */ +static char g_root_dir[MR_PATH_MAX] = MR_ROOT_DIR; /* 浏览根(生产=MR_ROOT_DIR,宿主测试可改) */ static meow_widget *g_tb; static meow_widget *g_lv; static meow_scroll *g_scroll; static int g_list_x, g_list_y, g_list_w, g_list_h; -static int g_tb_home_idx = -1; +static int g_act_refresh = -1; +static int g_act_settings = -1; +static int g_tb_back; /* 顶栏返回箭头是否显示(根目录隐藏) */ +static int g_sd_missing; /* 1=根目录不可访问(未检测到 SD 卡的空态) */ -static struct { - int idx; - meow_img *img; -} g_thumbs[MR_THUMB_MAX]; -static int g_thumb_n; - -/* ---- 后台工作线程:探测队列(优先)+ 缩略图队列 ---- */ -static pthread_t g_worker; -static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; -static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER; -static int g_worker_on; -static int g_worker_stop; -static int g_gen; - -static probe_job g_pq[MR_PQ_MAX]; -static int g_pq_head, g_pq_tail, g_pq_n; -static char g_probe_inflight[MR_PATH_MAX]; - -static probe_res g_pres[MR_PRES_MAX]; -static int g_pres_n; - -static thumb_req g_treq[MR_TREQ_MAX]; -static int g_treq_head, g_treq_tail, g_treq_n; -static char g_thumb_inflight[MR_PATH_MAX]; - -static thumb_res g_tres[MR_TRES_MAX]; -static int g_tres_n; - -/* ---- 磁盘缩略图缓存(与解压页缓存同目录) ---- */ -static char g_cache_dir[MR_PATH_MAX]; -static int g_cache_on; - -/* ---- 顶部提示横幅(不可读目录等) ---- */ +/* ---- 顶部提示横幅(子目录不可读等) ---- */ static int g_hint_id; static char g_hint[160]; static uint64_t g_hint_until; @@ -133,351 +52,19 @@ static int g_hint_x, g_hint_y, g_hint_w, g_hint_h; static int g_drag; static int g_dragged; static int g_drag_last_y; +static int g_press_row; /* 按下行(-1=无):按下高亮,拖动超阈值清除 */ +static int g_press_delta; -/* 书库扫描/后台探测的档案册探测(M11 注入):logic/library 不依赖 zip 容器。 */ -static int cbz_archive_pages(const char *path, void *ud) +/* 清除按下高亮(抬手/拖动/切目录时调用)。 */ +static void press_clear(void) { - (void)ud; - return mr_zip_count_pages(path); + if (g_lv) meow_listview_set_sel(g_lv, -1); + g_press_row = -1; + g_press_delta = 0; } /* ============================================================ */ -/* 磁盘缓存(BMP 原始缩略图;文件名含源 mtime+size 做失效) */ -/* ============================================================ */ -static unsigned long long fnv1a64(const char *s) -{ - unsigned long long h = 1469598103934665603ULL; - for (const unsigned char *p = (const unsigned char *)s; *p; p++) { - h ^= (unsigned long long)*p; - h *= 1099511628211ULL; - } - return h; -} - -static void cache_init(void) -{ - const char *data = meow_config_dir(); - if (!data || !data[0]) return; - - /* 缩略图缓存与解压页缓存同目录(MeowData//cache)。目录推导、建立与 - * 启动清孤儿统一交给 logic/reader/page_cache,避免两处目录规则分叉。 */ - if (mr_cache_init(data) != 0 || mr_cache_dir()[0] == '\0') return; - snprintf(g_cache_dir, sizeof g_cache_dir, "%s", mr_cache_dir()); - g_cache_on = 1; -} - -static void cache_path(char *out, int outsz, const char *cover, const struct stat *st) -{ - unsigned long long h = fnv1a64(cover); - snprintf(out, (size_t)outsz, "%s/%016llx_%lld_%lld.bmp", g_cache_dir, h, - (long long)st->st_mtime, (long long)st->st_size); -} - -/* LRU:缓存文件数超过 MR_CACHE_MAX_FILES 时删最旧(读命中会 utimes 刷新 mtime)。 - * 每次只删一个最旧文件、最多扫 8 轮,避免在 14MB 内存口径下分配大数组。 */ -static void cache_prune(void) -{ - if (!g_cache_on || !g_cache_dir[0]) return; - for (int pass = 0; pass < 8; pass++) { - DIR *d = opendir(g_cache_dir); - if (!d) return; - int count = 0, found = 0; - char oldest[MR_PATH_MAX + 96]; - oldest[0] = '\0'; - time_t oldest_t = 0; - struct dirent *de; - while ((de = readdir(d)) != NULL) { - /* 解压页缓存(M11)与缩略图同目录:mrpage_* 归 page_cache 自己的 - * 64 页/32MiB LRU 管,这里绝不能把它们当缩略图统计或删除——否则 - * 档案页会被缩略图 LRU 清掉(P2-1)。判据与 page_cache 共用。 */ - if (mr_cache_is_page_name(de->d_name)) continue; - const char *dot = strrchr(de->d_name, '.'); - if (!dot || strcmp(dot, ".bmp") != 0) continue; - char full[MR_PATH_MAX + 96]; - int n = snprintf(full, sizeof full, "%s/%s", g_cache_dir, de->d_name); - if (n <= 0 || n >= (int)sizeof full) continue; - struct stat st; - if (stat(full, &st) != 0) continue; - count++; - if (!found || st.st_mtime < oldest_t) { - found = 1; - oldest_t = st.st_mtime; - snprintf(oldest, sizeof oldest, "%s", full); - } - } - closedir(d); - if (count <= MR_CACHE_MAX_FILES || !found) break; - unlink(oldest); - } -} - -static void put_u16(unsigned char *p, unsigned v) -{ - p[0] = (unsigned char)(v & 0xFF); - p[1] = (unsigned char)((v >> 8) & 0xFF); -} -static void put_u32(unsigned char *p, uint32_t v) -{ - p[0] = (unsigned char)(v & 0xFF); - p[1] = (unsigned char)((v >> 8) & 0xFF); - p[2] = (unsigned char)((v >> 16) & 0xFF); - p[3] = (unsigned char)((v >> 24) & 0xFF); -} - -/* 写 32bpp BI_RGB BMP(stb_image 可读回)。失败返回 -1(缓存只是加速,静默降级)。 */ -static int write_bmp(const char *path, const meow_img *im) -{ - int w = meow_img_w(im), h = meow_img_h(im); - const unsigned char *px = meow_img_pixels(im); - if (!path || w <= 0 || h <= 0 || w > MR_THUMB_DIM || h > MR_THUMB_DIM || !px) return -1; - - uint32_t stride = (uint32_t)w * 4; - uint32_t imgsz = stride * (uint32_t)h; - unsigned char fh[14] = { 0 }; - unsigned char ih[40] = { 0 }; - fh[0] = 'B'; fh[1] = 'M'; - put_u32(fh + 2, 14 + 40 + imgsz); - put_u32(fh + 10, 14 + 40); - put_u32(ih + 0, 40); - put_u32(ih + 4, (uint32_t)w); - put_u32(ih + 8, (uint32_t)h); - put_u16(ih + 12, 1); - put_u16(ih + 14, 32); - put_u32(ih + 20, imgsz); - put_u32(ih + 24, 2835); - put_u32(ih + 28, 2835); - - FILE *f = fopen(path, "wb"); - if (!f) return -1; - int ok = (fwrite(fh, 1, sizeof fh, f) == sizeof fh && - fwrite(ih, 1, sizeof ih, f) == sizeof ih); - unsigned char row[MR_THUMB_DIM * 4]; - for (int y = h - 1; ok && y >= 0; y--) { - const unsigned char *src = px + (size_t)y * (size_t)w * 4; - for (int x = 0; x < w; x++) { - row[x * 4 + 0] = src[x * 4 + 2]; - row[x * 4 + 1] = src[x * 4 + 1]; - row[x * 4 + 2] = src[x * 4 + 0]; - row[x * 4 + 3] = src[x * 4 + 3]; - } - if (fwrite(row, 1, stride, f) != stride) ok = 0; - } - if (fclose(f) != 0) ok = 0; - if (!ok) unlink(path); - return ok ? 0 : -1; -} - -/* 后台线程:缓存命中直接读;否则解码源图 → downscale → 落缓存。 - * 档案册(M11)的 cover 是 .cbz/.zip 路径:先解压档内首图到 page_cache, - * 再按普通图片路径解码;缩略图缓存键仍用档案本身(mtime/size 变化即失效)。 */ -static meow_img *thumb_load(const char *cover) -{ - struct stat st; - if (stat(cover, &st) != 0 || !S_ISREG(st.st_mode)) return NULL; - - char extracted[MR_CACHE_FILE_MAX]; - const char *src = cover; - if (mr_path_is_archive(cover)) { - if (!mr_zip_cover_path(cover, extracted, (int)sizeof extracted)) return NULL; - src = extracted; - } - - char cp[MR_PATH_MAX + 96]; - if (g_cache_on && g_cache_dir[0]) { - cache_path(cp, (int)sizeof cp, cover, &st); - if (access(cp, R_OK) == 0) { - meow_img *im = meow_img_load_file(cp); - if (im) { - utimes(cp, NULL); /* LRU 读命中刷新 */ - return im; - } - unlink(cp); /* 损坏缓存 */ - } - } - - /* 预算解码:封面可能是 12MP 手机照片,整幅解码峰值 ~48MB,会在低内存设备上 - * 挤掉随后 reader 打开所需的 opendir/malloc(用户报告「点开父文件夹=无法打开此册」)。 - * 缩略图最终只要 96px,先解到 ≤MR_THUMB_DECODE_DIM 再精修,视觉无损、峰值 ~1MB。 */ - meow_img *im = meow_img_load_file_dim(src, MR_THUMB_DECODE_DIM); - if (!im) return NULL; - meow_img *small = meow_img_downscale(im, MR_THUMB_DIM); - if (small) { meow_img_free(im); im = small; } - - if (g_cache_on && g_cache_dir[0]) { - if (write_bmp(cp, im) == 0) cache_prune(); - } - return im; -} - -static void probe_push_res_locked(const probe_job *job, int ok, const mr_library_probe *p) -{ - if (g_pres_n >= MR_PRES_MAX) return; - probe_res *r = &g_pres[g_pres_n++]; - r->gen = job->gen; - r->idx = job->idx; - r->is_archive = job->is_archive; - snprintf(r->path, sizeof r->path, "%s", job->path); - r->ok = ok; - if (ok && p) r->probe = *p; - else memset(&r->probe, 0, sizeof r->probe); -} - -static void thumb_push_res_locked(const thumb_req *req, meow_img *im) -{ - if (g_tres_n >= MR_TRES_MAX) { meow_img_free(im); return; } - thumb_res *r = &g_tres[g_tres_n++]; - r->gen = req->gen; - r->idx = req->idx; - r->img = im; -} - -static void *lib_worker(void *arg) -{ - (void)arg; - for (;;) { - int kind = 0; /* 1=探测 2=缩略图 */ - probe_job pj; - thumb_req tr; - - pthread_mutex_lock(&g_lock); - while (!g_worker_stop && g_pq_n == 0 && g_treq_n == 0) - pthread_cond_wait(&g_cv, &g_lock); - if (g_worker_stop) { pthread_mutex_unlock(&g_lock); break; } - if (g_pq_n > 0) { /* 探测优先:页数/角标比重缩略图更影响可读性 */ - kind = 1; - pj = g_pq[g_pq_head]; - g_pq_head = (g_pq_head + 1) % MR_PQ_MAX; - g_pq_n--; - snprintf(g_probe_inflight, sizeof g_probe_inflight, "%s", pj.path); - } else { - kind = 2; - tr = g_treq[g_treq_head]; - g_treq_head = (g_treq_head + 1) % MR_TREQ_MAX; - g_treq_n--; - snprintf(g_thumb_inflight, sizeof g_thumb_inflight, "%s", tr.cover); - } - pthread_mutex_unlock(&g_lock); - - if (kind == 1) { - mr_library_probe p; - int ok = (mr_library_probe_path(pj.path, pj.is_archive, - cbz_archive_pages, NULL, &p) == 0); - pthread_mutex_lock(&g_lock); - g_probe_inflight[0] = '\0'; - if (!g_worker_stop) probe_push_res_locked(&pj, ok, &p); - pthread_mutex_unlock(&g_lock); - } else { - meow_img *im = thumb_load(tr.cover); - pthread_mutex_lock(&g_lock); - g_thumb_inflight[0] = '\0'; - if (im && !g_worker_stop) thumb_push_res_locked(&tr, im); - else if (im) meow_img_free(im); - pthread_mutex_unlock(&g_lock); - } - } - return NULL; -} - -static void worker_start(void) -{ - if (g_worker_on) return; - g_worker_stop = 0; - if (pthread_create(&g_worker, NULL, lib_worker, NULL) == 0) g_worker_on = 1; -} - -static void worker_stop(void) -{ - if (!g_worker_on) return; - pthread_mutex_lock(&g_lock); - g_worker_stop = 1; - pthread_cond_broadcast(&g_cv); - pthread_mutex_unlock(&g_lock); - pthread_join(g_worker, NULL); - g_worker_on = 0; - - pthread_mutex_lock(&g_lock); - for (int i = 0; i < g_tres_n; i++) meow_img_free(g_tres[i].img); - g_tres_n = 0; - g_pres_n = 0; - g_treq_n = 0; - g_treq_head = g_treq_tail = 0; - g_pq_n = 0; - g_pq_head = g_pq_tail = 0; - g_probe_inflight[0] = '\0'; - g_thumb_inflight[0] = '\0'; - pthread_mutex_unlock(&g_lock); -} - -/* 探测请求:只投递(有界队列 + 去重),不做 IO。 */ -static void probe_enqueue(int idx, const char *path, int is_archive) -{ - if (!g_worker_on || !path || !path[0]) return; - pthread_mutex_lock(&g_lock); - if (g_worker_stop) { pthread_mutex_unlock(&g_lock); return; } - if (strcmp(g_probe_inflight, path) == 0) { pthread_mutex_unlock(&g_lock); return; } - for (int i = 0; i < g_pq_n; i++) { - int p = (g_pq_head + i) % MR_PQ_MAX; - if (strcmp(g_pq[p].path, path) == 0) { pthread_mutex_unlock(&g_lock); return; } - } - if (g_pq_n >= MR_PQ_MAX) { pthread_mutex_unlock(&g_lock); return; } - g_pq[g_pq_tail].gen = g_gen; - g_pq[g_pq_tail].idx = idx; - g_pq[g_pq_tail].is_archive = is_archive; - snprintf(g_pq[g_pq_tail].path, sizeof g_pq[g_pq_tail].path, "%s", path); - g_pq_tail = (g_pq_tail + 1) % MR_PQ_MAX; - g_pq_n++; - pthread_cond_signal(&g_cv); - pthread_mutex_unlock(&g_lock); -} - -/* 缩略图请求:bind 只投递,不阻塞首绘/滚动。 */ -static void thumb_enqueue(int idx, const char *cover) -{ - if (!g_worker_on || !cover || !cover[0]) return; - pthread_mutex_lock(&g_lock); - if (g_worker_stop) { pthread_mutex_unlock(&g_lock); return; } - if (strcmp(g_thumb_inflight, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } - for (int i = 0; i < g_treq_n; i++) { - int p = (g_treq_head + i) % MR_TREQ_MAX; - if (strcmp(g_treq[p].cover, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } - } - if (g_treq_n >= MR_TREQ_MAX) { pthread_mutex_unlock(&g_lock); return; } - g_treq[g_treq_tail].gen = g_gen; - g_treq[g_treq_tail].idx = idx; - snprintf(g_treq[g_treq_tail].cover, sizeof g_treq[g_treq_tail].cover, "%s", cover); - g_treq_tail = (g_treq_tail + 1) % MR_TREQ_MAX; - g_treq_n++; - pthread_cond_signal(&g_cv); - pthread_mutex_unlock(&g_lock); -} - -/* ============================================================ */ -/* 缩略图内存缓存 */ -/* ============================================================ */ -static void thumbs_free(void) -{ - for (int i = 0; i < g_thumb_n; i++) meow_img_free(g_thumbs[i].img); - g_thumb_n = 0; -} - -static meow_img *thumb_find(int idx) -{ - for (int i = 0; i < g_thumb_n; i++) - if (g_thumbs[i].idx == idx) return g_thumbs[i].img; - return NULL; -} - -static int thumb_put(int idx, meow_img *im) -{ - if (g_thumb_n >= MR_THUMB_MAX) return 0; - g_thumbs[g_thumb_n].idx = idx; - g_thumbs[g_thumb_n].img = im; - g_thumb_n++; - return 1; -} - -/* ============================================================ */ -/* 行数据源(src2:每帧按需取可见行;零 IO,懒算请求只入队) */ +/* 行数据源(src2:每帧按需取可见行;零 IO、零格式化) */ /* ============================================================ */ static int lib_src(int idx, meow_list_item *item, void *ctx) { @@ -486,33 +73,26 @@ static int lib_src(int idx, meow_list_item *item, void *ctx) mr_library_row row; if (!mr_library_row_at(&g_lib, idx, &row)) return 0; + item->title = row.title; item->subtitle = row.subtitle; - item->enabled = row.disabled ? 0 : 1; - if (row.disabled) return 1; - - int i = row.index; - mr_library_entry *e = &g_lib.items[i]; - item->right = row.is_book ? MEOW_LIST_RIGHT_NONE : MEOW_LIST_RIGHT_ARROW; - - meow_img *im = thumb_find(i); - if (im) { - item->icon_kind = MEOW_LIST_ICON_IMG; - item->icon = im; + if (row.disabled) { + item->enabled = 0; + return 1; + } + item->enabled = 1; + if (row.kind == MR_LIB_KIND_DIR) { + item->icon_kind = MEOW_LIST_ICON_FOLDER; + item->right = MEOW_LIST_RIGHT_ARROW; /* 目录=可进入 */ } else { - item->icon_kind = row.is_book ? MEOW_LIST_ICON_FILE : MEOW_LIST_ICON_FOLDER; - /* 文件夹册用缩略图源图;档案册没有图片文件,用档案路径(worker 解压首图)。 */ - const char *src = NULL; - if (e->cover[0]) src = e->cover; - else if (row.is_book && row.is_archive) src = e->path; - if (src) thumb_enqueue(i, src); + item->icon_kind = MEOW_LIST_ICON_FILE; /* 图片/档案册=文件符号 */ + item->right = MEOW_LIST_RIGHT_NONE; /* 点开即读,无子级 */ } - if (e->probe_state == MR_LIB_PENDING) probe_enqueue(i, e->path, e->is_archive); return 1; } /* ============================================================ */ -/* 布局 / 列表重建 */ +/* 布局 */ /* ============================================================ */ static int lib_top_y(void) { @@ -527,6 +107,12 @@ static void hint_place(void) g_hint_h = MR_HINT_H; } +static const char *empty_text_for_state(void) +{ + if (g_sd_missing) return "未检测到 SD 卡(插卡后点右上角刷新)"; + return "空文件夹"; +} + static void list_rebuild(void) { if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } @@ -539,33 +125,30 @@ static void list_rebuild(void) int rows = g_lib.count + (g_lib.truncated ? 1 : 0); g_lv = meow_listview_create(g_list_x, g_list_y, g_list_w, g_list_h, rows, - lib_src, NULL, MEOW_LIST_SEL_NONE); + lib_src, NULL, MEOW_LIST_SEL_SINGLE); if (!g_lv) return; meow_listview_set_item_renderer(g_lv, 2, 1, 1); meow_listview_set_item_tier(g_lv, 2); - /* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式: - * 每行 surfaceContainer 圆角卡 + 行间空隙 + 卡内 tonal 选中。 */ + /* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式(与文件管理器一致)。 */ meow_listview_set_container(g_lv, 1); - meow_listview_set_empty_text(g_lv, "空目录(无可进入的子目录)"); + meow_listview_set_empty_text(g_lv, empty_text_for_state()); g_scroll = meow_scroll_wrap(g_lv, 1); - if (g_scroll) meow_scroll_set_snap(g_scroll, MR_DEFAULT_ROW_H); + /* 不设 snap:文件管理器手感是自由滚动,逐行吸附会发黏。 */ } static void update_topbar(void) { if (!g_tb) return; const char *title = "图库"; - if (strcmp(g_cur_dir, "/") == 0) { - title = "文件系统 /"; - } else if (strcmp(g_cur_dir, g_root_dir) != 0) { + if (strcmp(g_cur_dir, g_root_dir) != 0) { const char *slash = strrchr(g_cur_dir, '/'); if (slash && slash[1]) title = slash + 1; } meow_topappbar_set_title(g_tb, title); - /* 顶栏「上级」是显式浏览动作(访问放开,可越过 TF 根上溯到 '/'); - * 左缘返回是系统返回语义,TF 根=退出 App(见 library_go_up)。到 '/' 才隐藏。 */ - meow_topappbar_set_back(g_tb, strcmp(g_cur_dir, "/") != 0); + /* 根目录锁定:根=浏览天花板,顶栏不显示返回/上级。 */ + g_tb_back = (strcmp(g_cur_dir, g_root_dir) != 0); + meow_topappbar_set_back(g_tb, g_tb_back); } static void list_invalidate(void) @@ -602,24 +185,17 @@ static void library_draw_hint(void *ctx) meow_ui_text(x + 20, ty, g_hint, th->font_body, th->error); } -/* 目录切换:作废旧异步结果、释放内存缩略图、重建列表(新池强制重新取行)。 */ -static void reset_async(void) +/* ============================================================ */ +/* 目录加载 */ +/* ============================================================ */ +static void repaint_all(void) { - pthread_mutex_lock(&g_lock); - g_gen++; - g_pq_n = 0; - g_pq_head = g_pq_tail = 0; - g_treq_n = 0; - g_treq_head = g_treq_tail = 0; - g_pres_n = 0; - for (int i = 0; i < g_tres_n; i++) meow_img_free(g_tres[i].img); - g_tres_n = 0; - g_probe_inflight[0] = '\0'; - g_thumb_inflight[0] = '\0'; - pthread_mutex_unlock(&g_lock); + if (g_ctrl.active) + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); } -/* 目录切换:成功 1;不可读/不存在 0(提示;首次加载时仍建空列表)。 */ +/* 切到 target(空/失败列表也会切);成功 1,目录不可读 0。 + * 根目录不可读 → SD 卡空态;子目录不可读 → 停留原目录 + 提示。 */ static int load_dir(const char *dir) { /* dir 可能指向 g_lib 里的行路径(点击进入子目录):先拷到本地, @@ -631,133 +207,96 @@ static int load_dir(const char *dir) } snprintf(target, sizeof target, "%s", dir); + int is_root = (strcmp(target, g_root_dir) == 0); + mr_library next; memset(&next, 0, sizeof next); - - uint64_t t0 = meow_ui_time_ms(); - int n = mr_library_scan_ex(&next, target, MR_LIB_MAX_DEPTH, - cbz_archive_pages, NULL); - uint64_t ms = meow_ui_time_ms() - t0; + int n = mr_library_scan(&next, target, MR_LIB_MAX_DEPTH); if (n < 0) { mr_library_free(&next); + if (is_root) { + /* 未插卡/挂载失败/根不是目录:友好空态 + 可重试,绝不弹错误。 */ + mr_library_free(&g_lib); + snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); + g_sd_missing = 1; + list_rebuild(); + update_topbar(); + repaint_all(); + meow_log(MEOW_LOG_INFO, "library: storage root unavailable: %s (errno=%d %s)", + target, errno, strerror(errno)); + return 0; + } const char *slash = strrchr(target, '/'); const char *name = (slash && slash[1]) ? slash + 1 : target; char msg[200]; - snprintf(msg, sizeof msg, "无法读取目录:%s", name); + snprintf(msg, sizeof msg, "无法读取文件夹:%s", name); show_hint(msg); meow_log(MEOW_LOG_WARN, "library scan failed: %s (errno=%d %s)", target, errno, strerror(errno)); - /* 启动首帧根目录不可读:仍建一个空列表,页面可交互(返回/回根/上溯)。 */ - if (!g_lv) { - mr_library_free(&g_lib); - snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); - reset_async(); - thumbs_free(); - list_rebuild(); - update_topbar(); - } return 0; } - if (ms > 120) - meow_log(MEOW_LOG_WARN, "library scan slow: %s %llums (%d rows)", - target, (unsigned long long)ms, n); mr_library_free(&g_lib); g_lib = next; snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); + g_sd_missing = 0; - reset_async(); - thumbs_free(); list_rebuild(); update_topbar(); g_drag = 0; g_dragged = 0; - if (g_ctrl.active) - meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + repaint_all(); return 1; } +/* 顶栏刷新:重扫当前目录(无 SD 卡时就地重试;列表由 load_dir 重建)。 */ +static void refresh_current(void) +{ + load_dir(g_cur_dir); +} + int library_at_root(void) { return strcmp(g_cur_dir, g_root_dir) == 0; } +/* 系统返回语义:逐级上移;到浏览根(TF 卡根)返回 0 → app.c 退出 App。 + * 根目录锁定:不存在越过 MR_ROOT_DIR 的导航。 */ int library_go_up(void) { - /* 系统返回语义:TF 根 = 退出 App(app.c 收到 0 后 request_exit), - * 不再上溯系统目录;文件系统 "/" 同理。 */ - if (strcmp(g_cur_dir, g_root_dir) == 0) return 0; - if (strcmp(g_cur_dir, "/") == 0) return 0; - return library_go_parent(); -} - -/* 顶栏「上级」:显式浏览动作,访问放开——可越过 TF 根一路到 '/'。 */ -int library_go_parent(void) -{ - if (strcmp(g_cur_dir, "/") == 0) return 0; + if (library_at_root()) return 0; char parent[MR_PATH_MAX]; if (mr_path_parent(g_cur_dir, parent, (int)sizeof parent) != 0) return 0; + /* 防御:父目录若越出浏览根(理论上不会发生),钳回根。 */ + size_t rl = strlen(g_root_dir); + if (strlen(parent) < rl || strncmp(parent, g_root_dir, rl) != 0) + snprintf(parent, sizeof parent, "%s", g_root_dir); if (!load_dir(parent)) { - /* 父目录不可读 → 提示并停留。特例:父目录是文件系统 "/" 时仍切到 "/" 的空列表, - * 让下一次返回=退出 App(Android 沙箱可能读不了 "/",否则会卡在最后一个可读目录)。 */ - if (strcmp(parent, "/") == 0) { - mr_library_free(&g_lib); - snprintf(g_cur_dir, sizeof g_cur_dir, "%s", parent); - reset_async(); - thumbs_free(); - list_rebuild(); - update_topbar(); - if (g_ctrl.active) - meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); - } + /* 父目录不可读 → 提示并停留(不越根、不丢当前列表)。 */ return 1; } return 1; } -/* TopAppBar「TF卡」:回到书库根(已在根则回到列表顶部)。 */ -static void library_go_tf_root(void) -{ - if (strcmp(g_cur_dir, g_root_dir) == 0) { - if (g_scroll) meow_scroll_stop(g_scroll); - if (g_lv) meow_listview_set_scroll(g_lv, 0); - list_invalidate(); - return; - } - load_dir(g_root_dir); -} - -/* 点击行:先确保懒判定完成(单层探测,通常已在缓存里),书=进阅读器,否则进目录。 */ +/* 点击行:目录=进入;图片=从该图开始读;档案册=打开档案册。 */ static void library_open_entry(int idx) { if (idx < 0 || idx >= g_lib.count) return; - mr_library_entry *e = &g_lib.items[idx]; + const mr_library_entry *e = &g_lib.items[idx]; - if (e->probe_state == MR_LIB_PENDING) { - mr_library_probe p; - if (mr_library_probe_path(e->path, e->is_archive, cbz_archive_pages, NULL, &p) == 0) { - mr_library_apply_probe(&g_lib, idx, &p); - } else { - meow_log(MEOW_LOG_WARN, "library probe failed: %s (errno=%d %s)", e->path, - errno, strerror(errno)); - mr_library_mark_error(&g_lib, idx); - } - list_invalidate(); - } - if (e->probe_state == MR_LIB_FAILED) { - char msg[200]; - snprintf(msg, sizeof msg, "无法读取:%s", e->name); - show_hint(msg); + if (e->kind == MR_LIB_KIND_DIR) { + load_dir(e->path); return; } - if (e->has_images == 1) { mr_reader_open_dir(e->path); return; } - if (e->is_archive) { /* 档案册但没有图片页(坏档/空档):不导航 */ - char msg[200]; - snprintf(msg, sizeof msg, "无法阅读(档内没有图片页):%s", e->name); - show_hint(msg); + if (e->kind == MR_LIB_KIND_IMAGE) { + /* 下标由 logic 用与阅读器目录册相同的过滤器/比较器数出,保证「点 A 开 A」。 */ + int start = mr_library_image_index(&g_lib, idx); + mr_reader_open_dir_at(g_cur_dir, start < 0 ? 0 : start); return; } - load_dir(e->path); + if (e->kind == MR_LIB_KIND_ARCHIVE) { + mr_reader_open_dir_at(e->path, MR_READER_START_RESUME); + } } /* ============================================================ */ @@ -768,12 +307,16 @@ void library_on_tap(int x, int y) if (!g_ctrl.active) return; g_drag = 0; g_dragged = 0; + press_clear(); if (g_tb) { int hit = meow_topappbar_hit(g_tb, x, y); - if (hit == MEOW_TOPBAR_BACK) { library_go_parent(); return; } - if (g_tb_home_idx >= 0 && hit == g_tb_home_idx) { library_go_tf_root(); return; } - if (hit >= 0) { meow_view_page_push(ACT_SETTINGS); return; } + if (hit == MEOW_TOPBAR_BACK) { library_go_up(); return; } + if (g_act_refresh >= 0 && hit == g_act_refresh) { refresh_current(); return; } + if (g_act_settings >= 0 && hit == g_act_settings) { + meow_view_page_push(ACT_SETTINGS); + return; + } } if (!g_lv) return; int idx = meow_listview_hit(g_lv, x, y); @@ -811,6 +354,7 @@ void library_on_touch_down(int x, int y) * 否则系统返回手势的 move 仍会驱动列表滚动。 */ g_drag = 0; g_dragged = 0; + press_clear(); /* 屏底 28px 是系统「上滑回桌面」热区:不从这里启动列表拖拽,避免拦截系统手势。 */ if (y >= meow_ui_height() - 28) return; /* 左缘 24px 是系统返回手势带(与 reader 同口径):列表不启动拖拽,把滑动留给系统返回。 */ @@ -820,6 +364,9 @@ void library_on_touch_down(int x, int y) g_drag = 1; g_dragged = 0; g_drag_last_y = y; + /* 按下高亮(与文件管理器一致;拖动超阈值即清除)。 */ + g_press_row = meow_listview_hit(g_lv, x, y); + if (g_press_row >= 0) meow_listview_set_sel(g_lv, g_press_row); } void library_on_touch_move(int x, int y) @@ -829,61 +376,24 @@ void library_on_touch_move(int x, int y) int dy = g_drag_last_y - y; if (dy == 0) return; if (dy > 1 || dy < -1) g_dragged = 1; + if (g_press_row >= 0) { + g_press_delta += dy > 0 ? dy : -dy; + if (g_press_delta > 10) press_clear(); /* 拖动:清除按下高亮 */ + } g_drag_last_y = y; if (g_scroll) meow_scroll_track(g_scroll, dy, meow_ui_time_ms()); else meow_listview_scroll_by(g_lv, dy); list_invalidate(); } -/* 16ms tick:收下后台探测/缩略图结果并局部补位;提示横幅到期自动清除。 */ +/* 16ms tick:只剩提示横幅到期清除(列表同步扫描,无后台补位)。 */ void library_on_tick(void) { if (!g_ctrl.active) return; - if (g_hint[0] && meow_ui_time_ms() >= g_hint_until) { g_hint[0] = '\0'; hint_invalidate(); } - - probe_res pres[MR_PRES_MAX]; - int pn = 0; - pthread_mutex_lock(&g_lock); - pn = g_pres_n; - for (int i = 0; i < pn; i++) pres[i] = g_pres[i]; - g_pres_n = 0; - pthread_mutex_unlock(&g_lock); - - int changed = 0; - for (int i = 0; i < pn; i++) { - probe_res *r = &pres[i]; - if (r->gen != g_gen || r->idx < 0 || r->idx >= g_lib.count) continue; - mr_library_entry *e = &g_lib.items[r->idx]; - if (strcmp(e->path, r->path) != 0) continue; - if (r->ok) { - if (mr_library_apply_probe(&g_lib, r->idx, &r->probe) && e->cover[0]) - thumb_enqueue(r->idx, e->cover); - } else { - mr_library_mark_error(&g_lib, r->idx); - } - changed = 1; - } - - thumb_res got[MR_TRES_MAX]; - int n = 0; - pthread_mutex_lock(&g_lock); - n = g_tres_n; - for (int i = 0; i < n; i++) got[i] = g_tres[i]; - g_tres_n = 0; - pthread_mutex_unlock(&g_lock); - - for (int i = 0; i < n; i++) { - if (got[i].gen == g_gen && got[i].idx >= 0 && got[i].idx < g_lib.count && - !thumb_find(got[i].idx) && thumb_put(got[i].idx, got[i].img)) - changed = 1; - else - meow_img_free(got[i].img); - } - if (changed) list_invalidate(); } /* ============================================================ */ @@ -894,16 +404,14 @@ static void library_page_create(meow_view *v, void *ctx) mr_page_ctrl *c = (mr_page_ctrl *)ctx; c->view = v; - cache_init(); - worker_start(); - int w = meow_ui_width(); int sb = meow_ui_statusbar_height(); g_tb = meow_topappbar_create(0, sb, w, MR_TOPAPPBAR_H, "图库"); if (g_tb) { - g_tb_home_idx = meow_topappbar_add_action(g_tb, "TF卡"); - int ai = meow_topappbar_add_action(g_tb, ""); - if (ai >= 0) meow_topappbar_set_item_icon(g_tb, ai, MEOW_ICON_SETTINGS); + g_act_refresh = meow_topappbar_add_action(g_tb, ""); + if (g_act_refresh >= 0) meow_topappbar_set_item_icon(g_tb, g_act_refresh, MEOW_ICON_REFRESH); + g_act_settings = meow_topappbar_add_action(g_tb, ""); + if (g_act_settings >= 0) meow_topappbar_set_item_icon(g_tb, g_act_settings, MEOW_ICON_SETTINGS); } hint_place(); @@ -936,7 +444,8 @@ static void library_page_enter(meow_view *v, void *ctx) hint_place(); g_drag = 0; g_dragged = 0; - worker_start(); /* 离开时停止(见 leave),回页恢复探测/缩略图 */ + /* 无 SD 卡空态:重进页面即重扫(用户要求的可重试路径之一)。 */ + if (g_sd_missing) load_dir(g_cur_dir); update_topbar(); mr_nav_report(); meow_ui_paint_full(); @@ -948,24 +457,19 @@ static void library_page_leave(meow_view *v, void *ctx) ((mr_page_ctrl *)ctx)->active = 0; g_drag = 0; g_dragged = 0; + press_clear(); if (g_scroll) meow_scroll_stop(g_scroll); - /* 停止后台解码:进 reader 前释放/停止潜在的大图解码,避免与 open_book 的 - * opendir/malloc 抢内存(设备上「点开父文件夹=无法打开此册」的诱因之一)。 - * worker_stop 会 join,保证 page_push 的 on_enter 打开册时线程已退出。 */ - worker_stop(); } static void library_page_destroy(meow_view *v, void *ctx) { (void)v; (void)ctx; - worker_stop(); if (g_hint_id) { meow_draw_unregister(g_hint_id); g_hint_id = 0; } if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } if (g_lv) { meow_widget_free(g_lv); g_lv = NULL; } if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } - thumbs_free(); + g_act_refresh = g_act_settings = -1; mr_library_free(&g_lib); - mr_library_probe_cache_clear(); } meow_view *library_view_create(void) @@ -1031,10 +535,29 @@ const char *library_test_hint(void) return g_hint; } -int library_test_go_root(void) +/* 顶栏返回箭头是否显示(根目录必须隐藏)。 */ +int library_test_topbar_back(void) { - library_go_tf_root(); - return strcmp(g_cur_dir, g_root_dir) == 0; + return g_tb_back; +} + +/* 当前列表空态文案(无 SD 卡/空文件夹)。 */ +const char *library_test_empty_text(void) +{ + return empty_text_for_state(); +} + +/* 1=根目录不可访问(未检测到 SD 卡的空态)。 */ +int library_test_sd_missing(void) +{ + return g_sd_missing; +} + +/* 顶栏刷新动作:重扫当前目录(无 SD 卡时就地重试)。 */ +int library_test_retry(void) +{ + refresh_current(); + return library_test_rows(); } int library_test_go_up(void) @@ -1042,36 +565,9 @@ int library_test_go_up(void) return library_go_up(); } -int library_test_go_parent(void) -{ - return library_go_parent(); -} - /* 跟手拖拽是否在本次手势中被启动(左缘/屏底热区排除的回归观测口)。 */ int library_test_drag_active(void) { return g_drag; } - -/* 后台探测/缩略图线程是否在跑(离开书库页应停止,回页恢复)。 */ -int library_test_worker_on(void) -{ - return g_worker_on; -} - -/* 缩略图 LRU 接缝(M11 P2-1 回归):指向当前缓存目录并同步跑一次 prune。 */ -const char *library_test_cache_dir(void) -{ - return g_cache_dir; -} - -int library_test_cache_on(void) -{ - return g_cache_on; -} - -void library_test_cache_prune(void) -{ - cache_prune(); -} #endif diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 442c3e6..1fba5ef 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -2040,6 +2040,7 @@ static void reader_state_reset(void) static void reader_page_enter(meow_view *v, void *ctx) { mr_page_ctrl *c = (mr_page_ctrl *)ctx; + c->view = v; c->active = 1; g_quitting = 0; @@ -2162,6 +2163,14 @@ const char *mr_reader_dir(void) void mr_reader_open_dir_at(const char *path, int start_index) { if (!path) return; + /* 已在本页(书库再次点另一张图/另一本册时可能仍是 ACT_READER): + * SDK 对同一 view 的重复 push 是 no-op(view_enter 见 ENTERED 直接返回), + * 只改 g_dir 会串到旧册状态。先真正 pop(回收当前册 + 续读落盘), + * 换目标后再 push,走完整的 leave/enter 生命周期。 */ + int reopen = (meow_ui_activity_active() == ACT_READER) && + (meow_ui_activity_depth() > 1); + if (reopen) meow_view_page_pop(); + snprintf(g_dir, sizeof g_dir, "%s", path); /* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */ g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME From 4befbfe14c6afac066c4975a6a91036f5e52c107 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 20:04:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E4=B9=A6=E5=BA=93?= =?UTF-8?q?=E7=82=B9=E5=9B=BE=E6=94=B9=E6=8C=89=E6=96=87=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=EF=BC=88=E4=BF=AE=20P1=20=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E9=94=99=E4=BD=8D=EF=BC=89+=20=E5=86=8C=E9=97=B4=E8=A1=94?= =?UTF-8?q?=E6=8E=A5=E6=A0=B9=E9=94=81=E5=AE=9A=EF=BC=88M18=20=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=20r1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1(会静默开错图):取消「书库从列表推导图片下标」。 - 新增 mr_reader_open_dir_at_name(dir, name):阅读器打开目录册后,在**自己刚枚举出的 全量目录册**(mr_dir_book,与翻页同一份枚举)里按 basename 定位起始页(先精确, 再大小写不敏感兜底;找不到退回续读/首页)。书库点击只传文件名。 - 删除 mr_library_image_index(不再有跨枚举的隐式契约)。 - 截断提示文案改为「仅显示前 2000 项(图片可在阅读器中翻到)」。 - 修复前(cb02722 探针,2100 张 4 位名夹具):rows=2000/truncated=1/ library_images=2000/reader_pages=2100/**mismatched=590**(首行 p1412 → 打开 p1411); 评审夹具为 1995/2000。修复后:2000 个可见图片行按名定位 **0 mismatch**, 2100 页全量可翻;26 次真实 UI 点开(首尾各 3 + 20 个确定性伪随机)逐张命中。 连带处理(评审 r1): 1. 删掉 reader_page_enter 的 c->view 死写(reopen 判定用 activity active/depth); 顺手删掉 library_page_create 里同款死写。 2. 阅读器册间衔接根锁定:新增 mr_path_within_root + mr_book_adjacent_rooted, book_switch 用浏览根(默认 MR_ROOT_DIR,测试可注入)钳制相邻册; 根外的兄弟册按「没有相邻册」处理。test_cont_adjacent A7 验证「明文 _ex 会越根、 rooted 拒绝、根内翻册不受影响」;test_mode_switch_host H6 验证条漫末页不越根。 3. test_reader_perf 不再依赖顶栏 tap 命中时序:模式切换改走测试钩子 mr_reader_test_toggle_mode()(与顶栏动作同一个 mode_set),负载下稳定。 4. page_cache 注释去掉「缩略图缓存/缩略图线程」过时描述(只改文字)。 回归:run_tests.sh(library 81 / nav 74 / lazy 57 / perf 25 / … / open_book 含 2100 用例) 与 run_webtoon_tests.sh(含 A7/H6)全 PASS;交叉构建零警告。 --- tuku/README.md | 4 +- tuku/logic/library.c | 41 +++++++--- tuku/logic/library.h | 17 +++-- tuku/logic/reader/page_cache.c | 2 +- tuku/logic/reader/page_cache.h | 13 ++-- tuku/test/run_tests.sh | 4 +- tuku/test/test_browse_lazy.c | 121 +++++++++++++++++++++++------- tuku/test/test_browse_perf.c | 2 - tuku/test/test_browse_ui_host.c | 28 ++++--- tuku/test/test_cont_adjacent.c | 82 +++++++++++++++++++- tuku/test/test_library.c | 41 +++++----- tuku/test/test_mode_switch_host.c | 32 +++++++- tuku/test/test_open_book_host.c | 113 ++++++++++++++++++++++++++++ tuku/test/test_reader_perf.c | 18 +++-- tuku/ui/library/library.c | 24 ++++-- tuku/ui/reader/reader.c | 80 +++++++++++++++++--- tuku/ui/reader/reader.h | 13 ++++ 17 files changed, 523 insertions(+), 112 deletions(-) diff --git a/tuku/README.md b/tuku/README.md index e3dc6a2..6cd3a95 100644 --- a/tuku/README.md +++ b/tuku/README.md @@ -11,8 +11,10 @@ - 列表 = 当前目录一层的**子目录 + 受支持图片(png/jpg/jpeg/bmp)+ 档案册(.cbz/.zip)**; 目录优先,组内自然序(大小写不敏感,同键按原始字节兜底,`A.jpg`/`a.jpg` 不抖动)。 -- 点目录 = 进入(空目录也进);点图片 = 从该图开始读(下标与阅读器目录册同一套过滤器/比较器); +- 点目录 = 进入(空目录也进);点图片 = 从该图开始读(按**文件名**交给阅读器, + 由阅读器的全量目录册定位起始页——列表被 `MR_LIB_SCAN_MAX` 截断时也不会点错图); 点档案册 = 打开档案册(第 1 页/续读)。 +- 单目录超过 2000 项时列表只显示前 2000 项并给出「已截断」提示行;图片仍可进入阅读器翻到。 - 缩略图功能已关闭:行内只有类型图标 + 文件名 + 类型·大小;首帧只做单层 `opendir/readdir + lstat`,滚动/点击路径不解码、不访问文件、不重排字符串。 - 浏览根锁定 TF 卡根(`/usr/data/mnt/sd_0`):根目录不显示返回箭头,左缘返回 = 退出 App; diff --git a/tuku/logic/library.c b/tuku/logic/library.c index be142d7..a8582fc 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -257,13 +257,16 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) { - static char trunc_sub[48]; + static char trunc_sub[96]; static int trunc_ready; if (!lib || !out) return 0; if (idx == lib->count && lib->truncated) { if (!trunc_ready) { - snprintf(trunc_sub, sizeof trunc_sub, "仅显示前 %d 项", MR_LIB_SCAN_MAX); + /* 截断只是列表显示上限:图片仍能从任一可见图片进入阅读器后翻到, + * 文案不要让人误以为「没有更多图片」。 */ + snprintf(trunc_sub, sizeof trunc_sub, + "仅显示前 %d 项(图片可在阅读器中翻到)", MR_LIB_SCAN_MAX); trunc_ready = 1; } memset(out, 0, sizeof *out); @@ -284,14 +287,15 @@ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) return 1; } -int mr_library_image_index(const mr_library *lib, int idx) +int mr_path_within_root(const char *path, const char *root) { - if (!lib || idx < 0 || idx >= lib->count) return -1; - if (lib->items[idx].kind != MR_LIB_KIND_IMAGE) return -1; - int n = 0; - for (int i = 0; i < idx; i++) - if (lib->items[i].kind == MR_LIB_KIND_IMAGE) n++; - return n; + if (!path || !path[0] || !root || !root[0]) return 0; + size_t rl = strlen(root); + while (rl > 1 && root[rl - 1] == '/') rl--; + if (rl == 1 && root[0] == '/') return path[0] == '/'; + if (strncmp(path, root, rl) != 0) return 0; + char c = path[rl]; + return c == '\0' || c == '/'; } int mr_path_parent(const char *path, char *out, int outsz) @@ -433,6 +437,25 @@ int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, return rc; } +int mr_book_adjacent_rooted(const char *dir, int delta, const char *root, + mr_lib_archive_fn archive_pages, void *ud, + char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + if (!dir || !dir[0] || !root || !root[0]) return -1; + /* 只允许在浏览根内翻册:当前册或相邻册越根一律当「没有相邻册」。 */ + if (!mr_path_within_root(dir, root)) return 0; + + int r = mr_book_adjacent_ex(dir, delta, out, outsz, archive_pages, ud); + if (r != 1) return r; + if (!mr_path_within_root(out, root)) { + out[0] = '\0'; + return 0; + } + return 1; +} + int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) { return mr_book_adjacent_ex(dir, delta, out, outsz, NULL, NULL); diff --git a/tuku/logic/library.h b/tuku/logic/library.h index a4fd7d0..b51966f 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -86,12 +86,9 @@ int mr_path_parent(const char *path, char *out, int outsz); * 返回的 title/subtitle 指向 lib 内部或函数内静态缓冲(截断提示行),调用方不要长期持有。 */ int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out); -/* 图片行在该目录图片自然序中的下标(0 基);idx 不是图片行/越界返回 -1。 - * - * ⚠️ 这是书库→阅读器的契约:阅读器的目录册(logic/reader/page_source.c)只枚举 - * 「非隐藏 + mr_path_is_image + 普通文件」并按 mr_natural_cmp 排序。书库扫描用的是 - * 同一套过滤器与比较器,因此本函数数出的下标就是 mr_reader_open_dir_at() 的起始页。 */ -int mr_library_image_index(const mr_library *lib, int idx); +/* path 是否落在浏览根内(等于 root 或以 "root/" 开头)。1=在内;0=越界/参数非法。 + * 根锁定用:书库导航与阅读器「相邻册」都不得越过 SD 卡根。 */ +int mr_path_within_root(const char *path, const char *root); /* 目录是否**直接**含受支持图片。1=有;0=没有/不可读。 * 仅用于阅读器的「相邻册」判定,不再是书库行语义。 */ @@ -111,6 +108,14 @@ typedef int (*mr_lib_archive_fn)(const char *path, void *ud); int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, mr_lib_archive_fn archive_pages, void *ud); +/* 册间衔接(根锁定版):在 mr_book_adjacent_ex 之上拒绝浏览根之外的相邻册。 + * dir 必须在 root 内;找到的相邻册也必须在 root 内,否则按「没有相邻册」返回 0。 + * 1=找到并写入 out;0=没有相邻册或越根被拒(out 为空串);-1=参数非法。 + * 阅读器用它保证「读到末页翻册」不会跳到 SD 卡根之外(例如挂载点以上的其它目录)。 */ +int mr_book_adjacent_rooted(const char *dir, int delta, const char *root, + mr_lib_archive_fn archive_pages, void *ud, + char *out, int outsz); + #ifdef MR_LIBRARY_TEST_HOOKS /* 宿主测试用 IO 计数(scan/dir_has_images 的 opendir/readdir/lstat 次数;线程安全不保证)。 */ typedef struct mr_library_io_stats { diff --git a/tuku/logic/reader/page_cache.c b/tuku/logic/reader/page_cache.c index 4cf403b..9c813ce 100644 --- a/tuku/logic/reader/page_cache.c +++ b/tuku/logic/reader/page_cache.c @@ -323,7 +323,7 @@ int mr_cache_store(const char *path, const void *data, size_t len) int i = idx_find_locked(path); if (i >= 0) idx_remove_locked(i); make_room_locked((long long)len); - pthread_mutex_unlock(&g_lock); /* 写盘不持锁:大页不阻塞缩略图线程 */ + pthread_mutex_unlock(&g_lock); /* 写盘不持锁:大页不阻塞并发读者 */ FILE *f = fopen(tmp, "wb"); int ok = f && fwrite(data, 1, len, f) == len; diff --git a/tuku/logic/reader/page_cache.h b/tuku/logic/reader/page_cache.h index e0c4bda..96e5171 100644 --- a/tuku/logic/reader/page_cache.h +++ b/tuku/logic/reader/page_cache.h @@ -5,21 +5,20 @@ * 解码回调按路径解码——两种阅读模式(翻页/条漫)与目录册走完全相同的代码路径。 * * 目录与上限(纪律): - * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picbrowser/data, - * 与缩略图缓存同目录;文件名统一前缀 mrpage_,与缩略图 *.bmp 互不影响); + * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picbrowser/data); + * 文件名统一前缀 mrpage_,与其它可能落在这目录的临时文件互不影响; * - 上限 = 64 页 或 32MiB,先到者触发 LRU 淘汰(按最近使用计数); * - **单条目溢出**:某页解压后本身就超过 32MiB 时允许一次性溢出——只保留该条、 * 其它条目全部淘汰,占用上限受页源单页上限 64MiB(MR_ZIP_PAGE_MAX_BYTES)约束。 * 这样超大页仍可阅读,不会因为预算拒绝落缓存而变成永久空白页; - * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页) - * (注意:缩略图 LRU 也必须在同一目录里跳过 mrpage_*,否则会互删); + * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页); * - 写入走 ".part" + rename,进程崩溃不会留下半个缓存文件。 * * 键 = 档案路径 + 档案 mtime/size + 条目名 + 页下标:档案被替换(mtime/size 变)或 * 同名条目重复出现时不会命中旧内容。文件名保留图片扩展名(解码入口按扩展名区分 * JPEG 的「解码期缩解」路径)。 * - * 线程:内部互斥量;可在解码线程/缩略图线程/主线程并发调用。 + * 线程:内部互斥量;可在解码线程/主线程并发调用。 * 纯 C:宿主测试可直接编译(见 tuku/test/test_cbz.c)。 */ #ifndef MR_PAGE_CACHE_H @@ -42,8 +41,8 @@ extern "C" { * 成功 0;参数非法/缓冲不足 -1。 */ int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz); -/* 文件名是否属于解压页缓存(mrpage_ 前缀)。缩略图 LRU 与启动清孤儿共用这一判据, - * 保证两套缓存同目录时互不误删(P2-1)。1=解压页缓存。 */ +/* 文件名是否属于解压页缓存(mrpage_ 前缀)。启动清孤儿用这一判据, + * 保证同目录的其它文件不被误删。1=解压页缓存。 */ int mr_cache_is_page_name(const char *name); /* 建立/切换缓存目录并清孤儿。0=可用;-1=不可用(之后 store/touch 均失败)。 diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index dbdde77..2c35f8a 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -1,8 +1,8 @@ #!/bin/bash # run_tests.sh — 宿主 gcc 构建并运行图库测试(不链 SDK 静态库、不需要设备)。 -# - test_library:单层目录扫描(目录+图片+档案册)/自然排序/图片下标/父目录/阅读方向 +# - test_library:单层目录扫描(目录+图片+档案册)/自然排序/父目录/根前缀判据/阅读方向 # - test_browse_nav:访问放开(系统/空/无图目录可进入)、图片与档案册成行、截断、不可读 -# - test_browse_lazy:行合成零 IO/副标题预生成/图片下标 == 阅读器目录册下标(集成陷阱) +# - test_browse_lazy:行合成零 IO/副标题预生成/书库行与阅读器目录册同序(按名定位契约) # - test_browse_perf:500 目录×8 层嵌套 + 2000 项大目录,断言单层扫描与行合成成本 # - test_settings_reader_persist:阅读方向 meow_config 持久化(默认/落盘/重启恢复/ # 坏值/非法值/只读一次/写失败只告警) diff --git a/tuku/test/test_browse_lazy.c b/tuku/test/test_browse_lazy.c index 731048a..3441527 100644 --- a/tuku/test/test_browse_lazy.c +++ b/tuku/test/test_browse_lazy.c @@ -1,12 +1,15 @@ -/* test_browse_lazy.c — 浏览行模型 / 零 IO 行合成 / 图片下标一致性(纯 C,无 SDK)。 +/* test_browse_lazy.c — 浏览行模型 / 零 IO 行合成 / 书库行与阅读器目录册同序(纯 C,无 SDK)。 * * 文件名沿用历史(旧的「懒判定」已随 M18 文件管理器模型删除);本测试现在覆盖: * L1 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行; - * L2 **书库→阅读器下标记号契约**:mr_library_image_index 数出的下标 == 阅读器目录册 - * (logic/reader/page_source.c: mr_dir_book_init)里同一张图的下标。 + * L2 **书库行与阅读器目录册同源**:书库图片行的名称序列 == 阅读器目录册 + * (logic/reader/page_source.c: mr_dir_book_init)的页名序列(同过滤器/同比较器)。 + * 点图现在由阅读器按文件名自己定位下标,书库不再推导下标;本用例守住「两边枚举一致」。 * 夹具含大小写混排(A.jpg/a.jpg)、数字序号(img2/img10)、大写扩展名(IMG3.JPG)、 * 前导零(page01/page1)、夹在图片之间的档案册与普通文件; - * L3 扫描期副标题:每行都有内容(不再是占位「…」,渲染期不做格式化)。 + * L3 扫描期副标题:每行都有内容(不再是占位「…」,渲染期不做格式化); + * L4 截断安全(评审 P1-1):2100 张图(默认 cap=2000)时,2000 个可见图片行按文件名 + * 都能定位到同一文件(0 mismatch),被截掉的图仍在阅读器页表里可翻到。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ @@ -137,8 +140,8 @@ static void test_rows_zero_io(const char *root) mr_library_free(&lib); } -/* ---- L2:书库图片下标 == 阅读器目录册下标 ---- */ -static void test_image_index_matches_reader(const char *dir) +/* ---- L2:书库图片行名称序列 == 阅读器目录册页名序列 ---- */ +static void test_library_rows_match_reader(const char *dir) { mr_library lib; memset(&lib, 0, sizeof lib); @@ -149,25 +152,15 @@ static void test_image_index_matches_reader(const char *dir) int pages = mr_dir_book_init(&db, dir, 720, fake_load, fake_unload, NULL); CHECK(pages > 0, "reader dir book pages=%d", pages); - /* 书库里的图片行按出现顺序,其下标必须依次是 0..n-1(无空洞/无重排)。 */ - int expect = 0; - for (int i = 0; i < lib.count; i++) { - if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; - int idx = mr_library_image_index(&lib, i); - CHECK(idx == expect, "image row %s index=%d (want %d)", - lib.items[i].name, idx, expect); - expect++; - } - CHECK(expect == pages, "library images=%d vs reader pages=%d", expect, pages); - if (expect != pages) { - mr_library_free(&lib); - mr_dir_book_free(&db); - return; - } + int images = 0; + for (int i = 0; i < lib.count; i++) + if (lib.items[i].kind == MR_LIB_KIND_IMAGE) images++; + CHECK(images == pages, "library images=%d vs reader pages=%d", images, pages); - /* 逐张对齐:书库第 k 张图 == 阅读器目录册第 k 页(下标即起始页)。 */ + /* 逐张对齐:书库第 k 张图 == 阅读器目录册第 k 页(同过滤器/同自然序)。 + * 点图时阅读器按文件名重新定位下标,所以「名字集合 + 顺序」一致就是契约。 */ int k = 0; - for (int i = 0; i < lib.count; i++) { + for (int i = 0; i < lib.count && k < pages; i++) { if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; char page_path[MR_PATH_MAX]; mr_dir_book_path(&db, k, page_path, (int)sizeof page_path); @@ -176,17 +169,90 @@ static void test_image_index_matches_reader(const char *dir) k++; } - /* 档案册/目录不占图片下标。 */ + /* 目录/档案册不是页:它们的名字不能出现在阅读器页名里。 */ for (int i = 0; i < lib.count; i++) { if (lib.items[i].kind == MR_LIB_KIND_IMAGE) continue; - CHECK(mr_library_image_index(&lib, i) == -1, - "non-image %s has no image index", lib.items[i].name); + int found = 0; + for (int j = 0; j < pages; j++) + if (db.names[j] && strcmp(db.names[j], lib.items[i].name) == 0) found = 1; + CHECK(found == 0, "non-image %s is not a reader page", lib.items[i].name); } mr_dir_book_free(&db); mr_library_free(&lib); } +/* ---- L4:2100 张图(默认 cap)——列表截断但按名定位 2000/2000 不错位 ---- */ +static void test_cap_name_lookup(const char *root) +{ + char big[MR_PATH_MAX], p[MR_PATH_MAX]; + joinp(big, sizeof big, root, "big"); + CHECK(mkdir_p(big) == 0, "mkdir big"); + for (int i = 1; i <= 2100; i++) { + snprintf(p, sizeof p, "%s/p%04d.jpg", big, i); + if (touch_file(p) != 0) { CHECK(0, "big image %s", p); return; } + } + + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan(&lib, big, MR_LIB_MAX_DEPTH); + CHECK(n == MR_LIB_SCAN_MAX, "big dir lists cap rows (%d)", n); + CHECK(lib.truncated == 1 && lib.count == MR_LIB_SCAN_MAX, "big dir truncated at cap"); + + mr_dir_book db; + memset(&db, 0, sizeof db); + CHECK(mr_dir_book_init(&db, big, 720, fake_load, fake_unload, NULL) == 2100, + "reader enumerates all 2100 pages"); + + /* 书库不再推导下标;点击传文件名。这里验证 2000 个可见图片行按名定位 + * 都能命中**同一个文件**(0 mismatch),被截掉的 100 张也仍在页表里。 */ + int checked = 0, mismatch = 0; + for (int i = 0; i < lib.count; i++) { + if (lib.items[i].kind != MR_LIB_KIND_IMAGE) continue; + checked++; + int idx = -1; + for (int j = 0; j < db.count; j++) + if (db.names[j] && strcmp(db.names[j], lib.items[i].name) == 0) { idx = j; break; } + char page[MR_PATH_MAX]; + mr_dir_book_path(&db, idx, page, (int)sizeof page); + if (idx < 0 || strcmp(page, lib.items[i].path) != 0) mismatch++; + } + CHECK(checked == MR_LIB_SCAN_MAX, "checked %d library image rows", checked); + CHECK(mismatch == 0, "name lookup mismatch=%d (want 0)", mismatch); + + /* 被截掉的图(列表外的那 100 张)在阅读器页表里仍存在(可翻到)。 + * 截断发生在 readdir 顺序(非自然序),不假定是哪 100 张,按集合差验证。 */ + int missing = 0, missing_idx = -1; + char missing_name[MR_NAME_MAX] = ""; + for (int j = 0; j < db.count; j++) { + int in_list = 0; + for (int i = 0; i < lib.count; i++) + if (strcmp(lib.items[i].name, db.names[j]) == 0) { in_list = 1; break; } + if (!in_list) { + if (missing == 0) { + missing_idx = j; + snprintf(missing_name, sizeof missing_name, "%s", db.names[j]); + } + missing++; + } + } + CHECK(missing == 2100 - MR_LIB_SCAN_MAX, "truncated-out images=%d (want 100)", missing); + char want_path[MR_PATH_MAX], page[MR_PATH_MAX]; + snprintf(want_path, sizeof want_path, "%s/%s", big, missing_name); + mr_dir_book_path(&db, missing_idx, page, (int)sizeof page); + CHECK(missing_idx >= 0 && strcmp(page, want_path) == 0, + "truncated-out %s is still reader page %d", missing_name, missing_idx); + + mr_library_row row; + CHECK(mr_library_row_at(&lib, lib.count, &row) == 1 && row.disabled == 1 && + strstr(row.subtitle, "2000") && strstr(row.subtitle, "阅读器"), + "truncation row keeps images discoverable: %s", + row.subtitle ? row.subtitle : ""); + + mr_dir_book_free(&db); + mr_library_free(&lib); +} + int main(void) { char tmpl[] = "./mr_rows_XXXXXX"; @@ -219,7 +285,8 @@ int main(void) test_rows_zero_io(root); joinp(p, sizeof p, root, "mixed"); - test_image_index_matches_reader(p); + test_library_rows_match_reader(p); + test_cap_name_lookup(root); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); diff --git a/tuku/test/test_browse_perf.c b/tuku/test/test_browse_perf.c index 6c52e6b..25236a3 100644 --- a/tuku/test/test_browse_perf.c +++ b/tuku/test/test_browse_perf.c @@ -165,8 +165,6 @@ int main(void) CHECK(strcmp(biglib.items[PERF_BIG - 1].name, "img1999.png") == 0, "last=%s", biglib.items[PERF_BIG - 1].name); CHECK(biglib.items[PERF_BIG - 1].kind == MR_LIB_KIND_IMAGE, "last is image"); - CHECK(mr_library_image_index(&biglib, PERF_BIG - 1) == PERF_BIG - 1, - "image index at end"); } printf(" big dir scan: %lldus, %d rows, opendir=%ld lstat=%ld\n", big_us, bn, st.opendirs, st.lstats); diff --git a/tuku/test/test_browse_ui_host.c b/tuku/test/test_browse_ui_host.c index 57f64e4..cf56ba6 100644 --- a/tuku/test/test_browse_ui_host.c +++ b/tuku/test/test_browse_ui_host.c @@ -5,7 +5,8 @@ * 类型·大小(不再有占位/后台补位),图标/右侧箭头按类型区分; * U2 行合成零 IO:反复取可见行不触发 opendir/readdir/lstat(渲染期无文件访问); * U3 点击语义:点目录=进入(空目录也进);点图片=从该图开始读 - * (mr_reader_open_dir_at(当前目录, 图片下标));点档案册=打开档案册; + * (mr_reader_open_dir_at_name(当前目录, 文件名);阅读器自己定位下标); + * 点档案册=打开档案册; * U4 根目录锁定:根不显示顶栏返回、library_go_up 在根返回 0(app.c 退出); * 子目录返回=回根;不存在越过浏览根的导航; * U5 无 SD 卡容错:浏览根不可访问(不存在/不是目录)→ 友好空态「未检测到 SD 卡」, @@ -47,18 +48,28 @@ int library_test_go_up(void); int library_test_drag_active(void); int library_test_has_list(void); -/* 阅读器入口打桩:只记录被打开的册路径与起始页(本测试不建 reader 页)。 */ +/* 阅读器入口打桩:记录被打开的册路径 / 起始页 / 按名定位的文件名(本测试不建 reader 页)。 */ #define MR_READER_START_RESUME (-1) void mr_reader_open_dir_at(const char *dir, int start_index); +void mr_reader_open_dir_at_name(const char *dir, const char *name); static char g_opened[MR_PATH_MAX]; +static char g_opened_name[MR_NAME_MAX]; static int g_opened_start; static int g_opened_n; void mr_reader_open_dir_at(const char *dir, int start_index) { snprintf(g_opened, sizeof g_opened, "%s", dir ? dir : ""); + g_opened_name[0] = '\0'; g_opened_start = start_index; g_opened_n++; } +void mr_reader_open_dir_at_name(const char *dir, const char *name) +{ + snprintf(g_opened, sizeof g_opened, "%s", dir ? dir : ""); + snprintf(g_opened_name, sizeof g_opened_name, "%s", name ? name : ""); + g_opened_start = MR_READER_START_RESUME; + g_opened_n++; +} static int g_fail; #define CHECK(cond, ...) do { \ @@ -279,22 +290,17 @@ int main(void) CHECK(library_test_load(pics_dir) == 1, "entered Pictures"); CHECK(strcmp(library_test_dir(), pics_dir) == 0, "current dir=%s", library_test_dir()); - /* 图片下标 = 该目录里排在该图之前的图片行数(阅读器目录册同序)。 */ + /* 点图片:把**文件名**交给阅读器(阅读器用自己的全量目录册定位起始页; + * 书库列表可能被 MR_LIB_SCAN_MAX 截断,所以不再传下标)。 */ const char *target = "img10.png"; int ti = row_index_for(target); CHECK(ti >= 0, "target image row present"); - int expect_idx = 0; - for (int i = 0; i < ti; i++) { - meow_list_item tmp; - if (!library_test_src(i, &tmp)) continue; - if (tmp.subtitle && strncmp(tmp.subtitle, "图片 · ", strlen("图片 · ")) == 0) - expect_idx++; - } g_opened_n = 0; tap_row(target); CHECK(g_opened_n == 1 && strcmp(g_opened, pics_dir) == 0, "tap image opens reader for current dir (%s)", g_opened); - CHECK(g_opened_start == expect_idx, "start index=%d (want %d)", g_opened_start, expect_idx); + CHECK(strcmp(g_opened_name, target) == 0, "reader gets file name (%s)", g_opened_name); + CHECK(g_opened_start == MR_READER_START_RESUME, "name-based open does not pass an index"); CHECK(strcmp(library_test_dir(), pics_dir) == 0, "tap image does not navigate"); g_opened_n = 0; diff --git a/tuku/test/test_cont_adjacent.c b/tuku/test/test_cont_adjacent.c index 170c7d5..cf2392a 100644 --- a/tuku/test/test_cont_adjacent.c +++ b/tuku/test/test_cont_adjacent.c @@ -7,7 +7,9 @@ * A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册; * A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录; * A6 书库行模型(M18):目录+图片+档案册同列、目录优先;副标题扫描期生成, - * 不再有「· 续读 Pn」角标(角标随「目录自动当书」链路删除,续读仍由 reader 恢复)。 + * 不再有「· 续读 Pn」角标(角标随「目录自动当书」链路删除,续读仍由 reader 恢复); + * A7 册间衔接根锁定(M18 评审 P2-1):相邻册越过浏览根时按「没有相邻册」处理, + * 根内正常翻册不受影响(mr_book_adjacent_rooted)。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. test/test_cont_adjacent.c logic/library.c logic/reader.c \ @@ -160,7 +162,7 @@ static void test_adjacent(const char *root) } /* A6 文件管理器行模型:扫描即出最终行(无占位/无后台补位/无续读角标); - * 目录优先;当前层的图片行带图片下标(点图 → reader 起始页)。 */ + * 目录优先;图片/档案册按类型成行。 */ static void test_library_rows(const char *root) { mr_test_cfg_clear(); @@ -191,7 +193,6 @@ static void test_library_rows(const char *root) if (strcmp(e->name, "loose.png") == 0) { seen_img = 1; CHECK(e->kind == MR_LIB_KIND_IMAGE, "loose.png is an image row"); - CHECK(mr_library_image_index(&lib, i) == 0, "loose.png is image #0"); } } CHECK(seen1 == 1, "vol1 listed"); @@ -199,6 +200,80 @@ static void test_library_rows(const char *root) mr_library_free(&lib); } +/* A7 册间衔接的根锁定:相邻册必须在浏览根内,否则按「没有相邻册」处理。 + * 夹具:base/{aaa_outside, root}/{...};root 自己也是「册」(直接含图), + * 明文 _ex 会给出 root 的兄弟 aaa_outside,rooted 必须拒绝(不越 SD 卡根)。 */ +static void test_rooted_adjacent(void) +{ + char tmpl[] = "./mr_rooted_XXXXXX"; + char *base = mkdtemp(tmpl); + CHECK(base != NULL, "mkdtemp rooted fixture"); + if (!base) return; + + char root[MR_PATH_MAX], outside[MR_PATH_MAX], in1[MR_PATH_MAX], in2[MR_PATH_MAX]; + joinp(root, sizeof root, base, "root"); + joinp(outside, sizeof outside, base, "aaa_outside"); + joinp(in1, sizeof in1, root, "aaa_inside1"); + joinp(in2, sizeof in2, root, "aaa_inside2"); + CHECK(mkdir_p(outside) == 0 && mkdir_p(in1) == 0 && mkdir_p(in2) == 0, + "mkdir rooted fixture"); + + char p[MR_PATH_MAX]; + joinp(p, sizeof p, root, "000.png"); CHECK(touch_file(p) == 0, "root image"); + joinp(p, sizeof p, outside, "001.png"); CHECK(touch_file(p) == 0, "outside image"); + joinp(p, sizeof p, in1, "001.png"); CHECK(touch_file(p) == 0, "inside1 image"); + joinp(p, sizeof p, in2, "001.png"); CHECK(touch_file(p) == 0, "inside2 image"); + + char out[MR_PATH_MAX]; + + /* 明文衔接:root 的兄弟 aaa_outside 在浏览根之外,_ex 会返回它。 */ + memset(out, 0, sizeof out); + CHECK(mr_book_adjacent_ex(root, -1, out, (int)sizeof out, NULL, NULL) == 1 && + strcmp(out, outside) == 0, + "plain adjacency reaches outside root (%s)", out); + + /* 根锁定版:同样的调用必须拒绝越根。 */ + memset(out, 0x7f, sizeof out); + out[0] = '\0'; + CHECK(mr_book_adjacent_rooted(root, -1, root, NULL, NULL, + out, (int)sizeof out) == 0 && out[0] == '\0', + "rooted adjacency refuses book outside root (%s)", out); + CHECK(mr_book_adjacent_rooted(root, 1, root, NULL, NULL, + out, (int)sizeof out) == 0 && out[0] == '\0', + "rooted adjacency: no next inside root"); + + /* 根内正常翻册仍然可用(不能被误锁死)。 */ + memset(out, 0, sizeof out); + CHECK(mr_book_adjacent_rooted(in1, 1, root, NULL, NULL, + out, (int)sizeof out) == 1 && + strcmp(out, in2) == 0, "rooted next inside root (%s)", out); + CHECK(mr_book_adjacent_rooted(in2, -1, root, NULL, NULL, + out, (int)sizeof out) == 1 && + strcmp(out, in1) == 0, "rooted prev inside root (%s)", out); + CHECK(mr_book_adjacent_rooted(in1, -1, root, NULL, NULL, + out, (int)sizeof out) == 0, + "rooted: no book before inside1 (outside is out of root)"); + CHECK(mr_book_adjacent_rooted(in2, 1, root, NULL, NULL, + out, (int)sizeof out) == 0, + "rooted: no book after inside2"); + + /* 当前册本身在根外 → 视为没有相邻册。 */ + CHECK(mr_book_adjacent_rooted(outside, 1, root, NULL, NULL, + out, (int)sizeof out) == 0, "current dir outside root"); + + /* 参数边界。 */ + CHECK(mr_book_adjacent_rooted(root, 1, NULL, NULL, NULL, + out, (int)sizeof out) == -1, "rooted NULL root"); + CHECK(mr_book_adjacent_rooted(NULL, 1, root, NULL, NULL, + out, (int)sizeof out) == -1, "rooted NULL dir"); + CHECK(mr_book_adjacent_rooted(root, 1, root, NULL, NULL, NULL, 32) == -1, + "rooted NULL out"); + CHECK(mr_book_adjacent_rooted(root, 1, "", NULL, NULL, + out, (int)sizeof out) == -1, "rooted empty root"); + + rm_rf(base); +} + int main(void) { char tmpl[] = "./mr_cont_adj_XXXXXX"; @@ -240,6 +315,7 @@ int main(void) test_has_images(root); test_adjacent(root); test_library_rows(root); + test_rooted_adjacent(); rm_rf(root); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); diff --git a/tuku/test/test_library.c b/tuku/test/test_library.c index 1fcb9a3..bf04cc9 100644 --- a/tuku/test/test_library.c +++ b/tuku/test/test_library.c @@ -5,7 +5,7 @@ * T2 扩展名过滤:图片 png/jpg/jpeg/bmp 与档案册 cbz/zip 大小写不敏感;gif/txt 不支持; * T3 单层扫描:目录 + 受支持图片 + 档案册混排(目录优先、组内自然序); * 其它普通文件/隐藏项/符号链接不列;副标题扫描期生成一次(无占位/无懒判定); - * 图片行下标(mr_library_image_index)与行类型;不可读路径 -> -1;max_depth 忽略; + * 行类型;不可读路径 -> -1;max_depth 忽略;浏览根前缀判据 mr_path_within_root; * T4 阅读方向状态(logic/reader.c)。 * * 编译(在 tuku/ 下): @@ -80,13 +80,6 @@ static const mr_library_entry *find_entry(const mr_library *lib, const char *nam return NULL; } -static int find_index(const mr_library *lib, const char *name) -{ - for (int i = 0; i < lib->count; i++) - if (strcmp(lib->items[i].name, name) == 0) return i; - return -1; -} - static void test_natural(void) { CHECK(mr_natural_cmp("page2", "page10") < 0, "page2 < page10"); @@ -214,17 +207,27 @@ static void test_scan(void) CHECK(find_entry(&lib, "a.png") == NULL, "no recursion into subdirs"); CHECK(find_entry(&lib, "Deeper") == NULL, "no recursion into subdirs (deeper)"); - /* 图片下标 = 本层图片自然序(书库点图 → 阅读器起始页的契约)。 */ - int i_cover = find_index(&lib, "cover.JPG"); - int i_root = find_index(&lib, "root.png"); - CHECK(mr_library_image_index(&lib, i_cover) == 0, "cover.JPG is image #0"); - CHECK(mr_library_image_index(&lib, i_root) == 1, "root.png is image #1"); - CHECK(mr_library_image_index(&lib, find_index(&lib, "book.cbz")) == -1, - "archive has no image index"); - CHECK(mr_library_image_index(&lib, find_index(&lib, "Empty")) == -1, - "dir has no image index"); - CHECK(mr_library_image_index(&lib, -1) == -1 && mr_library_image_index(&lib, 99) == -1, - "out-of-range image index"); + /* 浏览根前缀判据(书库导航/阅读器册间衔接的根锁定;纯路径工具)。 */ + CHECK(mr_path_within_root("/usr/data/mnt/sd_0/a/b", "/usr/data/mnt/sd_0") == 1, + "nested path inside root"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0", "/usr/data/mnt/sd_0") == 1, + "root itself is inside"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0/", "/usr/data/mnt/sd_0") == 1, + "root with trailing slash is inside"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0evil", "/usr/data/mnt/sd_0") == 0, + "prefix-lookalike rejected"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_00", "/usr/data/mnt/sd_0") == 0, + "prefix-lookalike with digits rejected"); + CHECK(mr_path_within_root("/usr/data/mnt", "/usr/data/mnt/sd_0") == 0, + "parent of root rejected"); + CHECK(mr_path_within_root("/usr/data/mnt/sd_0x/a", "/usr/data/mnt/sd_0") == 0, + "sibling-prefixed path rejected"); + CHECK(mr_path_within_root("/a/b", "/a/") == 1, "root trailing slash tolerated"); + CHECK(mr_path_within_root("/a", "/") == 1, "filesystem root accepts abs path"); + CHECK(mr_path_within_root("/", "/") == 1, "filesystem root itself"); + CHECK(mr_path_within_root("rel/path", "/") == 0, "relative path not inside /"); + CHECK(mr_path_within_root(NULL, "/") == 0 && mr_path_within_root("/a", NULL) == 0 && + mr_path_within_root("/a", "") == 0, "NULL/empty safe"); /* 行合成:纯内存,返回扫描期生成的字符串。 */ mr_library_row row; diff --git a/tuku/test/test_mode_switch_host.c b/tuku/test/test_mode_switch_host.c index 1a7d9d8..a77aa1d 100644 --- a/tuku/test/test_mode_switch_host.c +++ b/tuku/test/test_mode_switch_host.c @@ -8,7 +8,8 @@ * H3 跳页滑块:展开 → 拖到末页 → 目标页解码完成后落位(不是先白屏再跳); * H4 离开/重进:恢复上次页 + 模式(续读); * H5 册间衔接:条漫末页上拉 → 下一册首页;首页下拉 → 上一册末页;首册再无上一册时 - * 停在原地(不退出、不崩)。 + * 停在原地(不退出、不崩); + * H6 册间衔接根锁定:浏览根内没有相邻册时,不跨到根外的兄弟册(M18 评审观察项)。 * * 编译:test/run_webtoon_tests.sh(需 SDK 源码树;只发布 meow_ui.a 时自动 SKIP)。 */ @@ -164,6 +165,10 @@ int main(void) meow_view *reader = reader_view_create(); meow_view_page_push(ACT_LIBRARY); + /* 阅读器册间衔接的浏览根:测试夹具根(生产默认 MR_ROOT_DIR)。 */ + mr_reader_test_set_root(root); + CHECK(strcmp(mr_reader_test_root(), root) == 0, "reader browse root installed"); + /* TopAppBar 动作格(位图):右起每格 48px;动作 0=模式 / 1=跳页 / 2=fit。 */ int w = meow_ui_width(); int mode_x = w - 10 - 48 * 3 + 24; @@ -258,6 +263,31 @@ int main(void) CHECK(strcmp(mr_reader_dir(), book1) == 0, "no previous book: stay put"); CHECK(meow_ui_activity_active() == ACT_READER, "no previous book: still in reader (no exit)"); + /* ---- H6 册间衔接根锁定:浏览根=book2 时,兄弟册 book1 在根外,不得翻过去 ---- */ + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library for root-lock case"); + mr_reader_test_set_root(book2); + CHECK(strcmp(mr_reader_test_root(), book2) == 0, "reader root narrowed to book2"); + mr_reader_open_dir_at(book2, 0); /* 显式首页,避开续读页差异 */ + CHECK(meow_ui_activity_active() == ACT_READER, "reopened book2"); + CHECK(strcmp(mr_reader_dir(), book2) == 0, "at book2"); + if (mr_reader_ui_mode() != MR_MODE_WEBTOON) mr_reader_test_toggle_mode(); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon for root-lock case"); + CHECK(mr_reader_ui_page() == 0, "root-lock case at first page (got %d)", + mr_reader_ui_page()); + + /* 首页下拉 → book1 是 book2 的上一册,但在浏览根外:必须原地不动。 */ + reader_on_touch_down(240, 300); + for (int i = 1; i <= 6; i++) { + reader_on_touch_move(240, 300 + i * 40); + usleep(5 * 1000); + } + CHECK(swipe(MEOW_SWIPE_DOWN, 240) == 1, "root-lock down-swipe consumed"); + CHECK(strcmp(mr_reader_dir(), book2) == 0, + "adjacent book outside browse root is refused (dir=%s)", mr_reader_dir()); + CHECK(meow_ui_activity_active() == ACT_READER, "root-lock: still in reader"); + mr_reader_test_set_root(root); /* 复原夹具根 */ + /* ---- 收尾 ---- */ meow_view_page_pop(); CHECK(meow_ui_activity_active() == ACT_LIBRARY, "popped back to library"); diff --git a/tuku/test/test_open_book_host.c b/tuku/test/test_open_book_host.c index dc3a503..f9f82fa 100644 --- a/tuku/test/test_open_book_host.c +++ b/tuku/test/test_open_book_host.c @@ -14,6 +14,7 @@ */ #include "tuku_internal.h" #include "ui/reader/reader.h" +#include "logic/reader/page_source.h" #include #include @@ -28,12 +29,14 @@ void library_test_set_root(const char *root); const char *library_test_dir(void); int library_test_rows(void); int library_test_src(int idx, meow_list_item *item); +int library_test_open_row(int idx); /* ui/reader/reader.c 的测试接缝(-DMR_READER_HOST_TEST) */ int mr_reader_ui_mode(void); int mr_reader_ui_page(void); int mr_reader_ui_count(void); int mr_reader_ui_open_err(void); +void mr_reader_open_dir_at_name(const char *dir, const char *name); static int g_fail; #define CHECK(cond, ...) do { \ @@ -152,6 +155,22 @@ static int expected_image_index(const char *name) return n; } +/* 阅读器目录册用的假解码回调(本测试不经过它们取页)。 */ +static meow_img *cap_fake_load(const char *path, void *ud, int maxdim) +{ + (void)path; (void)ud; (void)maxdim; + return NULL; +} +static void cap_fake_unload(meow_img *img) { (void)img; } + +/* 在阅读器的全量目录册里按文件名取页下标(与阅读器打开时同一份枚举)。 */ +static int reader_page_of_name(const mr_dir_book *db, const char *name) +{ + for (int i = 0; i < db->count; i++) + if (db->names[i] && strcmp(db->names[i], name) == 0) return i; + return -1; +} + int main(void) { const char *base = getenv("TMPDIR"); @@ -184,6 +203,16 @@ int main(void) if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: image write failed\n"); return 0; } } + /* Big:2100 张图(超过默认 MR_LIB_SCAN_MAX=2000,复现评审 P1-1 截断错位场景)。 + * 1x1 BMP(stb 可解)以控制夹具体积/建盘时间。 */ + char big[MR_PATH_MAX]; + snprintf(big, sizeof big, "%s/Big", root); + if (mkdir_p(big) != 0) { printf("SKIP: mkdir Big failed\n"); return 0; } + for (int i = 1; i <= 2100; i++) { + snprintf(p, sizeof p, "%s/p%04d.bmp", big, i); + if (write_bmp24(p, 1, 1) != 0) { printf("SKIP: big image write failed\n"); return 0; } + } + meow_ui_opts o = { 480, 720 }; if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } @@ -238,6 +267,90 @@ int main(void) mr_reader_ui_page(), want2); meow_view_page_pop(); + /* ---- P1-1 回归:单目录 2100 张图(超过默认 cap=2000)不得「点 A 开 B」 ---- */ + CHECK(library_test_load(root) == 1, "load root for cap regression"); + tap_row("Big"); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "tap Big dir stays in library"); + CHECK(strcmp(library_test_dir(), big) == 0, "entered Big (%s)", library_test_dir()); + CHECK(library_test_rows() == MR_LIB_SCAN_MAX + 1, + "Big rows=%d (cap + truncation row)", library_test_rows()); + + /* 截断提示行仍在,且文案不让人误以为「没有更多图」。 */ + meow_list_item trunc_it; + CHECK(library_test_src(library_test_rows() - 1, &trunc_it) == 1 && + trunc_it.enabled == 0 && trunc_it.title && strcmp(trunc_it.title, "已截断") == 0, + "truncation row present"); + CHECK(trunc_it.subtitle && strstr(trunc_it.subtitle, "2000") && + strstr(trunc_it.subtitle, "阅读器"), + "truncation text keeps images discoverable: %s", + trunc_it.subtitle ? trunc_it.subtitle : ""); + + /* 阅读器目录册是**全量** 2100 页(书库 cap 只是列表显示上限)。 */ + mr_dir_book db; + memset(&db, 0, sizeof db); + CHECK(mr_dir_book_init(&db, big, 720, cap_fake_load, cap_fake_unload, NULL) == 2100, + "reader sees all 2100 pages"); + + /* 首 3 + 尾 3 + 20 个确定性伪随机行,逐一点开断言「点哪张开哪张」。 */ + int picks[26], np = 0; + picks[np++] = 0; + picks[np++] = 1; + picks[np++] = 2; + picks[np++] = MR_LIB_SCAN_MAX - 3; + picks[np++] = MR_LIB_SCAN_MAX - 2; + picks[np++] = MR_LIB_SCAN_MAX - 1; + unsigned seed = 12345u; + for (int i = 0; i < 20; i++) { + seed = seed * 1103515245u + 12345u; + picks[np++] = (int)((seed >> 16) % MR_LIB_SCAN_MAX); + } + + int mismatched = 0; + for (int i = 0; i < np; i++) { + int row = picks[i]; + meow_list_item it; + memset(&it, 0, sizeof it); + CHECK(library_test_src(row, &it) == 1 && it.title, "pick %d: row source", row); + if (!it.title) continue; + int want_page = reader_page_of_name(&db, it.title); + CHECK(want_page >= 0, "pick %d: %s is a reader page", row, it.title); + CHECK(library_test_open_row(row) == 1, "open row %d", row); + CHECK(meow_ui_activity_active() == ACT_READER, "pick %d opens reader", row); + CHECK(strcmp(mr_reader_dir(), big) == 0, "pick %d reader dir", row); + CHECK(mr_reader_ui_count() == 2100, "pick %d reader pages=%d", row, + mr_reader_ui_count()); + if (mr_reader_ui_page() != want_page) mismatched++; + CHECK(mr_reader_ui_page() == want_page, + "pick %d: clicked %s -> page %d (want %d)", row, it.title, + mr_reader_ui_page(), want_page); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "pick %d back to library", row); + } + CHECK(mismatched == 0, "no click/page mismatch across %d picks", np); + + /* 被书库截掉的图(第 2001 张起)在阅读器里仍可打开/翻到。 */ + CHECK(reader_page_of_name(&db, "p2099.bmp") == 2098, "p2099 lives at page 2098"); + mr_reader_open_dir_at_name(big, "p2099.bmp"); + CHECK(meow_ui_activity_active() == ACT_READER, "truncated-out image opens reader"); + CHECK(mr_reader_ui_count() == 2100, "truncated-out image: all pages present"); + CHECK(mr_reader_ui_page() == 2098, "truncated-out image opens at its page (%d)", + mr_reader_ui_page()); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library after truncated image"); + CHECK(library_test_rows() == MR_LIB_SCAN_MAX + 1, "library list unchanged after reads"); + mr_dir_book_free(&db); + + /* 名字找不到(文件已改名/删除):退回续读/首页,不崩、不串册。 */ + mr_reader_open_dir_at_name(pics, "does-not-exist.bmp"); + CHECK(meow_ui_activity_active() == ACT_READER, "unknown name still opens reader"); + CHECK(strcmp(mr_reader_dir(), pics) == 0, "unknown name keeps target dir"); + CHECK(mr_reader_ui_count() == 6 && mr_reader_ui_open_err() == 0, + "unknown name falls back safely (pages=%d err=%d)", + mr_reader_ui_count(), mr_reader_ui_open_err()); + CHECK(mr_reader_ui_page() >= 0 && mr_reader_ui_page() < 6, + "unknown name fallback page in range (%d)", mr_reader_ui_page()); + meow_view_page_pop(); + /* 档案册路径(无 zip 夹具时只验证入口不被当成目录):点 txt 不存在行;跳过。 */ meow_view_free(reader); diff --git a/tuku/test/test_reader_perf.c b/tuku/test/test_reader_perf.c index 866481a..5a84afc 100644 --- a/tuku/test/test_reader_perf.c +++ b/tuku/test/test_reader_perf.c @@ -146,6 +146,14 @@ static void double_tap(int x, int y) pump_ms(120); } +/* 直接切模式(绕过顶栏 tap 的命中时序):本文件只关心绘制/解码,不验证 chrome 命中; + * 顶栏动作本身的回归在 test_mode_switch_host 覆盖。M18 评审:负载下该用例曾偶发。 */ +static void mode_toggle(void) +{ + mr_reader_test_toggle_mode(); + pump_ms(100); +} + static void draw_once(void) { meow_ui_paint_full(); } int main(void) @@ -239,7 +247,7 @@ int main(void) "paged drag: zero resample (scaled %d -> %d)", st, mr_reader_ui_scaled_draws()); /* ---- P4 条漫模式:切换后滚动,长宽比 1.6 页也必须零重采样 ---- */ - tap(mode_x, bar_y); + mode_toggle(); CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "switched to webtoon"); pump_ms(800); draw_once(); @@ -260,14 +268,14 @@ int main(void) mr_reader_ui_scaled_draws()); /* ---- P5 「先适高、再切条漫」:strip 恒按屏宽排版,解码必须跟随 ---- */ - tap(mode_x, bar_y); /* 回翻页 */ + mode_toggle(); /* 回翻页 */ CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "back to paged"); pump_ms(500); tap(fit_x, bar_y); /* 适宽 → 适高 */ CHECK(mr_reader_ui_fit() == MR_FIT_HEIGHT, "fit-height selected (got %d)", mr_reader_ui_fit()); pump_ms(700); - tap(mode_x, bar_y); /* 适高档 → 条漫 */ + mode_toggle(); /* 适高档 → 条漫 */ CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon after fit-height"); pump_ms(900); draw_once(); @@ -290,7 +298,7 @@ int main(void) mr_reader_ui_scaled_draws()); /* ---- P6 双击放大再缩回(评审 P2-1:缩回后必须按新几何重解)---- */ - tap(mode_x, bar_y); /* 回翻页 */ + mode_toggle(); /* 回翻页 */ CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "paged for zoom test"); pump_ms(600); draw_once(); @@ -338,7 +346,7 @@ int main(void) CHECK(mr_reader_ui_blit_draws() > bt && mr_reader_ui_scaled_draws() == st, "800x1280 paged drag: zero resample (blit +%d, scaled +%d)", mr_reader_ui_blit_draws() - bt, mr_reader_ui_scaled_draws() - st); - tap(mode_x, bar_y); /* 条漫 */ + mode_toggle(); /* 条漫 */ CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "800x1280 webtoon"); pump_ms(800); draw_once(); diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index f7f7322..41bac1e 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -4,7 +4,8 @@ * * 浏览语义(M18,用户要求「完全像文件管理器」): * - 列表 = 当前目录一层的「子目录 + 受支持图片 + .cbz/.zip 档案册」,目录优先、组内自然序; - * 点目录=进入(空目录也进),点图片=从该图开始读(mr_reader_open_dir_at(目录, 图片下标)), + * 点目录=进入(空目录也进),点图片=从该图开始读(mr_reader_open_dir_at_name(目录, 文件名), + * 由阅读器在全量目录册里定位起始页;列表被截断也不会点错图), * 点档案册=打开档案册(第 1 页/续读); * - 首帧只做**单层**枚举(一次 opendir/readdir+lstat):没有「目录含图=一本书」的探测、 * 页数、封面、角标,也没有懒算线程/缩略图解码——行副标题在扫描期生成一次; @@ -268,8 +269,7 @@ int library_go_up(void) char parent[MR_PATH_MAX]; if (mr_path_parent(g_cur_dir, parent, (int)sizeof parent) != 0) return 0; /* 防御:父目录若越出浏览根(理论上不会发生),钳回根。 */ - size_t rl = strlen(g_root_dir); - if (strlen(parent) < rl || strncmp(parent, g_root_dir, rl) != 0) + if (!mr_path_within_root(parent, g_root_dir)) snprintf(parent, sizeof parent, "%s", g_root_dir); if (!load_dir(parent)) { /* 父目录不可读 → 提示并停留(不越根、不丢当前列表)。 */ @@ -289,9 +289,9 @@ static void library_open_entry(int idx) return; } if (e->kind == MR_LIB_KIND_IMAGE) { - /* 下标由 logic 用与阅读器目录册相同的过滤器/比较器数出,保证「点 A 开 A」。 */ - int start = mr_library_image_index(&g_lib, idx); - mr_reader_open_dir_at(g_cur_dir, start < 0 ? 0 : start); + /* 按文件名定位起始页:由阅读器用自己的全量目录册算下标。 + * 书库列表可能被 MR_LIB_SCAN_MAX 截断,绝不能从列表推导下标(会点 A 开 B)。 */ + mr_reader_open_dir_at_name(g_cur_dir, e->name); return; } if (e->kind == MR_LIB_KIND_ARCHIVE) { @@ -401,8 +401,8 @@ void library_on_tick(void) /* ============================================================ */ static void library_page_create(meow_view *v, void *ctx) { - mr_page_ctrl *c = (mr_page_ctrl *)ctx; - c->view = v; + (void)v; + (void)ctx; int w = meow_ui_width(); int sb = meow_ui_statusbar_height(); @@ -525,6 +525,14 @@ int library_test_src(int idx, meow_list_item *item) return lib_src(idx, item, NULL); } +/* 直接打开第 idx 行(等价点击行体;测试里避免滚动/坐标干扰)。 */ +int library_test_open_row(int idx) +{ + if (idx < 0 || idx >= g_lib.count) return 0; + library_open_entry(idx); + return 1; +} + int library_test_hint_active(void) { return g_hint[0] != '\0'; diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 1fba5ef..96bb0d8 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -21,7 +21,7 @@ * * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page * 经 meow_config 持久化(键 reader.mode/page.<册标识>,见 logic/reader.c),下次打开同一册 - * 自动恢复。书库行角标由 logic/library.c 扫描时读取同一份状态(副标题后缀「续读 Pn」)。 + * 自动恢复。书库列表是文件管理器模型(M18),不再显示续读角标。 * * 跳页:chrome 内 meow_slider 选目标页,松手后先把目标页解码进缓存再落位(不白屏不闪烁; * 2.5s 超时则降级落位并提示)。 @@ -86,6 +86,11 @@ static mr_page_ctrl g_ctrl; static char g_dir[MR_PATH_MAX]; static int g_start_at = MR_READER_START_RESUME; /* 下次 on_enter 的显式起始页(-1=续读) */ +/* 下次 on_enter 的「按文件名定位起始页」(书库点图;只对目录册生效,open_book 消费后清空)。 + * 用文件名而不是下标:书库列表可能被 MR_LIB_SCAN_MAX 截断,下标不可信。 */ +static char g_start_name[MR_NAME_MAX]; +/* 浏览根(生产=MR_ROOT_DIR,宿主测试可改):册间衔接不得越过它。 */ +static char g_root[MR_PATH_MAX] = MR_ROOT_DIR; static mr_book_handle g_handle; /* 目录册或档案册(M11 统一开册) */ static mr_book g_book; @@ -1433,10 +1438,28 @@ static void open_book_reset_state(void) g_meta = NULL; } -/* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。 */ +/* 在目录册里按文件名定位页下标:先精确,再大小写不敏感(书库展示名与磁盘名一致, + * 兜底只为容错)。-1=没找到。 */ +static int dir_book_find_index(const mr_dir_book *db, const char *name) +{ + if (!db || !name || !name[0] || db->count <= 0) return -1; + for (int i = 0; i < db->count; i++) + if (db->names[i] && strcmp(db->names[i], name) == 0) return i; + for (int i = 0; i < db->count; i++) + if (db->names[i] && strcasecmp(db->names[i], name) == 0) return i; + return -1; +} + +/* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。 + * 若 g_start_name 非空(书库点图),优先用**刚枚举出来的目录册**按 basename 定位起始页: + * 枚举源与翻页完全一致,且不受书库 MR_LIB_SCAN_MAX 截断影响。 */ static int open_book(const char *dir, int start_page, int mode) { if (!dir || !dir[0]) return 0; + char want_name[MR_NAME_MAX]; + snprintf(want_name, sizeof want_name, "%s", g_start_name); + g_start_name[0] = '\0'; /* 只在本次打开生效 */ + open_book_reset_state(); /* reader_page_enter 传入的 dir 就是 g_dir:自拷贝是 snprintf 的未定义行为, * 其它调用(册间衔接)才需要复制。 */ @@ -1472,6 +1495,12 @@ static int open_book(const char *dir, int start_page, int mode) meow_log(MEOW_LOG_WARN, "reader meta cache unavailable (%d pages)", g_count); int page = start_page < 0 ? g_count - 1 : start_page; + /* 书库点图:按文件名在刚打开的目录册里定位(与翻页同一份枚举/比较器)。 + * 找不到(文件已改名/删除)才退回 start_page/续读。 */ + if (want_name[0] && g_handle.kind == MR_BOOK_KIND_DIR) { + int idx = dir_book_find_index(&g_handle.u.dir, want_name); + if (idx >= 0) page = idx; + } page = mr_nav_clamp_page(page, g_count); if (page < 0) page = 0; @@ -1525,8 +1554,9 @@ static int book_switch(int delta) if (delta == 0) return 0; if (g_count <= 0) return 0; char next[MR_PATH_MAX]; - int r = mr_book_adjacent_ex(g_dir, delta > 0 ? 1 : -1, next, (int)sizeof next, - cbz_pages_probe, NULL); + /* 根锁定:相邻册必须仍在浏览根内,绝不跳到 SD 卡挂载点以上。 */ + int r = mr_book_adjacent_rooted(g_dir, delta > 0 ? 1 : -1, g_root, + cbz_pages_probe, NULL, next, (int)sizeof next); if (r != 1) { toast(delta > 0 ? "已是最后一册" : "已是第一册"); return 0; @@ -2040,7 +2070,6 @@ static void reader_state_reset(void) static void reader_page_enter(meow_view *v, void *ctx) { mr_page_ctrl *c = (mr_page_ctrl *)ctx; - c->view = v; c->active = 1; g_quitting = 0; @@ -2120,6 +2149,7 @@ static void reader_page_leave(meow_view *v, void *ctx) free(g_meta); g_meta = NULL; g_start_at = MR_READER_START_RESUME; + g_start_name[0] = '\0'; /* 未消费的文件名定位不跨页残留 */ mr_book_close_auto(&g_book, &g_handle); if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } @@ -2160,7 +2190,9 @@ const char *mr_reader_dir(void) return g_dir; } -void mr_reader_open_dir_at(const char *path, int start_index) +/* 打开入口共用部分:已在本页时先 pop(完整 leave/enter 生命周期),再设目标并 push。 + * start_name 非空 = 按文件名定位(只对目录册),start_index 忽略。 */ +static void reader_open_common(const char *path, int start_index, const char *start_name) { if (!path) return; /* 已在本页(书库再次点另一张图/另一本册时可能仍是 ACT_READER): @@ -2172,13 +2204,31 @@ void mr_reader_open_dir_at(const char *path, int start_index) if (reopen) meow_view_page_pop(); snprintf(g_dir, sizeof g_dir, "%s", path); - /* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */ - g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME - : (start_index < 0 ? 0 : start_index); - meow_log(MEOW_LOG_INFO, "reader open: %s (start=%d)", g_dir, g_start_at); + g_start_name[0] = '\0'; + if (start_name && start_name[0]) { + /* 按文件名定位:由 open_book 在刚枚举出的目录册里找下标(不受列表截断影响)。 */ + snprintf(g_start_name, sizeof g_start_name, "%s", start_name); + g_start_at = MR_READER_START_RESUME; + meow_log(MEOW_LOG_INFO, "reader open: %s (name=%s)", g_dir, g_start_name); + } else { + /* -1=续读(旧入口语义);其它负数钳到 0;上界在 open_book 里按页数钳。 */ + g_start_at = start_index == MR_READER_START_RESUME ? MR_READER_START_RESUME + : (start_index < 0 ? 0 : start_index); + meow_log(MEOW_LOG_INFO, "reader open: %s (start=%d)", g_dir, g_start_at); + } meow_view_page_push(ACT_READER); } +void mr_reader_open_dir_at(const char *path, int start_index) +{ + reader_open_common(path, start_index, NULL); +} + +void mr_reader_open_dir_at_name(const char *dir, const char *name) +{ + reader_open_common(dir, MR_READER_START_RESUME, name); +} + void mr_reader_open_dir(const char *path) { mr_reader_open_dir_at(path, MR_READER_START_RESUME); @@ -2205,4 +2255,14 @@ int mr_reader_ui_scaled_draws(void) { return g_scaled_draws; } int mr_reader_ui_decode_count(void) { return g_decode_count; } int mr_reader_ui_fit(void) { return g_fit; } int mr_reader_ui_chrome(void) { return g_chrome; } +/* 直接切模式(避开顶栏 tap 命中时序):与顶栏动作走同一个 mode_set。 */ +void mr_reader_test_toggle_mode(void) +{ + mode_set(g_mode == MR_MODE_PAGED ? MR_MODE_WEBTOON : MR_MODE_PAGED, -1); +} +void mr_reader_test_set_root(const char *root) +{ + if (root && root[0]) snprintf(g_root, sizeof g_root, "%s", root); +} +const char *mr_reader_test_root(void) { return g_root; } #endif diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index f841316..e7562a0 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -19,6 +19,14 @@ void reader_on_touch_move(int x, int y); * 显式下标优先于续读记录:用户在列表里点了某张图,就不该被上次的页码带走。 */ #define MR_READER_START_RESUME (-1) void mr_reader_open_dir_at(const char *dir, int start_index); + +/* 书库 → 阅读器(按文件名定位起始页;M18 评审 P1-1 的首选入口): + * 打开 dir 后,由阅读器自己的目录册枚举(logic/reader/page_source.c: mr_dir_book_init, + * 与翻页用的是同一份全量枚举)按 basename 找起始页(先大小写敏感,再大小写不敏感兜底)。 + * 找不到则退回续读记录/首页。这样书库不需要(也不能)从可能被 MR_LIB_SCAN_MAX 截断的 + * 列表里推导下标——目录条目超过 2000 时也不会「点 A 开 B」。name 为 NULL/空则等价续读。 */ +void mr_reader_open_dir_at_name(const char *dir, const char *name); + /* 保留旧入口:等价于 mr_reader_open_dir_at(dir, MR_READER_START_RESUME)。 */ void mr_reader_open_dir(const char *dir); @@ -36,6 +44,11 @@ int mr_reader_ui_scaled_draws(void);/* 累计重采样绘页次数(每帧重 int mr_reader_ui_decode_count(void);/* 累计解码次数(查重解抖动/活锁) */ int mr_reader_ui_fit(void); /* 当前 fit 档(MR_FIT_*) */ int mr_reader_ui_chrome(void); /* 1=顶栏可见 */ +/* 直接切模式(绕过顶栏 tap 的命中时序;测试用,产品路径仍走顶栏动作)。 */ +void mr_reader_test_toggle_mode(void); +/* 浏览根(生产=MR_ROOT_DIR):册间衔接不得越过它。测试可指向合成夹具根。 */ +void mr_reader_test_set_root(const char *root); +const char *mr_reader_test_root(void); #endif #ifdef __cplusplus