/* test_library.c — 图库 logic 层宿主测试(纯 C,无 SDK/硬件)。 * * 覆盖(M18 文件管理器模型): * T1 自然排序:数字段数值序(page2 -1;max_depth 忽略;浏览根前缀判据 mr_path_within_root; * T4 阅读方向状态(logic/reader.c); * T5 目录内续读入口的页码判定(mr_cont_resume_page):无记录/空目录 -> 无入口, * 页数未知(列表截断)不夹持,保存页超页数钳到末页,记录按目录隔离。 * * 编译(在 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 "test/settings_reader_stub.h" #include #include #include #include #include #include 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); } /* 目录内「续读」入口的页码判定(M33)。 */ static void test_cont_resume(void) { const char *dir = "/sdcard/album"; const char *other = "/sdcard/other"; mr_test_cfg_clear(); CHECK(mr_cont_resume_page(dir, 5) == -1, "no record -> no resume entry"); mr_cont_set_page(dir, 3); CHECK(mr_cont_resume_page(dir, 5) == 3, "page in range kept"); CHECK(mr_cont_resume_page(dir, 4) == 3, "last valid page kept"); CHECK(mr_cont_resume_page(dir, 0) == -1, "empty dir -> no resume entry"); CHECK(mr_cont_resume_page(dir, -1) == 3, "unknown page count (truncated list) -> unclamped"); CHECK(mr_cont_resume_page(dir, 3) == 2, "page == pages -> clamp to last"); mr_cont_set_page(dir, 99); CHECK(mr_cont_resume_page(dir, 3) == 2, "page beyond pages -> clamp to last"); CHECK(mr_cont_resume_page(dir, 0) == -1, "stale record in empty dir -> no entry"); CHECK(mr_cont_resume_page(other, 9) == -1, "resume records are per directory (no leak)"); CHECK(mr_cont_resume_page(NULL, 9) == -1, "NULL dir -> no entry"); /* 第一页也是有效记录(用户就停在第一页退出):入口要显示。 */ mr_cont_set_page(dir, 0); CHECK(mr_cont_resume_page(dir, 1) == 0, "first page record is a valid entry"); mr_test_cfg_clear(); } int main(void) { test_natural(); test_ext(); test_parent(); test_scan(); test_direction(); test_cont_resume(); printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); return g_fail ? 1 : 0; }