用户反馈四件事一起改: - 「目录里有一张图就被当成最终目录」→ 取消「目录含图=一本书」全部链路 (探测/页数/封面/续读角标/懒算 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;交叉构建零警告。
275 lines
9.4 KiB
C
275 lines
9.4 KiB
C
/* test_browse_nav.c — 浏览模型 / 进入目录 / 截断(纯 C,无 SDK)。
|
||
*
|
||
* 覆盖(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 -pthread -o build/host/test_browse_nav
|
||
*/
|
||
#include "logic/library.h"
|
||
|
||
#include <dirent.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
#include <unistd.h>
|
||
|
||
static int g_pass, g_fail;
|
||
#define CHECK(cond, ...) do { \
|
||
if (cond) { g_pass++; } \
|
||
else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \
|
||
} while (0)
|
||
|
||
static int mkdir_p(const char *path)
|
||
{
|
||
char tmp[MR_PATH_MAX];
|
||
snprintf(tmp, sizeof tmp, "%s", path);
|
||
for (size_t i = 1; i <= strlen(tmp); i++) {
|
||
if (tmp[i] == '/' || tmp[i] == '\0') {
|
||
char ch = tmp[i];
|
||
tmp[i] = '\0';
|
||
if (mkdir(tmp, 0755) != 0) {
|
||
struct stat st;
|
||
if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1;
|
||
}
|
||
tmp[i] = ch;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static int touch_file(const char *path)
|
||
{
|
||
FILE *f = fopen(path, "wb");
|
||
if (!f) return -1;
|
||
fputs("x", f);
|
||
return fclose(f);
|
||
}
|
||
|
||
static int rm_rf(const char *path)
|
||
{
|
||
struct stat st;
|
||
if (lstat(path, &st) != 0) return -1;
|
||
if (!S_ISDIR(st.st_mode)) { chmod(path, 0700); return unlink(path); }
|
||
chmod(path, 0700);
|
||
DIR *d = opendir(path);
|
||
if (!d) return -1;
|
||
struct dirent *de;
|
||
while ((de = readdir(d)) != NULL) {
|
||
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue;
|
||
char full[MR_PATH_MAX];
|
||
snprintf(full, sizeof full, "%s/%s", path, de->d_name);
|
||
rm_rf(full);
|
||
}
|
||
closedir(d);
|
||
return rmdir(path);
|
||
}
|
||
|
||
static void joinp(char *out, int outsz, const char *a, const char *b)
|
||
{
|
||
snprintf(out, (size_t)outsz, "%s/%s", a, b);
|
||
}
|
||
|
||
static const mr_library_entry *find_entry(const mr_library *lib, const char *name)
|
||
{
|
||
for (int i = 0; i < lib->count; i++)
|
||
if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i];
|
||
return NULL;
|
||
}
|
||
|
||
/* ---- 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 == 9, "root lists 9 rows (got %d)", n);
|
||
CHECK(lib.truncated == 0, "root not truncated");
|
||
|
||
static const char *want[9] = {
|
||
"book", "empty", "lost+found", "MeowAPPs", "MeowData", "MeowOS", "noimg",
|
||
"album.cbz", "loose.png"
|
||
};
|
||
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);
|
||
|
||
for (int i = 0; i < 7; i++)
|
||
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, "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)。 */
|
||
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,
|
||
"enter %s", lib.items[i].name);
|
||
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) == 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");
|
||
mr_library empty;
|
||
memset(&empty, 0, sizeof empty);
|
||
CHECK(mr_library_scan(&empty, p, MR_LIB_MAX_DEPTH) == 0, "empty dir enterable (0 items)");
|
||
mr_library_free(&empty);
|
||
|
||
mr_library_free(&lib);
|
||
}
|
||
|
||
/* ---- 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/m18", MR_LIB_MAX_DEPTH) == -1, "missing -> -1");
|
||
CHECK(lib.count == 0 && lib.truncated == 0, "failed scan leaves empty lib");
|
||
mr_library_free(&lib);
|
||
|
||
joinp(p, sizeof p, root, "loose.png");
|
||
memset(&lib, 0, sizeof lib);
|
||
CHECK(mr_library_scan(&lib, p, MR_LIB_MAX_DEPTH) == -1, "file (not dir) -> -1");
|
||
mr_library_free(&lib);
|
||
|
||
if (geteuid() != 0) { /* root 下 EACCES 不生效,跳过权限用例 */
|
||
joinp(p, sizeof p, root, "noread");
|
||
CHECK(mkdir_p(p) == 0, "mkdir noread");
|
||
CHECK(chmod(p, 0000) == 0, "chmod 000");
|
||
memset(&lib, 0, sizeof lib);
|
||
CHECK(mr_library_scan(&lib, p, MR_LIB_MAX_DEPTH) == -1, "unreadable -> -1");
|
||
CHECK(lib.count == 0, "unreadable lib empty");
|
||
mr_library_free(&lib);
|
||
chmod(p, 0700);
|
||
}
|
||
}
|
||
|
||
/* ---- N3 ---- */
|
||
static void test_parent_path(void)
|
||
{
|
||
char cur[MR_PATH_MAX];
|
||
snprintf(cur, sizeof cur, "/usr/data/mnt/sd_0/a/b/c");
|
||
int steps = 0;
|
||
while (strcmp(cur, "/") != 0 && steps < 32) {
|
||
char up[MR_PATH_MAX];
|
||
int r = mr_path_parent(cur, up, (int)sizeof up);
|
||
CHECK(r == 0, "parent of %s", cur);
|
||
if (r != 0) break;
|
||
CHECK(strcmp(up, cur) != 0, "parent strictly shorter at %s", cur);
|
||
memcpy(cur, up, sizeof cur);
|
||
steps++;
|
||
}
|
||
CHECK(strcmp(cur, "/") == 0, "reached filesystem root ('%s')", cur);
|
||
CHECK(steps == 7, "steps to / = %d (want 7)", steps);
|
||
|
||
char up[MR_PATH_MAX];
|
||
CHECK(mr_path_parent("/", up, (int)sizeof up) == 0 && strcmp(up, "/") == 0,
|
||
"root parent stays /");
|
||
CHECK(mr_path_parent("/usr/data/mnt/sd_0", up, (int)sizeof up) == 0 &&
|
||
strcmp(up, "/usr/data/mnt") == 0,
|
||
"path helper is root-agnostic: %s", up);
|
||
}
|
||
|
||
/* ---- N4 ---- */
|
||
static void test_truncation(void)
|
||
{
|
||
CHECK(MR_LIB_SCAN_MAX == 2000, "default cap is 2000 (got %d)", MR_LIB_SCAN_MAX);
|
||
|
||
char tmpl[] = "./mr_nav_XXXXXX";
|
||
char *root = mkdtemp(tmpl);
|
||
CHECK(root != NULL, "mkdtemp");
|
||
if (!root) return;
|
||
|
||
int total = MR_LIB_SCAN_MAX + 8;
|
||
char p[MR_PATH_MAX];
|
||
for (int i = 0; i < total; i++) {
|
||
snprintf(p, sizeof p, "%s/d%04d", root, i);
|
||
if (mkdir(p, 0755) != 0) { CHECK(0, "mkdir %s", p); break; }
|
||
}
|
||
|
||
mr_library lib;
|
||
memset(&lib, 0, sizeof lib);
|
||
int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH);
|
||
CHECK(n == MR_LIB_SCAN_MAX, "cap applied: n=%d", n);
|
||
CHECK(lib.truncated == 1, "truncated flag set");
|
||
CHECK(lib.count == MR_LIB_SCAN_MAX, "count == cap");
|
||
|
||
mr_library_row row;
|
||
CHECK(mr_library_row_at(&lib, lib.count, &row) == 1 && row.disabled == 1,
|
||
"truncation row present");
|
||
CHECK(strcmp(row.title, "已截断") == 0, "truncation row title=%s", row.title);
|
||
|
||
mr_library_free(&lib);
|
||
rm_rf(root);
|
||
}
|
||
|
||
int main(void)
|
||
{
|
||
char tmpl[] = "./mr_nav_XXXXXX";
|
||
char *root = mkdtemp(tmpl);
|
||
CHECK(root != NULL, "mkdtemp");
|
||
if (!root) return 1;
|
||
|
||
char p[MR_PATH_MAX];
|
||
const char *dirs[] = { "MeowAPPs", "MeowData", "MeowOS/sub", "lost+found",
|
||
"book", "empty", "noimg", ".hidden" };
|
||
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, "MeowOS/001.png");
|
||
touch_file(p);
|
||
joinp(p, sizeof p, root, "book/001.png");
|
||
touch_file(p);
|
||
joinp(p, sizeof p, root, "noimg/readme.txt");
|
||
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");
|
||
joinp(link, sizeof link, root, "linkdir");
|
||
symlink(target, link);
|
||
|
||
test_open_access(root);
|
||
test_unreadable(root);
|
||
test_parent_path();
|
||
|
||
rm_rf(root);
|
||
test_truncation();
|
||
|
||
printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail);
|
||
return g_fail ? 1 : 0;
|
||
}
|