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;交叉构建零警告。
285 lines
12 KiB
C
285 lines
12 KiB
C
/* test_library.c — 图库 logic 层宿主测试(纯 C,无 SDK/硬件)。
|
||
*
|
||
* 覆盖(M18 文件管理器模型):
|
||
* T1 自然排序:数字段数值序(page2<page10)、前导零、数字/字母混合、大小写确定性;
|
||
* T2 扩展名过滤:图片 png/jpg/jpeg/bmp 与档案册 cbz/zip 大小写不敏感;gif/txt 不支持;
|
||
* T3 单层扫描:目录 + 受支持图片 + 档案册混排(目录优先、组内自然序);
|
||
* 其它普通文件/隐藏项/符号链接不列;副标题扫描期生成一次(无占位/无懒判定);
|
||
* 行类型;不可读路径 -> -1;max_depth 忽略;浏览根前缀判据 mr_path_within_root;
|
||
* T4 阅读方向状态(logic/reader.c)。
|
||
*
|
||
* 编译(在 tuku/ 下):
|
||
* gcc -Wall -Wextra -O1 -I. -I../sdk test/test_library.c logic/library.c logic/reader.c \
|
||
* test/settings_reader_stub.c -pthread -o build/host/test_library
|
||
* 或直接 test/run_tests.sh。
|
||
*/
|
||
#include "logic/library.h"
|
||
#include "logic/reader.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)) return unlink(path);
|
||
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 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;
|
||
}
|
||
|
||
static void test_natural(void)
|
||
{
|
||
CHECK(mr_natural_cmp("page2", "page10") < 0, "page2 < page10");
|
||
CHECK(mr_natural_cmp("page10", "page2") > 0, "page10 > page2");
|
||
CHECK(mr_natural_cmp("page2", "page02") < 0, "fewer leading zeros first");
|
||
CHECK(mr_natural_cmp("x001", "x1") > 0, "x1 < x001");
|
||
CHECK(mr_natural_cmp("Chapter 9", "Chapter 10") < 0, "chapter 9 < 10");
|
||
CHECK(mr_natural_cmp("img2a", "img10b") < 0, "mixed img2a < img10b");
|
||
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");
|
||
}
|
||
|
||
static void test_ext(void)
|
||
{
|
||
CHECK(mr_path_is_image("a.png") == 1, "png");
|
||
CHECK(mr_path_is_image("a.JPG") == 1, "JPG uppercase");
|
||
CHECK(mr_path_is_image("a.jpeg") == 1, "jpeg");
|
||
CHECK(mr_path_is_image("a.BmP") == 1, "BMP mixed case");
|
||
CHECK(mr_path_is_image("a.gif") == 0, "gif unsupported");
|
||
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)
|
||
{
|
||
char out[MR_PATH_MAX];
|
||
CHECK(mr_path_parent("/usr/data/mnt/sd_0/a/b", out, sizeof out) == 0 &&
|
||
strcmp(out, "/usr/data/mnt/sd_0/a") == 0, "parent nested");
|
||
CHECK(mr_path_parent("/usr/data/mnt/sd_0/a/", out, sizeof out) == 0 &&
|
||
strcmp(out, "/usr/data/mnt/sd_0") == 0, "parent trailing slash");
|
||
CHECK(mr_path_parent("/a", out, sizeof out) == 0 && strcmp(out, "/") == 0, "parent root child");
|
||
CHECK(mr_path_parent("/", out, sizeof out) == 0 && strcmp(out, "/") == 0, "parent root");
|
||
}
|
||
|
||
static void test_scan(void)
|
||
{
|
||
char tmpl[] = "./mr_test_XXXXXX";
|
||
char *root = mkdtemp(tmpl);
|
||
CHECK(root != NULL, "mkdtemp");
|
||
if (!root) return;
|
||
|
||
char p[MR_PATH_MAX];
|
||
|
||
snprintf(p, sizeof p, "%s/Series 10", root); mkdir_p(p);
|
||
snprintf(p, sizeof p, "%s/Series 10/page1.png", root); touch_file(p);
|
||
snprintf(p, sizeof p, "%s/Series 10/page2.jpg", root); touch_file(p);
|
||
|
||
snprintf(p, sizeof p, "%s/Series 2", root); mkdir_p(p);
|
||
snprintf(p, sizeof p, "%s/Series 2/page10.png", root); touch_file(p);
|
||
|
||
snprintf(p, sizeof p, "%s/Series 02", root); mkdir_p(p);
|
||
snprintf(p, sizeof p, "%s/Series 02/page1.bmp", root); touch_file(p);
|
||
|
||
snprintf(p, sizeof p, "%s/Series 3", root); mkdir_p(p);
|
||
snprintf(p, sizeof p, "%s/Series 3/page1.gif", root); touch_file(p);
|
||
|
||
snprintf(p, sizeof p, "%s/Empty", root); mkdir_p(p);
|
||
|
||
snprintf(p, sizeof p, "%s/Nested/Deeper", root); mkdir_p(p);
|
||
snprintf(p, sizeof p, "%s/Nested/Deeper/a.png", root); touch_file(p);
|
||
|
||
snprintf(p, sizeof p, "%s/.hidden", root); mkdir_p(p);
|
||
snprintf(p, sizeof p, "%s/.hidden/h.png", root); touch_file(p);
|
||
|
||
snprintf(p, sizeof p, "%s/MeowAPPs", root); mkdir_p(p);
|
||
snprintf(p, sizeof p, "%s/MeowAPPs/m.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 == 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 == 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 < 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);
|
||
}
|
||
|
||
/* 行类型与副标题(扫描期生成一次,不是占位)。 */
|
||
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, ".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)");
|
||
|
||
/* 浏览根前缀判据(书库导航/阅读器册间衔接的根锁定;纯路径工具)。 */
|
||
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;
|
||
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) == 11, "max_depth ignored (single layer)");
|
||
mr_library_free(&lib1);
|
||
|
||
mr_library_free(&lib);
|
||
|
||
/* 不可读目录 / 不是目录:返回 -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);
|
||
}
|
||
|
||
static void test_direction(void)
|
||
{
|
||
mr_reader_set_direction(MR_DIR_RTL);
|
||
CHECK(mr_reader_get_direction() == MR_DIR_RTL, "default RTL");
|
||
mr_reader_set_direction(MR_DIR_LTR);
|
||
CHECK(mr_reader_get_direction() == MR_DIR_LTR, "switch LTR");
|
||
mr_reader_set_direction(99);
|
||
CHECK(mr_reader_get_direction() == MR_DIR_LTR, "invalid ignored");
|
||
mr_reader_set_direction(MR_DIR_RTL);
|
||
}
|
||
|
||
int main(void)
|
||
{
|
||
test_natural();
|
||
test_ext();
|
||
test_parent();
|
||
test_scan();
|
||
test_direction();
|
||
printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail);
|
||
return g_fail ? 1 : 0;
|
||
}
|