Files
MeowOS-SDK/tuku/test/test_library.c
T
AskaEth 4befbfe14c 图库: 书库点图改按文件名定位(修 P1 截断错位)+ 册间衔接根锁定(M18 评审 r1)
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;交叉构建零警告。
2026-11-05 20:26:40 +08:00

285 lines
12 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
}