@pls-compile-owo/fisheye 0.3.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +62 -1
- package/fisheye.mjs +88 -39
- package/lib/audio.mjs +646 -0
- package/lib/batch.mjs +191 -0
- package/lib/bili.mjs +86 -30
- package/lib/budget.mjs +117 -0
- package/lib/env.mjs +1 -0
- package/lib/frame.mjs +1 -1
- package/lib/i18n.mjs +147 -0
- package/lib/ocr.mjs +386 -0
- package/lib/progress.mjs +140 -0
- package/lib/resume.mjs +378 -0
- package/lib/retry.mjs +209 -0
- package/lib/stages.mjs +317 -32
- package/lib/util.mjs +9 -8
- package/lib/ytdlp.mjs +393 -0
- package/mcp-video-server.js +261 -0
- package/package.json +3 -2
- package/test/audio.test.mjs +23 -0
- package/test/batch.test.mjs +212 -0
- package/test/bili.test.mjs +70 -0
- package/test/budget.test.mjs +63 -0
- package/test/env.test.mjs +35 -0
- package/test/frame.test.mjs +35 -0
- package/test/i18n.test.mjs +45 -0
- package/test/ocr-sample.png +0 -0
- package/test/ocr.test.mjs +89 -0
- package/test/progress.test.mjs +139 -0
- package/test/resume.test.mjs +232 -0
- package/test/retry.test.mjs +69 -0
- package/test/ytdlp.test.mjs +179 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// audio 模块单测:能力探测(不依赖本机是否装 whisper,环境无关)
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { audioCaps, findWhisper } from '../lib/audio.mjs'
|
|
5
|
+
|
|
6
|
+
test('audioCaps:返回稳定的能力描述结构', () => {
|
|
7
|
+
const caps = audioCaps()
|
|
8
|
+
assert.equal(typeof caps, 'object')
|
|
9
|
+
assert.equal(typeof caps.available, 'boolean')
|
|
10
|
+
assert.equal(typeof caps.reason, 'string')
|
|
11
|
+
assert.equal(typeof caps.summary, 'string')
|
|
12
|
+
if (caps.available) {
|
|
13
|
+
assert.ok(caps.whisper)
|
|
14
|
+
assert.ok(caps.cmd)
|
|
15
|
+
} else {
|
|
16
|
+
assert.ok(caps.hint)
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('findWhisper:无可用 whisper 时返回 null 或合法对象(不抛错)', () => {
|
|
21
|
+
const r = findWhisper({ ffmpeg: null, env: {} })
|
|
22
|
+
assert.ok(r === null || (r && typeof r.cmd === 'string'))
|
|
23
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// batch 模块单测:批量清单解析 / 串行与并发执行 / 汇总报告
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { parseBatchList, runBatch, formatBatchReport } from '../lib/batch.mjs'
|
|
5
|
+
|
|
6
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
7
|
+
|
|
8
|
+
test('parseBatchList:混合清单分类(BV / 文件 / URL / 空行 / 注释 / 非法行)', () => {
|
|
9
|
+
const input = String.raw`
|
|
10
|
+
# 今晚要跑的一批
|
|
11
|
+
BV1ziWP6qENy
|
|
12
|
+
|
|
13
|
+
C:\videos\a.mp4
|
|
14
|
+
./clips/b.mkv
|
|
15
|
+
https://www.bilibili.com/video/BV1xx411c7mD
|
|
16
|
+
video.webm
|
|
17
|
+
D:/movies/c.mov
|
|
18
|
+
这不是视频
|
|
19
|
+
`
|
|
20
|
+
const { items, errors } = parseBatchList(input)
|
|
21
|
+
assert.deepEqual(items, [
|
|
22
|
+
{ kind: 'bv', value: 'BV1ziWP6qENy' },
|
|
23
|
+
{ kind: 'file', value: String.raw`C:\videos\a.mp4` },
|
|
24
|
+
{ kind: 'file', value: './clips/b.mkv' },
|
|
25
|
+
{ kind: 'url', value: 'https://www.bilibili.com/video/BV1xx411c7mD' },
|
|
26
|
+
{ kind: 'file', value: 'video.webm' },
|
|
27
|
+
{ kind: 'file', value: 'D:/movies/c.mov' }
|
|
28
|
+
])
|
|
29
|
+
assert.equal(errors.length, 1)
|
|
30
|
+
assert.equal(errors[0].raw, '这不是视频')
|
|
31
|
+
// 行号按物理行算(1-based),空行 / 注释行也占行号
|
|
32
|
+
const expectedLine = input.split(/\r?\n/).findIndex((line) => line.trim() === '这不是视频') + 1
|
|
33
|
+
assert.equal(errors[0].line, expectedLine)
|
|
34
|
+
assert.match(errors[0].reason, /无法识别/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('parseBatchList:数组输入、trim 与 # 注释行', () => {
|
|
38
|
+
const { items, errors } = parseBatchList([
|
|
39
|
+
' BV1ziWP6qENy ',
|
|
40
|
+
'# 注释',
|
|
41
|
+
'',
|
|
42
|
+
' ',
|
|
43
|
+
'../up/c.mkv'
|
|
44
|
+
])
|
|
45
|
+
assert.deepEqual(items, [
|
|
46
|
+
{ kind: 'bv', value: 'BV1ziWP6qENy' },
|
|
47
|
+
{ kind: 'file', value: '../up/c.mkv' }
|
|
48
|
+
])
|
|
49
|
+
assert.deepEqual(errors, [])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('parseBatchList:空输入 / 纯注释返回空清单', () => {
|
|
53
|
+
assert.deepEqual(parseBatchList(''), { items: [], errors: [] })
|
|
54
|
+
assert.deepEqual(parseBatchList([]), { items: [], errors: [] })
|
|
55
|
+
assert.deepEqual(parseBatchList('# only\n\n # comments'), { items: [], errors: [] })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('parseBatchList:非视频扩展名的路径算 file,纯文本词进 errors', () => {
|
|
59
|
+
const { items, errors } = parseBatchList(['notes.txt', String.raw`C:\docs\readme.txt`, 'BV1ziWP6qENy-x'])
|
|
60
|
+
assert.deepEqual(items, [{ kind: 'file', value: String.raw`C:\docs\readme.txt` }])
|
|
61
|
+
assert.equal(errors.length, 2)
|
|
62
|
+
assert.deepEqual(errors.map((e) => e.raw), ['notes.txt', 'BV1ziWP6qENy-x'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('parseBatchList:非法 input 类型不抛错,进 errors', () => {
|
|
66
|
+
const { items, errors } = parseBatchList(42)
|
|
67
|
+
assert.deepEqual(items, [])
|
|
68
|
+
assert.equal(errors.length, 1)
|
|
69
|
+
assert.equal(errors[0].line, 0)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('runBatch:concurrency=1 串行执行,结果按输入顺序', async () => {
|
|
73
|
+
const order = []
|
|
74
|
+
let active = 0
|
|
75
|
+
let peak = 0
|
|
76
|
+
const summary = await runBatch([1, 2, 3], async (n) => {
|
|
77
|
+
active++
|
|
78
|
+
peak = Math.max(peak, active)
|
|
79
|
+
order.push(n)
|
|
80
|
+
await sleep(5)
|
|
81
|
+
active--
|
|
82
|
+
return n * 10
|
|
83
|
+
})
|
|
84
|
+
assert.deepEqual(order, [1, 2, 3])
|
|
85
|
+
assert.equal(peak, 1) // 串行:任意时刻只有一个在跑
|
|
86
|
+
assert.equal(summary.total, 3)
|
|
87
|
+
assert.equal(summary.ok, 3)
|
|
88
|
+
assert.equal(summary.failed, 0)
|
|
89
|
+
assert.deepEqual(summary.results.map((r) => r.item), [1, 2, 3])
|
|
90
|
+
assert.deepEqual(summary.results.map((r) => r.result), [10, 20, 30])
|
|
91
|
+
for (const row of summary.results) {
|
|
92
|
+
assert.equal(row.ok, true)
|
|
93
|
+
assert.equal(typeof row.ms, 'number')
|
|
94
|
+
assert.ok(row.ms >= 0)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('runBatch:单个失败不中断整体,失败项带 error', async () => {
|
|
99
|
+
const summary = await runBatch(['a', 'b', 'c'], async (item) => {
|
|
100
|
+
if (item === 'b') throw new Error('炸了')
|
|
101
|
+
return item.toUpperCase()
|
|
102
|
+
})
|
|
103
|
+
assert.equal(summary.total, 3)
|
|
104
|
+
assert.equal(summary.ok, 2)
|
|
105
|
+
assert.equal(summary.failed, 1)
|
|
106
|
+
assert.deepEqual(summary.results.map((r) => r.ok), [true, false, true])
|
|
107
|
+
assert.equal(summary.results[1].item, 'b')
|
|
108
|
+
assert.match(summary.results[1].error, /炸了/)
|
|
109
|
+
assert.equal(summary.results[2].result, 'C') // 失败之后仍继续执行
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('runBatch:concurrency=2 时并发度不超过 2,结果顺序不变', async () => {
|
|
113
|
+
const items = [1, 2, 3, 4, 5]
|
|
114
|
+
let active = 0
|
|
115
|
+
let peak = 0
|
|
116
|
+
const summary = await runBatch(items, async (n) => {
|
|
117
|
+
active++
|
|
118
|
+
peak = Math.max(peak, active)
|
|
119
|
+
await sleep(20)
|
|
120
|
+
active--
|
|
121
|
+
return n
|
|
122
|
+
}, { concurrency: 2 })
|
|
123
|
+
assert.equal(peak, 2) // 两个 worker 各自跑,不会超过上限
|
|
124
|
+
assert.equal(summary.ok, 5)
|
|
125
|
+
assert.equal(summary.failed, 0)
|
|
126
|
+
// 后完成的项不会把结果顺序打乱
|
|
127
|
+
assert.deepEqual(summary.results.map((r) => r.item), items)
|
|
128
|
+
assert.deepEqual(summary.results.map((r) => r.result), items)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('runBatch:onItem 在每项开始前回调(进度用)', async () => {
|
|
132
|
+
const events = []
|
|
133
|
+
const summary = await runBatch(['a', 'b'], async (item) => {
|
|
134
|
+
events.push(`run:${item}`)
|
|
135
|
+
return item
|
|
136
|
+
}, {
|
|
137
|
+
concurrency: 1,
|
|
138
|
+
onItem: (item, index) => { events.push(`item:${index}:${item}`) }
|
|
139
|
+
})
|
|
140
|
+
assert.deepEqual(events, ['item:0:a', 'run:a', 'item:1:b', 'run:b'])
|
|
141
|
+
assert.equal(summary.ok, 2)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('runBatch:空列表直接返回零统计,不调用 runner', async () => {
|
|
145
|
+
let called = 0
|
|
146
|
+
const summary = await runBatch([], async () => { called++ })
|
|
147
|
+
assert.deepEqual(summary, { results: [], total: 0, ok: 0, failed: 0 })
|
|
148
|
+
assert.equal(called, 0)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('runBatch:非法 concurrency 回落为串行,参数类型错误抛 TypeError', async () => {
|
|
152
|
+
const summary = await runBatch([1, 2], async (n) => n, { concurrency: 0 })
|
|
153
|
+
assert.equal(summary.ok, 2)
|
|
154
|
+
await assert.rejects(() => runBatch([], 'not a function'), TypeError)
|
|
155
|
+
await assert.rejects(() => runBatch('not an array', async () => {}), TypeError)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('formatBatchReport:含总数 / 成功 / 失败 / 耗时 / 失败明细', () => {
|
|
159
|
+
const summary = {
|
|
160
|
+
total: 3,
|
|
161
|
+
ok: 2,
|
|
162
|
+
failed: 1,
|
|
163
|
+
results: [
|
|
164
|
+
{ item: { kind: 'bv', value: 'BV1ziWP6qENy' }, ok: true, result: {}, ms: 1000 },
|
|
165
|
+
{ item: { kind: 'file', value: String.raw`C:\videos\a.mp4` }, ok: false, error: '找不到文件', ms: 20 },
|
|
166
|
+
{ item: { kind: 'url', value: 'https://example.com/v.mp4' }, ok: true, result: {}, ms: 800 }
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
const report = formatBatchReport(summary)
|
|
170
|
+
assert.match(report, /共 3 条/)
|
|
171
|
+
assert.match(report, /成功 2 条/)
|
|
172
|
+
assert.match(report, /失败 1 条/)
|
|
173
|
+
assert.match(report, /1\.8s/) // 1000 + 20 + 800 ms
|
|
174
|
+
assert.match(report, /失败明细/)
|
|
175
|
+
assert.match(report, /\[file\] C:\\videos\\a\.mp4/)
|
|
176
|
+
assert.match(report, /找不到文件/)
|
|
177
|
+
// 失败明细只列失败项:成功的 BV 不出现在报告里
|
|
178
|
+
assert.ok(!report.includes('[bv] BV1ziWP6qENy'))
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('formatBatchReport:全部成功时不打印失败明细', () => {
|
|
182
|
+
const report = formatBatchReport({
|
|
183
|
+
total: 2,
|
|
184
|
+
ok: 2,
|
|
185
|
+
failed: 0,
|
|
186
|
+
results: [
|
|
187
|
+
{ item: { kind: 'bv', value: 'BV1ziWP6qENy' }, ok: true, ms: 300 },
|
|
188
|
+
{ item: { kind: 'bv', value: 'BV1xx411c7mD' }, ok: true, ms: 200 }
|
|
189
|
+
]
|
|
190
|
+
})
|
|
191
|
+
assert.match(report, /失败 0 条/)
|
|
192
|
+
assert.ok(!report.includes('失败明细'))
|
|
193
|
+
assert.match(report, /500ms/)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('parseBatchList + runBatch + formatBatchReport:全流程', async () => {
|
|
197
|
+
const { items, errors } = parseBatchList(String.raw`BV1ziWP6qENy
|
|
198
|
+
坏行
|
|
199
|
+
C:\v.mp4`)
|
|
200
|
+
assert.equal(items.length, 2)
|
|
201
|
+
assert.equal(errors.length, 1)
|
|
202
|
+
const summary = await runBatch(items, async (item) => {
|
|
203
|
+
if (item.kind === 'file') throw new Error('文件不存在')
|
|
204
|
+
return item.value
|
|
205
|
+
})
|
|
206
|
+
assert.equal(summary.total, 2)
|
|
207
|
+
assert.equal(summary.ok, 1)
|
|
208
|
+
assert.equal(summary.failed, 1)
|
|
209
|
+
const report = formatBatchReport(summary)
|
|
210
|
+
assert.match(report, /共 2 条/)
|
|
211
|
+
assert.match(report, /文件不存在/)
|
|
212
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// bili 模块单测:官方字幕选轨(纯函数,不联网)
|
|
2
|
+
// 「外文视频只转写不翻译」的修复核心在 pickSubtitleTracks:
|
|
3
|
+
// 主轨保留原文,同时把中文轨(B站 l2 机器翻译)单独挑出来给 translated。
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { pickSubtitleTracks, clean, keyOf, UA, MIXIN_TAB } from '../lib/bili.mjs'
|
|
7
|
+
|
|
8
|
+
const en = { lan: 'en-US', lan_doc: '英语', subtitle_url: '//aisubtitle.hdslb.com/en.json' }
|
|
9
|
+
const zh = { lan: 'zh-CN', lan_doc: '中文翻译', subtitle_url: '//aisubtitle.hdslb.com/zh.json' }
|
|
10
|
+
|
|
11
|
+
test('pickSubtitleTracks:外文主轨 + 中文翻译轨', () => {
|
|
12
|
+
const t = pickSubtitleTracks([en, zh])
|
|
13
|
+
assert.equal(t.main.lan, 'en-US')
|
|
14
|
+
assert.equal(t.translate.lan, 'zh-CN')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('pickSubtitleTracks:只有中文轨 → 主轨中文、翻译 null', () => {
|
|
18
|
+
const zhOnly = { lan: 'ai-zh', lan_doc: '中文(AI)', subtitle_url: '//x/zh.json' }
|
|
19
|
+
const t = pickSubtitleTracks([zhOnly])
|
|
20
|
+
assert.equal(t.main.lan, 'ai-zh')
|
|
21
|
+
assert.equal(t.translate, null)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('pickSubtitleTracks:空数组 → null', () => {
|
|
25
|
+
assert.equal(pickSubtitleTracks([]), null)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('pickSubtitleTracks:外文无中文翻译 → 翻译 null', () => {
|
|
29
|
+
const ja = { lan: 'ja-JP', lan_doc: '日语', subtitle_url: '//x/ja.json' }
|
|
30
|
+
const t = pickSubtitleTracks([ja])
|
|
31
|
+
assert.equal(t.main.lan, 'ja-JP')
|
|
32
|
+
assert.equal(t.translate, null)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('pickSubtitleTracks:多条中文轨仍只取一条(ai-zh 优先)', () => {
|
|
36
|
+
const t = pickSubtitleTracks([en, { lan: 'zh-Hans', lan_doc: '简体中文' }, { lan: 'ai-zh', lan_doc: '中文(AI)' }])
|
|
37
|
+
assert.equal(t.main.lan, 'en-US')
|
|
38
|
+
assert.equal(t.translate.lan, 'ai-zh')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('pickSubtitleTracks:preferZh=false 中文当主轨、不产生翻译', () => {
|
|
42
|
+
const t = pickSubtitleTracks([en, zh], false)
|
|
43
|
+
assert.equal(t.main.lan, 'zh-CN')
|
|
44
|
+
assert.equal(t.translate, null)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('pickSubtitleTracks:忽略无 lan 的脏项;全脏 → null', () => {
|
|
48
|
+
const t = pickSubtitleTracks([{ subtitle_url: '//x' }, null, en, zh])
|
|
49
|
+
assert.equal(t.main.lan, 'en-US')
|
|
50
|
+
assert.equal(t.translate.lan, 'zh-CN')
|
|
51
|
+
assert.equal(pickSubtitleTracks([{ subtitle_url: '//x' }, null]), null)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// —— 既有纯函数测试(0.5.0 起)——
|
|
55
|
+
test('clean:去除 WBI 禁止字符', () => {
|
|
56
|
+
assert.equal(clean("a!b'c(d)e*f"), 'abcdef')
|
|
57
|
+
assert.equal(clean('plain123'), 'plain123')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('keyOf:从 URL 提取文件名(去扩展名)', () => {
|
|
61
|
+
assert.equal(keyOf('https://example.com/path/file.txt'), 'file')
|
|
62
|
+
assert.equal(keyOf('https://a.b/c/d/e.f.js'), 'e.f')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('UA 与 MIXIN_TAB 常量存在', () => {
|
|
66
|
+
assert.equal(typeof UA, 'string')
|
|
67
|
+
assert.ok(UA.includes('Mozilla'))
|
|
68
|
+
assert.ok(Array.isArray(MIXIN_TAB))
|
|
69
|
+
assert.equal(MIXIN_TAB.length, 64)
|
|
70
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// budget 模块单测:token 估算 / 环境变量覆盖 / 模型系数
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { estimateTokens, tokensPerUnit, modelFactor, COST } from '../lib/budget.mjs'
|
|
5
|
+
|
|
6
|
+
const ENV_KEYS = ['FISHEYE_TOKENS_PER_GRID', 'FISHEYE_TOKENS_PER_SUBS', 'FISHEYE_TOKENS_PER_FINE', 'FISHEYE_TOKEN_MODEL']
|
|
7
|
+
function withEnv(overrides, fn) {
|
|
8
|
+
const saved = {}
|
|
9
|
+
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k] }
|
|
10
|
+
Object.assign(process.env, overrides)
|
|
11
|
+
try { return fn() } finally {
|
|
12
|
+
for (const k of ENV_KEYS) {
|
|
13
|
+
if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('默认值:grid/subs/fine = 1200/1000/1300', () => {
|
|
19
|
+
withEnv({}, () => {
|
|
20
|
+
assert.equal(estimateTokens('grid'), 1200)
|
|
21
|
+
assert.equal(estimateTokens('subs'), 1000)
|
|
22
|
+
assert.equal(estimateTokens('fine'), 1300)
|
|
23
|
+
assert.equal(estimateTokens('fine', 3), 3900)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('COST 默认表与旧版一致', () => {
|
|
28
|
+
assert.deepEqual({ ...COST }, { grid: 1200, subs: 1000, fine: 1300 })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('环境变量覆盖单张成本', () => {
|
|
32
|
+
withEnv({ FISHEYE_TOKENS_PER_GRID: '1500', FISHEYE_TOKENS_PER_SUBS: '900' }, () => {
|
|
33
|
+
assert.equal(estimateTokens('grid'), 1500)
|
|
34
|
+
assert.equal(estimateTokens('subs', 2), 1800)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('FISHEYE_TOKEN_MODEL:qwen/gemini/数字', () => {
|
|
39
|
+
withEnv({ FISHEYE_TOKEN_MODEL: 'qwen' }, () => {
|
|
40
|
+
assert.ok(modelFactor() > 1)
|
|
41
|
+
assert.ok(estimateTokens('grid') > 1200)
|
|
42
|
+
})
|
|
43
|
+
withEnv({ FISHEYE_TOKEN_MODEL: 'gemini' }, () => {
|
|
44
|
+
assert.ok(modelFactor() < 1)
|
|
45
|
+
})
|
|
46
|
+
withEnv({ FISHEYE_TOKEN_MODEL: '2' }, () => {
|
|
47
|
+
assert.equal(modelFactor(), 2)
|
|
48
|
+
assert.equal(estimateTokens('grid'), 2400)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('非法模型名回退 1.0;未知 kind 抛错', () => {
|
|
53
|
+
withEnv({ FISHEYE_TOKEN_MODEL: 'nonsense-model' }, () => {
|
|
54
|
+
assert.equal(modelFactor(), 1.0)
|
|
55
|
+
})
|
|
56
|
+
assert.throws(() => estimateTokens('unknown-kind'))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('count 非正/非有限 → 0', () => {
|
|
60
|
+
assert.equal(estimateTokens('grid', 0), 0)
|
|
61
|
+
assert.equal(estimateTokens('grid', -5), 0)
|
|
62
|
+
assert.equal(estimateTokens('grid', Number.NaN), 0)
|
|
63
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// env 模块单测:工具探测(onPath / resolveTool)
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { onPath, resolveTool } from '../lib/env.mjs'
|
|
6
|
+
|
|
7
|
+
test('onPath:Windows 的 where 命令存在', () => {
|
|
8
|
+
if (process.platform === 'win32') {
|
|
9
|
+
assert.equal(onPath('where'), true)
|
|
10
|
+
} else {
|
|
11
|
+
assert.equal(onPath('which'), true)
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('onPath:不存在的命令返回 false', () => {
|
|
16
|
+
assert.equal(onPath('definitely-not-a-real-command-xyz-123'), false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('resolveTool:环境变量优先', () => {
|
|
20
|
+
const saved = process.env.FISHEYE_TEST_TOOL
|
|
21
|
+
process.env.FISHEYE_TEST_TOOL = os.tmpdir()
|
|
22
|
+
try {
|
|
23
|
+
const r = resolveTool('FISHEYE_TEST_TOOL', 'testTool', [], 'nonexistent-cmd-xyz')
|
|
24
|
+
assert.ok(r)
|
|
25
|
+
assert.equal(r.cmd, os.tmpdir())
|
|
26
|
+
assert.match(r.from, /环境变量/)
|
|
27
|
+
} finally {
|
|
28
|
+
if (saved === undefined) delete process.env.FISHEYE_TEST_TOOL; else process.env.FISHEYE_TEST_TOOL = saved
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('resolveTool:全部找不到返回 null', () => {
|
|
33
|
+
const r = resolveTool('FISHEYE_TEST_TOOL_MISSING', 'missingTool', [os.tmpdir() + '\\no-such-exe.exe'], 'no-such-cmd-xyz-456')
|
|
34
|
+
assert.equal(r, null)
|
|
35
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// frame 模块单测:广告剔除 / 取帧时间点选择(纯函数部分)
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { subtractAds, pickTimes } from '../lib/frame.mjs'
|
|
5
|
+
|
|
6
|
+
test('subtractAds:无广告返回整段', () => {
|
|
7
|
+
assert.deepEqual(subtractAds(0, 100, []), [{ a: 0, b: 100 }])
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('subtractAds:中间广告被剔除', () => {
|
|
11
|
+
const out = subtractAds(0, 100, [{ from: 40, to: 60 }])
|
|
12
|
+
assert.deepEqual(out, [{ a: 0, b: 40 }, { a: 60, b: 100 }])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('subtractAds:整段都是广告 → 空', () => {
|
|
16
|
+
assert.deepEqual(subtractAds(0, 100, [{ from: -5, to: 105 }]), [])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('pickTimes:返回 n 个递增时间点', () => {
|
|
20
|
+
const r = pickTimes(0, 100, 9, [], [])
|
|
21
|
+
assert.ok(r && Array.isArray(r.times))
|
|
22
|
+
assert.equal(r.times.length, 9)
|
|
23
|
+
for (let i = 1; i < r.times.length; i++) assert.ok(r.times[i] > r.times[i - 1])
|
|
24
|
+
for (const t of r.times) assert.ok(t >= 0 && t <= 100)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('pickTimes:时间点不落在广告段内', () => {
|
|
28
|
+
const r = pickTimes(0, 100, 9, [], [{ from: 20, to: 80 }])
|
|
29
|
+
assert.ok(r && Array.isArray(r.times))
|
|
30
|
+
for (const t of r.times) assert.ok(t <= 20 || t >= 80, `time ${t} 落在广告段内部`)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('pickTimes:整段广告返回 null', () => {
|
|
34
|
+
assert.equal(pickTimes(0, 100, 9, [], [{ from: -1, to: 101 }]), null)
|
|
35
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// i18n 模块单测:语言切换与文案回退
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { getLang, t } from '../lib/i18n.mjs'
|
|
5
|
+
|
|
6
|
+
function withLang(value, fn) {
|
|
7
|
+
const saved = process.env.FISHEYE_LANG
|
|
8
|
+
if (value === undefined) delete process.env.FISHEYE_LANG; else process.env.FISHEYE_LANG = value
|
|
9
|
+
try { return fn() } finally {
|
|
10
|
+
if (saved === undefined) delete process.env.FISHEYE_LANG; else process.env.FISHEYE_LANG = saved
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('默认中文', () => {
|
|
15
|
+
withLang(undefined, () => {
|
|
16
|
+
assert.equal(getLang(), 'zh')
|
|
17
|
+
assert.ok(t('usage').startsWith('用法'))
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('FISHEYE_LANG=en 英文', () => {
|
|
22
|
+
withLang('en', () => {
|
|
23
|
+
assert.equal(getLang(), 'en')
|
|
24
|
+
assert.ok(t('usage').startsWith('Usage'))
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('en-US 归一化为 en', () => {
|
|
29
|
+
withLang('en-US', () => assert.equal(getLang(), 'en'))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('未知语言回退中文', () => {
|
|
33
|
+
withLang('fr', () => assert.equal(getLang(), 'zh'))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('占位符替换', () => {
|
|
37
|
+
withLang(undefined, () => {
|
|
38
|
+
const s = t('err.unknownStage', { stage: 'foo' })
|
|
39
|
+
assert.ok(s.includes('foo'))
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('缺失 key 原样返回(不抛错)', () => {
|
|
44
|
+
assert.equal(t('no.such.key.here'), 'no.such.key.here')
|
|
45
|
+
})
|
|
Binary file
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// ocr 模块单测:能力探测结构(环境无关)+ 有 tesseract 时实测一张自带文字的小图
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { findTesseract, ocrCaps, ocrImage } from '../lib/ocr.mjs'
|
|
8
|
+
|
|
9
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const SAMPLE = path.join(HERE, 'ocr-sample.png')
|
|
11
|
+
|
|
12
|
+
test('ocrCaps:结构完整、字段类型正确', () => {
|
|
13
|
+
const caps = ocrCaps()
|
|
14
|
+
assert.equal(typeof caps.available, 'boolean')
|
|
15
|
+
assert.equal(typeof caps.reason, 'string')
|
|
16
|
+
assert.equal(typeof caps.summary, 'string')
|
|
17
|
+
assert.ok(caps.summary.includes('OCR:'))
|
|
18
|
+
assert.ok(caps.summary.includes(caps.available ? '可用' : '不可用'))
|
|
19
|
+
assert.ok(caps.hint === null || typeof caps.hint === 'string')
|
|
20
|
+
assert.ok(caps.cmd === null || typeof caps.cmd === 'string')
|
|
21
|
+
assert.ok(caps.binDir === null || typeof caps.binDir === 'string')
|
|
22
|
+
assert.ok(caps.tessdata === null || typeof caps.tessdata === 'string')
|
|
23
|
+
assert.ok(caps.version === null || typeof caps.version === 'string')
|
|
24
|
+
assert.ok(Array.isArray(caps.langs))
|
|
25
|
+
for (const l of caps.langs) assert.equal(typeof l, 'string')
|
|
26
|
+
// 可用时必须能指到真实 cmd,且语言包列表非空
|
|
27
|
+
if (caps.available) {
|
|
28
|
+
assert.ok(caps.cmd && fs.existsSync(caps.cmd))
|
|
29
|
+
assert.ok(caps.langs.length > 0)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('ocrCaps:探测异常也不抛(env 为怪值)', () => {
|
|
34
|
+
const caps = ocrCaps({ env: { FISHEYE_TESSERACT: 123 } })
|
|
35
|
+
assert.equal(typeof caps.available, 'boolean')
|
|
36
|
+
assert.equal(typeof caps.summary, 'string')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('findTesseract:返回 null 或合法对象(环境无关)', () => {
|
|
40
|
+
const t = findTesseract()
|
|
41
|
+
if (t === null) return // 没装 tesseract 的机器上这是合法结果
|
|
42
|
+
assert.equal(typeof t.cmd, 'string')
|
|
43
|
+
assert.ok(fs.existsSync(t.cmd), `cmd 应该存在:${t.cmd}`)
|
|
44
|
+
assert.equal(
|
|
45
|
+
path.basename(t.cmd).toLowerCase(),
|
|
46
|
+
process.platform === 'win32' ? 'tesseract.exe' : 'tesseract',
|
|
47
|
+
)
|
|
48
|
+
assert.equal(typeof t.binDir, 'string')
|
|
49
|
+
assert.ok(fs.existsSync(t.binDir))
|
|
50
|
+
assert.ok(t.tessdata === null || typeof t.tessdata === 'string')
|
|
51
|
+
if (t.tessdata) assert.ok(fs.existsSync(t.tessdata))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('findTesseract:FISHEYE_TESSERACT 指向不存在的文件时优雅回退', () => {
|
|
55
|
+
const t = findTesseract({ env: { ...process.env, FISHEYE_TESSERACT: path.join(HERE, 'no-such-dir', 'tesseract.exe') } })
|
|
56
|
+
assert.ok(t === null || (typeof t.cmd === 'string' && fs.existsSync(t.cmd)))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('findTesseract:FISHEYE_TESSERACT 可指向安装目录', () => {
|
|
60
|
+
const real = findTesseract()
|
|
61
|
+
if (!real) return // 环境无关:没装就跳过
|
|
62
|
+
const t = findTesseract({ env: { ...process.env, FISHEYE_TESSERACT: real.binDir } })
|
|
63
|
+
assert.ok(t)
|
|
64
|
+
assert.equal(t.cmd.toLowerCase(), real.cmd.toLowerCase())
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('ocrImage:图片不存在时返回 null', () => {
|
|
68
|
+
const r = ocrImage(path.join(HERE, 'no-such-image.png'), { quiet: true })
|
|
69
|
+
assert.equal(r, null)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('ocrImage:实测识别 test/ocr-sample.png 里的 HELLO 123', (t) => {
|
|
73
|
+
const caps = ocrCaps()
|
|
74
|
+
if (!caps.available) {
|
|
75
|
+
t.skip('本机没有可用的 tesseract')
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (!fs.existsSync(SAMPLE)) {
|
|
79
|
+
t.skip('缺少 test/ocr-sample.png')
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const r = ocrImage(SAMPLE, { lang: 'eng', quiet: true })
|
|
83
|
+
assert.ok(r, 'tesseract 可用时应该识别出文本')
|
|
84
|
+
assert.equal(typeof r.text, 'string')
|
|
85
|
+
assert.match(r.text, /HELLO/i)
|
|
86
|
+
assert.match(r.text, /123/)
|
|
87
|
+
assert.equal(typeof r.cmd, 'string')
|
|
88
|
+
assert.equal(r.lang, 'eng')
|
|
89
|
+
})
|