feat: 开源蟹蟹输入RTOS适配改版并采用MIT商用许可

这个提交包含在:
sydxky 于 2026-11-14 12:10:10 +08:00 提交
当前提交 29374caffd
116 个文件已更改
+351523

没有与搜索条件相匹配的文件

@@ -0,0 +1,44 @@
import fs from 'node:fs'
import vm from 'node:vm'
import assert from 'node:assert/strict'
import * as engine from '../src/common/ime-engine.js'
const ux=fs.readFileSync(new URL('../src/keyboard/index.ux',import.meta.url),'utf8')
const source=ux.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .+$/gm,'').replace('export default','module.exports=')
let count=0
const closed=[],store=new Map(),timers=new Map();let nextTimer=1,storageFails=false
function check(ok,label){assert.ok(ok,label);count++}
function page(params={}){
const module={exports:{}}
vm.runInNewContext(source,{module,console,...engine,router:{close(p,r){closed.push(r)}},storage:{get:k=>store.get(k),set:(k,v)=>{if(storageFails)throw Error('storage full');store.set(k,v)},delete:k=>store.delete(k)},file:{readText:async({uri})=>fs.readFileSync(new URL('../src'+uri,import.meta.url),'utf8')},setTimeout:fn=>{const id=nextTimer++;timers.set(id,fn);return id},clearTimeout:id=>timers.delete(id),setInterval:fn=>{const id=nextTimer++;timers.set(id,fn);return id},clearInterval:id=>timers.delete(id)})
const p={...module.exports,...JSON.parse(JSON.stringify(module.exports.data)),...params};delete p.data;p.onInit();return p
}
let p=page();check(p.mode==='t9'&&!p.learning,'T9 large keys, learning opt-in')
p.chinese();check(p.keyRows.map(row=>row.map(key=>key.value).join('')).join('/')==='qwertyuiop/asdfghjkl/zxcvbnm','26-key pinyin uses standard QWERTY rows')
p.english();check(p.keyRows.map(row=>row.map(key=>key.value).join('')).join('/')==='qwertyuiop/asdfghjkl/zxcvbnm','English uses the same standard QWERTY order')
p.fourteen();check(p.mode==='14'&&p.keyRows.map(row=>row.map(key=>key.label).join(' ')).join('/')==='QW ER TY UI OP/AS DF GH JK L/ZX CV BN M','14-key layout matches public grouping')
for(const code of engine.code14Of('nihao'))p.press(code)
p.invalidate();await p.query(p.requestId)
check(p.compositionDisplay==='BN UI GH AS OP'&&p.bar.some(x=>x.word==='你好'),'14-key UX queries and displays paired composition')
p.english();check(p.mode==='14'&&p.composition===engine.code14Of('nihao'),'14-key mode switch preserves uncommitted composition')
p.rawInput();check(p.text===''&&p.composition===engine.code14Of('nihao'),'14-key tokens never commit as raw letters')
p.choose(p.bar.find(x=>x.word==='你好'));check(p.text==='你好'&&!p.composition,'14-key selection commits real dictionary word')
p=page()
p.press('6');p.press('4');p.press('4');p.press('2');p.press('6');p.invalidate();await p.query(p.requestId)
const hello=p.allCandidates.find(x=>x.word==='你好');check(hello,'full T9 phrase candidates')
p.choose({...hello});check(p.text==='你好'&&!p.composition,'candidate event payload may be reactive copy')
p.finish();check(closed.at(-1).accepted&&closed.at(-1).text==='你好','finish returns text')
p=page({initial:'unchanged'});p.insert('!');p.cancel();check(closed.at(-1).accepted===false,'cancel does not return edited text')
p=page({inputType:'password',initial:'😀secret'});p.toggleLearning();check(!p.learning&&!p.display.includes('secret'),'password masked and cannot learn');p.onHide();check(!p.text&&!p.initial&&!p.original,'password clears on hide')
p=page({initial:'你😀好'});p.cursor=2;p.backspace();check(p.text==='你好','UI Unicode deletion')
p.startDelete();check(p.repeatDelay!==null,'delete hold delay starts');p.onHide();check(p.repeatDelay===null&&p.repeatTimer===null,'hide stops repeat timers')
p=page();let resolve;p.dictionary={lookup:()=>new Promise(yes=>resolve=yes)};p.composition='64';const pending=p.query(p.requestId);p.onHide();resolve([{word:'你',consume:2,pinyin:'ni'}]);await pending;check(p.total===0,'late dictionary response cannot update hidden page')
p=page({maxLength:2,initial:'你'});p.composition='nihao';p.allCandidates=[{word:'你好',consume:5,pinyin:'nihao'}];p.choose(p.allCandidates[0]);check(p.text==='你'&&p.composition==='nihao','candidate is not silently truncated')
p.finish();check(p.composition==='nihao','finish does not silently drop uncommitted composition')
p=page({inputType:'number'});p.insert('12x😀.3');check(p.text==='12.3','number field filters nonnumeric input')
p=page({inputType:'url'});p.insert('https://a b.com');check(p.text==='https://ab.com','URL field filters whitespace')
p.symbols();const symbols=p.keyRows.flat().map(x=>x.value);p.symbols();symbols.push(...p.keyRows.flat().map(x=>x.value));check(Array.from({length:94},(_,i)=>String.fromCharCode(i+33)).filter(x=>!/[a-z0-9]/i.test(x)).every(x=>symbols.includes(x)),'all printable ASCII punctuation supported')
p.english();p.english();check(p.keyRows.flat().some(x=>x.value==='Z'),'uppercase supported')
p=page({initial:'test'});p.clearText();check(p.text==='test'&&p.clearArmed,'destructive clear needs second tap');p.clearText();check(p.text==='','confirmed clear')
p.composition='shi';p.schedule();const owned=p.lookupTimer;p.onDestroy();check(!timers.has(owned)&&p.lookupTimer===null,'destroy releases owned lookup timer')
p=page();p.learning=true;p.composition='64';p.allCandidates=[{word:'你',pinyin:'ni',consume:2}];storageFails=true;p.choose(p.allCandidates[0]);check(p.text==='你'&&!p.composition&&!p.learning,'storage failure cannot duplicate committed candidate');p.toggleLearning();check(!p.learning,'storage failure leaves learning off');storageFails=false
console.log(JSON.stringify({suite:'ime-client',assertions:count,status:'passed'}))
@@ -0,0 +1,52 @@
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import {fileURLToPath} from 'node:url'
import {createDictionary,digitsOf,code14Of,KEY14_GROUPS,display14,edit,erase,preview,learn,sanitizePreferences,chars} from '../src/common/ime-engine.js'
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../src')
let count=0
function check(ok,label){assert.ok(ok,label);count++}
const dictionary=createDictionary(async uri=>fs.readFileSync(root+uri,'utf8'))
const meta=JSON.parse(fs.readFileSync(root+'/assets/ime-dict/meta.json','utf8'))
check(meta.keys>10000&&meta.candidates>50000,'full upstream lexicon present')
for(const [input,expected] of [['ni','你'],['nihao','你好'],['shijie','世界'],['zhongguo','中国'],['shurufa','输入法'],['beijing','北京']]){
const alpha=await dictionary.lookup(input,false,{})
check(alpha.some(x=>x.word===expected&&x.consume===input.length),'full pinyin '+input)
const t9=await dictionary.lookup(digitsOf(input),true,{})
check(t9.some(x=>x.word===expected&&x.consume===input.length),'T9 '+input)
}
const full=await dictionary.lookup('nihaoshijie',false,{})
check(full.some(x=>x.word==='你好'&&x.consume===5),'continuous input consumes matched prefix')
check(KEY14_GROUPS.length===14&&KEY14_GROUPS.join('').split('').sort().join('')==='abcdefghijklmnopqrstuvwxyz','14 groups cover each QWERTY letter once')
check(code14Of('nihao')==='c3754'&&code14Of('a!')==='','14-key stable token mapping rejects nonletters')
for(const [input,expected]of[['nihao','你好'],['shijie','世界'],['zhongguo','中国'],['shurufa','输入法'],['beijing','北京'],['lv','绿']]){
const result=await dictionary.lookup(code14Of(input),'14',{})
check(result.some(x=>x.word===expected&&x.consume===input.length),'14-key disambiguation '+input)
}
const pairSentence=await dictionary.lookup(code14Of('nihaoshijie'),'14',{})
check(pairSentence.some(x=>x.word==='你好'&&x.consume===5),'14-key continuous input consumes only selected prefix')
const pairMany=await dictionary.lookup(code14Of('shi'),'14',{})
const fullShi=await dictionary.lookup('shi',false,{})
check(fullShi.filter(x=>x.pinyin==='shi').every(x=>pairMany.some(y=>x.word===y.word&&x.consume===y.consume)),'14-key retains every shi candidate')
const pairLast=pairMany.at(-1),pairInput=code14Of('shi'),pairPreferences=learn({},'k14:'+pairInput,pairLast)
check((await dictionary.lookup(pairInput,'14',pairPreferences))[0].word===pairLast.word,'14-key learned candidate order uses separate scheme namespace')
check((await dictionary.lookup(pairInput,'14',{[pairInput]:[{word:pairLast.word,consume:pairLast.consume}]}))[0].word===(await dictionary.lookup(pairInput,'14',{}))[0].word,'plain pinyin preferences cannot leak into 14-key ranking')
check((await dictionary.lookup('hello!','14',{})).length===0,'14-key rejects invalid token stream')
check(display14('c3754')==='BN UI GH AS OP'&&display14('c3754'.repeat(6)).length<24,'14-key preview displays bounded paired-letter groups')
const many=await dictionary.lookup('shi',false,{})
const original=JSON.parse(fs.readFileSync(root+'/assets/ime-dict/s'+digitsOf('shi')+'.json','utf8')).entries.shi
check(original.every(word=>many.some(x=>x.word===word&&x.consume===3)),'every original shi candidate remains accessible')
check(dictionary.cacheSize<=4,'bounded shard cache')
check((await dictionary.lookup('not valid!',false,{})).length===0,'reject invalid composition')
const candidate=many.at(-1),preferences=learn({},'shi',candidate)
check((await dictionary.lookup('shi',false,preferences))[0].word===candidate.word,'real selected candidate learns ordering')
const injected=await dictionary.lookup('shi',false,{shi:[{word:'injected',consume:3}]})
check(injected.every(x=>x.word!=='injected'),'preference cannot inject nonexistent candidate')
check(Object.keys(sanitizePreferences(JSON.parse('{"bad!":[],"shi":[{"word":7}]}'))).length===1,'sanitize persisted preference shape')
check(edit('你好',1,'😀',3).text==='你😀好','Unicode insertion at cursor')
check(erase('你😀好',2).text==='你好','Unicode backspace preserves surrogate pairs')
check(edit('你好',2,'世界',3).limited,'maximum length enforced')
check(!preview('my-secret',5,true).includes('secret'),'secret preview masked')
check(chars('😀').length===1,'Unicode length counts codepoints')
dictionary.clear();check(dictionary.cacheSize===0,'cache release')
console.log(JSON.stringify({suite:'ime-engine',assertions:count,keys:meta.keys,candidates:meta.candidates,status:'passed'}))
@@ -0,0 +1,39 @@
"""Check actual Inspector geometry, including clipping by inner containers."""
import json,math,pathlib
def nodes_of_class(node,kind):
result=[node]if node.get('class')==kind else[]
for child in node.get('children',[]):result.extend(nodes_of_class(child,kind))
return result
def inspect(path,profile):
frame=json.loads(pathlib.Path(path).read_text(encoding='utf-8'))['frames'][-1]
pages=[]
def visible_pages(node,parent_visible=True):
visible=parent_visible and node.get('visible',True)
if not visible:return
if node.get('class')=='AppletPage':pages.append(node)
for child in node.get('children',[]):visible_pages(child,visible)
visible_pages(frame['widgets'])
page=pages[-1]
circle=profile.startswith('round');width=466 if circle else 390;height=466 if circle else 450
failures=[];buttons=[];icons=[]
def visit(node,ancestors=(),px=0,py=0,visible=True):
g=node['geometry'];x=px+g['x'];y=py+g['y'];w=g['w'];h=g['h']
visible=visible and node.get('visible',True)
if not visible:return
box=[x,y,w,h];kind=node['class'];identifier=node.get('id','')
if kind in ['Button','ImageBox','Label']:
for ancestor in ancestors:
ax,ay,aw,ah=ancestor['box']
if x<ax-1 or y<ay-1 or x+w>ax+aw+1 or y+h>ay+ah+1:
failures.append({'reason':'ancestor clipping','class':kind,'id':identifier,'box':box,'ancestor':ancestor})
if kind in ['Button','ImageBox']:
radius=max(math.hypot(tx-233,ty-233)for tx in[x,x+w]for ty in[y,y+h])if circle else None
if(circle and radius>230)or(not circle and(x<0 or y<0 or x+w>width or y+h>height)):
failures.append({'reason':'screen boundary','class':kind,'id':identifier,'box':box,'radius':radius})
record={'id':identifier,'box':box,'radius':radius}
(buttons if kind=='Button'else icons).append(record)
for child in node.get('children',[]):visit(child,ancestors+({'class':kind,'id':identifier,'box':box},),x,y,visible)
visit(page)
return {'page':page.get('id'),'buttons':len(buttons),'icons':len(icons),'max_button_radius':round(max([b['radius']or 0 for b in buttons],default=0),2),'failures':failures,'passed':not failures}
@@ -0,0 +1,76 @@
"""Real Glyphix input acceptance on four ordinary/QuickApp profiles."""
import argparse,datetime,hashlib,importlib.util,json,os,pathlib,time
APP=pathlib.Path(__file__).resolve().parents[1]
spec=importlib.util.spec_from_file_location('emu',APP.parent/'tests/emulator-validate.py')
emu=importlib.util.module_from_spec(spec);spec.loader.exec_module(emu)
emu.APPS=['inputmethod','imefixture']
layout_spec=importlib.util.spec_from_file_location('layout',APP/'tests/layout.py')
layout=importlib.util.module_from_spec(layout_spec);layout_spec.loader.exec_module(layout)
def save(path,data):
temporary=path.with_suffix('.tmp')
with temporary.open('w',encoding='utf-8') as handle:
json.dump(data,handle,ensure_ascii=False,indent=2);handle.flush();os.fsync(handle.fileno())
os.replace(temporary,path)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--profiles',nargs='+',default=emu.PROFILES)
parser.add_argument('--work',type=pathlib.Path,required=True)
parser.add_argument('--output',type=pathlib.Path,required=True)
args=parser.parse_args();args.output.mkdir(parents=True,exist_ok=True)
report={'recorded_at':datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8))).isoformat(),'method':'real native @system.test, no networking or real credentials','artifacts':{},'cases':[],'layouts':[]}
for profile in args.profiles:
process,artifacts=emu.start(profile,args.work,'inputmethod');report['artifacts'][profile]=artifacts
def run(name,body,timeout=45):
case=profile+'-'+name
emu.evaluate('var caseName='+json.dumps(case)+';')
emu.evaluate('var caseBody=async function(){'+body+'};')
emu.evaluate("tc.testcase(caseName,async function(){await caseBody();console.log('IME_PASS '+caseName)});tc.run(caseName)")
logs=[];passed=finished=False;deadline=time.monotonic()+timeout
while time.monotonic()<deadline:
time.sleep(.1);fresh=[x for x in emu.request('/logs') if x.get('tag')!='eval'];logs.extend(fresh)
passed=passed or any('IME_PASS '+case in x.get('body','') for x in fresh)
finished=finished or any(x.get('tag')=='tester' and 'testcase '+case+' finished' in x.get('body','') for x in fresh)
if passed and finished:break
if any('terminated:' in x.get('body','') or 'Uncaught' in x.get('body','') or 'Exception:' in x.get('body','') for x in fresh):break
entry={'profile':profile,'case':name,'passed':passed and finished,'checks':[x['body'].strip() for x in logs if 'IME_CHECK ' in x.get('body','')]}
report['cases'].append(entry);save(args.output/'results.json',report);save(args.output/(case+'.logs.json'),logs)
time.sleep(.35) # Settle native route/layout animations before capturing.
emu.snapshot(case,args.output)
time.sleep(.25);emu.snapshot(case,args.output) # Read a freshly composited frame.
geometry=layout.inspect(args.output/(case+'.tree.json'),profile)
report['layouts'].append({'profile':profile,'case':name,**geometry});save(args.output/'results.json',report)
print(json.dumps(entry,ensure_ascii=False),flush=True)
if not entry['passed']:raise RuntimeError('Native failure '+case+': '+str(logs[-4:]))
if not geometry['passed']:raise RuntimeError('Clipped native layout '+case+': '+str(geometry['failures'][:3]))
try:
emu.evaluate("var router=require('@system.router').default,tc=require('@system.test').default,launch=require('@system.launch').default,root=router.getPage(0),p=null;function check(ok,m){if(!ok)throw new Error(m);console.log('IME_CHECK '+m)};async function click(t){var ids={'取消':'ime-cancel','空格':'ime-space','删除':'ime-delete','完成':'ime-finish','更多':'ime-expand','编辑':'ime-tools','上一页':'ime-page-prev','下一页':'ime-page-next','键盘':'ime-back','返回键盘':'ime-back'};if(ids[t]){await tc.getById(ids[t]).click();return}var pattern=t.split('').map(function(c){return String.fromCharCode(92)+'u'+('0000'+c.charCodeAt(0).toString(16)).slice(-4)}).join('');await tc.getHasText(new RegExp('^'+pattern+'$')).click()}")
emu.evaluate("async function ready(){var end=Date.now()+5000;while(p.busy&&Date.now()<end)await tc.wait(.05);check(!p.busy,'dictionary settled')};function reset(){p.invalidate();p.text='';p.cursor=0;p.composition='';p.allCandidates=[];p.bar=[];p.total=0;p.panel='keys';p.mode='t9';p.limit=2048;p.renderKeys();p.updateDisplay()}")
run('auto-open',"await tc.getByClass('document').click();await tc.wait(.4);p=router.getPage(1);check(router.getLength()===2&&p.mode==='t9','field opens full screen T9');root.editText();check(router.getLength()===2,'duplicate opener guarded');")
run('t9-phrase',"for(var label of ['MNO','GHI','GHI','ABC','MNO'])await click(label);await ready();check(p.bar[0].word==='你好','frequency ranks common phrase first');await click('你好');check(p.text==='你好'&&!p.composition,'native candidate commits phrase');")
run('continuous',"reset();p.mode='py';p.renderKeys();p.composition='nihaoshijie';p.schedule();await ready();check(p.allCandidates.some(x=>x.word==='你好'&&x.consume===5),'prefix phrase retained');await click('你好');await ready();check(p.composition==='shijie','only matched pinyin consumed');await click('世界');check(p.text==='你好世界'&&!p.composition,'remaining syllables compose');")
run('complete-candidates',"reset();p.mode='py';p.renderKeys();p.composition='shi';p.schedule();await ready();var total=p.total;await click('更多');check(p.visibleCandidates.length===4&&p.pageCount>5,'full list paginated');for(var i=1;i<p.pageCount;i++)await click('下一页');check(p.candidatePage===p.pageCount-1,'last candidate page accessible');")
run('candidate-select',"var last=p.visibleCandidates[p.visibleCandidates.length-1];await click(last.word);check(p.text===last.word,'last original candidate can commit');")
run('full-pinyin',"reset();await click('26键');check(p.keyRows.map(row=>row.map(key=>key.value).join('')).join('/')==='qwertyuiop/asdfghjkl/zxcvbnm','standard 10-9-7 QWERTY order');for(var letter of ['s','h','u','r','u','f','a'])await click(letter);await ready();check(p.bar.some(x=>x.word==='输入法'),'long phrase in full dictionary');await click('输入法');check(p.text==='输入法','native full pinyin commit');")
run('14-key-phrase',"reset();await click('14键');check(p.mode==='14'&&p.keyRows.flat().length===14,'14-key mode has fourteen real buttons');for(var label of ['BN','UI','GH','AS','OP'])await click(label);await ready();check(p.bar.some(x=>x.word==='你好')&&p.allCandidates.some(x=>x.word==='不好'),'paired ambiguity retains both valid phrases');await click('完成');check(router.getLength()===2,'14-key uncommitted input blocks finish');await click('123');check(p.mode==='14'&&p.composition==='c3754','14-key mode switch preserves composition');await click('你好');check(p.text==='你好'&&!p.composition,'native 14-key phrase commit');")
run('14-key-continuous',"reset();await click('14键');for(var label of ['BN','UI','GH','AS','OP','AS','GH','UI','JK','UI','ER'])await click(label);await ready();await click('你好');await ready();check(p.composition.length===6,'14-key consumes five matched keys only');await click('世界');check(p.text==='你好世界'&&!p.composition,'14-key continuous phrase commit');")
run('14-key-candidates',"reset();await click('14键');for(var label of ['AS','GH','UI'])await click(label);await ready();check(p.total>20,'14-key full dictionary candidates');await click('更多');check(p.visibleCandidates.length===4,'14-key first full candidate page');await click('下一页');check(p.candidatePage===1,'14-key native next candidate page');await click('上一页');check(p.candidatePage===0,'14-key native previous candidate page');var selected=p.visibleCandidates[1];await click(selected.word);check(p.text===selected.word,'14-key expanded candidate commit');")
run('english',"reset();await click('abc');check(p.keyRows.map(row=>row.map(key=>key.value).join('')).join('/')==='qwertyuiop/asdfghjkl/zxcvbnm','English follows standard QWERTY');await click('abc');await click('Z');await click('ABC');await click('a');check(p.text==='Za','uppercase and lowercase native keys');")
run('symbols',"reset();await click('符号');for(var symbol of ['@','%','&','?',',','。']){if(!p.keyRows.some(row=>row.some(key=>key.value===symbol)))await click('符号');await click(symbol)}check(p.text==='@%&?,。','ASCII and Chinese punctuation native keys');")
run('cursor-unicode',"reset();p.text='你😀好';p.cursor=2;p.updateDisplay();await click('编辑');await click('删除一字');check(p.text==='你好','Unicode-safe deletion');await click('到开头');await click('返回键盘');await click('abc');await click('a');check(p.text==='a你好','insertion at moved cursor');")
run('hold-delete',"reset();p.mode='en';p.renderKeys();p.text='abcdefghijklmnop';p.cursor=16;p.updateDisplay();await tc.wait(.25);await tc.getById('ime-delete').scroll(0,0,.95);await tc.wait(.1);check(p.text.length<15,'hold repeats delete');check(p.repeatTimer===null&&p.repeatDelay===null,'release stops repeats');")
run('length-and-mode',"reset();p.limit=2;p.text='你';p.cursor=1;p.mode='py';p.renderKeys();p.composition='nihao';p.schedule();await ready();await click('你好');check(p.text==='你'&&p.composition==='nihao','overlong candidate not truncated');await click('完成');check(router.getLength()===2,'uncommitted composition not dropped');p.mode='t9';p.composition='64';p.renderKeys();p.english();check(p.mode==='t9'&&p.composition==='64','mode switch preserves uncommitted composition');")
run('learning',"reset();await click('编辑');check(!p.learning,'learning defaults off');await click('学习:关');check(p.learning,'learning opt-in');await click('返回键盘');p.composition='64';p.schedule();await ready();await click(p.bar[1].word);check(Object.keys(p.preferences).length>0,'only chosen candidate learned');await click('编辑');await click('清除学习');check(Object.keys(p.preferences).length===0,'learning cleared');await click('学习:开');await click('返回键盘');")
run('cancel',"p.text='discard';p.composition='';await click('取消');await tc.wait(.4);check(root.text===''&&router.getLength()===1,'cancel keeps original document');")
run('numeric',"await click('数字输入');await tc.wait(.3);p=router.getPage(1);check(p.mode==='num','numeric field auto keyboard');for(var digit of ['1','2','3','0'])await click(digit);await click('完成');await tc.wait(.3);check(root.status.indexOf('1230')>=0,'numeric result returned');")
run('password-privacy',"await click('私密输入');await tc.wait(.3);p=router.getPage(1);await click('s');await click('e');check(p.secret&&p.display.indexOf('s')<0&&!p.learning,'secret preview masked');await click('编辑');await click('学习:关');check(!p.learning,'secret cannot enable learning');await launch.launch('com.sydxky.rtos.imefixture');await tc.wait(.9);check(!p.active&&!p.text&&!p.initial,'secret erased on actual app switch');await tc.wait(2);await click('取消');")
run('long-text-clear',"await click('开始输入');await tc.wait(.3);p=router.getPage(1);p.text='长😀'.repeat(1000);p.cursor=2000;p.updateDisplay();check(p.count===2000&&p.display.length<60,'long text preview bounded');await click('编辑');await click('清空全文');check(p.count===2000,'clear needs confirmation');await click('确认清空');check(p.count===0,'confirmed clear');await click('返回键盘');")
run('lookup-performance',"reset();var times=[];for(var word of ['nihao','zhongguo','shijie','shurufa','beijing','tianqi','shanghai','gongzuo']){var now=Date.now();var candidates=await p.dictionary.lookup(word,false,{});times.push(Date.now()-now);check(candidates.length>0,'lookup '+word)}check(p.dictionary.cacheSize<=4,'bounded decoded shard cache');check(Math.max.apply(null,times)<1000,'emulator lookup under one second');console.log('IME_TIMING '+JSON.stringify(times));")
run('finish-save',"reset();p.insert('你好,RTOS!');await click('完成');await tc.wait(.4);check(root.text==='你好,RTOS!'&&router.getLength()===1,'finished text returned to opener');await click('保存草稿');check(root.saved,'explicit local draft save');")
finally:
if process.poll() is None:process.terminate()
process.wait(timeout=10);process.stdin.close()
report['passed']=all(x['passed'] for x in report['cases']+report['layouts']);save(args.output/'results.json',report)
if __name__=='__main__':main()
@@ -0,0 +1,71 @@
"""Capture all keyboard layouts and check clipping, round bounds and dark edge gaps."""
import argparse,datetime,importlib.util,json,math,pathlib,time
from PIL import Image
import layout
APP=pathlib.Path(__file__).resolve().parents[1]
spec=importlib.util.spec_from_file_location('native',APP/'tests/native.py')
native=importlib.util.module_from_spec(spec);spec.loader.exec_module(native);emu=native.emu
def dark_edges(path,profile):
im=Image.open(path).convert('RGB');points=[]
if profile.startswith('round'):
# 466px screen inside the vendor's 624px hardware skin. Stay 7px inside its edge.
im=im.crop((70,79,536,545))
for y in range(466):
for x in range(466):
radius=math.hypot(x-233,y-233)
if 224<=radius<=226 and max(im.getpixel((x,y)))<80:points.append([x,y])
else:
for y in range(4,446):
for x in [3,386]:
if max(im.getpixel((x,y)))<80:points.append([x,y])
for x in range(4,386):
for y in [3,446]:
if max(im.getpixel((x,y)))<80:points.append([x,y])
return len(points)
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--work',type=pathlib.Path,required=True);parser.add_argument('--output',type=pathlib.Path,required=True)
parser.add_argument('--profiles',nargs='+',default=emu.PROFILES);args=parser.parse_args();args.output.mkdir(parents=True,exist_ok=True)
report={'recorded_at':datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8))).isoformat(),'artifacts':{},'screens':[]}
def capture(profile,name):
# Inspector history can associate the new widget tree with the preceding
# back buffer on an otherwise idle page. Request two real redraws, restore
# the original caption, then retain only the fresh native frame.
emu.evaluate("var view=require('@system.router').default.getPages().slice(-1)[0];var captionKey=typeof view.hint==='string'?'hint':'status';var oldCaption=view[captionKey];view[captionKey]=oldCaption+' '")
time.sleep(.2)
emu.evaluate("view[captionKey]=oldCaption")
time.sleep(.4);emu.snapshot(profile+'-'+name,args.output)
time.sleep(.3);emu.snapshot(profile+'-'+name,args.output)
result=layout.inspect(args.output/(profile+'-'+name+'.tree.json'),profile)
result['dark_edge_pixels']=dark_edges(args.output/(profile+'-'+name+'.png'),profile)
result['passed']=result['passed'] and result['dark_edge_pixels']==0
result.update(profile=profile,screen=name);report['screens'].append(result)
native.save(args.output/'visual-results.json',report)
print(json.dumps(result,ensure_ascii=False),flush=True)
if not result['passed']:raise RuntimeError('Visual regression: '+profile+'-'+name)
for profile in args.profiles:
process,artifacts=emu.start(profile,args.work,'inputmethod');report['artifacts'][profile]=artifacts
try:
capture(profile,'home')
emu.evaluate("var r=require('@system.router').default,t=require('@system.test').default;var root=r.getPage(0);t.getByClass('document').click()")
time.sleep(1);emu.evaluate("var p=r.getPage(1);p.composition='64426';p.schedule()")
time.sleep(.5);capture(profile,'t9')
emu.evaluate("p.composition='shi';p.mode='py';p.renderKeys();p.schedule()")
time.sleep(.5);capture(profile,'pinyin')
emu.evaluate("p.expand()");capture(profile,'candidates')
emu.evaluate("p.showTools()");capture(profile,'tools')
emu.evaluate("p.composition='';p.setMode('14');p.composition='c3754';p.schedule()")
time.sleep(.5);capture(profile,'14-key')
emu.evaluate("p.expand()");capture(profile,'14-candidates')
for mode in ['en','num','sym']:
emu.evaluate("p.composition='';p.setMode("+json.dumps(mode)+")")
capture(profile,mode)
emu.evaluate("p.symbols()");capture(profile,'sym2')
finally:
if process.poll()is None:process.terminate()
process.wait(timeout=10);process.stdin.close()
report['passed']=all(screen['passed']for screen in report['screens']);native.save(args.output/'visual-results.json',report)
if __name__=='__main__':main()