1pub const PLAYER_JS: &str =
16 concat!(include_str!("vt.js"), "\n", include_str!("controller.js"), "\n", include_str!("player.js"),);
17
18pub const PLAYER_CSS: &str = include_str!("player.css");
21
22#[cfg(test)]
23mod tests {
24 use super::*;
25
26 #[test]
32 fn player_bundle_is_inline_safe_and_first_party() {
33 assert!(!PLAYER_JS.contains("</script"), "bundle would terminate an inline <script>");
34 assert!(!PLAYER_JS.contains("<!--"), "bundle would enter the script double-escaped state");
35 assert!(!PLAYER_JS.to_lowercase().contains("worker"), "bundle must not load a worker sidecar");
36 assert!(!PLAYER_CSS.contains("url("), "player CSS must not fetch fonts/images");
37 assert!(PLAYER_JS.contains("BeeCastPlayer") && PLAYER_JS.contains("Clean-room implementation"));
38 assert!(PLAYER_JS.contains("BeeCastController"));
39 assert!(PLAYER_JS.contains("beecast-player"));
40 for banned in ["asciinema-player", "AsciinemaPlayer", "@license", "Apache"] {
41 assert!(!PLAYER_JS.contains(banned) && !PLAYER_CSS.contains(banned), "third-party marker '{banned}'");
42 }
43 }
44
45 #[test]
48 fn overlay_and_marker_jumps_do_not_autoplay() {
49 assert!(
50 PLAYER_JS.contains("state.status !== 'playing' && state.duration > 0"),
51 "overlay must show whenever not playing, not only at t = 0"
52 );
53 assert!(!PLAYER_JS.contains("currentTime <= 1e-9 && state.duration"), "the t≈0-only overlay gate must stay gone");
54 assert!(
56 !PLAYER_JS.contains("this.play(origin || 'marker')") && !PLAYER_JS.contains("self.play('marker')"),
57 "marker/chapter navigation must not force play"
58 );
59 assert!(PLAYER_JS.contains("'|>'") || PLAYER_JS.contains("\"|>\""), "play overlay is a large monospace |>");
60 assert!(!PLAYER_JS.contains("▄█"), "the block-character triangle glyph must stay gone");
61 }
62
63 #[test]
67 fn fit_both_refuses_the_content_sized_shrink_ratchet() {
68 assert!(
69 PLAYER_JS.contains("mountHeightIsDefinite"),
70 "layout must probe whether the mount's height is independent of the screen box"
71 );
72 assert!(
73 PLAYER_JS.contains("before > 0 && before === after"),
74 "definite-height probe must compare mount clientHeight before/after collapsing the box"
75 );
76 assert!(
77 !PLAYER_JS.contains("availH - 4"),
78 "the availH-4 trigger forced another shrink on every ResizeObserver tick"
79 );
80 }
81
82 #[test]
84 fn public_api_surface_is_documented() {
85 for key in [
86 "BeeCastVT",
87 "BeeCastController",
88 "BeeCastPlayer",
89 "parseCast",
90 "appendCast",
91 "buildPacing",
92 "extendPacing",
93 "mapTime",
94 "create",
95 "getState",
96 "subscribe",
97 "setSpeed",
98 "publicMethods",
99 "supportedCssVariables",
100 "nonPublicFields",
101 ] {
102 assert!(PLAYER_JS.contains(key), "public surface missing {key}");
103 }
104 for token in
105 ["--beecast-color-surface", "--beecast-color-accent", "--beecast-color-focus", "--beecast-font-terminal"]
106 {
107 assert!(PLAYER_CSS.contains(token), "theme token missing {token}");
108 }
109 }
110
111 #[test]
114 fn vt_core_node_selftest() {
115 let dir = std::env::temp_dir().join(format!("beecast-vt-selftest-{}", std::process::id()));
116 std::fs::create_dir_all(&dir).unwrap();
117 let bundle = dir.join("player.js");
118 std::fs::write(&bundle, PLAYER_JS).unwrap();
119 let script = r#"
120const assert = require('assert');
121require(process.argv[2]);
122const VT = globalThis.BeeCastVT;
123const C = globalThis.BeeCastController;
124const P = globalThis.BeeCastPlayer;
125
126// ---- Phase 0: public keys --------------------------------------------------------------
127for (const k of ['parseCast','appendCast','buildPacing','extendPacing','mapTime','Term']) {
128 assert.strictEqual(typeof VT[k], 'function', 'BeeCastVT.' + k);
129}
130assert.strictEqual(typeof C.create, 'function');
131assert.strictEqual(typeof P.create, 'function');
132assert.ok(Array.isArray(P.publicMethods));
133for (const m of ['create','play','pause','toggle','seek','getCurrentTime','append','dispose']) {
134 assert.ok(P.publicMethods.includes(m), 'publicMethods missing ' + m);
135}
136assert.ok(P.nonPublicFields.includes('playing'));
137assert.ok(P.nonPublicFields.includes('pacedPos'));
138assert.ok(P.supportedCssVariables.includes('--beecast-color-accent'));
139
140// ---- VT core (existing coverage) -------------------------------------------------------
141let c = VT.parseCast('{"version":3,"term":{"cols":10,"rows":3}}\n# note\n[0.5,"o","hi"]\n[0.5,"m","chapter"]\n[1.0,"r","20x5"]\n');
142assert.strictEqual(c.cols, 10); assert.strictEqual(c.rows, 3);
143assert.strictEqual(c.events.length, 3);
144assert.strictEqual(c.duration, 2);
145
146c = VT.parseCast('{"version":2,"width":80,"height":24}\n[0.5,"o","a"]\n[2.0,"o","b"]\n');
147assert.strictEqual(c.duration, 2); assert.strictEqual(c.events[1].t, 2);
148
149c = VT.parseCast('{"version":1,"width":5,"height":2,"stdout":[[0.1,"x"],[0.2,"y"]]}');
150assert.strictEqual(c.cols, 5); assert.strictEqual(c.events.length, 2);
151
152c = VT.parseCast('{"version":3,"term":{"cols":10,"rows":3}}\n[1.0,"o","a"]\n');
153assert.strictEqual(VT.appendCast(c, '[0.5,"o",'), 0);
154assert.strictEqual(c.duration, 1);
155assert.strictEqual(VT.appendCast(c, '"b"]\n[0.5,"m","x"]\n'), 2);
156assert.strictEqual(c.duration, 2);
157assert.strictEqual(c.events.length, 3);
158assert.strictEqual(c.events[1].t, 1.5);
159
160c = VT.parseCast('{"version":2,"width":80,"height":24}\n[1.0,"o","a"]\n');
161VT.appendCast(c, '# noise\n{"version":2}\n[3.0,"o","b"]\n');
162assert.strictEqual(c.duration, 3); assert.strictEqual(c.events[1].t, 3);
163
164c = VT.parseCast('{"version":3,"term":{"cols":4,"rows":1}}\n[1.0,"o","hi"]\n[2.0,"o');
165assert.strictEqual(c.events.length, 1);
166VT.appendCast(c, '","yo"]\n');
167assert.strictEqual(c.events.length, 2); assert.strictEqual(c.duration, 3);
168
169c = VT.parseCast('{"version":1,"width":5,"height":2,"stdout":[[0.1,"x"]]}');
170assert.strictEqual(VT.appendCast(c, '[1.0,"o","y"]\n'), 0);
171
172const ev = [{t:1},{t:2}];
173const pacing = VT.buildPacing(ev, 2, null);
174ev.push({t:10});
175VT.extendPacing(pacing, ev, 2, 10);
176assert.strictEqual(pacing.pacedDuration, 10);
177assert.strictEqual(VT.mapTime(pacing.rec, pacing.paced, 10), 10);
178const limited = VT.buildPacing([{t:1}], 1, 2);
179VT.extendPacing(limited, [{t:1},{t:9}], 1, 9);
180assert.strictEqual(limited.pacedDuration, 3);
181assert.strictEqual(VT.mapTime(limited.paced, limited.rec, 3), 9);
182
183let t = new VT.Term(10, 3);
184t.write('hello\r\nworld');
185assert.deepStrictEqual(t.textLines(), ['hello', 'world', '']);
186t.write('\x1b[1;3Hga');
187assert.strictEqual(t.textLines()[0], 'hegao');
188t.write('\x1b[2J');
189assert.deepStrictEqual(t.textLines(), ['', '', '']);
190
191t = new VT.Term(10, 1);
192t.write('\x1b[31mred\x1b[0m ok');
193const runs = t.snapshot().rows[0];
194assert.strictEqual(runs[0].text, 'red'); assert.strictEqual(runs[0].fg, 1);
195t = new VT.Term(4, 1);
196t.write('\x1b[38;5;196mX\x1b[38;2;1;2;3mY');
197assert.strictEqual(t.snapshot().rows[0][0].fg, 196);
198assert.strictEqual(t.snapshot().rows[0][1].fg, '#010203');
199
200t = new VT.Term(3, 2);
201t.write('abc');
202assert.strictEqual(t.snapshot().cursor.y, 0);
203t.write('d');
204assert.deepStrictEqual(t.textLines(), ['abc', 'd']);
205
206t = new VT.Term(5, 4);
207t.write('aa\r\nbb\r\ncc\r\ndd');
208t.write('\x1b[2;3r\x1b[3;1H\n');
209assert.strictEqual(t.textLines()[0], 'aa');
210assert.strictEqual(t.textLines()[1], 'cc');
211assert.strictEqual(t.textLines()[3], 'dd');
212
213t = new VT.Term(5, 2);
214t.write('main');
215t.write('\x1b[?1049h\x1b[Halt');
216assert.strictEqual(t.textLines()[0], 'alt');
217t.write('\x1b[?1049l');
218assert.strictEqual(t.textLines()[0], 'main');
219
220t = new VT.Term(4, 1);
221t.write('\x1b(0qqx\x1b(B');
222assert.strictEqual(t.textLines()[0], '──│');
223t = new VT.Term(8, 1);
224t.write('\x1b]0;title\x07ok');
225assert.strictEqual(t.textLines()[0], 'ok');
226t.write('\x1b[?25l');
227assert.strictEqual(t.snapshot().cursor.visible, false);
228
229// ---- Phase 1: headless controller (no DOM) ---------------------------------------------
230function fakeClock() {
231 let now = 0;
232 const q = [];
233 return {
234 now: () => now,
235 requestAnimationFrame: (cb) => { q.push(cb); return q.length; },
236 cancelAnimationFrame: () => { q.length = 0; },
237 flush: (ms) => {
238 now += ms;
239 const batch = q.splice(0, q.length);
240 for (const cb of batch) cb(now);
241 },
242 };
243}
244
245const castText = '{"version":3,"term":{"cols":8,"rows":2}}\n[0,"o","hi"]\n[1.0,"o","!"]\n[1.0,"m","mid"]\n';
246const clock = fakeClock();
247const ctrl = C.create({
248 data: castText,
249 idleTimeLimit: 2,
250 speed: 1,
251 markers: [[0, 'start']],
252 clock: clock,
253});
254
255// Initial state
256let state = ctrl.getState();
257assert.strictEqual(state.status, 'idle');
258assert.strictEqual(state.currentTime, 0);
259assert.strictEqual(state.duration, 2);
260assert.strictEqual(state.speed, 1);
261assert.strictEqual(state.canAppend, true);
262assert.ok(state.markers.length >= 2); // sidecar + in-band
263assert.ok(state.markers.every(m => m.id && typeof m.time === 'number' && m.label != null));
264assert.ok(state.terminal.rows.length === 2);
265assert.strictEqual(state.dimensions.columns, 8);
266
267// Terminal snapshots are cached until output or resize dirties the terminal.
268let snapshotCalls = 0;
269const originalSnapshot = ctrl.term.snapshot;
270ctrl.term.snapshot = function () { snapshotCalls++; return originalSnapshot.call(this); };
271const cachedTerminal = ctrl.getState().terminal;
272assert.strictEqual(ctrl.getState().terminal, cachedTerminal);
273assert.strictEqual(snapshotCalls, 0, 'initial snapshot remains cached');
274ctrl.seek(1.5);
275const changedTerminal = ctrl.getState().terminal;
276assert.notStrictEqual(changedTerminal, cachedTerminal);
277assert.strictEqual(snapshotCalls, 1, 'events invalidate the cached snapshot once');
278assert.strictEqual(ctrl.getState().terminal, changedTerminal);
279assert.strictEqual(snapshotCalls, 1, 'unchanged state does not copy the terminal again');
280ctrl.seek(0);
281
282// subscribe delivers immediately and is removable
283const seen = [];
284const unsub = ctrl.subscribe((s, meta) => { seen.push(meta.type); });
285assert.ok(seen.includes('ready'));
286unsub();
287unsub(); // idempotent
288const n = seen.length;
289ctrl.setSpeed(2);
290// no more callbacks after unsubscribe
291assert.strictEqual(seen.length, n);
292
293// setSpeed does not rebuild terminal / changes state
294ctrl.setSpeed(1.5);
295assert.strictEqual(ctrl.getState().speed, 1.5);
296
297// play / pause / seek / getCurrentTime
298// First animation frame establishes lastTick (dt=0); the second advances the clock.
299ctrl.play();
300assert.strictEqual(ctrl.getState().status, 'playing');
301clock.flush(0);
302clock.flush(500); // 0.5s wall * 1.5 speed ≈ 0.75s paced
303assert.ok(ctrl.getCurrentTime() > 0);
304ctrl.pause();
305assert.strictEqual(ctrl.getState().status, 'paused');
306
307ctrl.seek(1.5);
308assert.ok(Math.abs(ctrl.getCurrentTime() - 1.5) < 1e-9);
309ctrl.seek(0);
310assert.ok(ctrl.getCurrentTime() < 1e-9);
311
312// toggle
313ctrl.toggle();
314assert.strictEqual(ctrl.getState().status, 'playing');
315ctrl.toggle();
316assert.strictEqual(ctrl.getState().status, 'paused');
317
318// replay after end
319ctrl.seek(2);
320ctrl.play();
321// playing from end rewinds
322assert.strictEqual(ctrl.getState().status, 'playing');
323assert.ok(ctrl.getCurrentTime() < 1e-6);
324
325// append live-follow at edge
326ctrl.pause();
327ctrl.seek(2);
328assert.ok(ctrl.getState().atLiveEdge);
329ctrl.append('[0.5,"o","more"]\n');
330assert.strictEqual(ctrl.getState().duration, 2.5);
331
332// Declared-live mode: parked mid-recording, setLive pins the playhead to the edge; an
333// append keeps it pinned (unconditionally — no positional check); a rewinding seek or
334// play() drops live; a seek TO the edge keeps it.
335ctrl.seek(1);
336ctrl.setLive(true);
337let live = ctrl.getState();
338assert.strictEqual(live.live, true);
339assert.ok(live.atLiveEdge, 'setLive parks at the edge');
340assert.ok(Math.abs(live.currentTime - live.duration) < 1e-9);
341ctrl.append('[0.5,"o","live"]\n');
342live = ctrl.getState();
343assert.strictEqual(live.live, true);
344assert.ok(Math.abs(live.currentTime - live.duration) < 1e-9, 'append keeps the pin');
345ctrl.seek(live.duration); // to the edge: still live
346assert.strictEqual(ctrl.getState().live, true);
347const liveEvents = [];
348const unsubLive = ctrl.subscribe((st, meta) => { liveEvents.push(meta.type); });
349ctrl.seek(0.5); // a rewind: live drops, with a livechange event
350assert.strictEqual(ctrl.getState().live, false);
351assert.ok(liveEvents.includes('livechange'));
352unsubLive();
353ctrl.setLive(true);
354ctrl.play(); // play from the parked edge is a rewind: live drops
355assert.strictEqual(ctrl.getState().live, false);
356ctrl.pause();
357
358// getState is a snapshot (mutating returned markers does not corrupt internal list)
359const s1 = ctrl.getState();
360s1.markers.push({ id: 'x', time: 99, type: 'chapter', label: 'x' });
361assert.ok(ctrl.getState().markers.every(m => m.id !== 'x'));
362
363// dispose makes commands no-ops
364ctrl.dispose();
365ctrl.play();
366ctrl.seek(1);
367ctrl.append('[1,"o","z"]\n');
368assert.strictEqual(ctrl.getState().status, 'idle');
369
370// Marker object form + tuple compatibility
371const c2 = C.create({
372 data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","a"]\n',
373 markers: [{ time: 0, label: 'A', type: 'chapter' }, [1, 'B']],
374 clock: fakeClock(),
375});
376const marks = c2.getState().markers;
377assert.strictEqual(marks[0].label, 'A');
378assert.strictEqual(marks[1].label, 'B');
379c2.dispose();
380
381// Source type text
382const c3 = C.create({
383 source: { type: 'text', data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","z"]\n' },
384 clock: fakeClock(),
385});
386assert.strictEqual(c3.getState().duration, 0);
387c3.dispose();
388
389// Discrete events survive playback: seek/speedchange/durationchange/markerchange emitted
390// WHILE PLAYING must each reach subscribers with their own meta type — timeupdate
391// coalescing must never swallow them.
392const clock4 = fakeClock();
393const c4 = C.create({ data: castText, idleTimeLimit: 2, clock: clock4 });
394const types = [];
395c4.subscribe(function (s, meta) { types.push(meta.type); });
396c4.play();
397clock4.flush(0);
398clock4.flush(200);
399assert.ok(types.includes('timeupdate'), 'timeupdate flows while playing');
400assert.strictEqual(c4.getState().status, 'playing');
401c4.seek(1.5, { origin: 'api' });
402assert.ok(types.includes('seek'), 'seek delivered while playing, saw: ' + types.join(','));
403c4.append('[5,"o","tail"]\n');
404assert.ok(types.includes('durationchange'), 'durationchange delivered while playing');
405c4.setSpeed(2);
406assert.ok(types.includes('speedchange'), 'speedchange delivered while playing');
407c4.setMarkers([[0.5, 'half']]);
408assert.ok(types.includes('markerchange'), 'markerchange delivered');
409assert.strictEqual(c4.getState().markers.filter(m => m.source === 'integration').length, 1);
410c4.dispose();
411
412// ---- marker jumps seek without autoplay ----------------------------------------------
413const clockM = fakeClock();
414const cM = C.create({
415 data: '{"version":3,"term":{"cols":4,"rows":1}}\n[0,"o","a"]\n[1.0,"o","b"]\n[2.0,"o","c"]\n',
416 markers: [[0, 'start'], [1, 'mid'], [2, 'end']],
417 clock: clockM,
418});
419cM.play();
420clockM.flush(0);
421cM.pause();
422assert.strictEqual(cM.getState().status, 'paused');
423cM.seek(0);
424cM.jumpMarker(1, 'keyboard');
425assert.ok(Math.abs(cM.getCurrentTime() - 1) < 1e-9, ' ] seeks to next marker');
426assert.strictEqual(cM.getState().status, 'paused', '[ ] must not autoplay');
427cM.play();
428assert.strictEqual(cM.getState().status, 'playing');
429cM.jumpMarker(1, 'keyboard');
430assert.ok(Math.abs(cM.getCurrentTime() - 2) < 1e-9);
431assert.strictEqual(cM.getState().status, 'playing', 'jump while playing stays playing');
432cM.pause();
433cM.jumpMarker(-1, 'keyboard');
434assert.ok(Math.abs(cM.getCurrentTime() - 1) < 1e-9);
435assert.strictEqual(cM.getState().status, 'paused', '[ while paused stays paused');
436cM.dispose();
437
438// ---- fit:'both' scale rule: content-sized mounts must not ratchet -----------------
439// Mirrors layout()'s definite-height gate (vertical fit only when the mount's height
440// does not track the screen box). The old `availH - 4` trigger shrank forever on
441// content-sized embeds.
442function fitScale(naturalH, availH, definite) {
443 let scale = 1;
444 if (definite && availH > 40 && naturalH * scale > availH) {
445 scale = Math.min(scale, availH / naturalH);
446 }
447 return scale;
448}
449let contentH = 400;
450const natural = 400, bar = 32;
451for (let i = 0; i < 30; i++) {
452 // Content-sized: mount height tracks the box — vertical fit must stay off.
453 const s = fitScale(natural, contentH - bar, false);
454 assert.strictEqual(s, 1, 'content-sized mount must not vertically scale');
455 contentH = natural * s + bar;
456}
457const short = fitScale(400, 200 - 32, true);
458assert.ok(short < 1 && short > 0, 'definite short mount scales down once');
459assert.strictEqual(fitScale(400, 200 - 32, true), short, 'definite scale is stable');
460// The banned ratchet: subtracting 4 from availH forces another shrink every pass.
461let ratchetH = 400;
462for (let i = 0; i < 5; i++) {
463 const avail = ratchetH - bar;
464 if (natural > avail - 4) ratchetH = natural * ((avail - 4) / natural) + bar;
465}
466assert.ok(ratchetH < 390, 'sanity: the old availH-4 rule really does ratchet');
467
468console.log('vt selftest OK');
469"#;
470 let spawned = std::process::Command::new("node")
471 .arg("-")
472 .arg(&bundle)
473 .stdin(std::process::Stdio::piped())
474 .stdout(std::process::Stdio::piped())
475 .stderr(std::process::Stdio::piped())
476 .spawn();
477 let Ok(mut child) = spawned else {
478 let _ = std::fs::remove_dir_all(&dir);
479 return; };
481 use std::io::Write;
482 child.stdin.take().unwrap().write_all(script.as_bytes()).unwrap();
483 let out = child.wait_with_output().unwrap();
484 let _ = std::fs::remove_dir_all(&dir);
485 assert!(
486 out.status.success() && String::from_utf8_lossy(&out.stdout).contains("vt selftest OK"),
487 "vt selftest failed:\nstdout: {}\nstderr: {}",
488 String::from_utf8_lossy(&out.stdout),
489 String::from_utf8_lossy(&out.stderr)
490 );
491 }
492}