import {test} from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import ts from '../apps/web/node_modules/typescript/lib/typescript.js';
function loadCatalog(file){
const code=ts.transpileModule(fs.readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const sandbox={exports:{},require:()=>({})};
vm.runInNewContext(code,sandbox);
return sandbox.exports.zhTW||sandbox.exports.en;
}
const zhTW=loadCatalog('apps/web/src/locales/zh-TW.ts');
const en=loadCatalog('apps/web/src/locales/en.ts');
const catalogs={'zh-TW':zhTW,en};
const i18n={locale:'en',t:(key,params)=>{const message=catalogs[i18n.locale]?.[key]??key;return params?message.replace(/\{(\w+)\}/g,(m,name)=>Object.hasOwn(params,name)?String(params[name]):m):message},getLocale:()=>i18n.locale};
const raw=fs.readFileSync('apps/web/src/schedule.tsx','utf8');
const js=ts.transpileModule(raw,{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.React}}).outputText;
const box={exports:{},require:()=>i18n};vm.runInNewContext(js,box);const {cronFromPreset,presetFromCron,defaultCronPreset,describeCron,describeCronHuman}=box.exports;
test('fixed intervals retain exact elapsed units',()=>{
for (const unit of ['minutes','hours','days']) {const p={...defaultCronPreset(),freq:'Interval',n:45,unit};const cron=cronFromPreset(p);const restored=presetFromCron(cron);assert.equal(restored.n,45);assert.equal(restored.unit,unit);}
});
test('unknown cron is preserved verbatim instead of silently rewritten',()=>{
for(const cron of ['0 0 9 * * 1','0 25 * * *','99 9 * * *','0 0 */3 * *','*/45 * * * *']){const p=presetFromCron(cron);assert.equal(p.freq,'Advanced');assert.equal(cronFromPreset(p),cron);}
});
test('blank advanced expressions are never converted to a scheduled job',()=>assert.equal(cronFromPreset({...defaultCronPreset(),freq:'Advanced',cron:''}),''));
test('VNC paste delegates once to confirmed backend and rejects another source',async()=>{
const source=fs.readFileSync('apps/web/vnc.html','utf8').match(/'),undefined);
assert.equal(sanitizeMarkdownUrl('/relative'),undefined);
});
test('zh-TW and en catalogs share keys and placeholders',()=>{
const zhKeys=Object.keys(zhTW).sort();
const enKeys=Object.keys(en).sort();
assert.deepEqual(enKeys,zhKeys);
for(const key of zhKeys){
const slots=s=>[...String(s).matchAll(/\{(\w+)\}/g)].map(m=>m[1]).sort();
assert.deepEqual(slots(en[key]),slots(zhTW[key]),key);
assert.notEqual(String(en[key]).trim(),'');
assert.notEqual(String(zhTW[key]).trim(),'');
}
});
test('describeCron and stored Chinese human strings follow the UI locale',()=>{
i18n.locale='en';
assert.equal(describeCron('0 9 * * *'),'Daily at 9:00 AM');
assert.equal(describeCron('0 9 * * 1-5'),'Weekdays at 9:00 AM');
assert.equal(describeCronHuman('每天 09:00'),'Daily at 9:00 AM');
assert.equal(describeCronHuman('每隔 45 分鐘(固定間隔)'),'Every 45 minutes (elapsed)');
i18n.locale='zh-TW';
assert.equal(describeCron('0 9 * * *'),'每天 09:00');
assert.equal(describeCronHuman('Daily at 9:00 AM'),'每天 09:00');
});
test('hanging up before microphone permission resolves releases the late stream',async()=>{
let grant; let stopped=0;
const context={exports:{},navigator:{mediaDevices:{getUserMedia:()=>new Promise(resolve=>grant=resolve)}}};
vm.runInNewContext(audioJs,context);
const audio=new context.exports.CallAudio({onCapture(){},onError(){}});
const starting=audio.start();
audio.stop();
grant({getTracks:()=>[{stop(){stopped++;}}]});
await starting;
assert.equal(stopped,1);
});
test('odd-sized audio frames stay sample-aligned instead of turning into noise',()=>{
const rendered=[];
const audio=new audioBox.exports.CallAudio({onCapture(){},onError(){}});
audio.context=fakePlaybackContext(rendered);
const source=new Int16Array([1000,-1000,2000,-2000,3000,-3000,4000,-4000]);
const bytes=new Uint8Array(source.buffer);
let offset=0;
for(const size of [3,5,7,1]){audio.play(bytes.slice(offset,offset+size).buffer);offset+=size}
const played=rendered.flatMap(chunk=>[...chunk]);
const expected=[...pcm16ToFloat(bytes.slice().buffer)];
assert.equal(played.length,expected.length);
expected.forEach((value,index)=>assert.ok(Math.abs(played[index]-value)<1e-6,`sample ${index}`));
});
test('microphone frames are batched instead of sent one websocket message per block',()=>{
const sent=[];
const audio=new audioBox.exports.CallAudio({onCapture:pcm=>sent.push(pcm.length),onError(){}});
const block=new Float32Array(64);
const blocksPerChunk=CAPTURE_CHUNK_SAMPLES/block.length;
for(let i=0;i