const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require(process.argv[2]);
const root = process.argv[3];
let timers = new Map(), nextTimer = 0;
function load(name) {
const exports = {};
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, name + '.ets'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
}).outputText, { exports, console, setTimeout: fn => { timers.set(++nextTimer, fn); return nextTimer; },
clearTimeout: id => timers.delete(id), Date, Map, Set, Number, Math, JSON,
require: name => { if (name.startsWith('./')) return load(name.slice(2)); throw Error(name); } });
return exports;
}
const { RemoteViewport, MouseInput, TouchInput, VideoSettings, NetworkStatistics, RecentPresence, asciiKey } = load('ControllerModels');
const viewport = new RemoteViewport(); viewport.fit(320, 200, 1920, 1080);
assert.equal(viewport.baseHeight, 180); assert.equal(viewport.top(), 10);
const before = viewport.x(100); viewport.zoom(2, 100, 100); assert.equal(viewport.x(100), before);
viewport.zoom(100, 160, 100); assert.equal(viewport.scale, 10);
viewport.pan(1e6, -1e6); assert.equal(viewport.offsetX, viewport.baseWidth * 4.5);
viewport.zoom(.00001, 160, 100); assert.equal(viewport.scale, 1); assert.equal(viewport.offsetX, 0);
assert(!viewport.contains(100, 0)); assert.equal(viewport.x(-100), 0);
const packets = [];
const mouse = new MouseInput((x, y, flag, wheel) => packets.push({ x, y, flag, wheel }));
const touch = new TouchInput(viewport, mouse);
function input(kind, points = []) { if (kind === 'down') touch.begin(); touch.touch(points.map(([x,y]) => ({x,y})), kind); }
input('down', [[100,100]]); input('move', [[120,100]]); input('up');
assert(packets.every(p => p.flag === 0)); // Relative movement never drags.
packets.length = 0; input('down', [[100,100]]); input('up'); assert.deepEqual(packets.map(p => p.flag), [1,2]);
packets.length = 0; input('down', [[100,100]]); touch.hold(); input('up'); assert.deepEqual(packets.map(p => p.flag), [3,4]);
touch.relative = false; packets.length = 0;
input('down', [[100,100]]); input('move', [[120,100]]); input('move', [[400,300]]); input('up');
assert.deepEqual(packets.map(p => p.flag), [0,1,0,0,2]); assert.equal(packets.at(-2).x, 1);
packets.length = 0;
input('down', [[100,100]]); input('down', [[100,100],[200,100]]); input('move', [[80,100],[220,100]]);
input('up', [[80,100]]); input('move', [[90,110]]); input('up');
assert(viewport.scale > 1); assert(packets.every(p => p.flag === 0));
viewport.reset(); packets.length = 0;
input('down', [[100,100]]); input('down', [[100,100],[200,100]]); input('move', [[100,125],[200,125]]); input('up');
assert.equal(packets.filter(p => p.flag === 7).length, 1);
mouse.button(true, true); const revision = mouse.revision;
packets.length = 0; input('down', [[100,100]]); input('move', [[110,100]]); input('up');
assert(mouse.left); assert(packets.every(p => p.flag === 0)); assert.equal(mouse.revision, revision);
mouse.button(false, true); mouse.drag(32,0,320,180); mouse.release();
assert.equal(packets.at(-2).flag, 2); assert.equal(packets.at(-1).flag, 4);
assert.equal(asciiKey('a'), 65); assert.equal(asciiKey('A'), 321); assert.equal(asciiKey('!'), 305); assert.equal(asciiKey('中'), 0);
const settings = new VideoSettings(1, 1280); assert.equal(settings.selection.quality, 0);
assert.equal(settings.begin({quality:1,frameRate:30,preference:1}), -1); settings.support(true);
const one = {quality:2,frameRate:60,preference:0}, two = {quality:1,frameRate:30,preference:2};
const id1 = settings.begin(one), id2 = settings.begin(two);
settings.receive(one, id1, true); assert(settings.pending); assert.equal(settings.applied.quality, 2);
settings.receive(two, id2, false); assert(!settings.pending); assert.equal(settings.selection.quality, 2);
const id3 = settings.begin(two); settings.expire(id2); assert(settings.pending); settings.expire(id3); assert.equal(settings.selection.quality, 2);
settings.receive(two,id3+1,true); assert.equal(settings.selection.quality,2);
settings.requestId = 0xffffffff; settings.appliedRequestId = 0xffffffff;
const wrapped = settings.begin(two); assert.equal(wrapped,0); settings.receive(two,wrapped,true); assert.equal(settings.selection.preference,2);
settings.support(false); assert(!settings.pending);
const network = new NetworkStatistics();
const traffic = {inbound:8000000,outbound:10000,loss:.02};
network.receive({rtt:40,mode:0,srtp:true,video:traffic,audio:traffic,data:traffic,total:traffic}, 100);
network.receive({rtt:80,mode:1,srtp:false,video:traffic,audio:traffic,data:traffic,total:traffic}, 200);
assert.equal(network.rtt,50); assert.equal(network.mode(201),'TURN 中继'); assert.equal(network.mode(3200),'—');
network.frame(6,7000,1920,1080,200); network.frame(6,1,1920,1080,250); assert.equal(network.fps(250),6); assert.equal(network.latency(250),7000);
assert.equal(network.fps(1200),0); network.reset(); assert.equal(network.width,0);
const subscriptions = []; const presence = new RecentPresence(ids => subscriptions.push([...ids]));
presence.watch(['111111111'],0); presence.connect(true,10); assert.equal(subscriptions.length,1);
presence.receive({type:'presence_update',id:'111111111',online:true},20); assert(!presence.online('111111111',21));
presence.receive({type:'presence',devices:[{id:'111111111',online:true},{id:'222222222',online:true}]},30);
assert(presence.online('111111111',31)); assert(!presence.online('222222222',31)); assert(!presence.online('111111111',60030));
presence.tick(30010); assert.equal(subscriptions.length,2); presence.connect(false,40000); presence.connect(true,41000); assert(!presence.online('111111111',41001));
const { AnnouncementInbox } = load('AnnouncementInbox');
const { announcementSpans, announcementByteLength, isAnnouncementURL } = load('AnnouncementText');
const spans = announcementSpans('说明\r\n[下载](HTTP://example.com/a(b));https://example.com/end. 普通文本');
assert.equal(spans.map(p=>p.text).join(''),'说明\n下载;https://example.com/end. 普通文本');
assert.deepEqual(Array.from(spans.filter(p=>p.url),p=>p.url),['HTTP://example.com/a(b)','https://example.com/end']);
assert(!isAnnouncementURL('javascript:alert(1)'));assert(!isAnnouncementURL('https://a\\b'));assert(!isAnnouncementURL('https://'));
assert.equal(announcementByteLength('中'.repeat(80)),240);assert.equal(announcementByteLength('😀'),4);
const requests = []; const saved = new Map(); let failSave = false;
const inbox = new AnnouncementInbox(r => requests.push({...r}), s => saved.get(s) ?? '{}', (s,v) => { if(failSave)throw Error(); saved.set(s,v); });
inbox.configure('server:9099:identity'); inbox.connect(true);
function answer(request, entries, total, revision=1) { inbox.receive({type:'announcements',request_id:request.request_id,summary_only:request.summary_only,offset:request.offset,total,catalog_revision:revision,items:entries}); }
const entries = Array.from({length:21},(_,i)=>({id:i+1,revision:1,title:`Title ${i+1}`,body:'Body',updated_at:1700000000}));
const first = requests.at(-1); answer(first,entries,21); assert.equal(requests.at(-1).summary_only,false);
answer(requests.at(-1),entries.slice(0,20),21); assert.equal(inbox.items.length,20); assert.equal(inbox.unread,21);
inbox.markRead(inbox.items[0]); assert.equal(inbox.unread,20);
failSave = true; inbox.dismiss(inbox.items[0]); assert.equal(inbox.total,21); assert(inbox.saveFailed);
failSave = false; inbox.dismiss(inbox.items[0]); answer(requests.at(-1),entries.slice(20),21); assert.equal(inbox.items.length,20); assert.equal(inbox.total,20);
const stale = requests.at(-1); inbox.refresh(); answer(stale,[],0); assert(inbox.loading); answer(requests.at(-1),entries.map(e=>({...e,revision:2})),21,2);
answer(requests.at(-1),entries.slice(0,20).map(e=>({...e,revision:2})),21,2); assert.equal(inbox.unread,21);
inbox.more(); answer(requests.at(-1),entries.slice(20).map(e=>({...e,revision:2})),21,2); assert.equal(inbox.items.length,21);
inbox.connect(false); const count=requests.length; inbox.more(); assert.equal(requests.length,count);
inbox.connect(true); for(const timer of [...timers.values()])timer(); assert(inbox.failed);
inbox.configure('utf8');inbox.connect(true);answer(requests.at(-1),[entries[0]],1);
answer(requests.at(-1),[{...entries[0],title:'中'.repeat(81)}],1);assert(inbox.failed);assert.equal(inbox.items.length,0);
console.log('PASS: mobile viewport/gestures, shared button ownership, keyboard, video ACK ordering, network expiry, presence and announcement pagination/revisions');