图库: 书库改文件管理器模型 + 关闭缩略图 + 根锁定/无卡容错(M18)

用户反馈四件事一起改:
- 「目录里有一张图就被当成最终目录」→ 取消「目录含图=一本书」全部链路
  (探测/页数/封面/续读角标/懒算 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;交叉构建零警告。
This commit is contained in:
AskaEth committed 2026-11-05 20:26:40 +08:00
1 parent d07b99056f
commit 64336f95d9
16 files changed
+940 -1927

No files matched your search

+84 -27
View File
@@ -1,11 +1,11 @@
/* test_library.c — 图库 logic 层宿主测试(纯 C,无 SDK/硬件)。
*
* 覆盖:
* T1 自然排序:数字段数值序(page2<page10)、前导零、数字/字母混合、大小写;
* T2 扩展名过滤:png/jpg/jpeg/bmp 大小写不敏感,gif/txt 不支持;
* T3 目录扫描(M13 单层语义):只列当前层直接子目录(自然升序),不递归探测子树;
* 系统目录/空目录/无图目录都列出;隐藏项/普通文件/符号链接不列;
* max_depth 兼容参数被忽略;行初始为「待探测」占位;父目录路径推导;
* 覆盖(M18 文件管理器模型):
* T1 自然排序:数字段数值序(page2<page10)、前导零、数字/字母混合、大小写确定性;
* T2 扩展名过滤:图片 png/jpg/jpeg/bmp 与档案册 cbz/zip 大小写不敏感;gif/txt 不支持;
* T3 单层扫描:目录 + 受支持图片 + 档案册混排(目录优先、组内自然序);
* 其它普通文件/隐藏项/符号链接不列;副标题扫描期生成一次(无占位/无懒判定);
* 图片行下标(mr_library_image_index)与行类型;不可读路径 -> -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);
}