Files
MeowOS-For-R3P2/patch_v2/reader_app/test/shelf_group_smoke.c
T
AskaEth 2881ce908a feat(reader): M33 目录模式逐级下钻 — 子目录/书分层显示(416 本不再平铺)
- folder 模式从「按第一级目录分段」改为当前目录视图:子目录在前(带子树书数),
  直接书在后;进入/返回只过滤已扫平的 g_books,不 readdir、不重扫(M32 索引不动)。
- 纯函数 shelf_build_rows_at(books,n,folder,cwd,...) + nav 工具(enter/up/breadcrumb/
  count)全部宿主可测;format 模式行序列/副题逐字不变,path 仍是完整路径(进度键不变)。
- 交互:点目录进入;顶栏返回箭头/左缘返回手势回上一级,根层再返回退出 App;顶栏标题=
  当前目录名(按像素宽截断),副题行=面包屑全路径+当前层书数;list/grid 均支持导航。
- 状态:folder↔format、list↔grid 切换保留 cwd;刷新重扫后 cwd 失效则收缩到有书的祖先。
- 边界:空目录不显示(导航只读 g_books,不额外 readdir);深层目录逐级进入;真实名为
  「未分类」的目录按普通目录处理,不与根层书合并。
- 测试:shelf_group_smoke 63→99 断言;M27 的按第一级分组断言改写为「逐级进入后可见该书」
  (同等强度),新增进入/返回/面包屑/计数/空目录/纯内存/rowcap/UTF-8 截断断言。
- 交付 0.1.7/code8(M32 已占用任务书里的 0.1.6/code7);真机 0.1.4 未被覆盖。
2026-11-10 19:26:44 +08:00

590 lines
30 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/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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
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/<cwd>");
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=<tmp>/data → base=<tmp> */
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;
}