Files
MeowOS-For-R3P2/patch_v2/reader_app/test/shelf_index_smoke.c
T
AskaEth e0dfab95c3 perf(reader): M32 书架索引落盘 — 冷启动只 stat 434 个目录 mtime,跳过 readdir(3.0s→~0.15s)
M29 只解决了「回书架不重扫」;首次进书架仍要全量扫 434 目录(真机 3.0–3.4s,
其中 opendir+readdir 占 96%)。本单把扫描结果 + 每个扫描目录的 mtime 存到
MeowData/os.meow.reader/index/shelf.idx,冷启动逐目录 stat 比对 mtime,命中即
完全跳过 readdir;任何异常一律 fail-open 全量重扫。

先量再选(任务 1):宿主 434 目录/595 本/51 rar 虚拟库(贴真机量级)
- 434 次目录 stat = 0.48ms/pass(1.1µs/次,不含 readdir);
- 读 72KB 索引 = 0.009ms;全量扫描 3.54ms;索引命中 1.29ms(ts_fn=NULL)。
真机按 M28 实测 1079 次 stat=160–170ms(≈150µs/次)外推:434 次 ≈ 65ms,
远低于「可能不可行」的 1s 阈值 → 选「校验全部扫描目录 mtime」(非只 level-1)。
strace 断言:indexonly 载入 0 次 getdents64、恰好 434 次 stat(真的没 readdir)。
生产路径 ≈ 65ms(stat) + <20ms(索引读) + 40–50ms(rdprog×595) + <20ms(build_rows)
≈ 0.13–0.15s(对比 3.0s)。

索引格式 v1(手工小端序列化,不依赖结构体布局):
header(magic/version/header_size/root_len) + root + dir 记录×(rel,mtime_s,
mtime_ns,books_here) + book 记录×(rel,size,fmt) + trailer(ndirs,nbooks,flags,
depth_skipped,crc32,magic)。truncated/depth_skipped 存在 flags/字段里,载入时
如实重建提示条;**不存阅读进度 ts**,载入后每本由 rdprog_load 现读再排序。

失效语义(存每个扫描过的目录,不只 level-1):
- level-2 新增 / level-3 深处新增 / 删书 → 该目录 mtime 变 → 重扫;
- 一级目录改名 → 父目录 mtime 变 + 旧目录 stat ENOENT → 重扫;
- 超深(>6 层)未扫描子树不在索引内,其内部变化不自动失效(本来就不显示),
  点「刷新」更新 depth_skipped 计数;mtime 精度受限(exFAT 10ms/FAT32 2s)的
  同刻度变更也可能漏判,点「刷新」必可纠正。
M29 语义不变:首次进书架=索引或全扫;回书架=不扫;刷新=删索引+全扫(之后原子
写回新索引)。

安全/健壮(宁可慢不可错):
- 写:shelf.idx.tmp 全量写完(尽力 fsync)后 rename;中途被杀只剩 tmp,正式索引
  要么旧要么完整新;rename 覆盖失败先删旧再改名;fflush/写错即放弃,绝不影响书架。
- 读:O_NOFOLLOW|O_NONBLOCK + S_ISREG(FIFO/符号链接直接拒绝不阻塞);CRC32 覆盖
  全部记录;rel 做绝对路径/“..”/内嵌 NUL/长度校验;版本/根路径/计数/逐目录 mtime
  任一不符即重扫;载入崩溃路径全部 goto 清理,不改书架可用性。
- writer_open 失败(卡满/写保护/无 MeowData)→ 扫描不采集目录、照常出书;
  commit 失败(含只读目录 rename 失败)不留正式索引。

测试:test/shelf_index_smoke 154 pass(含四种变更场景失效、截断/字节翻转/垃圾/
缺失/FIFO/符号链接/版本不符/根路径不符、ts 现读、truncated/depth_skipped 重建、
只读目录写失败、空 Books、root 缺失、.tmp 残渣),ASan+UBSan 干净;
test/shelf_index_bench 模拟 434 目录/595 本/51 rar:scan/load 均 595 本、
truncated=0/depth_skipped=0、索引 72,156B。build_reader.sh 全绿 0 warning
(core 6846 / seg 19 / chapter 71 / shelf_group 63 / index 154 / font 14 /
loaders PASS)。shelf_bench_ondev 增加 index_load/scan_save 计时与
MEOW_BENCH_IDX=miss(真机首开命中 vs 未命中对比,待有卡复核)。

交付:app.json 0.1.4/code5 → 0.1.5/code6;reader md5 df4cadcf
1,711,924B;mpk md5 fec7b1bb 814,535B;未签名不推卡。
2026-11-10 18:14:52 +08:00

427 lines
18 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_index_smoke.c — M32 书架索引宿主单测(纯 C,无设备/SDK 依赖)。
*
* 覆盖:
* I1 全量扫描 → 写索引 → 载入:书数/书名/分组/path/fmt/size/ts 全部与扫描一致
* I2 载入不缓存阅读进度:同一索引换 ts_fn 再载入,ts 立刻反映新值(含真实 rdprog 文件)
* I3 四种书库变更都必须失效(fail-open 重扫):
* level-2 新增 / level-3 深处新增 / level-1 目录改名 / 删书
* I4 坏档 fail-open:截断 / 中间字节翻转 / 垃圾内容 / 文件缺失 / 索引路径是目录 /
* 版本不符 / Books 根路径不符 —— 一律返回 0,不崩
* I5 truncated / depth_skipped 如实重建(索引里存了这两个值)
* I6 写盘失败不影响书架:索引目录不可写时 writer_open 返回 NULL、扫描照常出书;
* commit 失败不留下半截索引
* I7 空 Books / Books 不存在:空索引可载入;root_missing 不写索引
* I8 载入路径不做 readdir(由 build 验证步骤用 strace 另行断言)
*/
#include "shelf_model.h"
#include "shelf_index.h"
#include "progress.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.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)
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);
}
/* 进度按文件名给(I2:换 ts_fn 必须立即生效,证明索引没缓存 ts)。 */
static uint64_t g_ts_boost;
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 + g_ts_boost;
if (strcmp(b, "2.epub") == 0) return 50;
return 0;
}
/* 真实进度文件口径(I2 第二段)。 */
static uint64_t prog_ts_of(const char *path)
{
rdprog p;
return (rdprog_load(path, &p) == 1) ? p.ts : 0;
}
static const shelf_book *find_path(const shelf_book *books, int n, const char *path)
{
for (int i = 0; i < n; i++) if (strcmp(books[i].path, path) == 0) return &books[i];
return NULL;
}
static const shelf_book *find_title(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 idx_path(const char *root, char *out, int outsz)
{
return shelf_index_path(root, out, outsz);
}
static int scan_and_save(const char *root, shelf_book *books, int cap, shelf_scan_report *rep)
{
shelf_idx_writer *w = shelf_index_writer_open(root);
CHECK(w != NULL, "writer_open 成功");
int n = shelf_scan_books_ex(root, books, cap, ts_of, rep,
w ? shelf_index_writer_dir : NULL, w);
if (w) CHECK(shelf_index_writer_commit(w, books, n, rep) == 1, "writer_commit 成功");
return n;
}
/* 建一棵贴合真机布局的树(含 level-3 深处、超深目录、不支持格式、空目录)。 */
static void make_tree(const char *root)
{
char p[400];
mkdirs(root);
snprintf(p, sizeof p, "%s/轻小说", root); mkdirs(p);
snprintf(p, sizeof p, "%s/玄幻", root); mkdirs(p);
snprintf(p, sizeof p, "%s/B/sub", root); mkdirs(p);
snprintf(p, sizeof p, "%s/D/E/F", root); mkdirs(p);
snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M", root); mkdirs(p);
snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破", root); mkdirs(p);
snprintf(p, sizeof p, "%s/空目录", root); mkdirs(p);
snprintf(p, sizeof p, "%s/轻小说/1.txt", root); write_file(p, "a");
snprintf(p, sizeof p, "%s/轻小说/2.epub", root); write_file(p, "bb");
snprintf(p, sizeof p, "%s/轻小说/notes.md", root); write_file(p, "not a book");
snprintf(p, sizeof p, "%s/玄幻/3.txt", root); write_file(p, "ccc");
snprintf(p, sizeof p, "%s/根下.txt", root); write_file(p, "root book");
snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破/《斗破》.txt", root);
write_file(p, "doupo");
snprintf(p, sizeof p, "%s/B/sub/5.mobi", root); write_file(p, "mobi");
snprintf(p, sizeof p, "%s/D/E/F/6.txt", root); write_file(p, "deep3");
snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M/7.txt", root); write_file(p, "too deep");
}
static void test_roundtrip(const char *root)
{
static shelf_book scanb[SHELF_MAX_BOOKS], loadb[SHELF_MAX_BOOKS];
shelf_scan_report r1, r2;
int n = scan_and_save(root, scanb, SHELF_MAX_BOOKS, &r1);
CHECK(n == 7, "扫描到 7 本(7.txt 超深不计)");
CHECK(r1.depth_skipped == 1 && r1.truncated == 0, "深度超限计数=1");
char ip[500];
CHECK(idx_path(root, ip, sizeof ip) == 0, "索引路径可得");
struct stat st;
CHECK(stat(ip, &st) == 0 && st.st_size > 0, "索引文件已落盘");
printf("index: %s (%lld bytes)\n", ip, (long long)st.st_size);
int n2 = shelf_index_load(root, loadb, SHELF_MAX_BOOKS, ts_of, &r2);
CHECK(n2 == 1, "索引命中");
CHECK(r2.nbooks == n && r2.truncated == r1.truncated &&
r2.depth_skipped == r1.depth_skipped && r2.root_missing == 0, "报告重建一致");
for (int i = 0; i < n; i++) {
const shelf_book *b = find_path(loadb, r2.nbooks, scanb[i].path);
CHECK(b != NULL, "载入含同一 path");
if (!b) continue;
CHECK(strcmp(b->title, scanb[i].title) == 0, "title 一致");
CHECK(strcmp(b->group, scanb[i].group) == 0, "group 一致");
CHECK(strcmp(b->top, scanb[i].top) == 0, "top 一致");
CHECK(strcmp(b->sub, scanb[i].sub) == 0, "sub 一致");
CHECK(strcmp(b->sub_folder, scanb[i].sub_folder) == 0, "sub_folder 一致");
CHECK(b->fmt == scanb[i].fmt && b->size == scanb[i].size, "fmt/size 一致");
CHECK(b->ts == scanb[i].ts, "ts 一致");
}
/* 排序:最近阅读优先 */
CHECK(r2.nbooks >= 2 && strcmp(loadb[0].title, "1") == 0,
"载入后按 ts 排序(1.txt 100 在最前)");
}
/* I2:索引不缓存 ts —— 同一份索引,换 ts_fn 载入必须立即反映新值。 */
static void test_ts_not_cached(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report rep;
g_ts_boost = 0;
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) == 7, "重新扫描写索引");
g_ts_boost = 900;
static shelf_book lb[SHELF_MAX_BOOKS];
CHECK(shelf_index_load(root, lb, SHELF_MAX_BOOKS, ts_of, &rep) == 1, "索引命中");
const shelf_book *x = find_title(lb, rep.nbooks, "1");
CHECK(x && x->ts == 1000, "载入后 ts 用当前 ts_fn(100+900),不是索引里的旧值");
CHECK(x && strstr(x->sub, "读") != NULL, "副题随新 ts 重建(· 读)");
g_ts_boost = 0;
/* 真实进度文件:写 rdprog 后同一索引载入,ts/副题立即更新。 */
char path[400];
snprintf(path, sizeof path, "%s/根下.txt", root);
rdprog p;
memset(&p, 0, sizeof p);
p.valid = 1; p.seg = 2; p.ch = 9; p.fs = 20; p.ls = 130; p.enc = -1; p.ts = 777777;
CHECK(rdprog_save(path, &p) == 0, "写入测试进度");
CHECK(shelf_index_load(root, lb, SHELF_MAX_BOOKS, prog_ts_of, &rep) == 1, "索引命中(真实进度)");
const shelf_book *y = find_title(lb, rep.nbooks, "根下");
CHECK(y && y->ts == 777777, "ts 来自 rdprog_load 现读");
/* 删掉进度文件(注意 rdprog_save 的参数是书路径,进度文件另按 hash 落盘)。 */
char pdir[400], phash[24], ppath[440];
if (rdprog_dir(pdir, sizeof pdir) == 0) {
rdprog_hash(path, phash, sizeof phash);
snprintf(ppath, sizeof ppath, "%s/%s", pdir, phash);
unlink(ppath);
}
}
/* I3:四种变更都必须让索引失效。每次变更后:先断言 load=0(不静默用陈旧索引),
* 再全量重扫 + 写新索引,断言变化可见。 */
static void test_invalidation(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report rep;
char p[400], q[400];
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "基线扫描写索引");
/* ① level-2 直接新增 */
usleep(20000);
snprintf(p, sizeof p, "%s/轻小说/新书.txt", root); write_file(p, "new");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0,
"level-2 新增 → 索引失效");
int n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep);
CHECK(find_title(b, n, "新书") != NULL, "重扫后新书可见");
/* ② level-3 深处新增(Books/400+.../01、玄幻/01、斗破/) */
usleep(20000);
snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破/新书2.txt", root);
write_file(p, "new2");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0,
"level-3 深处新增 → 索引失效");
n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep);
CHECK(find_title(b, n, "新书2") != NULL, "重扫后深处新书可见");
/* ③ 一级目录改名 */
usleep(20000);
snprintf(p, sizeof p, "%s/玄幻", root);
snprintf(q, sizeof q, "%s/玄幻改", root);
CHECK(rename(p, q) == 0, "改名一级目录");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0,
"一级目录改名 → 索引失效");
n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep);
const shelf_book *m = find_title(b, n, "3");
CHECK(m && strcmp(m->top, "玄幻改") == 0 && strcmp(m->group, "玄幻改") == 0,
"重扫后分组键跟随新目录名");
/* ④ 删书 */
usleep(20000);
snprintf(p, sizeof p, "%s/根下.txt", root);
CHECK(unlink(p) == 0, "删除一本书");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0,
"删书 → 索引失效");
n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep);
CHECK(find_title(b, n, "根下") == NULL, "重扫后删掉的书消失");
}
static void test_bad_index(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report rep;
char ip[500];
CHECK(idx_path(root, ip, sizeof ip) == 0, "索引路径可得");
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "基线扫描写索引");
/* 截断:去掉 tail 10 字节(trailer 没了) */
struct stat st;
CHECK(stat(ip, &st) == 0, "索引存在");
CHECK(truncate(ip, st.st_size - 10) == 0, "截断索引");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "截断 → 拒绝");
/* 中间字节翻转:CRC 必须抓到 */
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引");
FILE *f = fopen(ip, "r+b");
CHECK(f != NULL, "打开索引改写");
if (f) {
CHECK(fseek(f, st.st_size / 2, SEEK_SET) == 0, "定位中部");
int c = fgetc(f);
CHECK(c != EOF, "读中部字节");
CHECK(fseek(f, st.st_size / 2, SEEK_SET) == 0, "回定位");
fputc(c ^ 0x01, f);
fclose(f);
}
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "字节翻转 → 拒绝");
/* 垃圾内容 */
write_file(ip, "this is not a shelf index at all, just garbage garbage garbage");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "垃圾内容 → 拒绝");
/* 文件缺失 */
unlink(ip);
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "文件缺失 → 拒绝");
/* FIFO:O_NONBLOCK 必须直接拒绝(不能挂死在 open 上) */
CHECK(mkfifo(ip, 0644) == 0, "建 FIFO 假索引");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "FIFO → 拒绝且不阻塞");
unlink(ip);
/* 符号链接:O_NOFOLLOW 只认普通文件,不跟随 */
CHECK(symlink("/etc/hostname", ip) == 0, "建符号链接假索引");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "符号链接 → 拒绝");
unlink(ip);
/* 索引路径是目录 */
mkdir(ip, 0755);
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "索引是目录 → 拒绝");
rmdir(ip);
/* 扫描中途被杀留下的 .tmp 残渣:绝不能影响正式索引的载入 */
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引");
char tmpip[520];
snprintf(tmpip, sizeof tmpip, "%s.tmp", ip);
write_file(tmpip, "half written index leftover");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 1, ".tmp 残渣不影响载入");
unlink(tmpip);
/* 版本不符:先把版本字段改成 2 */
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引");
f = fopen(ip, "r+b");
CHECK(f != NULL, "打开索引改版本");
if (f) {
unsigned char v[4] = { 2, 0, 0, 0 };
CHECK(fseek(f, 8, SEEK_SET) == 0, "定位版本字段");
fwrite(v, 1, sizeof v, f);
fclose(f);
}
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "版本不符 → 拒绝");
/* Books 根路径不符(同长度不同路径) */
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引");
char root2[400];
snprintf(root2, sizeof root2, "%s", root);
root2[strlen(root2) - 1] = 'z'; /* Books → Bookz */
CHECK(shelf_index_load(root2, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "根路径不符 → 拒绝");
}
/* I5:cap 截断 + 超深计数都要从索引如实重建。 */
static void test_report_rebuild(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report r1;
int n = scan_and_save(root, b, 3, &r1);
CHECK(n == 3 && r1.truncated == 1, "cap=3 扫描截断标记");
CHECK(shelf_index_load(root, b, 3, ts_of, &r1) == 1, "截断索引仍可命中");
CHECK(r1.truncated == 1 && r1.nbooks == 3, "截断标记如实重建");
}
/* I6:索引不可写 → 扫描照常;commit 失败无残留。 */
static void test_write_failure(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report rep;
char ip[500];
CHECK(idx_path(root, ip, sizeof ip) == 0, "索引路径可得");
char idir[500];
snprintf(idir, sizeof idir, "%s", ip);
char *slash = strrchr(idir, '/');
if (slash) *slash = 0;
mkdirs(idir);
if (geteuid() == 0) { printf("(skip 写失败用例:root 不受权限位限制)\n"); return; }
CHECK(chmod(idir, 0500) == 0, "索引目录改为只读");
shelf_idx_writer *w = shelf_index_writer_open(root);
CHECK(w == NULL, "只读目录 → writer_open 返回 NULL(不崩)");
/* 生产路径:w=NULL → 扫描不采集目录,仍必须出书。 */
int n = shelf_scan_books_ex(root, b, SHELF_MAX_BOOKS, ts_of, &rep, NULL, NULL);
CHECK(n > 0 && rep.root_missing == 0, "索引不可写时书架扫描照常");
CHECK(chmod(idir, 0755) == 0, "恢复目录权限");
/* commit 失败:打开 writer(tmp 已建)后再把目录改只读 → rename 失败 → 返回 0 且无残留。 */
unlink(ip); /* 先清掉前面用例留下的正式索引,才能断言「失败后不存在」 */
w = shelf_index_writer_open(root);
CHECK(w != NULL, "可写时 writer_open 成功");
if (w) {
CHECK(chmod(idir, 0500) == 0, "提交前目录改只读");
int navail = shelf_scan_books_ex(root, b, SHELF_MAX_BOOKS, ts_of, &rep,
shelf_index_writer_dir, w);
int ok = shelf_index_writer_commit(w, b, navail, &rep);
CHECK(ok == 0, "rename 失败 → commit 返回 0");
CHECK(chmod(idir, 0755) == 0, "恢复目录权限");
struct stat st;
CHECK(stat(ip, &st) != 0, "commit 失败不留正式索引");
}
}
/* I7:空 Books 可缓存;root 不存在不写索引。 */
static void test_empty_and_missing(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report rep;
char empty[400];
snprintf(empty, sizeof empty, "%s_empty/Books", root);
mkdirs(empty);
int n = scan_and_save(empty, b, SHELF_MAX_BOOKS, &rep);
CHECK(n == 0 && rep.root_missing == 0, "空 Books 扫描 0 本");
CHECK(shelf_index_load(empty, b, SHELF_MAX_BOOKS, ts_of, &rep) == 1, "空索引可命中");
CHECK(rep.nbooks == 0, "空索引 0 本");
char missing[400];
snprintf(missing, sizeof missing, "%s_nope/Books", root);
CHECK(shelf_index_load(missing, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "根不存在 → 拒绝");
/* writer_open 只依赖 App 数据目录(不 stat Books):根不存在时也能打开,
* 但扫描不到任何目录(ndirs=0)→ commit 必须失败且不发布半截索引。 */
shelf_scan_report rm;
shelf_idx_writer *w = shelf_index_writer_open(missing);
int mn = shelf_scan_books_ex(missing, b, SHELF_MAX_BOOKS, ts_of, &rm,
w ? shelf_index_writer_dir : NULL, w);
CHECK(mn == 0 && rm.root_missing == 1, "根不存在 → root_missing");
if (w) CHECK(shelf_index_writer_commit(w, b, mn, &rm) == 0, "根不存在 → commit 拒绝空索引");
}
int main(void)
{
const char *base = getenv("TMPDIR");
if (!base || !base[0]) base = "/tmp";
char tmpl[400];
snprintf(tmpl, sizeof tmpl, "%s/m32_index_XXXXXX", base);
char *tmp = mkdtemp(tmpl);
if (!tmp) { printf("mkdtemp 失败\n"); return 2; }
snprintf(g_cfgdir, sizeof g_cfgdir, "%s/data", tmp); /* 索引落 <tmp>/index */
mkdirs(g_cfgdir);
char root[400];
snprintf(root, sizeof root, "%s/Books", tmp);
make_tree(root);
test_roundtrip(root);
test_ts_not_cached(root);
test_invalidation(root);
test_bad_index(root);
test_report_rebuild(root);
test_write_failure(root);
test_empty_and_missing(root);
printf("shelf_index_smoke: %d pass, %d fail\n", g_pass, g_fail);
char cmd[512];
snprintf(cmd, sizeof cmd, "rm -rf '%s' '%s_empty' '%s_nope'", tmp, root, root);
if (system(cmd) != 0) { /* 忽略清理失败 */ }
return g_fail ? 1 : 0;
}