- 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 未被覆盖。
590 lines
30 KiB
C
590 lines
30 KiB
C
/* 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;
|
||
}
|