perf(输入法): 降低查词与候选内存开销并放大等尺寸26键
这个提交包含在:
1 父节点
100757f684
当前提交
7cd1ab906c
33 个文件已更改
+513
-126
没有与搜索条件相匹配的文件
@@ -33,7 +33,7 @@ p=page({inputType:'password',initial:'😀secret'});p.toggleLearning();check(!p.
|
||||
p=page({inputType:'password',revealSecret:true,initial:'Visible123!'});check(p.display.includes('Visible123!')&&p.secret&&!p.learning,'explicit password reveal preserves private input');p.toggleLearning();check(!p.learning,'revealed password cannot enable learning');p.onHide();check(!p.text&&!p.initial&&!p.original,'revealed 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();let resolve;p.dictionary={lookup:()=>new Promise(yes=>resolve=yes),clear(){}};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();p.mode='py';p.composition='shi';await p.query(p.requestId);p.expand();check(p.visibleCandidates.length===16&&p.candidateRows.length===4,'expanded single characters use 16 real candidate cells');p.nextPage();check(p.candidatePage===1&&p.visibleCandidates[0]===p.allCandidates[16],'next grid page continues without omissions');check(!ux.includes('item.pinyin}}')&&!ux.includes('class="reading"'),'expanded cells do not display pronunciation');
|
||||
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')
|
||||
|
||||
@@ -26,7 +26,8 @@ for(var mode of ['py','en'])for(var upper of [false,true]){
|
||||
p.invalidate();p.secret=false;p.text='';p.cursor=0;p.composition='';p.mode=mode;p.upper=upper;p.renderKeys();p.updateDisplay();await tc.wait(.15);
|
||||
var alphabet='qwertyuiopasdfghjklzxcvbnm',keys=[];
|
||||
for(var letter of alphabet)keys.push({key:letter,rect:await rect('ime-key-'+letter)});
|
||||
console.log('PROBE_CAPTURE '+JSON.stringify({name:theme+'-'+mode+'-'+(upper?'upper':'lower'),theme:theme,kind:'qwerty',keys:keys}));await tc.wait(1.1);}
|
||||
var controls=[];for(var id of ['ime-chinese','ime-14','ime-english','ime-tools','ime-space','ime-delete','ime-finish'])controls.push({key:id,rect:await rect(id)});
|
||||
console.log('PROBE_CAPTURE '+JSON.stringify({name:theme+'-'+mode+'-'+(upper?'upper':'lower'),theme:theme,kind:'qwerty',keys:keys,controls:controls}));await tc.wait(1.1);}
|
||||
}
|
||||
p.invalidate();p.secret=false;p.text='';p.cursor=0;p.composition='';p.mode='en';p.upper=false;p.renderKeys();p.updateDisplay();await tc.wait(.2);
|
||||
await tc.getById('ime-key-q').click();await tc.getById('ime-english').click();await tc.wait(.15);check(p.upper,'uppercase enabled');await tc.getById('ime-key-q').click();await tc.getById('ime-english').click();await tc.wait(.15);check(!p.upper,'lowercase restored');await tc.getById('ime-key-q').click();check(p.text==='qQq','actual native keys commit qQq');
|
||||
@@ -81,11 +82,18 @@ def main():
|
||||
state['passed']=abs(state['left_gap_px'])<=1 and abs(state['right_gap_px'])<=1 and 0<caret[2]<=2 and preview[0]<=caret[0]and caret[0]+caret[2]<=preview[0]+preview[2]
|
||||
else:
|
||||
width=466 if profile.startswith('round')else 390;height=466 if profile.startswith('round')else 450;failures=[]
|
||||
widths=[];heights=[]
|
||||
for key in state['keys']:
|
||||
x,y,w,h=rectangle(key['rect'])
|
||||
widths.append(w);heights.append(h)
|
||||
if w<=0 or h<=0 or x<0 or y<0 or x+w>width or y+h>height:failures.append(key['key'])
|
||||
if profile.startswith('round')and max(math.hypot(tx-233,ty-233)for tx in [x,x+w]for ty in [y,y+h])>231:failures.append(key['key'])
|
||||
state.update(failures=failures,passed=len(state['keys'])==26 and not failures)
|
||||
equal=max(widths)-min(widths)<=1 and max(heights)-min(heights)<=1
|
||||
for control in state['controls']:
|
||||
x,y,w,h=rectangle(control['rect'])
|
||||
if w<=0 or h<=0 or x<0 or y<0 or x+w>width or y+h>height:failures.append(control['key'])
|
||||
if profile.startswith('round')and max(math.hypot(tx-233,ty-233)for tx in [x,x+w]for ty in [y,y+h])>231:failures.append(control['key'])
|
||||
state.update(failures=failures,key_width_px=[min(widths),max(widths)],key_height_px=[min(heights),max(heights)],equal_size=equal,passed=len(state['keys'])==26 and not failures and equal)
|
||||
captures+=1;report['cases'].append(state);native.save(args.output/'caret-results.json',report);print(json.dumps(state,ensure_ascii=False),flush=True)
|
||||
if 'PROBE_DONE'in line:finished=True
|
||||
if finished:break
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import vm from 'node:vm'
|
||||
import {pathToFileURL,fileURLToPath} from 'node:url'
|
||||
import assert from 'node:assert/strict'
|
||||
import * as current from '../src/common/ime-engine.js'
|
||||
|
||||
const app=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..')
|
||||
const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1]
|
||||
assert(args.includes('--baseline'),'Use --baseline <v1.2.4 ime-engine.js>')
|
||||
const baseline=await import(pathToFileURL(path.resolve(option('--baseline'))))
|
||||
const cases=[]
|
||||
for(const text of ['shi','shij','shijie','niha','nihao','beij','beijing','zhongguo'])for(const mode of [false,true,'14'])cases.push([mode===false?text:mode==='14'?current.code14Of(text):current.digitsOf(text),mode])
|
||||
const identity=rows=>rows.map(x=>x.word+'@'+x.pinyin+'@'+x.consume+'@'+!!x.completion).sort()
|
||||
async function sample(module){
|
||||
let reads=0,readChars=0,largestRead=0,resultCount=0
|
||||
const dictionary=module.createDictionary(async uri=>{const text=fs.readFileSync(app+'/src'+uri,'utf8');reads++;readChars+=text.length;largestRead=Math.max(largestRead,text.length);return text})
|
||||
if(global.gc)global.gc()
|
||||
const before=process.memoryUsage().heapUsed,times=[],rows=[]
|
||||
for(let round=0;round<5;round++)for(const [raw,mode]of cases){
|
||||
const start=performance.now(),result=await dictionary.lookup(raw,mode,{},'你好');times.push(performance.now()-start)
|
||||
if(!round)rows.push(identity(result));resultCount+=result.length
|
||||
}
|
||||
if(global.gc)global.gc()
|
||||
const retainedHeapBytes=process.memoryUsage().heapUsed-before
|
||||
const sorted=times.slice().sort((a,b)=>a-b)
|
||||
const metrics={queries:times.length,resultCount,reads,readChars,largestRead,retainedHeapBytes,medianMs:sorted[Math.floor(sorted.length/2)],p95Ms:sorted[Math.floor(sorted.length*.95)],totalMs:times.reduce((a,b)=>a+b,0),stats:dictionary.stats||null}
|
||||
dictionary.clear();return {metrics,rows}
|
||||
}
|
||||
const old=await sample(baseline),now=await sample(current)
|
||||
assert.deepEqual(now.rows,old.rows,'Exact candidates and completions survive optimization in all three layouts')
|
||||
assert.equal(now.metrics.resultCount,old.metrics.resultCount)
|
||||
const meta=JSON.parse(fs.readFileSync(app+'/src/assets/ime-dict/meta.json','utf8'))
|
||||
const runtime=JSON.parse(fs.readFileSync(app+'/src/assets/ime-dict/runtime.json','utf8'))
|
||||
const masks=[['short','s','23456789'],['long','','23456789'],['dualShort','ds','0123456789abcd'],['dualLong','d','0123456789abcd'],['associations','a','0123456789abcdef'],['completions','c','0123456789abcdef']]
|
||||
const restoredNames=[]
|
||||
for(const [field,prefix,alphabet]of masks)for(let mask=0;mask<runtime[field].length;mask++){
|
||||
const length=field==='short'||field==='dualShort'?mask+1:field==='long'?4:field==='dualLong'?3:2,bits=runtime[field][mask]
|
||||
for(let i=0;i<alphabet.length**length;i++)if(parseInt(bits.slice((i>>3)*2,(i>>3)*2+2),16)&1<<(i&7)){
|
||||
let code='',value=i;for(let digit=0;digit<length;digit++){code=alphabet[value%alphabet.length]+code;value=Math.floor(value/alphabet.length)}restoredNames.push(prefix+code)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(restoredNames.sort(),[...meta.shards,...meta.association_shards,...meta.completion_shards].sort(),'Every runtime presence bit matches the audit manifest, including absent buckets')
|
||||
// Duplicate exact words must not consume the bounded completion budget. Legacy
|
||||
// manifests without predictive lists remain compatible; zero-frequency rows survive.
|
||||
const source={meta:{shards:['2223']},'2223':{entries:{abcd:['同'],abcde:['同',...Array.from({length:15},(_,i)=>'补'+i)]},frequencies:{同:999}}}
|
||||
const edge=current.createDictionary(async uri=>{const name=path.basename(uri,'.json');if(!(name in source))throw new Error('absent');return JSON.stringify(source[name])})
|
||||
const edgeRows=await edge.lookup('abcd',false,{},'')
|
||||
assert.equal(edgeRows.filter(x=>x.completion).length,12);assert.equal(edgeRows.filter(x=>x.word==='同').length,1)
|
||||
assert((await edge.predict('你')).length===0);edge.clear()
|
||||
const candidates=Array.from({length:12000},(_,i)=>({word:i%13===0?'完整词组':String.fromCodePoint(0x4e00+i%1200)}))
|
||||
const plan=current.candidatePageStarts(candidates),restored=[]
|
||||
let maxVisible=0
|
||||
for(let page=0;page<plan.length;page++){
|
||||
const rows=current.candidatePageRows(candidates,plan,page),visible=rows.flatMap(row=>row.items.map(cell=>cell.item));restored.push(...visible);maxVisible=Math.max(maxVisible,visible.length)
|
||||
assert(rows.reduce((sum,row)=>sum+row.units,0)<=4)
|
||||
}
|
||||
assert.deepEqual(restored,candidates);assert(maxVisible<=16)
|
||||
// A clear while file I/O is pending must not refill a closed session's cache.
|
||||
let release;const paused=current.createDictionary(uri=>uri.endsWith('runtime.json')?new Promise(resolve=>release=resolve):fs.readFileSync(app+'/src'+uri,'utf8'))
|
||||
const obsolete=paused.lookup('shi',false,{},'',()=>false);paused.clear();release(fs.readFileSync(app+'/src/assets/ime-dict/runtime.json','utf8'));assert.equal((await obsolete).length,0);assert.equal(paused.cacheSize,0)
|
||||
const reactive=text=>{const source=text.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .+$/gm,'').replace('export default','module.exports=');const module={exports:{}};vm.runInNewContext(source,{module,stylesForTheme:()=>({})});return Object.keys(module.exports.data).length}
|
||||
const report={method:'Node V8 comparison with identical dictionary; heap figures are host measurements, not MCU or watch RAM',exactCandidateCases:cases.length,completionCases:cases.length,runtimePresenceShards:restoredNames.length,baseline:old.metrics,optimized:now.metrics,pagePlan:{candidates:candidates.length,pages:plan.length,maxVisibleCells:maxVisible},runtimeIndexBytes:fs.statSync(app+'/src/assets/ime-dict/runtime.json').size,auditMetadataBytes:fs.statSync(app+'/src/assets/ime-dict/meta.json').size,reactiveProperties:{baseline:args.includes('--baseline-component')?reactive(fs.readFileSync(option('--baseline-component'),'utf8')):null,optimized:reactive(fs.readFileSync(app+'/src/components/CrabInput/index.ux','utf8'))},passed:true}
|
||||
if(args.includes('--output'))fs.writeFileSync(option('--output'),JSON.stringify(report,null,2)+'\n')
|
||||
console.log(JSON.stringify(report))
|
||||
@@ -10,7 +10,7 @@ function write(root,name,body){const file=path.join(root,name);fs.mkdirSync(path
|
||||
function fixture(root){
|
||||
write(root,'src/manifest.json',JSON.stringify({versionName:'fixture',router:{pages:{}}}))
|
||||
for(const name of ['keyboard/index.ux','components/CrabInput/index.ux','assets/ime-icons/icon.png',...['engine','theme','lifecycle'].map(n=>'common/ime-'+n+'.js')])write(root,'src/'+name,'first')
|
||||
write(root,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['2222']}));write(root,'src/assets/ime-dict/2222.json','{}')
|
||||
write(root,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['2222']}));write(root,'src/assets/ime-dict/2222.json','{}');write(root,'src/assets/ime-dict/runtime.json','{"version":1}')
|
||||
for(const name of ['crabKeyboard','jieba','remix-icon'])write(root,'third_party/'+name+'/LICENSE','MIT')
|
||||
write(root,'LICENSE','MIT');write(root,'NOTICE.md','third party notices')
|
||||
}
|
||||
@@ -18,7 +18,8 @@ let stop
|
||||
try{
|
||||
fixture(source);write(host,'src/manifest.json',JSON.stringify({router:{pages:{}}}));write(host,'src/assets/host-data.txt','preserve')
|
||||
write(host,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['1111']}));write(host,'src/assets/ime-dict/1111.json','old format')
|
||||
const first=syncInput(host,source);assert.equal(first.copied,13);assert.equal(first.removed,1);checkInput(host,source)
|
||||
const first=syncInput(host,source);assert.equal(first.copied,14);assert.equal(first.removed,1);checkInput(host,source)
|
||||
assert.equal(fs.readFileSync(path.join(host,'src/assets/ime-dict/runtime.json'),'utf8'),'{"version":1}','Compact runtime index is included in host synchronization')
|
||||
const timestamp=fs.statSync(path.join(host,'src/components/CrabInput/index.ux')).mtimeMs
|
||||
assert.equal(syncInput(host,source).copied,0);assert.equal(fs.statSync(path.join(host,'src/components/CrabInput/index.ux')).mtimeMs,timestamp)
|
||||
write(host,'src/common/ime-theme.js','stale');assert.throws(()=>checkInput(host,source),/differs/);assert.equal(syncInput(host,source).copied,1)
|
||||
|
||||
在新工单中引用
屏蔽一个用户