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