pc-selftest-id.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. /**
  2. * pc-selftest-id.js — 在 PC 上验证 ID 提取的纯函数(不碰设备)
  3. *
  4. * 为什么单独一份:设备的自检要过 MCP 下发,一轮几十秒;而 parse/filters
  5. * 是**零依赖纯函数**,拼起来就能在 PC 上跑。先用它把逻辑验对,
  6. * 再上设备验"真机环境里也没问题",两步分开,排查时能立刻区分是逻辑错还是环境错。
  7. *
  8. * 用法:node tools/pc-selftest-id.js
  9. */
  10. 'use strict';
  11. const fs = require('fs');
  12. const path = require('path');
  13. const vm = require('vm');
  14. const SRC = path.join(__dirname, '..', 'src');
  15. /* 只拼纯函数模块 —— 它们不引用 files/device/sleep 这些 AutoX 全局 */
  16. const MODULES = ['parse', 'filters', 'uidset', 'fingerprint', 'checkpoint', 'idsource'];
  17. const sandbox = {
  18. console: console,
  19. Date: Date, Math: Math, JSON: JSON,
  20. String: String, Number: Number, Object: Object,
  21. Array: Array, RegExp: RegExp, isNaN: isNaN, parseInt: parseInt,
  22. TikTokAuto: {}
  23. };
  24. /* uidset 与 filters 在函数体内取 logger / files,这里给最小桩。
  25. * files 用内存实现:断点读写的往返测试要靠它。 */
  26. sandbox.TikTokAuto.logger = {
  27. info: () => { }, warn: () => { }, error: () => { }, step: () => { }
  28. };
  29. const memFiles = {};
  30. sandbox.files = {
  31. exists: (p) => Object.prototype.hasOwnProperty.call(memFiles, p),
  32. read: (p) => (memFiles[p] === undefined ? '' : memFiles[p]),
  33. write: (p, content) => { memFiles[p] = String(content); },
  34. append: (p, content) => { memFiles[p] = (memFiles[p] || '') + String(content); },
  35. ensureDir: () => true
  36. };
  37. vm.createContext(sandbox);
  38. let source = 'var TikTokAuto = this.TikTokAuto;\n';
  39. for (const name of MODULES) {
  40. source += fs.readFileSync(path.join(SRC, `${name}.js`), 'utf8') + '\n';
  41. }
  42. vm.runInContext(source, sandbox);
  43. const parse = sandbox.TikTokAuto.parse;
  44. const filters = sandbox.TikTokAuto.filters;
  45. const uidset = sandbox.TikTokAuto.uidset;
  46. const fingerprint = sandbox.TikTokAuto.fingerprint;
  47. const checkpoint = sandbox.TikTokAuto.checkpoint;
  48. const idsource = sandbox.TikTokAuto.idsource;
  49. let pass = 0;
  50. let fail = 0;
  51. function check(name, condition, detail) {
  52. if (condition === true) {
  53. pass += 1;
  54. return;
  55. }
  56. fail += 1;
  57. console.log('FAIL ' + name + (detail === undefined ? '' : ' → ' + detail));
  58. }
  59. /* ---------------------------------------------------------- 计数解析 */
  60. const CASES = [
  61. ['594', 594], ['2,491', 2491], ['1.6M', 1600000], ['253.9M', 253900000],
  62. ['245K', 245000], ['371.7K', 371700], ['1.2万', 12000], ['1.5亿', 150000000],
  63. ['1.6M followers', 1600000], ['594 followers · 2.5K likes', 594],
  64. ['0', 0], ['12.3K', 12300], ['1,234', 1234], ['100万', 1000000]
  65. ];
  66. for (const [input, want] of CASES) {
  67. const got = parse.count(input);
  68. check(`parseCount("${input}") = ${want}`, got === want, `实际 ${got}`);
  69. }
  70. check('parseCount 空串 → null', parse.count('') === null);
  71. check('parseCount 非数字 → null', parse.count('abc') === null);
  72. check('parseCount 全角数字 "1.2万"', parse.count('1.2万') === 12000, String(parse.count('1.2万')));
  73. /* ---------------------------------------------------------- 语言 */
  74. check('中文 → zh', parse.language('小美爱穿搭') === 'zh');
  75. check('英文 → en', parse.language('Fashion Girl') === 'en');
  76. check('中文混英文 → zh', parse.language('小美的Fashion') === 'zh', parse.language('小美的Fashion'));
  77. check('英文混一个字 → en', parse.language('Fashion Girl 小') === 'en', parse.language('Fashion Girl 小'));
  78. check('纯 emoji → none', parse.language('🥷🏿💗✨') === 'none');
  79. check('阿拉伯文 → none(没有中英线索)', parse.language('عايشه حسين') === 'none', parse.language('عايشه حسين'));
  80. check('zh 不放行 en', parse.languageMatches('zh', 'en') === false);
  81. check('en 不放行 zh', parse.languageMatches('en', 'zh') === false);
  82. check('any 全放行', parse.languageMatches('any', 'en') === true && parse.languageMatches('any', 'none') === true);
  83. /* ---------------------------------------------------------- 区间 */
  84. check('未设区间 → 无脑通过', parse.inRange(null, 0, 0) === true);
  85. check('值在内', parse.inRange(500, 100, 1000) === true);
  86. check('值在下限外', parse.inRange(50, 100, 1000) === false);
  87. check('上限 0 = 不设限', parse.inRange(999999, 100, 0) === true);
  88. check('只设上限', parse.inRange(500, 0, 1000) === true && parse.inRange(5000, 0, 1000) === false);
  89. check('设了区间但值为 null → 拦', parse.inRange(null, 100, 1000) === false);
  90. check('isRangeOpen 双向为 0 才算空', parse.isRangeOpen(0, 0) === true && parse.isRangeOpen(1, 0) === false);
  91. /* ---------------------------------------------------------- 用户名 */
  92. check('剥 @', parse.username('@abc') === 'abc');
  93. check('剥 FID 隔离符', parse.username('\u2068ajman_fashion_week\u2069') === 'ajman_fashion_week',
  94. JSON.stringify(parse.username('\u2068ajman_fashion_week\u2069')));
  95. check('合法用户名', parse.looksLikeUsername('user3670597276145') === true);
  96. check('带点的用户名', parse.looksLikeUsername('mukhawar.shop') === true);
  97. check('文案不是用户名', parse.looksLikeUsername('This is a caption') === false);
  98. check('过长不是用户名', parse.looksLikeUsername('a'.repeat(31)) === false);
  99. /* ---------------------------------------------------------- 去重集合 */
  100. const set = uidset.create();
  101. check('首次 add → true', set.add('a') === true);
  102. check('重复 add → false', set.add('a') === false);
  103. check('size 只算唯一值', set.size() === 1);
  104. check('has 正确', set.has('a') === true && set.has('b') === false);
  105. /* ---------------------------------------------------------- 过滤 */
  106. check('无过滤全放行', filters.judge({ uid: 'x' }, {}).ok === true);
  107. check('私密被拦', filters.judge({ uid: 'x', isPrivate: true }, { private: true }).ok === false);
  108. check('公开放行', filters.judge({ uid: 'x', isPrivate: false }, { private: true }).ok === true);
  109. check('仅中文 + 中文昵称', filters.judge({ uid: 'x', nickname: '小美' }, { langRegion: 'zh' }).ok === true);
  110. check('仅中文 + 英文昵称被拦', filters.judge({ uid: 'x', nickname: 'Fashion' }, { langRegion: 'zh' }).ok === false);
  111. check('仅中文 + 纯 emoji 被拦', filters.judge({ uid: 'x', nickname: '🥷🏿' }, { langRegion: 'zh' }).ok === false);
  112. check('取样=个签:昵称英文但个签中文 → 放行',
  113. filters.judge({ uid: 'x', nickname: 'Fashion', signature: '小美' },
  114. { langRegion: 'zh', nicknameCheck: 'sign' }).ok === true);
  115. check('取样=昵称:同上应当被拦',
  116. filters.judge({ uid: 'x', nickname: 'Fashion', signature: '小美' },
  117. { langRegion: 'zh', nicknameCheck: 'nick' }).ok === false);
  118. check('粉丝达标', filters.judge({ uid: 'x', fans: 5940 }, { fans: { min: 1000, max: 0 } }).ok === true);
  119. check('粉丝不足', filters.judge({ uid: 'x', fans: 594 }, { fans: { min: 1000, max: 0 } }).ok === false);
  120. check('粉丝读不到 → 拦', filters.judge({ uid: 'x', fans: null }, { fans: { min: 1000, max: 0 } }).ok === false);
  121. check('粉丝上界', filters.judge({ uid: 'x', fans: 99999 }, { fans: { min: 0, max: 10000 } }).ok === false);
  122. const videoVerdict = filters.judge({ uid: 'x' }, { videos: { min: 10, max: 0 } });
  123. check('视频量不拦人', videoVerdict.ok === true);
  124. check('视频量要如实告知未生效', videoVerdict.soft.indexOf('视频量') >= 0, videoVerdict.soft);
  125. check('无头像:默认剪影被拦', filters.judge({ uid: 'x', hasAvatar: false }, { noAvatar: true }).ok === false);
  126. check('无头像:有头像放行', filters.judge({ uid: 'x', hasAvatar: true }, { noAvatar: true }).ok === true);
  127. const noAvatarUnknown = filters.judge({ uid: 'x', hasAvatar: null }, { noAvatar: true });
  128. check('无头像:判不了时放行', noAvatarUnknown.ok === true);
  129. check('无头像:判不了要告知', noAvatarUnknown.soft.indexOf('无头像') >= 0, noAvatarUnknown.soft);
  130. check('无过滤 → 不必开用户页', filters.needsProfile({}) === false);
  131. check('只有视频量 → 不必开用户页', filters.needsProfile({ videos: { min: 10, max: 0 } }) === false);
  132. check('有粉丝量 → 必须开', filters.needsProfile({ fans: { min: 10, max: 0 } }) === true);
  133. check('有语言 → 必须开', filters.needsProfile({ langRegion: 'zh' }) === true);
  134. /* 「读不到资料」必须拦下 —— 放行等于"打不开的人全都通过",
  135. * 过滤看着在跑、实际放进一批没检查过的账号,比不过滤更糟。
  136. * 但快路径(本来不需要资料)仍然要放行。 */
  137. check('需要资料但拿不到 → 拦下', filters.judge(null, { fans: { min: 1000, max: 0 } }).ok === false);
  138. check('不需要资料且没有资料 → 放行(快路径)', filters.judge(null, {}).ok === true);
  139. /* ---------------------------------------------------------- 列表遍历的停止判据 */
  140. /*
  141. * 这里验的是 2026-10-28 修的那个真 bug:
  142. * 旧实现把"本屏没有新 uid"和"翻屏后内容不变"两件事都当成"到底了",
  143. * 于是预载历史(跨轮去重)或预载上次产物(断点续跑)之后,
  144. * 前两屏全是旧人 → 在列表顶部就判定"列表到底",一条新人都拿不到。
  145. *
  146. * 用一个可控的假列表驱动真正的 walkList,这样测的是生产代码而不是复述逻辑。
  147. */
  148. function fakeProfile(pages) {
  149. let cursor = 0;
  150. sandbox.TikTokAuto.profile = {
  151. listRows: () => pages[Math.min(cursor, pages.length - 1)],
  152. scrollList: () => { cursor += 1; }
  153. };
  154. }
  155. /* ① 全部是旧人,但列表还在往下走 → 必须继续翻,不能判到底 */
  156. const seenAll = uidset.create();
  157. ['a1', 'a2', 'b1', 'b2', 'c1', 'c2'].forEach((uid) => seenAll.add(uid));
  158. fakeProfile([[{ uid: 'a1' }, { uid: 'a2' }],
  159. [{ uid: 'b1' }, { uid: 'b2' }],
  160. [{ uid: 'c1' }, { uid: 'c2' }],
  161. []]);
  162. let stat = {};
  163. let got = idsource.walkList({
  164. seen: seenAll, shouldStop: () => false, stat: stat,
  165. onRow: () => { throw new Error('全是旧人,不该处理任何一行'); }
  166. });
  167. check('全屏都是旧人 → 仍然继续往下翻(不判列表到底)',
  168. got === 0 && stat.screens >= 3, 'screens=' + stat.screens);
  169. check('全屏都是旧人时不重复处理', got === 0, 'handled=' + got);
  170. /* ② 翻不动了(每次读到的都是同一屏)→ 连续两次就停 */
  171. fakeProfile([[{ uid: 'a1' }, { uid: 'a2' }]]);
  172. stat = {};
  173. idsource.walkList({
  174. seen: uidset.create(), shouldStop: () => false, stat: stat,
  175. onRow: () => { }
  176. });
  177. check('翻屏后内容不变 → 连续两次就判到底', stat.screens === 2, 'screens=' + stat.screens);
  178. /* ③ 遇到新人要正常处理(回归:改动不能把"该收的人"也一起跳过) */
  179. fakeProfile([[{ uid: 'n1' }, { uid: 'n2' }], []]);
  180. const collected = [];
  181. idsource.walkList({
  182. seen: uidset.create(), shouldStop: () => false,
  183. onRow: (row) => { collected.push(row.uid); }
  184. });
  185. check('新人照常处理', collected.join(',') === 'n1,n2', collected.join(','));
  186. /* ④ maxScreens 是硬顶(续跑会按上次屏数放大它) */
  187. fakeProfile([[{ uid: 'a1' }], [{ uid: 'a2' }], [{ uid: 'a3' }], [{ uid: 'a4' }], []]);
  188. stat = {};
  189. idsource.walkList({
  190. seen: uidset.create(), shouldStop: () => false, stat: stat, maxScreens: 2,
  191. onRow: () => { }
  192. });
  193. check('maxScreens 到顶就停', stat.screens === 2, 'screens=' + stat.screens);
  194. /* ⑤ walkStep 纯函数:变 → 不变 → 变,stuck 必须归零 */
  195. let step = idsource.walkStep({ screens: 1, stuck: 1 },
  196. { sigBefore: 'a', sigAfter: 'b', rowsAfter: 3, maxScreens: 60 });
  197. check('内容变了就把 stuck 归零', step.action === 'continue' && step.state.stuck === 0, JSON.stringify(step));
  198. step = idsource.walkStep({ screens: 1, stuck: 0 },
  199. { sigBefore: 'a', sigAfter: '', rowsAfter: 0, maxScreens: 60 });
  200. check('翻完读不到行时不下结论(交给下一轮空行判定)', step.action === 'continue', JSON.stringify(step));
  201. /* ---------------------------------------------------------- 配置指纹 */
  202. /*
  203. * 指纹决定"断点还算不算数"。这里盯两件事:
  204. * ① 该变的必须变(改了取材/过滤/输出目录 → 断点失效)
  205. * ② 不该变的必须不变(改上限、时长、resolveUid → 不该让断点失效)
  206. * 第 ② 类最容易写错:多算一个字段,用户改个"至少运行几分钟"就白丢断点。
  207. */
  208. const baseId = () => ({
  209. sources: [
  210. { key: 'src-a', kind: 'anchor', anchors: 'ajman#other', lists: ['fans'],
  211. expand: 'fans', searchType: '', keyword: '', videoSource: '', videoKeyword: '', path: '' },
  212. { key: 'src-b', kind: 'search', searchType: 'users', keyword: '小猫',
  213. anchors: '', lists: [], expand: '', videoSource: '', videoKeyword: '', path: '' }
  214. ],
  215. filter: { private: true, noAvatar: false, langRegion: 'any', nicknameCheck: 'any',
  216. follow: {}, fans: { min: 1000, max: 500000 }, likes: {}, videos: { min: 0, max: 500000 } },
  217. output: { dir: '/sdcard/Download/tiktok_app', historyPath: '/sdcard/Download/tiktok_app/uid_extracted.txt',
  218. dedupeHistory: true, dryRun: false, limit: 100, resolveUid: true },
  219. guard: { maxRuntimeMs: 1800000 }
  220. });
  221. const fpBase = fingerprint.ofIdSection(baseId());
  222. check('指纹是四项(分项 + 总)',
  223. fpBase.v === 1 && !!fpBase.sources && !!fpBase.filter && !!fpBase.output && !!fpBase.all);
  224. /* ① 不该变的:键序 */
  225. const reordered = baseId();
  226. const flipped = {};
  227. Object.keys(reordered).reverse().forEach((k) => { flipped[k] = reordered[k]; });
  228. check('换对象键序 → 指纹不变', fingerprint.compare(fpBase, fingerprint.ofIdSection(flipped)).same === true);
  229. /* ② 不该变的:只改"跑多少/跑多久/是否解析 uid" */
  230. [['limit', 999], ['resolveUid', false], ['dryRun', true]].forEach(([k, v]) => {
  231. const one = baseId();
  232. one.output[k] = v;
  233. check(`改 output.${k} → 断点仍然有效`, fingerprint.compare(fpBase, fingerprint.ofIdSection(one)).same === true);
  234. });
  235. check('改 guard.maxRuntimeMs → 断点仍然有效', fingerprint.compare(fpBase, fingerprint.ofIdSection(
  236. Object.assign(baseId(), { guard: { maxRuntimeMs: 60000 } }))).same === true);
  237. /* ③ 不该变的:视频量门槛(filters.judge 只对它出 soft 提示,不参与判定) */
  238. const videosChanged = baseId();
  239. videosChanged.filter.videos = { min: 50, max: 999 };
  240. check('改视频量门槛 → 断点仍然有效(它不参与过滤)',
  241. fingerprint.compare(fpBase, fingerprint.ofIdSection(videosChanged)).same === true);
  242. /* ④ 该变的 */
  243. const fansChanged = baseId();
  244. fansChanged.filter.fans = { min: 500, max: 500000 };
  245. check('改粉丝门槛 → 只有 filter 变', JSON.stringify(fingerprint.compare(fpBase, fingerprint.ofIdSection(fansChanged)).changed) === '["filter"]');
  246. const dedupeChanged = baseId();
  247. dedupeChanged.output.dedupeHistory = false;
  248. check('关掉跨轮去重 → 只有 output 变',
  249. JSON.stringify(fingerprint.compare(fpBase, fingerprint.ofIdSection(dedupeChanged)).changed) === '["output"]');
  250. const sourcesReordered = baseId();
  251. sourcesReordered.sources = [baseId().sources[1], baseId().sources[0]];
  252. check('取材顺序调换 → sources 变(顺序影响"先取到谁")',
  253. fingerprint.compare(fpBase, fingerprint.ofIdSection(sourcesReordered)).changed.join(',') === 'sources');
  254. const keywordChanged = baseId();
  255. keywordChanged.sources[1].keyword = '小狗';
  256. check('换关键词 → 断点失效', fingerprint.compare(fpBase, fingerprint.ofIdSection(keywordChanged)).same === false);
  257. /* ⑤ 缺字段 / 空配置不能炸 */
  258. check('空配置也能算出指纹', !!fingerprint.ofIdSection({}).all);
  259. check('没 sources 时指纹稳定', fingerprint.ofIdSection({}).sources === fingerprint.ofIdSection({}).sources);
  260. /* ⑥ ★ 两份实现必须一致(界面 / 引擎各一份,算法漂移 = 界面说能续、引擎说不能) */
  261. const uiFpSandbox = { JSON: JSON, String: String, Number: Number, Object: Object, Array: Array, isNaN: isNaN };
  262. vm.createContext(uiFpSandbox);
  263. vm.runInContext(fs.readFileSync(path.join(SRC, '..', '..', 'tiktok-app', 'src', 'fingerprint.js'), 'utf8') +
  264. '\nthis.__F = Fingerprint;', uiFpSandbox);
  265. const UI_FP = uiFpSandbox.__F;
  266. check('界面侧指纹模块能加载', !!UI_FP && typeof UI_FP.ofIdSection === 'function');
  267. let mismatch = '';
  268. [baseId(), flipped, fansChanged, dedupeChanged, sourcesReordered, keywordChanged,
  269. videosChanged, {}, { sources: [] }].forEach((fixture, i) => {
  270. const a = fingerprint.ofIdSection(fixture);
  271. const b = UI_FP.ofIdSection(fixture);
  272. if (JSON.stringify(a) !== JSON.stringify(b)) {
  273. mismatch = 'fixture#' + i + ' 引擎=' + JSON.stringify(a) + ' 界面=' + JSON.stringify(b);
  274. }
  275. });
  276. check('引擎与界面算出的指纹逐字段相同(防算法漂移)', mismatch === '', mismatch);
  277. /* ---------------------------------------------------------- 断点模型 */
  278. const CP_PATH = '/sdcard/Download/tiktok_app/id_progress.json';
  279. const SIG = fingerprint.ofIdSection(baseId());
  280. const cp0 = checkpoint.empty('run-1', SIG, '/sdcard/Download/tiktok_app/uid_20261028_1200.txt',
  281. [{ key: 'src-a', kind: 'anchor', label: '账号 A' },
  282. { key: 'src-b', kind: 'search', label: '用户「小猫」' }]);
  283. check('断点初始状态是 running 且两条取材都 pending',
  284. cp0.state === 'running' && cp0.sources.length === 2 && cp0.sources.every((s) => s.status === 'pending'));
  285. check('断点记下了 runId 与结果文件', cp0.runId === 'run-1' && cp0.runFile.indexOf('uid_') > 0);
  286. checkpoint.markSource(cp0, 'src-a', 'running');
  287. check('标 running 后不算完成', checkpoint.sourceDone(cp0, 'src-a') === false);
  288. checkpoint.markSource(cp0, 'src-a', 'done', { screens: 12, handled: 214, added: 37 });
  289. check('标 done 后算完成且带上屏数/处理数',
  290. checkpoint.sourceDone(cp0, 'src-a') === true &&
  291. cp0.sources[0].screens === 12 && cp0.sources[0].handled === 214);
  292. check('还没做完时 allDone 为假', checkpoint.allDone(cp0) === false);
  293. /* 展开:同一个人的 fans 与 following 要分开记 */
  294. checkpoint.enqueueExpand(cp0, 'u1', 'fans');
  295. checkpoint.enqueueExpand(cp0, 'u2', 'fans');
  296. checkpoint.enqueueExpand(cp0, 'u1', 'following');
  297. check('展开项按 uid + 列表去重', cp0.expand.queue.length === 3, String(cp0.expand.queue.length));
  298. checkpoint.enqueueExpand(cp0, 'u1', 'fans');
  299. check('重复入队不再增加', cp0.expand.queue.length === 3, String(cp0.expand.queue.length));
  300. checkpoint.markExpandDone(cp0, 'u1', 'fans');
  301. check('u1 的 fans 完成后,following 仍然待做',
  302. checkpoint.expandDone(cp0, 'u1', 'fans') === true &&
  303. checkpoint.expandDone(cp0, 'u1', 'following') === false);
  304. check('pendingExpand 只返回没做过的',
  305. checkpoint.pendingExpand(cp0, cp0.expand.queue).length === 2,
  306. String(checkpoint.pendingExpand(cp0, cp0.expand.queue).length));
  307. checkpoint.markSource(cp0, 'src-b', 'done');
  308. check('取材全 done 但展开有剩 → allDone 仍为假', checkpoint.allDone(cp0) === false);
  309. checkpoint.markExpandDone(cp0, 'u2', 'fans');
  310. checkpoint.markExpandDone(cp0, 'u1', 'following');
  311. check('取材与展开都完成后 allDone 为真', checkpoint.allDone(cp0) === true);
  312. /* 存盘 → 读回 */
  313. checkpoint.save(CP_PATH, cp0);
  314. const cpBack = checkpoint.read(CP_PATH);
  315. check('断点能存能读', !!cpBack && cpBack.runId === 'run-1' && cpBack.sources.length === 2);
  316. memFiles[CP_PATH] = '{ 半截 JSON';
  317. check('半截文件当作没有断点(不抛异常)', checkpoint.read(CP_PATH) === null);
  318. memFiles[CP_PATH] = '';
  319. check('空文件当作没有断点', checkpoint.read(CP_PATH) === null);
  320. check('清空断点给的是覆盖用的空壳', checkpoint.clearStub().state === 'none');
  321. /* ---------------------------------------------------------- 续跑决策 */
  322. const runExists = true;
  323. check('没有断点 → 从零开始', checkpoint.planResume(null, SIG, runExists, {}).mode === 'fresh');
  324. check('上次已跑完 → 不提供继续',
  325. checkpoint.planResume(Object.assign({}, cp0, { state: 'done' }), SIG, runExists, {}).mode === 'fresh');
  326. check('演练模式 → 拒绝续跑',
  327. checkpoint.planResume(Object.assign({}, cp0, { state: 'stopped' }), SIG, runExists, { dryRun: true }).mode === 'reject');
  328. check('配置变了 → 拒绝,并说清是哪一段',
  329. (() => {
  330. const r = checkpoint.planResume(Object.assign({}, cp0, { state: 'stopped' }),
  331. fingerprint.ofIdSection(keywordChanged), runExists, {});
  332. return r.mode === 'reject' && r.changed.join(',') === 'sources' && r.message.indexOf('取材') >= 0;
  333. })());
  334. check('结果文件没了 → 拒绝',
  335. (() => {
  336. const r = checkpoint.planResume(Object.assign({}, cp0, { state: 'stopped' }), SIG, false, {});
  337. return r.mode === 'reject' && r.reason === 'run-file-missing';
  338. })());
  339. check('配置一致 + 文件在 → 可以续跑',
  340. checkpoint.planResume(Object.assign({}, cp0, { state: 'stopped' }), SIG, runExists, {}).mode === 'resume');
  341. /* ---------------------------------------------------------- 上限按累计算 */
  342. check('上限按累计:30 + 70 = 100 → 到顶', checkpoint.limitReached({ limitBase: 30 }, 100, 70) === true);
  343. check('上限按累计:30 + 69 = 99 → 没到', checkpoint.limitReached({ limitBase: 30 }, 100, 69) === false);
  344. check('不限(0)永远不到顶', checkpoint.limitReached({ limitBase: 0 }, 0, 999) === false);
  345. /* ---------------------------------------------------------- 文件名 */
  346. const name = parse.timestampName('uid', new Date(2026, 9, 20, 18, 31, 0));
  347. check('文件名带时分', name === 'uid_20261020_1831.txt', name);
  348. console.log('');
  349. console.log(`通过 ${pass} 项,失败 ${fail} 项`);
  350. process.exit(fail === 0 ? 0 : 1);