/* test/chapter_smoke.c — os.meow.reader 章节规范解析器宿主单测(纯 C,无 SDK/Freetype)。 * * 覆盖(按需求 5 样本 + 跨段 + 中文数字转换): * C1 标准中文数字章(第一章/第二章/第十一章 → 1/2/11,char_off 单调) * C2 阿拉伯章(第1章/第2章/第3章 → 1/2/3) * C3 楔子/序章/尾声/番外(无号,number==-1,char_off 单调) * C4 无章节纯文本(n==0,seg_char_start 仍完整) * C5 开头长书名+简介再进第一章(卷首识别 as entries[0]、章号、防误判正文行 ) * C6 跨段:>512KB 书,章落在后段(seg_idx>=1,char_off 跨段单调) * C7 中文数字转换:十一/二十三/一百零一/一千零一/两 * * 跑法:build_reader.sh 内 `test/chapter_smoke_host`;本文件只依赖 rdchapter + seg + rdenc。 */ #include "rdchapter.h" #include "seg.h" #include "rdenc.h" #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) static char g_dir[600]; static void write_file(const char *path, const char *data) { FILE *f = fopen(path, "wb"); fwrite(data, 1, strlen(data), f); fclose(f); } /* 建段目录 + 切段,返回段数(<0=错误)。 */ static int seg_for(const char *path, const char *base) { rdseg_set_base(base); char dir[600]; if (rdseg_dir_for(path, dir, sizeof dir) != 0) return -1; strcpy(g_dir, dir); return rdseg_build(path, RD_ENC_UTF8, dir); } static int scan_book(rdchapter *c, const char *basename) { return rdchapter_scan(g_dir, RD_ENC_UTF8, basename, c); } static void test_c1(void) { const char *txt = "/tmp/ch_s1.txt"; write_file(txt, "第一章 风起\n" "这是第一章的正文内容。\n" "第二章 云涌\n" "这是第二章的正文内容。\n" "第十一章 破晓\n" "这是第十一章的正文内容。\n"); CHECK(seg_for(txt, "/tmp/ch_base1") >= 1, "c1 seg build"); rdchapter c; rdchapter_init(&c); CHECK(scan_book(&c, "s1") == 0, "c1 scan"); CHECK(c.n == 3, "c1 n==3"); if (c.n == 3) { CHECK(c.entries[0].number == 1, "c1 #1"); CHECK(c.entries[1].number == 2, "c1 #2"); CHECK(c.entries[2].number == 11, "c1 #11"); CHECK(c.entries[0].char_off == 0, "c1 first at 0 (no preface)"); CHECK(c.entries[0].char_off < c.entries[1].char_off && c.entries[1].char_off < c.entries[2].char_off, "c1 char_off monotonic"); CHECK(c.entries[0].seg_idx == 0 && c.entries[0].seg_char == 0, "c1 seg0 char0"); CHECK(strcmp(c.entries[0].title, "第一章 风起") == 0, "c1 title"); CHECK(c.has_pre == 0, "c1 no preface"); } rdchapter_free(&c); remove(txt); } static void test_c2(void) { const char *txt = "/tmp/ch_s2.txt"; write_file(txt, "第1章 起源\n" "正文A。\n" "第2章 发展\n" "正文B。\n" "第3章 终局\n" "正文C。\n"); CHECK(seg_for(txt, "/tmp/ch_base2") >= 1, "c2 seg build"); rdchapter c; rdchapter_init(&c); CHECK(scan_book(&c, "s2") == 0, "c2 scan"); CHECK(c.n == 3, "c2 n==3"); if (c.n == 3) { CHECK(c.entries[0].number == 1, "c2 #1"); CHECK(c.entries[1].number == 2, "c2 #2"); CHECK(c.entries[2].number == 3, "c2 #3"); CHECK(c.entries[0].char_off < c.entries[1].char_off && c.entries[1].char_off < c.entries[2].char_off, "c2 char_off monotonic"); } rdchapter_free(&c); remove(txt); } static void test_c3(void) { const char *txt = "/tmp/ch_s3.txt"; write_file(txt, "楔子\n" "一段开篇。\n" "第一章 正传\n" "正文内容。\n" "尾声\n" "后记文字。\n" "番外 归途\n" "番外正文。\n"); CHECK(seg_for(txt, "/tmp/ch_base3") >= 1, "c3 seg build"); rdchapter c; rdchapter_init(&c); CHECK(scan_book(&c, "s3") == 0, "c3 scan"); CHECK(c.n == 4, "c3 n==4"); if (c.n == 4) { CHECK(c.entries[0].number == -1, "c3 楔子 number -1"); CHECK(strcmp(c.entries[0].title, "楔子") == 0, "c3 楔子 title"); CHECK(c.entries[1].number == 1, "c3 第一章 number 1"); CHECK(c.entries[2].number == -1, "c3 尾声 number -1"); CHECK(c.entries[3].number == -1, "c3 番外 number -1"); CHECK(c.entries[0].char_off < c.entries[1].char_off && c.entries[1].char_off < c.entries[2].char_off && c.entries[2].char_off < c.entries[3].char_off, "c3 char_off monotonic"); } rdchapter_free(&c); remove(txt); } static void test_c4(void) { const char *txt = "/tmp/ch_s4.txt"; write_file(txt, "这是一个没有章节标题的长篇小说文本。\n" "它只有连续不断的段落,没有任何章回节的标识。\n" "所有的文字都是纯粹的故事内容。\n"); CHECK(seg_for(txt, "/tmp/ch_base4") >= 1, "c4 seg build"); rdchapter c; rdchapter_init(&c); CHECK(scan_book(&c, "s4") == 0, "c4 scan"); CHECK(c.n == 0, "c4 no chapters"); CHECK(c.nsegs == 1, "c4 nsegs==1"); CHECK(c.seg_char_start && c.seg_char_start[0] == 0 && c.seg_char_start[1] > 0, "c4 seg_char_start"); rdchapter_free(&c); remove(txt); } static void test_c5(void) { const char *txt = "/tmp/ch_s5.txt"; write_file(txt, "《大漠孤烟》这是一本很长的书名,作者著。\n" "简介:这是一个故事的开头,讲述少年成长。\n" "第一章风雨欲来,全书开场,命运由此转折。\n" "第一章 离家\n" "正文内容。\n" "第二章 远行\n" "正文内容。\n"); CHECK(seg_for(txt, "/tmp/ch_base5") >= 1, "c5 seg build"); rdchapter c; rdchapter_init(&c); CHECK(scan_book(&c, "sample5") == 0, "c5 scan"); CHECK(c.n == 3, "c5 n==3 (preface + 2 chapters)"); if (c.n == 3) { CHECK(c.has_pre == 1, "c5 has preface"); CHECK(c.entries[0].number == -1, "c5 preface number -1"); CHECK(strcmp(c.entries[0].title, "sample5") == 0, "c5 preface title = basename"); CHECK(c.entries[0].char_off == 0, "c5 preface char_off 0"); CHECK(c.entries[1].number == 1 && c.entries[2].number == 2, "c5 chapter numbers 1/2"); CHECK(c.entries[1].char_off > 0, "c5 ch1 after preface"); CHECK(c.entries[0].char_off < c.entries[1].char_off && c.entries[1].char_off < c.entries[2].char_off, "c5 monotonic"); /* 防误判:正文行「第一章风雨欲来,全书开场。」带两个正文标点 → 未被识别为章 */ CHECK(c.entries[1].seg_char > 0, "c5 decoy line not counted (ch1 seg_char>0)"); } rdchapter_free(&c); remove(txt); } static void test_c6(void) { /* 跨段:写 ~600KB filler,在 ~600KB 处放一个章 → 应落在后段(seg_idx>=1) */ const char *txt = "/tmp/ch_s6.txt"; FILE *f = fopen(txt, "wb"); const char *para = "这是一段用于填充页面的中文正文,包含标点,中文English混合,反复出现,占据空间。\n"; size_t plen = strlen(para); size_t total = 0; while (total < 600 * 1024) { fwrite(para, 1, plen, f); total += plen; } fwrite("第二百章 跨段检测\n", 1, strlen("第二百章 跨段检测\n"), f); const char *tail = "这是跨段之后的一小段正文。\n"; fwrite(tail, 1, strlen(tail), f); fclose(f); CHECK(seg_for(txt, "/tmp/ch_base6") >= 2, "c6 seg build >=2"); rdchapter c; rdchapter_init(&c); CHECK(scan_book(&c, "s6") == 0, "c6 scan"); /* 前置 filler(600KB 无章节)→ entries[0]=卷首;真正章节在后段 */ CHECK(c.n == 2, "c6 n==2 (preface + 1 chapter)"); if (c.n == 2) { CHECK(c.entries[0].number == -1, "c6 preface number -1"); CHECK(c.entries[1].number == 200, "c6 number 200"); CHECK(c.entries[1].seg_idx >= 1, "c6 chapter in later segment"); CHECK(c.entries[1].seg_idx < c.nsegs, "c6 seg_idx in range"); CHECK(c.entries[1].seg_char >= 0, "c6 seg_char >=0"); CHECK(c.seg_char_start[0] == 0 && c.seg_char_start[0] < c.seg_char_start[1] && c.seg_char_start[c.nsegs - 1] < c.seg_char_start[c.nsegs], "c6 seg_char_start monotonic"); /* 跨段 char_off:章首偏移应在段 1 起点之后 */ CHECK(c.entries[1].char_off >= c.seg_char_start[c.entries[1].seg_idx], "c6 char_off within seg"); } rdchapter_free(&c); remove(txt); } static void test_c7(void) { CHECK(rdchapter_cn_num("十一", 6) == 11, "c7 十一=11"); CHECK(rdchapter_cn_num("二十三", 9) == 23, "c7 二十三=23"); CHECK(rdchapter_cn_num("一百零一", 12) == 101, "c7 一百零一=101"); CHECK(rdchapter_cn_num("一千零一", 12) == 1001, "c7 一千零一=1001"); CHECK(rdchapter_cn_num("两", 3) == 2, "c7 两=2"); CHECK(rdchapter_cn_num("一百", 6) == 100, "c7 一百=100"); CHECK(rdchapter_cn_num("foobar", 6) == -1, "c7 invalid -1"); } static void test_c8(void) { /* R3.4 英文章节:文字数字(one/twenty-one/two hundred fifty)+ 罗马(IV)+ 拒绝无空格(ChapterX) */ const char *txt = "/tmp/ch_s8.txt"; write_file(txt, "Chapter one\n" "正文。\n" "Chapter IV\n" "正文。\n" "Chapter twenty-one\n" "正文。\n" "Chapter two hundred fifty\n" "正文。\n" "ChapterX\n" "正文。\n" "Chapter twelve\n" "正文。\n"); CHECK(seg_for(txt, "/tmp/ch_base8") >= 1, "c8 seg build"); rdchapter c; rdchapter_init(&c); CHECK(scan_book(&c, "s8") == 0, "c8 scan"); /* ChapterX 不认(无空格);其余 5 个认 */ CHECK(c.n == 5, "c8 n==5"); if (c.n == 5) { static const int exp[5] = { 1, 4, 21, 250, 12 }; for (int i = 0; i < 5; i++) CHECK(c.entries[i].number == exp[i], "c8 word number value"); CHECK(c.entries[0].char_off < c.entries[1].char_off && c.entries[1].char_off < c.entries[2].char_off && c.entries[2].char_off < c.entries[3].char_off && c.entries[3].char_off < c.entries[4].char_off, "c8 char_off monotonic"); CHECK(strcmp(c.entries[0].title, "Chapter one") == 0, "c8 title Chapter one"); CHECK(strcmp(c.entries[3].title, "Chapter two hundred fifty") == 0, "c8 title two hundred fifty"); } rdchapter_free(&c); remove(txt); } int main(void) { test_c1(); test_c2(); test_c3(); test_c4(); test_c5(); test_c6(); test_c7(); test_c8(); printf("chapter_smoke: %d pass, %d fail\n", g_pass, g_fail); return g_fail ? 1 : 0; }