@memef1f1y/dsh-plugin-tts 0.6.2 → 0.6.3
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/README.md +67 -27
- package/README.zh.md +290 -263
- package/docs/USER-GUIDE.md +72 -17
- package/docs/acceptance-checklist.md +3 -3
- package/docs/scroll-follow-v2.md +102 -0
- package/lib/client.js +242 -130
- package/lib/index.mjs +86 -8
- package/package.json +1 -1
- package/tests/client-load.mjs +33 -20
- package/tests/smoke.mjs +55 -0
package/lib/index.mjs
CHANGED
|
@@ -1184,6 +1184,37 @@ function isLatinHeavy(text) {
|
|
|
1184
1184
|
*/
|
|
1185
1185
|
const ATOM_RE = /https?:\/\/[^\s,。!?;,!?;]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\d+(?:\.\d+)+/g;
|
|
1186
1186
|
|
|
1187
|
+
/**
|
|
1188
|
+
* Character spans of each part within the source text: [[start,end), …].
|
|
1189
|
+
* Walks the text once, tolerating whitespace differences (trimmed part edges,
|
|
1190
|
+
* collapsed newlines, Cloud's synthetic join gaps) — never message content
|
|
1191
|
+
* aware beyond ordering. Returns null when the parts cannot be walked in
|
|
1192
|
+
* order (then callers fall back to equal division). Pure, unit-tested.
|
|
1193
|
+
* Lets the client map "chunk k" to an exact text slice for scroll-follow
|
|
1194
|
+
* highlight (docs/scroll-follow-v2.md P1-3) instead of guessing by length.
|
|
1195
|
+
*/
|
|
1196
|
+
function spanParts(text, parts) {
|
|
1197
|
+
const src = String(text || '');
|
|
1198
|
+
const spans = [];
|
|
1199
|
+
let pos = 0;
|
|
1200
|
+
for (const part of parts) {
|
|
1201
|
+
while (pos < src.length && /\s/.test(src[pos])) pos++;
|
|
1202
|
+
const start = pos;
|
|
1203
|
+
let ok = true;
|
|
1204
|
+
for (const ch of String(part)) {
|
|
1205
|
+
if (/\s/.test(ch)) {
|
|
1206
|
+
while (pos < src.length && /\s/.test(src[pos])) pos++;
|
|
1207
|
+
} else {
|
|
1208
|
+
if (src[pos] !== ch) { ok = false; break; }
|
|
1209
|
+
pos++;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (!ok) return null;
|
|
1213
|
+
spans.push([start, pos]);
|
|
1214
|
+
}
|
|
1215
|
+
return spans;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1187
1218
|
/** Split text into chunks of <= maxChars, preferring sentence/segment boundaries. */
|
|
1188
1219
|
function splitText(text, maxChars) {
|
|
1189
1220
|
const atoms = [];
|
|
@@ -1893,6 +1924,9 @@ function ingestSessionEvent(event) {
|
|
|
1893
1924
|
// NEVER the message text itself, and NEVER API keys.
|
|
1894
1925
|
// ---------------------------------------------------------------------------
|
|
1895
1926
|
const recentErrors = [];
|
|
1927
|
+
// Ring capacity: 20 used to evict real evidence under test traffic (smoke
|
|
1928
|
+
// runs share this file), so chunk-triage entries went missing from exports.
|
|
1929
|
+
const RECENT_CAP = 30;
|
|
1896
1930
|
// Persisted alongside calibration.json/settings.json so the failure history
|
|
1897
1931
|
// survives dsh restarts (a fresh update erases in-memory history otherwise).
|
|
1898
1932
|
// Same schema as the in-memory entries; no message text, no keys.
|
|
@@ -1916,14 +1950,14 @@ function recordHostError(entry) {
|
|
|
1916
1950
|
const e = Object.assign({ at: Date.now() }, entry);
|
|
1917
1951
|
if (!e.kind) e.kind = classifyHostError(e.error);
|
|
1918
1952
|
recentErrors.push(e);
|
|
1919
|
-
while (recentErrors.length >
|
|
1953
|
+
while (recentErrors.length > RECENT_CAP) recentErrors.shift();
|
|
1920
1954
|
saveRecentErrors();
|
|
1921
1955
|
} catch (err) { /* logging must never break synthesis */ }
|
|
1922
1956
|
}
|
|
1923
1957
|
function saveRecentErrors() {
|
|
1924
1958
|
try {
|
|
1925
1959
|
mkdirSync(CAL_DIR, { recursive: true });
|
|
1926
|
-
writeFileSync(RECENT_FILE, JSON.stringify(recentErrors.slice(-
|
|
1960
|
+
writeFileSync(RECENT_FILE, JSON.stringify(recentErrors.slice(-RECENT_CAP)));
|
|
1927
1961
|
} catch (e) { /* best-effort */ }
|
|
1928
1962
|
}
|
|
1929
1963
|
function loadRecentErrors() {
|
|
@@ -1931,7 +1965,7 @@ function loadRecentErrors() {
|
|
|
1931
1965
|
if (!existsSync(RECENT_FILE)) return;
|
|
1932
1966
|
const data = JSON.parse(readFileSync(RECENT_FILE, 'utf8'));
|
|
1933
1967
|
if (Array.isArray(data)) {
|
|
1934
|
-
for (const e of data.slice(-
|
|
1968
|
+
for (const e of data.slice(-RECENT_CAP)) {
|
|
1935
1969
|
if (e && typeof e === 'object') recentErrors.push(e);
|
|
1936
1970
|
}
|
|
1937
1971
|
}
|
|
@@ -1953,6 +1987,11 @@ const WORKER_META = {
|
|
|
1953
1987
|
spawnTimeoutMs: 45000,
|
|
1954
1988
|
retry: 'transient-once-800ms-backoff'
|
|
1955
1989
|
};
|
|
1990
|
+
// Host chunk-pipeline build tag, surfaced in /diagnose next to worker meta.
|
|
1991
|
+
// Bump on every Host-side playback fix: package.json versions only move on
|
|
1992
|
+
// releases, so without this two different Host builds are indistinguishable
|
|
1993
|
+
// in an exported log (exactly what stalled the 1/3/2 swap triage).
|
|
1994
|
+
const HOST_BUILD = '2026-09-26.02-job-summary';
|
|
1956
1995
|
|
|
1957
1996
|
export function apply(ctx) {
|
|
1958
1997
|
const webServer = ctx.get('webServer');
|
|
@@ -2059,6 +2098,30 @@ export function apply(ctx) {
|
|
|
2059
2098
|
return p;
|
|
2060
2099
|
}
|
|
2061
2100
|
|
|
2101
|
+
// One summary line per finished chunked job (drained or cancelled): without
|
|
2102
|
+
// it a serve-order incident leaves zero host trace (the 1/3/2 swap recorded
|
|
2103
|
+
// nothing). Idempotent — re-polls after completion must not duplicate it.
|
|
2104
|
+
// No message text, only counts (same privacy rule as the error ring).
|
|
2105
|
+
function noteJobDone(job, outcome) {
|
|
2106
|
+
if (!job || job.doneLogged) return;
|
|
2107
|
+
job.doneLogged = true;
|
|
2108
|
+
try {
|
|
2109
|
+
const skipped = Object.keys(job.skipErrors || {}).map((k) => Number(k) + 1).sort((a, b) => a - b);
|
|
2110
|
+
recordHostError({
|
|
2111
|
+
provider: job.provider || '?',
|
|
2112
|
+
voice: job.voice,
|
|
2113
|
+
total: job.parts.length,
|
|
2114
|
+
served: job.serveIdx,
|
|
2115
|
+
skipped,
|
|
2116
|
+
stage: 'job',
|
|
2117
|
+
kind: 'info',
|
|
2118
|
+
ms: Date.now() - (job.created || Date.now()),
|
|
2119
|
+
error: 'outcome=' + outcome + ' served=' + job.serveIdx + '/' + job.parts.length +
|
|
2120
|
+
' skipped=[' + skipped.join(',') + ']'
|
|
2121
|
+
});
|
|
2122
|
+
} catch (e) { /* logging must never break serving */ }
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2062
2125
|
// Serve the next index in order, awaiting its own synthesis when needed.
|
|
2063
2126
|
// Never synthesizes serveIdx+1 before serveIdx resolves, so the client —
|
|
2064
2127
|
// which fills queue[want] in request order — always gets parts in order.
|
|
@@ -2069,13 +2132,17 @@ export function apply(ctx) {
|
|
|
2069
2132
|
const idx = job.serveIdx;
|
|
2070
2133
|
if (idx >= job.parts.length) {
|
|
2071
2134
|
job.finishedAt = job.finishedAt || Date.now();
|
|
2135
|
+
noteJobDone(job, 'drained');
|
|
2072
2136
|
return { done: true };
|
|
2073
2137
|
}
|
|
2074
2138
|
const known = job.urls[idx];
|
|
2075
2139
|
if (typeof known === 'string' || known === false) {
|
|
2076
2140
|
job.serveIdx++;
|
|
2077
2141
|
const more = job.serveIdx < job.parts.length;
|
|
2078
|
-
if (!more)
|
|
2142
|
+
if (!more) {
|
|
2143
|
+
job.finishedAt = job.finishedAt || Date.now();
|
|
2144
|
+
noteJobDone(job, 'drained');
|
|
2145
|
+
}
|
|
2079
2146
|
if (known === false) {
|
|
2080
2147
|
return { skipped: idx + 1, error: job.skipErrors ? job.skipErrors[idx] || '' : '', more, index: idx };
|
|
2081
2148
|
}
|
|
@@ -2090,7 +2157,10 @@ export function apply(ctx) {
|
|
|
2090
2157
|
}
|
|
2091
2158
|
job.serveIdx++;
|
|
2092
2159
|
const more = job.serveIdx < job.parts.length;
|
|
2093
|
-
if (!more)
|
|
2160
|
+
if (!more) {
|
|
2161
|
+
job.finishedAt = job.finishedAt || Date.now();
|
|
2162
|
+
noteJobDone(job, 'drained');
|
|
2163
|
+
}
|
|
2094
2164
|
if (r && r.url) return { url: r.url, more, index: idx };
|
|
2095
2165
|
if (r && r.skipped) return { skipped: idx + 1, error: r.error || '', more, index: idx };
|
|
2096
2166
|
return { done: true };
|
|
@@ -2106,6 +2176,7 @@ export function apply(ctx) {
|
|
|
2106
2176
|
if (!job) return false;
|
|
2107
2177
|
job.done = true;
|
|
2108
2178
|
job.finishedAt = job.finishedAt || Date.now();
|
|
2179
|
+
noteJobDone(job, 'cancelled');
|
|
2109
2180
|
jobs.delete(jobId);
|
|
2110
2181
|
return true;
|
|
2111
2182
|
}
|
|
@@ -2227,6 +2298,9 @@ export function apply(ctx) {
|
|
|
2227
2298
|
const parts = provider === 'google-cloud-tts'
|
|
2228
2299
|
? splitCloudPackets(text)
|
|
2229
2300
|
: splitText(text, maxChars);
|
|
2301
|
+
// Block spans for scroll-follow highlight mapping (P1-3); omitted
|
|
2302
|
+
// when unwalkable — the client falls back to equal division.
|
|
2303
|
+
const spans = spanParts(text, parts);
|
|
2230
2304
|
if (parts.length > 1) {
|
|
2231
2305
|
const jobId = 'j' + (++jobSeq).toString(36);
|
|
2232
2306
|
const job = {
|
|
@@ -2277,7 +2351,8 @@ export function apply(ctx) {
|
|
|
2277
2351
|
chunks: [first.url],
|
|
2278
2352
|
total: parts.length,
|
|
2279
2353
|
ratio: cal.ratio,
|
|
2280
|
-
chunkSec: cal.chunkSec
|
|
2354
|
+
chunkSec: cal.chunkSec,
|
|
2355
|
+
...(spans ? { spans } : {})
|
|
2281
2356
|
});
|
|
2282
2357
|
return;
|
|
2283
2358
|
}
|
|
@@ -2308,7 +2383,8 @@ export function apply(ctx) {
|
|
|
2308
2383
|
total: parts.length,
|
|
2309
2384
|
ratio: cal.ratio,
|
|
2310
2385
|
chunkSec: cal.chunkSec,
|
|
2311
|
-
skipped: prewarmSkipped
|
|
2386
|
+
skipped: prewarmSkipped,
|
|
2387
|
+
...(spans ? { spans } : {})
|
|
2312
2388
|
});
|
|
2313
2389
|
return;
|
|
2314
2390
|
}
|
|
@@ -2756,8 +2832,9 @@ export function apply(ctx) {
|
|
|
2756
2832
|
checks,
|
|
2757
2833
|
plugin: PLUGIN_META,
|
|
2758
2834
|
worker: WORKER_META,
|
|
2835
|
+
hostBuild: HOST_BUILD,
|
|
2759
2836
|
activeJobs,
|
|
2760
|
-
recent: recentErrors.slice(-
|
|
2837
|
+
recent: recentErrors.slice(-RECENT_CAP)
|
|
2761
2838
|
});
|
|
2762
2839
|
} catch (e) {
|
|
2763
2840
|
writeRouteError(res, e);
|
|
@@ -3268,6 +3345,7 @@ export const __test = {
|
|
|
3268
3345
|
classifyHostError,
|
|
3269
3346
|
countWorkerRetries,
|
|
3270
3347
|
splitText,
|
|
3348
|
+
spanParts,
|
|
3271
3349
|
splitCloudPackets,
|
|
3272
3350
|
chunkCal,
|
|
3273
3351
|
cloudTier,
|
package/package.json
CHANGED
package/tests/client-load.mjs
CHANGED
|
@@ -324,40 +324,53 @@ if (!failed) {
|
|
|
324
324
|
// 4) onBarClick never throws on missing event + reads live shared.speaking
|
|
325
325
|
const clickOk = /const onBarClick[\s\S]{0,600}?typeof e\.stopPropagation[\s\S]{0,200}?shared\.speaking\) togglePause\(\)/.test(src);
|
|
326
326
|
check('onBarClick hardened (no-throw + live speaking)', clickOk);
|
|
327
|
-
// 5)
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
327
|
+
// 5) scroll-follow v2: block-index stepwise signal, visibility-driven
|
|
328
|
+
// minimal scrolling (no polling loop), latch-off with manual resume.
|
|
329
|
+
const stepOk = /const ttsBlockIdx = \(\) =>/.test(src) && /ttsBlockHead/.test(src) &&
|
|
330
|
+
!/ttsLiveRatio/.test(src) && !/chunkStartedAt \|\| Date\.now\(\)\)\) \/ 1000/.test(src);
|
|
331
|
+
const noPoll = !/setInterval\(keepVisible/.test(src) && !/setInterval\(ttsFollowTick/.test(src);
|
|
332
|
+
const latchOk = /ttsFollow\.latched = false/.test(src) && /follow\.resume/.test(src) &&
|
|
333
|
+
!/ttsUserScrollAt\.t < 5000/.test(src);
|
|
334
|
+
check('scroll-follow v2 stepwise signal, no poll, latch-off resume', stepOk && noPoll && latchOk,
|
|
335
|
+
`step=${stepOk} noPoll=${noPoll} latch=${latchOk}`);
|
|
336
|
+
// 5b) self-scroll fence via timestamp + scrollend (covers smooth-scroll
|
|
337
|
+
// duration); takeover sources are wheel/touchmove/pointer-drag only, so
|
|
338
|
+
// host autoscroll during streaming never latches follow off.
|
|
339
|
+
const selfOk = /ttsFollow\.selfUntil = Date\.now\(\) \+ 600/.test(src) && /scrollend/.test(src);
|
|
340
|
+
const takeOk = /onTouchMove/.test(src) && /onPointerMove/.test(src) && /onWheel/.test(src);
|
|
341
|
+
check('follow self-fence + gesture takeover sources', selfOk && takeOk,
|
|
342
|
+
`self=${selfOk} take=${takeOk}`);
|
|
340
343
|
// 5c) message anchor: button click climbs to the message root (WeakRef),
|
|
341
|
-
//
|
|
344
|
+
// fingerprint relocate survives re-renders; paragraph hits are never
|
|
345
|
+
// cached as anchors (fragile, single-tick use).
|
|
342
346
|
const anchorOk = /shared\.readingMsgEl = new WeakRef\(best\)/.test(src) &&
|
|
343
347
|
/shared\.readingMsgEl \? shared\.readingMsgEl\.deref\(\) : null/.test(src);
|
|
344
348
|
check('follow uses anchored message element', anchorOk);
|
|
345
349
|
// 5d) anchor survives re-render (fingerprint relocate) + wrong-element
|
|
346
|
-
// guard (never rubber-band
|
|
350
|
+
// guard (never rubber-band on an absurd hit) + nearest-scroll.
|
|
347
351
|
const relocOk = /shared\.readingMsgKey = \{ head/.test(src) && /mark\("relocated"\)/.test(src);
|
|
348
|
-
const guardOk = /mark\("bad-target"\)/.test(src);
|
|
352
|
+
const guardOk = /mark\("bad-target"\)/.test(src) && /block:"nearest"/.test(src);
|
|
349
353
|
check('anchor relocates + bad target skipped', relocOk && guardOk,
|
|
350
354
|
`reloc=${relocOk} guard=${guardOk}`);
|
|
355
|
+
// 5e) reduced-motion respected: follow bails before touching the view.
|
|
356
|
+
check('follow respects prefers-reduced-motion', /ttsReducedMotion\(\)/.test(src) && /mark\("reduced-motion"\)/.test(src));
|
|
357
|
+
// 5f) exact server spans feed the block locator when present (P1-3),
|
|
358
|
+
// equal-division fallback otherwise; spans reset per utterance.
|
|
359
|
+
const spansOk = /shared\.chunkSpans = Array\.isArray\(result\.spans\)/.test(src) &&
|
|
360
|
+
/shared\.chunkSpans = null/.test(src) && /sp\[idx - 1\]\[0\]/.test(src);
|
|
361
|
+
check('follow consumes server block spans with fallback', spansOk);
|
|
351
362
|
// 6) compact bar: no "n / total" counter in bar text (tooltip only)
|
|
352
363
|
const noCounter = !/overlay\.reading"\)\) \+ " " \+ idx/.test(src) && !/\("overlay\.paused"\) : t\("overlay\.reading"\)\) \+/.test(src);
|
|
353
364
|
const pausedOk = /shared\.paused \? t\("overlay\.paused"\) : t\("overlay\.reading"\)(?!\) \+)/.test(src);
|
|
354
365
|
check('bar compact: paused/reading text only, no counter', noCounter && pausedOk,
|
|
355
366
|
`noCounter=${noCounter} pausedOk=${pausedOk}`);
|
|
356
|
-
// 7)
|
|
367
|
+
// 7) v2 block highlight restored as the visual anchor for follow: soft
|
|
368
|
+
// background, never the old box-shadow style.
|
|
369
|
+
const hlCss = /\.dsh-tts-sentence-active\{[^}]*background/.test(src);
|
|
357
370
|
const noShadowCss = !/\.dsh-tts-sentence-active\{[^}]*box-shadow/.test(src);
|
|
358
|
-
const
|
|
359
|
-
check('
|
|
360
|
-
`
|
|
371
|
+
const addActive = /classList\.add\("dsh-tts-sentence-active"\)/.test(src);
|
|
372
|
+
check('block highlight restored (soft bg, no shadow)', hlCss && noShadowCss && addActive,
|
|
373
|
+
`css=${hlCss} noShadow=${noShadowCss} add=${addActive}`);
|
|
361
374
|
// 8) playback mutual exclusion: playSeq generation guards all audio
|
|
362
375
|
// callbacks; stop hard-aborts decode via load(); chunk queue is indexed
|
|
363
376
|
// (no push-on-resolve reorder).
|
package/tests/smoke.mjs
CHANGED
|
@@ -330,6 +330,23 @@ if (speakRoute && audioRoute) {
|
|
|
330
330
|
drained++;
|
|
331
331
|
}
|
|
332
332
|
check('rvc-next drains to total chunks', fetched === longParsed.total && more === false, `fetched=${fetched} total=${longParsed.total}`);
|
|
333
|
+
// Job-completion summary: draining to the end must leave exactly one
|
|
334
|
+
// stage=job outcome=drained entry (served==total) in the diagnose log —
|
|
335
|
+
// the trace a serve-order incident would previously have left none of.
|
|
336
|
+
{
|
|
337
|
+
const dgJob = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
|
|
338
|
+
const drJob = await call(dgJob, mockReq('/dsh-tts-api/diagnose', JSON.stringify({})), mockRes());
|
|
339
|
+
const ddJob = JSON.parse(drJob.body);
|
|
340
|
+
const jobHit = Array.isArray(ddJob.recent) && ddJob.recent.find((e) =>
|
|
341
|
+
e && e.stage === 'job' && /outcome=drained/.test(e.error || '') &&
|
|
342
|
+
e.total === longParsed.total && e.served === longParsed.total &&
|
|
343
|
+
Array.isArray(e.skipped) && e.skipped.length === 0);
|
|
344
|
+
check('drained job leaves a served==total summary in diagnose.recent',
|
|
345
|
+
!!jobHit, JSON.stringify((ddJob.recent || []).filter((e) => e && e.stage === 'job')));
|
|
346
|
+
check('diagnose exposes hostBuild tag (which Host code ran)',
|
|
347
|
+
typeof ddJob.hostBuild === 'string' && ddJob.hostBuild.length > 0,
|
|
348
|
+
JSON.stringify(ddJob.hostBuild));
|
|
349
|
+
}
|
|
333
350
|
check('rvc-next done for unknown job', (await call(nextRoute, mockReq('/dsh-tts-api/rvc-next?job=unknown'), mockRes())).body === JSON.stringify({ done: true, gone: true }));
|
|
334
351
|
// a prewarmed chunk url must serve wav through the audio route
|
|
335
352
|
const cRes = await call(audioRoute, mockReq(longParsed.chunks[0]), mockRes());
|
|
@@ -404,6 +421,17 @@ if (speakRoute && audioRoute) {
|
|
|
404
421
|
&& skipParsed.chunks.length >= 2 && skipParsed.chunks[0] && skipParsed.chunks[1] === false
|
|
405
422
|
&& Array.isArray(skipParsed.skipped) && skipParsed.skipped.length === 1 && skipParsed.skipped[0] === 2,
|
|
406
423
|
skipRes.body.slice(0, 200));
|
|
424
|
+
// P1-3 spans ride the same response: count matches total and the span
|
|
425
|
+
// slices rebuild the sent text (whitespace-insensitive).
|
|
426
|
+
{
|
|
427
|
+
const sps = skipParsed.spans;
|
|
428
|
+
const rebuilt = Array.isArray(sps)
|
|
429
|
+
? sps.map(([a, b]) => skipText.slice(a, b)).join('').replace(/\s/g, '')
|
|
430
|
+
: null;
|
|
431
|
+
check('chunked /speak carries exact block spans',
|
|
432
|
+
Array.isArray(sps) && sps.length === skipParsed.total && rebuilt === skipText.replace(/\s/g, ''),
|
|
433
|
+
`spans=${sps && sps.length} total=${skipParsed.total}`);
|
|
434
|
+
}
|
|
407
435
|
if (typeof skipParsed.jobId === 'string' && Array.isArray(skipParsed.chunks)) {
|
|
408
436
|
const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
|
|
409
437
|
// serveIdx starts past prewarm; the on-demand serves must be the
|
|
@@ -800,6 +828,33 @@ if (speakRoute && audioRoute) {
|
|
|
800
828
|
// short text: single chunk, unchanged semantics
|
|
801
829
|
const r4 = splitText('你好。', 30);
|
|
802
830
|
check('splitText: short text stays one chunk', r4.length === 1 && r4[0] === '你好。', JSON.stringify(r4));
|
|
831
|
+
|
|
832
|
+
// spanParts (scroll-follow P1-3): per-part char spans walk the source in
|
|
833
|
+
// order; concatenated span slices reproduce the text minus whitespace.
|
|
834
|
+
const spanOf = plugin.__test.spanParts;
|
|
835
|
+
check('__test.spanParts exposed', typeof spanOf === 'function');
|
|
836
|
+
if (typeof spanOf === 'function') {
|
|
837
|
+
const st = '第一句。第二句很长很长,需要切块 Parece que sí。第三句。';
|
|
838
|
+
const sp = splitText(st, 12);
|
|
839
|
+
const ssp = spanOf(st, sp);
|
|
840
|
+
const rebuilt = Array.isArray(ssp)
|
|
841
|
+
? ssp.map(([a, b]) => st.slice(a, b)).join('').replace(/\s/g, '')
|
|
842
|
+
: null;
|
|
843
|
+
check('spanParts: spans walk in order and rebuild the text',
|
|
844
|
+
Array.isArray(ssp) && ssp.length === sp.length &&
|
|
845
|
+
rebuilt === st.replace(/\s/g, ''),
|
|
846
|
+
`parts=${sp.length} spans=${JSON.stringify(ssp)}`);
|
|
847
|
+
// Cloud synthetic join gaps (latin alnum boundary spaces) match zero
|
|
848
|
+
// source whitespace instead of failing the walk.
|
|
849
|
+
const cl = 'Visit https://example.com/a.b.c now please ok。'.repeat(6);
|
|
850
|
+
const cp = plugin.__test.splitCloudPackets(cl);
|
|
851
|
+
const csp = spanOf(cl, cp);
|
|
852
|
+
check('spanParts: cloud packets walk despite synthetic gaps',
|
|
853
|
+
Array.isArray(csp) && csp.length === cp.length,
|
|
854
|
+
`packets=${cp.length} spans=${csp && csp.length}`);
|
|
855
|
+
check('spanParts: unwalkable parts return null (fallback signal)',
|
|
856
|
+
spanOf('abc', ['zzz']) === null);
|
|
857
|
+
}
|
|
803
858
|
}
|
|
804
859
|
}
|
|
805
860
|
|