/* test/shelf_group_smoke.c — M27/M33 书架目录模型宿主单测(纯 C,gcc 直接跑,不依赖设备/SD 卡)。 * * 覆盖: * G1 扫描:格式识别 / 标题去扩展名 / 目录归属(group=完整相对路径,top=第一级目录) * G2 format 模式 = 改动前行为(格式段头 TXT/FB2/EPUB/MOBI/CBZ,最近阅读优先、其次书名) * G3(M33 改写)folder 模式 = 逐级进入:根层只列子目录(排序/计数)+ 根下书; * 进 400+ → 01、玄幻 → 01、斗破 才能看到书(不再把深层书平铺在顶级) * G4 空目录不产生导航行;depth 3/4 收录,depth > SHELF_SCAN_MAX_DEPTH 计数且不出书 * G5.1(M33 改写)a/b 与 a/c 合并为目录 a(2),逐级进入后可见书 1/2; * 组件边界(a 不含 ab/c);G5.2 字面名「未分类」的目录与根层书是两个独立行 * G6 UTF-8 安全截断(中文目录名不切出半个字符) * G7 超上限不静默:truncated=1(含「cap 卡在目录边界」与「恰好装满不误报」两种); * 行数上限(段头 ≤ 书数 ⇒ 2N 必够) * G8 进度不受分组影响:path 不变、rdprog_load 前后同值、ts 两种模式一致 * G9 副题格式化的唯一实现 shelf_fmt_sub:扫描与「刚读完置顶」共用 * (ts 变化后重算 → 「· 读」立即出现;folder 子路径前缀随 group 重建) * G10(M33)导航纯函数:虚拟目录(磁盘不存在)也能出行序列(不 readdir); * 进出/面包屑/目录名/rowcap/UTF-8 截断 */ #include "shelf_model.h" #include "progress.h" #include "reader_conf.h" #include #include #include #include #include static int g_pass = 0, g_fail = 0; #define CHECK(cond, msg) do { \ if (cond) g_pass++; \ else { g_fail++; printf("FAIL %s:%d %s\n", __FILE__, __LINE__, msg); } \ } while (0) /* progress.c 的目录来源:测试里指向临时目录(见 main)。 */ static char g_cfgdir[400]; const char *meow_config_dir(void) { return g_cfgdir; } static void mkdirs(const char *path) { char t[400]; snprintf(t, sizeof t, "%s", path); for (char *p = t + 1; *p; p++) { if (*p == '/') { *p = 0; mkdir(t, 0755); *p = '/'; } } mkdir(t, 0755); } static void write_file(const char *path, const char *body) { FILE *f = fopen(path, "wb"); if (!f) { printf("无法创建 %s\n", path); exit(2); } fputs(body, f); fclose(f); } /* 按文件名给阅读时间:1.txt=100, 2.epub=50, 其它=0(验证 ts 排序 + 组内排序)。 */ static uint64_t ts_of(const char *path) { const char *b = strrchr(path, '/'); b = b ? b + 1 : path; if (strcmp(b, "1.txt") == 0) return 100; if (strcmp(b, "2.epub") == 0) return 50; return 0; } /* 把行序列打成一串便于整体比对:S:<段头>|D:<目录>(<书数>)|B:<书名>(<目录>)| */ static void dump_rows(const shelf_row *rows, int nrows, const shelf_book *books, char *out, int outsz) { int off = 0; out[0] = 0; for (int i = 0; i < nrows && off < outsz; i++) { if (rows[i].dir) { off += snprintf(out + off, (size_t)(outsz - off), "D:%s(%d)|", rows[i].name, rows[i].count); } else if (rows[i].section) { const char *label = rows[i].label ? rows[i].label : shelf_fmt_name(rows[i].fmt); off += snprintf(out + off, (size_t)(outsz - off), "S:%s|", label); } else { const shelf_book *b = &books[rows[i].book]; off += snprintf(out + off, (size_t)(outsz - off), "B:%s(%s)|", b->title, b->group); } } } static const shelf_book *find_book(const shelf_book *books, int n, const char *title) { for (int i = 0; i < n; i++) if (strcmp(books[i].title, title) == 0) return &books[i]; return NULL; } static int utf8_complete(const char *s) { const unsigned char *p = (const unsigned char *)s; while (*p) { int n; if (*p < 0x80) n = 1; else if ((*p & 0xE0) == 0xC0) n = 2; else if ((*p & 0xF0) == 0xE0) n = 3; else if ((*p & 0xF8) == 0xF0) n = 4; else return 0; for (int i = 1; i < n; i++) if (!p[i] || (p[i] & 0xC0) != 0x80) return 0; p += n; } return 1; } static void test_scan_and_modes(const char *root, const char *tmproot) { /* ---- 造树(贴合真机真实布局)---- * Books/轻小说/1.txt (ts100)、Books/轻小说/2.epub (ts50)、 * Books/kh/3.txt、Books/根下.txt(根)、Books/B/sub/5.mobi(depth2)、 * Books/D/E/F/6.txt(depth3)、Books/F2/a/b/c/9.txt(depth4)、 * Books/400+本高质量完本合集/01、玄幻/01、斗破/《斗破》.txt(depth3 真实三级布局)、 * Books/C/(空)、Books/轻小说/notes.md(不支持)、 * Books/G/H/I/J/K/L/M/{7,8}.txt(depth7,超上限 → 只计数)。 */ char p[400]; snprintf(p, sizeof p, "%s/轻小说", root); mkdirs(p); snprintf(p, sizeof p, "%s/kh", root); mkdirs(p); snprintf(p, sizeof p, "%s/B/sub", root); mkdirs(p); snprintf(p, sizeof p, "%s/C", root); mkdirs(p); snprintf(p, sizeof p, "%s/D/E/F", root); mkdirs(p); snprintf(p, sizeof p, "%s/F2/a/b/c", root); mkdirs(p); snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破", root); mkdirs(p); snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M", root); mkdirs(p); snprintf(p, sizeof p, "%s/轻小说/1.txt", root); write_file(p, "one"); snprintf(p, sizeof p, "%s/轻小说/2.epub", root); write_file(p, "two"); snprintf(p, sizeof p, "%s/kh/3.txt", root); write_file(p, "three"); snprintf(p, sizeof p, "%s/根下.txt", root); write_file(p, "root"); snprintf(p, sizeof p, "%s/B/sub/5.mobi", root); write_file(p, "five"); snprintf(p, sizeof p, "%s/D/E/F/6.txt", root); write_file(p, "six"); snprintf(p, sizeof p, "%s/F2/a/b/c/9.txt", root); write_file(p, "nine"); snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破/《斗破》.txt", root); write_file(p, "doupo"); snprintf(p, sizeof p, "%s/轻小说/notes.md", root); write_file(p, "ignored"); snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M/7.txt", root); write_file(p, "too-deep"); snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M/8.txt", root); write_file(p, "too-deep2"); shelf_book books[64]; shelf_scan_report rep; int n = shelf_scan_books(root, books, 64, ts_of, &rep); CHECK(n == 8, "扫描到 8 本(.md 忽略;depth3/4 收录;depth7 不收录)"); CHECK(rep.truncated == 0, "未触发上限"); CHECK(rep.depth_skipped == 2, "depth7 的 2 个文件计入 depth_skipped(不静默)"); CHECK(rep.root_missing == 0, "root 存在"); /* G1 目录归属(group=完整相对路径;top=第一级分组键)与 path */ const shelf_book *b1 = find_book(books, n, "1"); const shelf_book *b2 = find_book(books, n, "2"); const shelf_book *b3 = find_book(books, n, "3"); const shelf_book *b4 = find_book(books, n, "根下"); const shelf_book *b5 = find_book(books, n, "5"); const shelf_book *b6 = find_book(books, n, "6"); const shelf_book *b9 = find_book(books, n, "9"); const shelf_book *bdp = find_book(books, n, "《斗破》"); CHECK(b1 && strcmp(b1->group, "轻小说") == 0 && strcmp(b1->top, "轻小说") == 0, "轻小说/1.txt → group/top=轻小说"); CHECK(b2 && strcmp(b2->top, "轻小说") == 0, "轻小说/2.epub → top=轻小说"); CHECK(b3 && strcmp(b3->top, "kh") == 0, "kh/3.txt → top=kh(四种情况之三)"); CHECK(b4 && b4->group[0] == 0 && b4->top[0] == 0, "Books/根下.txt → group/top 空"); CHECK(b5 && strcmp(b5->group, "B/sub") == 0 && strcmp(b5->top, "B") == 0, "B/sub/5.mobi → group=B/sub、top=B"); CHECK(b6 && strcmp(b6->top, "D") == 0, "depth3 的 D/E/F/6.txt 被扫到"); CHECK(b9 && strcmp(b9->top, "F2") == 0, "depth4 的 F2/a/b/c/9.txt 被扫到"); CHECK(bdp && strcmp(bdp->top, "400+本高质量完本合集") == 0, "真实三级布局 → top=400+本高质量完本合集(四种情况之二)"); CHECK(bdp && strcmp(bdp->group, "400+本高质量完本合集/01、玄幻/01、斗破") == 0, "完整相对路径保留在 group[]"); CHECK(find_book(books, n, "7") == NULL && find_book(books, n, "8") == NULL, "depth7 的文件不产生书"); if (b1) { char want[400]; snprintf(want, sizeof want, "%s/轻小说/1.txt", root); CHECK(strcmp(b1->path, want) == 0, "path 仍是完整绝对路径"); } /* 副题:format 模式必须与 base 逐字一致;子路径只在 folder 副题里出现 */ CHECK(bdp && strcmp(bdp->sub, "TXT · 5B") == 0 && strstr(bdp->sub, "/") == NULL, "format 副题与 base 逐字一致(不含子路径)"); CHECK(bdp && strstr(bdp->sub_folder, "01、玄幻/01、斗破") != NULL, "folder 副题含 01、玄幻/01、斗破"); CHECK(b5 && strcmp(b5->sub, "MOBI · 4B") == 0, "MOBI 副题与 base 逐字一致"); CHECK(b5 && strstr(b5->sub_folder, "sub") != NULL, "folder 副题含 sub"); CHECK(b3 && strcmp(b3->sub, "TXT · 5B") == 0 && strcmp(b3->sub_folder, b3->sub) == 0, "一级目录书两种副题相同(无多余子路径)"); CHECK(b4 && strcmp(b4->sub, "TXT · 4B") == 0 && strcmp(b4->sub_folder, b4->sub) == 0, "根下书两种副题相同"); /* G2 format 模式(改动前行为):格式段头 + 最近阅读优先 + 全局书名排序 */ shelf_row rows[128]; int nf = shelf_build_rows(books, n, 0, rows, 128); char got[4096]; dump_rows(rows, nf, books, got, sizeof got); CHECK(strcmp(got, "S:TXT|B:1(轻小说)|B:3(kh)|B:6(D/E/F)|B:9(F2/a/b/c)|" "B:《斗破》(400+本高质量完本合集/01、玄幻/01、斗破)|B:根下()|" "S:EPUB|B:2(轻小说)|S:MOBI|B:5(B/sub)|") == 0, "format 模式段头/顺序与旧行为一致"); CHECK(nf == 11, "format 行数 = 3 段头 + 8 书"); /* G3(M33 改写)folder 模式 = Books 根层导航:子目录在前(排序、带子树书数), * 根下书在后。M27 的「第一级目录段头」语义已被逐级进入取代(断言同等甚至更强: * 不仅可见该书,还锁定了它所在的目录层级与计数)。 */ int nd = shelf_build_rows(books, n, 1, rows, 128); dump_rows(rows, nd, books, got, sizeof got); CHECK(strcmp(got, "D:400+本高质量完本合集(1)|D:B(1)|D:D(1)|D:F2(1)|D:kh(1)|D:轻小说(2)|B:根下()|") == 0, "folder 根层:子目录在前(排序+子树书数),根下书在后"); CHECK(nd == 7, "folder 根层行数 = 6 目录 + 1 根下书"); /* G4 空目录不产生导航行(C 不在序列里) */ CHECK(strstr(got, "D:C") == NULL, "空目录 C 不出现在根层"); /* G3.1 逐级进入:400+ 系列(三级分类/作品/书名)不再把 1 本书平铺在顶级, * 而是每层只看到下一层目录;最终进入作品目录才看到书。 */ char cwd[SHELF_GROUP_MAX], next[SHELF_GROUP_MAX]; cwd[0] = 0; CHECK(shelf_nav_count_under(books, n, "") == 8, "根层子树共 8 本"); CHECK(shelf_nav_count_under(books, n, "轻小说") == 2, "轻小说子树 2 本"); CHECK(shelf_nav_count_under(books, n, "400+本高质量完本合集") == 1, "400+ 子树 1 本"); CHECK(shelf_nav_count_under(books, n, "不存在") == 0, "不存在的目录 0 本"); CHECK(shelf_nav_enter("", "400+本高质量完本合集", next, sizeof next) == 1 && strcmp(next, "400+本高质量完本合集") == 0, "从根进入 400+ 目录"); snprintf(cwd, sizeof cwd, "%s", next); int n1 = shelf_build_rows_at(books, n, 1, cwd, rows, 128); dump_rows(rows, n1, books, got, sizeof got); CHECK(strcmp(got, "D:01、玄幻(1)|") == 0, "进 400+ → 只看到分类目录 01、玄幻(不是 1 本书平铺)"); snprintf(next, sizeof next, "%s", cwd); CHECK(shelf_nav_enter(next, "01、玄幻", cwd, sizeof cwd) == 1, "进入 01、玄幻"); int n2 = shelf_build_rows_at(books, n, 1, cwd, rows, 128); dump_rows(rows, n2, books, got, sizeof got); CHECK(strcmp(got, "D:01、斗破(1)|") == 0, "进 01、玄幻 → 看到作品目录 01、斗破"); snprintf(next, sizeof next, "%s", cwd); CHECK(shelf_nav_enter(next, "01、斗破", cwd, sizeof cwd) == 1, "进入 01、斗破"); int n3 = shelf_build_rows_at(books, n, 1, cwd, rows, 128); dump_rows(rows, n3, books, got, sizeof got); CHECK(strcmp(got, "B:《斗破》(400+本高质量完本合集/01、玄幻/01、斗破)|") == 0, "进 01、斗破 → 看到书《斗破》(导航后可见该书)"); CHECK(n3 == 1 && rows[0].book >= 0 && strcmp(books[rows[0].book].title, "《斗破》") == 0, "书行指向正确的 books[] 下标(点书能打开正确路径)"); /* 返回逐级正确(与左缘手势/顶栏返回箭头同一函数) */ CHECK(shelf_nav_up(cwd, next, sizeof next) == 1 && strcmp(next, "400+本高质量完本合集/01、玄幻") == 0, "返回一级:01、斗破 → 01、玄幻"); CHECK(shelf_nav_up(next, cwd, sizeof cwd) == 1 && strcmp(cwd, "400+本高质量完本合集") == 0, "再返回:01、玄幻 → 400+"); CHECK(shelf_nav_up(cwd, next, sizeof next) == 1 && next[0] == 0, "再返回:400+ → Books 根"); CHECK(shelf_nav_up("", next, sizeof next) == 0 && next[0] == 0, "根层再返回 = 不动作(退出交给 App)"); /* 扁平目录直接出书;只有一层子目录没有书时仍显示该子目录 */ int nq = shelf_build_rows_at(books, n, 1, "轻小说", rows, 128); dump_rows(rows, nq, books, got, sizeof got); CHECK(strcmp(got, "B:1(轻小说)|B:2(轻小说)|") == 0, "轻小说(扁平)→ 直接看到 2 本书"); int nb = shelf_build_rows_at(books, n, 1, "B", rows, 128); dump_rows(rows, nb, books, got, sizeof got); CHECK(strcmp(got, "D:sub(1)|") == 0, "B 层没有书、只有子目录 sub → 仍显示 sub"); int nbs = shelf_build_rows_at(books, n, 1, "B/sub", rows, 128); dump_rows(rows, nbs, books, got, sizeof got); CHECK(strcmp(got, "B:5(B/sub)|") == 0, "继续进入 B/sub → 看到书 5"); /* 面包屑 / 目录名文案 */ char bc[64], dn[64]; CHECK(shelf_breadcrumb("", bc, sizeof bc) == 6 && strcmp(bc, "Books/") == 0, "根层面包屑 = Books/"); CHECK(shelf_breadcrumb("轻小说", bc, sizeof bc) == 15 && strcmp(bc, "Books/轻小说") == 0, "子目录面包屑 = Books/"); CHECK(shelf_dir_name("", dn, sizeof dn) == 0, "根层无目录名"); CHECK(shelf_dir_name("400+本高质量完本合集/01、玄幻", dn, sizeof dn) > 0 && strcmp(dn, "01、玄幻") == 0, "目录名取最后一级"); /* G7 行数上限:段头 ≤ 书数,2N 足够;rowcap 小时只写 rowcap 行 */ CHECK(SHELF_MAX_ROWS >= SHELF_MAX_BOOKS * 2, "SHELF_MAX_ROWS ≥ 2×书上限"); int nc = shelf_build_rows(books, n, 1, rows, 3); CHECK(nc == 3, "rowcap=3 时返回 3(不越界写)"); /* G7 超上限可见:cap=2 时 truncated=1 */ shelf_book small[2]; shelf_scan_report rep2; int ns = shelf_scan_books(root, small, 2, ts_of, &rep2); CHECK(ns == 2 && rep2.truncated == 1, "超上限时 truncated=1(不静默)"); /* P2-1:cap 正好卡在目录边界(A/d1 填满,A/d2 被丢)也必须置 truncated */ char rootc[400]; snprintf(rootc, sizeof rootc, "%s/capbound", tmproot); snprintf(p, sizeof p, "%s/A/d1", rootc); mkdirs(p); snprintf(p, sizeof p, "%s/A/d2", rootc); mkdirs(p); snprintf(p, sizeof p, "%s/A/d1/0.txt", rootc); write_file(p, "0"); snprintf(p, sizeof p, "%s/A/d1/1.txt", rootc); write_file(p, "1"); snprintf(p, sizeof p, "%s/A/d2/0.txt", rootc); write_file(p, "0"); snprintf(p, sizeof p, "%s/A/d2/1.txt", rootc); write_file(p, "1"); shelf_book cb[2]; shelf_scan_report rep6; int ncb = shelf_scan_books(rootc, cb, 2, NULL, &rep6); CHECK(ncb == 2 && rep6.truncated == 1, "cap 卡在目录边界时 truncated=1(不静默)"); /* 反向:恰好装满且后面没有书(只剩空目录)不能误报 truncated */ char rootd[400]; snprintf(rootd, sizeof rootd, "%s/capok", tmproot); snprintf(p, sizeof p, "%s/A", rootd); mkdirs(p); snprintf(p, sizeof p, "%s/A/0.txt", rootd); write_file(p, "0"); snprintf(p, sizeof p, "%s/A/1.txt", rootd); write_file(p, "1"); snprintf(p, sizeof p, "%s/Empty", rootd); mkdirs(p); shelf_book cb2[2]; shelf_scan_report rep7; int ncb2 = shelf_scan_books(rootd, cb2, 2, NULL, &rep7); CHECK(ncb2 == 2 && rep7.truncated == 0, "恰好装满且后续为空目录 → 不误报 truncated"); /* 不存在的根:root_missing=1 */ shelf_scan_report rep3; char nope[400]; snprintf(nope, sizeof nope, "%s/no_such_dir", tmproot); CHECK(shelf_scan_books(nope, small, 2, NULL, &rep3) == 0 && rep3.root_missing == 1, "根不存在时 root_missing=1"); /* G5.1(M33 改写)a/b 与 a/c 在根层合并为同一个目录行 a(计数=2); * 进入 a 才分别看到 b、c,进入 b/c 才看到书——旧断言「同属组 a」被换成 * 「导航后可见该书」,并额外锁定目录计数。 */ char root3[400]; snprintf(root3, sizeof root3, "%s/mini2", tmproot); snprintf(p, sizeof p, "%s/a/b", root3); mkdirs(p); snprintf(p, sizeof p, "%s/a/c", root3); mkdirs(p); snprintf(p, sizeof p, "%s/a/b/1.txt", root3); write_file(p, "b"); snprintf(p, sizeof p, "%s/a/c/2.txt", root3); write_file(p, "c"); shelf_book mb2[8]; shelf_scan_report rep5; int nm2 = shelf_scan_books(root3, mb2, 8, NULL, &rep5); int nd2 = shelf_build_rows_at(mb2, nm2, 1, "", rows, 128); dump_rows(rows, nd2, mb2, got, sizeof got); CHECK(strcmp(got, "D:a(2)|") == 0, "a/b 与 a/c 在根层合并为目录 a(计数 2)"); CHECK(nd2 == 1, "根层只有 1 行目录(不再按书平铺)"); int nd2a = shelf_build_rows_at(mb2, nm2, 1, "a", rows, 128); dump_rows(rows, nd2a, mb2, got, sizeof got); CHECK(strcmp(got, "D:b(1)|D:c(1)|") == 0, "进入 a → 子目录 b、c 各 1 本"); int nd2b = shelf_build_rows_at(mb2, nm2, 1, "a/b", rows, 128); dump_rows(rows, nd2b, mb2, got, sizeof got); CHECK(strcmp(got, "B:1(a/b)|") == 0, "进入 a/b → 导航后可见书 1"); int nd2c = shelf_build_rows_at(mb2, nm2, 1, "a/c", rows, 128); dump_rows(rows, nd2c, mb2, got, sizeof got); CHECK(strcmp(got, "B:2(a/c)|") == 0, "进入 a/c → 导航后可见书 2"); CHECK(find_book(mb2, nm2, "1") && strstr(find_book(mb2, nm2, "1")->sub_folder, "b · TXT") != NULL, "a/b 的 folder 副题含 b"); CHECK(find_book(mb2, nm2, "2") && strstr(find_book(mb2, nm2, "2")->sub_folder, "c · TXT") != NULL, "a/c 的 folder 副题含 c"); CHECK(find_book(mb2, nm2, "1") && strcmp(find_book(mb2, nm2, "1")->sub, "TXT · 1B") == 0, "a/b 的 format 副题仍与 base 一致"); /* G5.1b 组件边界:a 的子树不包含 ab/c(a/b 与 ab/c 不能因前缀混淆) */ shelf_book bnd[3]; memset(bnd, 0, sizeof bnd); snprintf(bnd[0].group, sizeof bnd[0].group, "a/b"); snprintf(bnd[0].title, sizeof bnd[0].title, "one"); snprintf(bnd[1].group, sizeof bnd[1].group, "ab/c"); snprintf(bnd[1].title, sizeof bnd[1].title, "two"); snprintf(bnd[2].group, sizeof bnd[2].group, "a"); snprintf(bnd[2].title, sizeof bnd[2].title, "three"); bnd[0].fmt = bnd[1].fmt = bnd[2].fmt = 0; int nbnd = shelf_build_rows_at(bnd, 3, 1, "a", rows, 128); dump_rows(rows, nbnd, bnd, got, sizeof got); CHECK(strcmp(got, "D:b(1)|B:three(a)|") == 0, "cwd=a 只含 a/b(不含 ab/c)且列出直接书 three"); CHECK(shelf_nav_count_under(bnd, 3, "a") == 2, "count_under(a)=2(a/b + 直接书,不含 ab/c)"); /* G5.2(M33 改写)字面名「未分类」是真实目录,与根层书是两个不同实体: * 根层同时出现目录行「未分类」和书 x;进入该目录看到 7(旧断言不合并 → 同等强度)。 */ char root2[400], mini[420]; snprintf(root2, sizeof root2, "%s/mini", tmproot); snprintf(mini, sizeof mini, "%s/未分类", root2); mkdirs(mini); snprintf(p, sizeof p, "%s/x.txt", root2); write_file(p, "x"); snprintf(p, sizeof p, "%s/未分类/7.txt", root2); write_file(p, "seven"); shelf_book mb[8]; shelf_scan_report rep4; int nm = shelf_scan_books(root2, mb, 8, NULL, &rep4); int ndm = shelf_build_rows_at(mb, nm, 1, "", rows, 128); dump_rows(rows, ndm, mb, got, sizeof got); CHECK(strcmp(got, "D:未分类(1)|B:x()|") == 0, "根层书 x 与名为「未分类」的真实目录并存(不按显示名合并)"); CHECK(ndm == 2 && rows[0].dir && strcmp(rows[0].name, "未分类") == 0 && !rows[1].dir && rows[1].book >= 0 && strcmp(mb[rows[1].book].title, "x") == 0, "目录行「未分类」与根层书 x 是两个独立行"); int ndm2 = shelf_build_rows_at(mb, nm, 1, "未分类", rows, 128); dump_rows(rows, ndm2, mb, got, sizeof got); CHECK(strcmp(got, "B:7(未分类)|") == 0, "进入「未分类」目录 → 看到书 7(未被当特殊值吞掉)"); /* G6 UTF-8 安全截断 */ char src[200], dst[96]; int k = 0; for (int i = 0; i < 40; i++) { memcpy(src + k, "轻", 3); k += 3; } src[k] = 0; /* 120 字节 */ int ncpy = shelf_copy_utf8(dst, 96, src); CHECK(ncpy == 93 && strlen(dst) == 93, "长中文目录名截到 93 字节(31 字,整字丢弃)"); CHECK(strncmp(dst, src, 93) == 0 && utf8_complete(dst), "截断后仍是合法 UTF-8 前缀"); CHECK(shelf_copy_utf8(dst, 3, "轻x") == 0 && dst[0] == 0, "cap=3 放不下 3 字节汉字时整段丢弃"); CHECK(shelf_copy_utf8(dst, 4, "轻x") == 3 && strcmp(dst, "轻") == 0, "cap=4 恰好放下一个汉字"); char ascii[128]; memset(ascii, 'a', 100); ascii[100] = 0; CHECK(shelf_copy_utf8(dst, 96, ascii) == 95, "ASCII 90+ 字节按 cap-1 截断"); } /* G9(M31):副题格式化的唯一实现 —— 扫描与「刚读完置顶」共用 shelf_fmt_sub。 * 置顶只改 ts 不重算副题时,「· 读」要等一次全量刷新才出现(M30 遗留),这里锁住语义。 */ static void test_fmt_sub(void) { shelf_book b; memset(&b, 0, sizeof b); strcpy(b.title, "t"); b.fmt = 0; b.size = 5; shelf_fmt_sub(&b); CHECK(strcmp(b.sub, "TXT · 5B") == 0, "无进度副题不含「读」"); CHECK(strcmp(b.sub_folder, b.sub) == 0, "根下书两种副题相同"); /* 刚读完:ts 从 0 变正 → 重算后「· 读」立即出现(不等全量刷新) */ b.ts = 100; shelf_fmt_sub(&b); CHECK(strcmp(b.sub, "TXT · 5B · 读") == 0, "ts>0 重算后副题含「· 读」"); CHECK(strcmp(b.sub_folder, b.sub) == 0, "根下书重算后两种副题仍相同"); /* folder 模式:子路径前置,其余与默认副题一致 */ shelf_book f; memset(&f, 0, sizeof f); strcpy(f.title, "f"); strcpy(f.group, "a/b"); f.fmt = 2; f.size = 4 * 1024; shelf_fmt_sub(&f); CHECK(strcmp(f.sub, "EPUB · 4KB") == 0, "folder 书的默认副题不带子路径"); CHECK(strcmp(f.sub_folder, "b · EPUB · 4KB") == 0, "folder 副题 = 子路径 · 默认副题"); f.ts = 7; shelf_fmt_sub(&f); CHECK(strcmp(f.sub_folder, "b · EPUB · 4KB · 读") == 0, "folder 副题重算后「· 读」出现在末尾"); /* 一级目录书:rel 无 '/' → 两种副题相同 */ shelf_book g; memset(&g, 0, sizeof g); strcpy(g.group, "轻小说"); g.fmt = 3; g.size = 100; shelf_fmt_sub(&g); CHECK(strcmp(g.sub, "MOBI · 100B") == 0 && strcmp(g.sub_folder, g.sub) == 0, "一级目录书无子路径前缀"); } /* G10(M33):导航纯函数——只在 books[].group 上做前缀过滤,目录在磁盘上**不存在** * 也能出正确行序列(证明导航不引入 opendir/readdir/stat)。 */ static void test_nav_pure(void) { shelf_book fake[4]; memset(fake, 0, sizeof fake); snprintf(fake[0].group, sizeof fake[0].group, "ghost/a/b"); snprintf(fake[0].title, sizeof fake[0].title, "A"); snprintf(fake[1].group, sizeof fake[1].group, "ghost/a/c"); snprintf(fake[1].title, sizeof fake[1].title, "B"); snprintf(fake[2].group, sizeof fake[2].group, "ghost/d"); snprintf(fake[2].title, sizeof fake[2].title, "C"); snprintf(fake[3].group, sizeof fake[3].group, "ghost"); snprintf(fake[3].title, sizeof fake[3].title, "D"); for (int i = 0; i < 4; i++) fake[i].fmt = 0; shelf_row rows[16]; char got[512]; int n0 = shelf_build_rows_at(fake, 4, 1, "", rows, 16); dump_rows(rows, n0, fake, got, sizeof got); CHECK(strcmp(got, "D:ghost(4)|") == 0, "虚拟目录(磁盘不存在)也能出导航行:根层 ghost(4)"); int n1 = shelf_build_rows_at(fake, 4, 1, "ghost", rows, 16); dump_rows(rows, n1, fake, got, sizeof got); CHECK(strcmp(got, "D:a(2)|D:d(1)|B:D(ghost)|") == 0, "ghost 层:子目录按名排序 + 直接书在后"); int n2 = shelf_build_rows_at(fake, 4, 1, "ghost/a", rows, 16); dump_rows(rows, n2, fake, got, sizeof got); CHECK(strcmp(got, "D:b(1)|D:c(1)|") == 0, "ghost/a 层:b、c 各 1 本"); int ncap = shelf_build_rows_at(fake, 4, 1, "ghost", rows, 2); CHECK(ncap == 2, "rowcap=2 时只写 2 行且不越界"); /* 面包屑超长:UTF-8 安全、保留末尾、前缀 "…" */ char longcwd[256], out[32]; int k = 0; for (int i = 0; i < 60; i++) { memcpy(longcwd + k, "轻", 3); k += 3; } longcwd[k] = 0; int bn = shelf_breadcrumb(longcwd, out, (int)sizeof out); CHECK(bn > 0 && bn < (int)sizeof out && utf8_complete(out), "超长面包屑仍为合法 UTF-8 且不越界"); CHECK(strncmp(out, "…", 3) == 0, "超长面包屑以省略号前缀保留当前目录末尾"); /* 进入目录超长:拒绝且不写 out */ char tiny[8] = "keep"; CHECK(shelf_nav_enter("abcdefgh", "ij", tiny, (int)sizeof tiny) == 0 && strcmp(tiny, "keep") == 0, "拼接超长时 shelf_nav_enter 返回 0 且不改 out"); } static void test_progress_stability(const char *root) { /* G8:写一份进度,切换分组模式前后 rdprog_load 必须同值,path 不变。 */ char path[400]; snprintf(path, sizeof path, "%s/轻小说/1.txt", root); rdprog p; memset(&p, 0, sizeof p); p.valid = 1; p.seg = 7; p.ch = 123; p.fs = 22; p.ls = 140; p.enc = 1; p.ts = 100; CHECK(rdprog_save(path, &p) == 0, "写入测试进度"); shelf_book books[64]; shelf_scan_report rep; int n = shelf_scan_books(root, books, 64, ts_of, &rep); const shelf_book *b = find_book(books, n, "1"); CHECK(b && strcmp(b->path, path) == 0, "扫描后 path 未变"); CHECK(b && b->ts == 100, "扫描用 rdprog_load 的时间戳"); /* 快照所有 path,跑两种模式的 build_rows,再逐条比对。 */ char before[64][400]; for (int i = 0; i < n; i++) snprintf(before[i], sizeof before[i], "%s", books[i].path); shelf_row rows[128]; shelf_build_rows(books, n, 0, rows, 128); rdprog after_f; int rf = rdprog_load(path, &after_f); shelf_build_rows(books, n, 1, rows, 128); rdprog after_d; int rd = rdprog_load(path, &after_d); CHECK(rf == 1 && rd == 1, "两种模式下进度都存在"); CHECK(after_f.seg == p.seg && after_f.ch == p.ch && after_f.fs == p.fs && after_f.ls == p.ls && after_f.enc == p.enc && after_f.ts == p.ts && after_d.seg == p.seg && after_d.ch == p.ch && after_d.ts == p.ts, "切换 format/folder 后 rdprog_load 同值"); int same = 1; for (int i = 0; i < n; i++) if (strcmp(before[i], books[i].path) != 0) same = 0; CHECK(same, "build_rows 前后所有 path 不变(进度键稳定)"); /* 两种模式下 ts 一致(排序输入相同) */ shelf_book books2[64]; int n2 = shelf_scan_books(root, books2, 64, ts_of, &rep); const shelf_book *b2 = find_book(books2, n2, "1"); CHECK(n2 == n && b2 && b2->ts == 100, "重扫(另一模式无差别)ts 一致"); } static void test_conf_default(const char *tmproot) { /* 旧用户 reader.conf 没有 ui.shelf_group:必须默认 format(书架不变样),persist 后补写 format。 */ char cpath[400]; snprintf(cpath, sizeof cpath, "%s/reader.conf", tmproot); /* g_cfgdir=/data → base= */ write_file(cpath, "ui.shelf_layout=list\nrd.fs=20\nrd.ls=130\n"); const rdconf *c = rdconf_get(); CHECK(c && strcmp(c->shelf_group, "format") == 0, "无 ui.shelf_group 键 → 默认 format"); CHECK(c && strcmp(c->shelf, "list") == 0 && c->fs == 20, "其它配置项照常读取"); CHECK(rdconf_persist() == 0, "reader.conf 写回成功"); FILE *f = fopen(cpath, "rb"); CHECK(f != NULL, "写回后可读"); if (f) { char buf[1024]; size_t n = fread(buf, 1, sizeof buf - 1, f); fclose(f); buf[n] = 0; CHECK(strstr(buf, "ui.shelf_group=format\n") != NULL, "写回文件含 ui.shelf_group=format"); } } int main(void) { const char *base = getenv("TMPDIR"); if (!base || !base[0]) base = "/tmp"; char tmpl[400]; snprintf(tmpl, sizeof tmpl, "%s/shelf_group_XXXXXX", base); char *tmp = mkdtemp(tmpl); if (!tmp) { printf("mkdtemp 失败\n"); return 2; } snprintf(g_cfgdir, sizeof g_cfgdir, "%s/data", tmp); /* progress/reader.conf 落临时目录 */ mkdirs(g_cfgdir); char root[400]; snprintf(root, sizeof root, "%s/Books", tmp); mkdirs(root); test_scan_and_modes(root, tmp); test_fmt_sub(); test_nav_pure(); test_progress_stability(root); test_conf_default(tmp); printf("shelf_group_smoke: %d pass, %d fail\n", g_pass, g_fail); /* 清理临时树(保留失败现场意义不大,测试可重复) */ char cmd[512]; snprintf(cmd, sizeof cmd, "rm -rf '%s'", tmp); if (system(cmd) != 0) { /* 忽略 */ } return g_fail ? 1 : 0; }