const test = require('node:test'); const assert = require('node:assert/strict'); const {readFileSync} = require('node:fs'); const {resolve} = require('node:path'); const Module = require('node:module'); const ts = require('typescript'); const React = require('react'); const defaults = {lookback_days:30, new_work_interval_seconds:1800, metric_initial_interval_seconds:3600, metric_multiplier:2, metric_max_interval_seconds:86400, metric_age_seconds:2592000, ai_base_url:'https://example.com/v1', ai_api_key:'saved-key', ai_model:'model-a', updated_at:'server-only'}; function deferred() { let resolve, reject; const promise = new Promise((yes, no) => {resolve=yes; reject=no;}); return {promise, resolve, reject}; } function harness(options={}) { const states=[], effects=[], queued=[], timers=new Map(), calls=[], saves=[], notices=[], logs=[]; let cursor=0, timerID=0; const values={}; const form={setFieldsValue(value){Object.assign(values,value);}, getFieldValue(name){return values[name];}, setFieldValue(name,value){values[name]=value;}}; const antd={}; for(const name of ['Alert','Button','Card','Flex','Input','InputNumber','Select','Tabs']) antd[name]=()=>null; antd.Input.Password=()=>null; antd.Typography={Paragraph:()=>null,Text:()=>null}; antd.Form=()=>null; antd.Form.Item=()=>null; antd.Form.useForm=()=>[form]; antd.Form.useWatch=(name)=>values[name]; antd.App={useApp:()=>({message:{success:v=>notices.push(v),error:v=>notices.push(v)}})}; const file=resolve(__dirname,'../src/pages/creator/settings/index.tsx'); const loaded=new Module(file,module); loaded.filename=file; loaded.require=id=>{ if(id==='@test/timers') return {setTimeout(fn){const id=++timerID; timers.set(id,fn); return id;},clearTimeout(id){timers.delete(id);}}; if(id==='react') return { useState(initial){const i=cursor++; if(!(i in states)) states[i]=typeof initial==='function'?initial():initial; return [states[i],value=>{states[i]=typeof value==='function'?value(states[i]):value;}];}, useRef(initial){const i=cursor++; if(!(i in states)) states[i]={current:initial}; return states[i];}, useCallback(callback,deps){const i=cursor++; const prior=states[i]; if(!prior||deps.some((v,j)=>!Object.is(v,prior.deps[j]))) states[i]={callback,deps}; return states[i].callback;}, useEffect(callback,deps){const i=cursor++; const prior=effects[i]; if(!prior||deps.some((v,j)=>!Object.is(v,prior.deps[j]))) {prior?.cleanup?.(); effects[i]={deps}; queued.push(()=>{effects[i].cleanup=callback();});}}, }; if(id==='antd') return antd; if(id==='@ant-design/icons') return {ReloadOutlined:()=>null}; if(id==='@/utils/helpers') return {conflictMessage:(e,f)=>e?.message||f}; if(id==='@/services/api') return { creatorGet:async()=>{if(options.loadError) throw new Error(options.loadError); return options.settings||defaults;}, creatorAction:async(path,payload)=>{calls.push({path,payload}); return options.models ? options.models(payload,calls.length) : {models:['model-a','model-b']};}, creatorUpdate:async(path,payload)=>{saves.push({path,payload}); if(options.saveError) throw new Error(options.saveError); return payload;}, }; return require(id); }; const code=ts.transpileModule(readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX,target:ts.ScriptTarget.ES2020}}).outputText; loaded._compile(`const {setTimeout,clearTimeout}=require('@test/timers');\n${code}`,file); const h={antd,form,values,calls,saves,notices,logs, render(){cursor=0; return loaded.exports.default();}, async flush(){for(let i=0;i<5;i++){h.render(); while(queued.length) queued.shift()(); for(let j=0;j<12;j++) await Promise.resolve();} return h.render();}, async tick(){const pending=[...timers.values()]; timers.clear(); pending.forEach(fn=>fn()); return h.flush();}, async change(changed){Object.assign(values,changed); const tree=h.render(); find(tree,antd.Form)?.props.onValuesChange?.(changed,values); return h.flush();}, dispose(){for(const effect of effects) effect?.cleanup?.();}, }; return h; } function all(node,predicate){const result=[]; function visit(value){if(Array.isArray(value)) return value.forEach(visit); if(!React.isValidElement(value)) return; if(predicate(value)) result.push(value); visit(value.props.children); visit(value.props.label); if(Array.isArray(value.props.items)) value.props.items.forEach(item=>visit(item.children));} visit(node); return result;} function text(node){if(node==null) return ''; if(typeof node==='string'||typeof node==='number') return String(node); if(Array.isArray(node)) return node.map(text).join(''); return React.isValidElement(node)?text(node.props.children):'';} const find=(tree,type)=>all(tree,n=>n.type===type)[0]; const alerts=(h,tree)=>all(tree,n=>n.type===h.antd.Alert).map(n=>`${text(n.props.title)} ${text(n.props.description)}`).join('\n'); const refresh=(h,tree)=>all(tree,n=>n.type===h.antd.Button).find(n=>text(n.props.children)==='刷新模型'); test('AI service uses password input, searchable discovered models and persists all settings',async t=>{ const h=harness(); t.after(h.dispose); await h.flush(); let tree=await h.tick(); assert.deepEqual(h.calls,[{path:'/creator/settings/models',payload:{base_url:defaults.ai_base_url,api_key:defaults.ai_api_key}}]); assert.ok(find(tree,h.antd.Input.Password)); const select=find(tree,h.antd.Select); assert.ok(select.props.showSearch); assert.equal(select.props.mode,undefined); assert.deepEqual(select.props.options.map(o=>o.value),['model-a','model-b']); const fields=all(tree,n=>n.type===h.antd.Form.Item).map(n=>n.props.name); assert.ok(fields.includes('ai_base_url')); assert.ok(fields.includes('ai_api_key')); assert.ok(fields.includes('ai_model')); assert.ok(!fields.includes('ai_provider')); assert.ok(!fields.includes('ai_configured')); assert.ok(find(tree,h.antd.Tabs).props.items.every(item=>item.forceRender)); await find(tree,h.antd.Form).props.onFinish({...h.values}); await h.flush(); assert.equal(h.saves[0].payload.ai_api_key,'saved-key'); assert.equal(h.saves[0].payload.ai_model,'model-a'); assert.equal(h.saves[0].payload.lookback_days,30); assert.equal(h.saves[0].payload.updated_at,undefined); assert.equal(h.notices[0],'采集与 AI 配置已保存。'); }); test('connection edits clear selection and stale replies cannot override new models',async t=>{ const old=deferred(); const h=harness({models:(_,n)=>n===1?old.promise:{models:['new-model']}}); t.after(h.dispose); await h.flush(); await h.tick(); await h.change({ai_base_url:'https://other.example/v1',ai_api_key:'new-key'}); assert.equal(h.values.ai_model,undefined); let tree=await h.tick(); assert.deepEqual(find(tree,h.antd.Select).props.options.map(o=>o.value),['new-model']); old.resolve({models:['stale-model']}); tree=await h.flush(); assert.deepEqual(find(tree,h.antd.Select).props.options.map(o=>o.value),['new-model']); assert.equal(h.calls[1].payload.api_key,'new-key'); }); test('failed discovery is explicit and refresh recovers without a manual model fallback',async t=>{ const h=harness({models:(_,n)=>n===1?Promise.reject(new Error('HTTP 401: invalid key')):{models:['model-a']}}); t.after(h.dispose); await h.flush(); let tree=await h.tick(); assert.match(alerts(h,tree),/401/); assert.equal(find(tree,h.antd.Select).props.disabled,true); assert.deepEqual(find(tree,h.antd.Select).props.options,[]); refresh(h,tree).props.onClick(); await h.flush(); tree=await h.tick(); assert.equal(alerts(h,tree),''); assert.equal(find(tree,h.antd.Select).props.disabled,false); }); test('empty discovery and unavailable saved model are explicit',async t=>{ for(const models of [[],['different-model']]){ const h=harness({models:()=>({models})}); t.after(h.dispose); await h.flush(); const tree=await h.tick(); assert.ok(alerts(h,tree)); assert.equal(h.values.ai_model,undefined); } }); test('unconfigured AI does not fetch and collection settings remain saveable',async t=>{ const h=harness({settings:{...defaults,ai_base_url:'',ai_api_key:'',ai_model:''}}); t.after(h.dispose); await h.flush(); const tree=await h.tick(); assert.equal(h.calls.length,0); assert.equal(find(tree,h.antd.Select).props.disabled,true); await find(tree,h.antd.Form).props.onFinish({...h.values}); assert.equal(h.saves.length,1); }); test('partial connection or a model not from discovery cannot be saved',async t=>{ const h=harness(); t.after(h.dispose); await h.flush(); await h.tick(); await h.change({ai_api_key:'changed'}); let tree=h.render(); await find(tree,h.antd.Form).props.onFinish({...h.values,ai_model:'model-a'}); assert.equal(h.saves.length,0); await h.tick(); tree=h.render(); await find(tree,h.antd.Form).props.onFinish({...h.values,ai_model:'invented-model'}); assert.equal(h.saves.length,0); }); const durationFields=['new_work_interval_seconds','metric_initial_interval_seconds','metric_max_interval_seconds','metric_age_seconds']; const durationItem=(h,tree,field)=>all(tree,n=>n.type===h.antd.Form.Item).find(n=>n.props.name===field); async function validateDuration(h,tree,field,value) { const item=durationItem(h,tree,field); assert.ok(item.props.rules?.length,`${field} must validate duration input`); for(const rule of item.props.rules) await rule.validator({},value); } test('all second-based fields display duration units without changing numeric settings',async t=>{ const h=harness(); t.after(h.dispose); const tree=await h.flush(); assert.deepEqual(durationFields.map(field=>h.values[field]),['30m','1h','24h','720h']); for(const field of durationFields){ const item=durationItem(h,tree,field); assert.ok(all(item,n=>n.type===h.antd.Input).length,`${field} must accept a unit suffix`); assert.ok(!text(item.props.label).includes('(秒)')); assert.match(text(item.props.extra),/s.*秒.*m.*分钟.*h.*小时/); } assert.equal(h.values.lookback_days,30); assert.equal(h.values.metric_multiplier,2); }); test('duration display picks the largest exact unit and keeps every stored second',async t=>{ for(const [seconds,display] of [[1,'1s'],[59,'59s'],[60,'1m'],[90,'90s'],[3600,'1h'],[5400,'90m'],[3661,'3661s'],[9223372036,'9223372036s']]){ const settings={...defaults}; durationFields.forEach(field=>settings[field]=seconds); const h=harness({settings}); t.after(h.dispose); await h.flush(); for(const field of durationFields) assert.equal(h.values[field],display); } }); test('invalid stored durations show an explicit load error instead of a fabricated value',async t=>{ for(const seconds of [0,-1,1.5,null,'60',9223372037]){ const h=harness({settings:{...defaults,metric_age_seconds:seconds}}); t.after(h.dispose); const tree=await h.flush(); assert.match(alerts(h,tree),/采集时长无效/); assert.equal(h.values.metric_age_seconds,undefined); } }); test('duration fields accept seconds minutes hours and optional spacing, saving integer seconds',async t=>{ const h=harness(); t.after(h.dispose); await h.flush(); const tree=await h.tick(); const inputs=[' 30 s ','5m','2 H',' 48h ']; for(const [i,field] of durationFields.entries()) await validateDuration(h,tree,field,inputs[i]); const values={...h.values}; durationFields.forEach((field,i)=>values[field]=inputs[i]); await find(tree,h.antd.Form).props.onFinish(values); assert.equal(h.saves.length,1); assert.deepEqual(durationFields.map(field=>h.saves[0].payload[field]),[30,300,7200,172800]); assert.ok(durationFields.every(field=>Number.isSafeInteger(h.saves[0].payload[field]))); assert.deepEqual(durationFields.map(field=>h.values[field]),['30s','5m','2h','48h']); assert.equal(h.saves[0].payload.lookback_days,30); assert.equal(h.saves[0].payload.metric_multiplier,2); }); test('empty, malformed, nonpositive and overflowing durations are rejected for every time field',async t=>{ const h=harness(); t.after(h.dispose); const tree=await h.flush(); for(const field of durationFields){ for(const input of ['',undefined,null,30,'30','0s','-1h','1.5h','1d','1h30m','1e3s','Infinitys','1 m junk','9223372037s','153722868m','2562048h','9007199254740993s']){ await assert.rejects(()=>validateDuration(h,tree,field,input),/正整数|过大/,`${field}: ${input}`); } for(const input of ['1s','9223372036s','153722867m','2562047h']) await validateDuration(h,tree,field,input); } assert.equal(h.saves.length,0); }); test('invalid duration cannot be sent even if form submission bypasses validation',async t=>{ const h=harness(); t.after(h.dispose); await h.flush(); const tree=await h.tick(); await find(tree,h.antd.Form).props.onFinish({...h.values,metric_age_seconds:'2d'}); assert.equal(h.saves.length,0); assert.match(h.notices.join(' '),/正整数/); }); test('unchanged duration settings round-trip exactly when saving only AI settings',async t=>{ const h=harness(); t.after(h.dispose); await h.flush(); const tree=await h.tick(); await find(tree,h.antd.Form).props.onFinish({...h.values,ai_model:'model-b'}); for(const field of durationFields) assert.equal(h.saves[0].payload[field],defaults[field]); assert.equal(h.saves[0].payload.ai_model,'model-b'); }); test('load and save failures remain visible',async t=>{ const broken=harness({loadError:'settings unavailable'}); t.after(broken.dispose); assert.match(alerts(broken,await broken.flush()),/settings unavailable/); const h=harness({saveError:'database unavailable'}); t.after(h.dispose); await h.flush(); const tree=await h.tick(); await find(tree,h.antd.Form).props.onFinish({...h.values}); assert.match(h.notices.join(' '),/database unavailable/); });