fix(输入法): 修复光标间隙并扩大26键及明确大小写切换
这个提交包含在:
1 父节点
b95e7caae7
当前提交
100757f684
35 个文件已更改
+675
-243
没有与搜索条件相匹配的文件
@@ -3,15 +3,15 @@ import vm from 'node:vm'
|
||||
import assert from 'node:assert/strict'
|
||||
import * as engine from '../src/common/ime-engine.js'
|
||||
import {normalizeTheme,themeStyles as stylesForTheme} from '../src/common/ime-theme.js'
|
||||
const ux=fs.readFileSync(new URL('../src/keyboard/index.ux',import.meta.url),'utf8')
|
||||
const ux=fs.readFileSync(new URL('../src/components/CrabInput/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,normalizeTheme,stylesForTheme,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
|
||||
vm.runInNewContext(source,{module,console,...engine,registerInput(){},unregisterInput(){},normalizeTheme,stylesForTheme,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={$emit:(name,r)=>closed.push(r),...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')
|
||||
@@ -44,6 +44,8 @@ p.english();p.english();check(p.keyRows.flat().some(x=>x.value==='Z'),'uppercase
|
||||
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
|
||||
p=page({initial:'你好'});p.mode='t9';await p.query(p.requestId);check(p.prediction&&p.bar.some(x=>x.word==='世界'),'component carries 1.2.3 contextual associations');p.choose(p.bar.find(x=>x.word==='世界'));check(p.text==='你好世界','predicted suffix commits without composition');
|
||||
p=page({initial:'once',config:{session:42}});const before=closed.length;p.finish();p.finish();p.cancel();check(closed.length===before+1&&closed.at(-1).session===42,'component emits one result per mount');p.onDestroy();check(closed.length===before+1,'destroy does not emit a second result');
|
||||
p=page();p.mode='py';p.composition='niha';await p.query(p.requestId)
|
||||
p.choose(p.bar[0]);p.invalidate();await p.query(p.requestId)
|
||||
check(p.text==='你好'&&p.prediction&&p.bar[0].word==='世界','partial spelling commits once and offers context continuation')
|
||||
@@ -55,4 +57,7 @@ const beforeStale=p.text;p.choose(steadyBar[0]);check(p.text===beforeStale,'reta
|
||||
p=page({initial:'你好'});p.composition='';await p.query(p.requestId);p.space();check(p.text==='你好 '&&!p.text.includes('世界')&&!p.prediction&&!p.bar.length,'space ends the prediction context without auto-committing or leaving stale suggestions')
|
||||
p=page({initial:'你好'});p.composition='';await p.query(p.requestId);p.newline();check(p.text==='你好\n'&&!p.prediction&&!p.bar.length,'newline ends the prediction context and clears stale suggestions')
|
||||
p=page({inputType:'password',initial:'你好'});check(p.inputContext()==='','password content is never used as predictive context')
|
||||
p=page({initial:'不'});check(p.previewBefore==='不'&&p.previewAfter===''&&p.display==='不','entered text is separated from the native thin cursor without a text spacer')
|
||||
p=page({initial:'你😀好'});p.cursor=2;p.updateDisplay();check(p.previewBefore==='你😀'&&p.previewAfter==='好','moving the cursor updates both preview runs without splitting emoji')
|
||||
p=page();p.english();p.press('q');p.english();p.press('Q');p.english();p.press('q');check(p.text==='qQq'&&!p.upper,'case switching changes the actual committed English characters in both directions')
|
||||
console.log(JSON.stringify({suite:'ime-client',assertions:count,status:'passed'}))
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Native caret/QWERTY fixture using an owned emulator without Inspector."""
|
||||
import argparse,hashlib,importlib.util,json,math,pathlib,shutil,subprocess,time
|
||||
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;emu.APPS=['inputmethod']
|
||||
|
||||
PROBE='''<template><div><p>输入组件原生验收</p></div></template>
|
||||
<script>
|
||||
import router from '@system.router'
|
||||
import tc from '@system.test'
|
||||
import {activeInputs} from '../common/ime-lifecycle.js'
|
||||
import {themeStyles} from '../common/ime-theme.js'
|
||||
export default {started:false,onShow(){if(this.started)return;this.started=true;setTimeout(()=>{
|
||||
tc.testcase('preview-qwerty',async()=>{
|
||||
router.push({uri:'/keyboard',params:{initial:'不'}});await tc.wait(.5);
|
||||
var page=router.getPages().slice(-1)[0],p=activeInputs().filter(input=>input.$page===page)[0];
|
||||
function check(ok,label){if(!ok)throw new Error(label);console.log('PROBE_CHECK '+label)}
|
||||
async function rect(id){return await p.$element(id).getBoundingClientRect()}
|
||||
var cases=[['chinese','不',1,false],['phrase','你好',2,false],['ascii','watch',5,false],['mixed','ab你😀好',4,false],['start','你好',0,false],['middle','你好',1,false],['long','长😀'.repeat(30),30,false],['secret','私密😀',2,true]];
|
||||
for(var theme of ['bluewhite','blackgreen']){
|
||||
p.invalidate();p.inputTheme=theme;p.themeStyles=themeStyles(theme);
|
||||
for(var item of cases){p.invalidate();p.mode='en';p.upper=false;p.secret=item[3];p.revealSecret=false;p.text=item[1];p.cursor=item[2];p.composition='';p.renderKeys();p.updateDisplay();await tc.wait(.15);
|
||||
console.log('PROBE_CAPTURE '+JSON.stringify({name:theme+'-'+item[0],theme:theme,kind:'caret',before:await rect('ime-preview-before'),caret:await rect('ime-caret'),after:await rect('ime-preview-after'),preview:await rect('ime-preview')}));await tc.wait(1.1);}
|
||||
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);}
|
||||
}
|
||||
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');
|
||||
console.log('PROBE_DONE');});tc.run('preview-qwerty');},300)}}
|
||||
</script>'''
|
||||
|
||||
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);fixture=args.work/'fixture';fixture.mkdir(parents=True,exist_ok=True)
|
||||
shutil.copytree(APP/'src',fixture/'src',dirs_exist_ok=True);shutil.copy2(APP/'glyphix.config.js',fixture/'glyphix.config.js')
|
||||
metadata=json.loads((fixture/'src/manifest.json').read_text(encoding='utf-8'));metadata['router']['entry']='native-probe';metadata['router']['pages']['native-probe']={'component':'index'}
|
||||
(fixture/'src/manifest.json').write_text(json.dumps(metadata,ensure_ascii=False),encoding='utf-8')
|
||||
(fixture/'src/native-probe').mkdir(exist_ok=True);(fixture/'src/native-probe/index.ux').write_text(PROBE,encoding='utf-8')
|
||||
source_hash=hashlib.sha256((APP/'src/components/CrabInput/index.ux').read_bytes()).hexdigest()
|
||||
assert (fixture/'src/components/CrabInput/index.ux').read_bytes()==(APP/'src/components/CrabInput/index.ux').read_bytes()
|
||||
shell=shutil.which('pwsh')or'powershell';stable=pathlib.Path(r'C:\Users\herry\AppData\Local\Programs\PowerShell\7\pwsh.exe')
|
||||
if stable.exists():shell=str(stable)
|
||||
with (args.output/'fixture-build.log').open('w',encoding='utf-8')as log:
|
||||
result=subprocess.run([shell,'-NoProfile','-File',str(APP.parent/'build-rtos-game.ps1'),'-ProjectRoot',str(fixture)],stdout=log,stderr=subprocess.STDOUT)
|
||||
assert result.returncode==0,'See fixture-build.log'
|
||||
report={'method':'owned native console; production keyboard copied byte-for-byte into an auto-running fixture','production_keyboard_sha256':source_hash,'production_keyboard_path':'src/components/CrabInput/index.ux','production_route_sha256':hashlib.sha256((APP/'src/keyboard/index.ux').read_bytes()).hexdigest(),'inspector_used':False,'cases':[],'profiles':[],'passed':False}
|
||||
def rectangle(value):
|
||||
if isinstance(value,dict)and'rect'in value:value=value['rect']
|
||||
return [value.get('left',value.get('x',0)),value.get('top',value.get('y',0)),value.get('width',value.get('w',0)),value.get('height',value.get('h',0))]
|
||||
for profile in args.profiles:
|
||||
image,artifacts=emu.prepare(profile,args.work)
|
||||
device='generic-watch-'+('466x466'if profile.startswith('round')else'390x450');package=metadata['package']
|
||||
binary=fixture/'.glyphix-work'/('dist/'+device+'-quickapp/'+package+'/bundle.pkg'if profile.endswith('-quickapp')else'image/'+device+'/apps/'+package+'.pkg')
|
||||
shutil.copy2(binary,image/'apps'/(package+'.pkg'))
|
||||
command=[str(emu.ROOT/'temp/glyphix-v0.7.2-windows-x64/bin/glyphix-emu.exe'),'--config','pkgs.db','--target',package]
|
||||
if profile.startswith('round'):command+=['--width','624','--height','624','--dpi','326','--font-size','32','--round','--surface','surface/surface-466x466.json']
|
||||
else:command+=['--width','390','--height','450','--dpi','321','--font-size','29']
|
||||
command+=['--memory-profile','8192','--font-map','font-faces','--font-family','sans-serif']
|
||||
log_path=args.output/(profile+'-console.log');seen=set();finished=False;captures=0
|
||||
with log_path.open('w',encoding='utf-8')as log:
|
||||
process=subprocess.Popen(command,cwd=image,stdin=subprocess.PIPE,stdout=log,stderr=subprocess.STDOUT,creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
try:
|
||||
deadline=time.monotonic()+100
|
||||
while time.monotonic()<deadline and process.poll()is None:
|
||||
lines=log_path.read_text(encoding='utf-8',errors='replace').splitlines()
|
||||
for line in lines:
|
||||
if 'PROBE_CAPTURE 'in line:
|
||||
try:state=json.JSONDecoder().raw_decode(line.split('PROBE_CAPTURE ',1)[1].lstrip())[0]
|
||||
except json.JSONDecodeError:continue
|
||||
if state['name']in seen:continue
|
||||
seen.add(state['name']);state['profile']=profile
|
||||
# The vendor console accepts relative screenshot names only.
|
||||
filename=profile+'-'+state['name']+'.png';process.stdin.write(('screenshot '+filename+'\n').encode());process.stdin.flush()
|
||||
if state['kind']=='caret':
|
||||
before,caret,after,preview=[rectangle(state[key])for key in ['before','caret','after','preview']]
|
||||
state.update(left_gap_px=caret[0]-before[0]-before[2],right_gap_px=after[0]-caret[0]-caret[2])
|
||||
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=[]
|
||||
for key in state['keys']:
|
||||
x,y,w,h=rectangle(key['rect'])
|
||||
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)
|
||||
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
|
||||
time.sleep(.05)
|
||||
assert finished and captures==24,'Native fixture did not finish; see '+str(log_path)
|
||||
assert 'terminated:'not in log_path.read_text(encoding='utf-8',errors='replace'),'Native assertion failed'
|
||||
for state in report['cases']:
|
||||
if state['profile']!=profile:continue
|
||||
filename=profile+'-'+state['name']+'.png';snapshot=image/filename
|
||||
assert snapshot.is_file() and snapshot.read_bytes().startswith(b'\x89PNG\r\n\x1a\n'),'Screenshot missing: '+filename
|
||||
shutil.copy2(snapshot,args.output/filename);state['screenshot']=filename
|
||||
report['profiles'].append({'profile':profile,'native_case_switch_passed':True,'captures':captures,'fixture_binary_sha256':hashlib.sha256(binary.read_bytes()).hexdigest()})
|
||||
finally:
|
||||
if process.poll()is None:process.terminate()
|
||||
process.wait(timeout=10);process.stdin.close()
|
||||
report['passed']=all(row['passed']for row in report['cases']);native.save(args.output/'caret-results.json',report)
|
||||
assert report['passed'],'See caret-results.json'
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -2,7 +2,7 @@ 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,candidatePages,edit,erase,preview,learn,sanitizePreferences,chars} from '../src/common/ime-engine.js'
|
||||
import {createDictionary,digitsOf,code14Of,KEY14_GROUPS,display14,candidatePages,edit,erase,preview,previewParts,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++}
|
||||
@@ -55,6 +55,12 @@ check(erase('你😀好',2).text==='你好','Unicode backspace preserves surroga
|
||||
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')
|
||||
check(previewParts('不',1,false).before==='不'&&previewParts('不',1,false).after==='','end cursor has no inserted spacer or font-glyph advance')
|
||||
check(previewParts('你😀好',2,false).before==='你😀'&&previewParts('你😀好',2,false).after==='好','middle cursor splits at a Unicode codepoint boundary')
|
||||
check(previewParts('你好',0,false).before===''&&previewParts('你好',0,false).after==='你好','start cursor precedes text without padding')
|
||||
check(previewParts('a b',2,false).before==='a '&&previewParts('a b',2,false).after==='b','intentional spaces are preserved')
|
||||
check(!JSON.stringify(previewParts('my-secret',5,true)).includes('secret'),'split preview masks password on both sides')
|
||||
check(chars(previewParts('长😀'.repeat(1000),1000,false).before).length<=8&&chars(previewParts('长😀'.repeat(1000),1000,false).after).length<=8,'long preview keeps the real cursor inside round-screen width')
|
||||
dictionary.clear();check(dictionary.cacheSize===0,'cache release')
|
||||
check((await dictionary.lookup('niha',false,{}))[0].word==='你好','unfinished pinyin completes a real word before shorter fallback')
|
||||
check((await dictionary.lookup(digitsOf('niha'),true,{}))[0].word==='你好','unfinished T9 shares the completion policy')
|
||||
|
||||
@@ -45,29 +45,29 @@ def main():
|
||||
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');")
|
||||
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){await tc.wait(.35);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();await tc.wait(.35);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();await tc.wait(.35)}")
|
||||
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');await tc.wait(.35)};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=require('pkg://com.sydxky.rtos.inputmethod/common/ime-lifecycle.js').activeInputs().filter(input=>input.$page===router.getPage(1))[0];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('你好');await ready();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('completion-prediction',"reset();p.mode='py';p.renderKeys();for(var letter of ['n','i','h','a'])await click(letter);await ready();check(p.bar[0].word==='你好'&&p.bar[0].consume===4,'unfinished spelling completes a real phrase');await click('你好');await ready();check(p.prediction&&p.bar[0].word==='世界','committed word offers bounded context continuation');await click('世界');check(p.text==='你好世界'&&!p.composition,'prediction appends once without repeating the prefix');")
|
||||
run('steady-candidates',"reset();p.mode='py';p.renderKeys();p.composition='shi';p.schedule();await ready();var previous=p.bar[0].word,previewBefore=p.display;p.press('j');check(p.busy&&p.bar[0].word===previous&&p.display===previewBefore,'pending result retains preview and candidates');p.choose(p.bar[0]);check(!p.text,'stale retained candidate cannot commit');await ready();")
|
||||
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===16&&p.candidateRows.length===4&&p.pageCount>1,'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-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('你好');await ready();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<=16&&p.visibleCandidates.length>4,'14-key adaptive candidate grid');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('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('大写');await click('Z');await click('小写');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('numeric',"await click('数字输入');await tc.wait(.3);p=require('pkg://com.sydxky.rtos.inputmethod/common/ime-lifecycle.js').activeInputs().filter(input=>input.$page===router.getPage(1))[0];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=require('pkg://com.sydxky.rtos.inputmethod/common/ime-lifecycle.js').activeInputs().filter(input=>input.$page===router.getPage(1))[0];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.$valid||!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=require('pkg://com.sydxky.rtos.inputmethod/common/ime-lifecycle.js').activeInputs().filter(input=>input.$page===router.getPage(1))[0];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:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
import assert from 'node:assert/strict'
|
||||
import {syncInput,checkInput,watchInput} from '../scripts/install.mjs'
|
||||
const parent=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../.glyphix-work')
|
||||
fs.mkdirSync(parent,{recursive:true})
|
||||
const scratch=fs.mkdtempSync(path.join(parent,'sync-test-')),source=path.join(scratch,'source'),host=path.join(scratch,'host')
|
||||
function write(root,name,body){const file=path.join(root,name);fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,body)}
|
||||
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','{}')
|
||||
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')
|
||||
}
|
||||
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 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)
|
||||
write(source,'src/common/ime-engine.js','second');assert.throws(()=>checkInput(host,source),/changed/)
|
||||
write(host,'input-component.json',JSON.stringify({schema:1,integration:'component'}));syncInput(host,source)
|
||||
assert.ok(!JSON.parse(fs.readFileSync(path.join(host,'src/manifest.json'),'utf8')).router.pages.keyboard)
|
||||
assert.ok(!fs.existsSync(path.join(host,'src/keyboard/index.ux')))
|
||||
write(source,'src/keyboard/index.ux','unused compatibility route update');checkInput(host,source)
|
||||
const events=[];stop=watchInput(host,source,event=>events.push(event))
|
||||
const initial=events.length
|
||||
write(source,'src/components/CrabInput/index.ux','component update')
|
||||
write(source,'src/common/ime-theme.js','theme update')
|
||||
write(source,'src/assets/ime-icons/new-icon.png','new icon')
|
||||
write(source,'src/common/ime-layout.js','new related module')
|
||||
write(source,'src/assets/ime-dict/meta.json',JSON.stringify({shards:['3333'],association_shards:['a0f'],completion_shards:['c01']}));write(source,'src/assets/ime-dict/3333.json','{"changed":true}');write(source,'src/assets/ime-dict/a0f.json','{}');write(source,'src/assets/ime-dict/c01.json','{}')
|
||||
const deadline=Date.now()+8000
|
||||
while(Date.now()<deadline){await new Promise(resolve=>setTimeout(resolve,50));try{checkInput(host,source);break}catch{}}
|
||||
const current=checkInput(host,source)
|
||||
assert.ok(fs.existsSync(path.join(host,'src/assets/ime-dict/a0f.json'))&&fs.existsSync(path.join(host,'src/assets/ime-dict/c01.json')),'prediction shards synchronize')
|
||||
assert.notEqual(current.sourceHash,first.sourceHash);assert.ok(events.length>initial)
|
||||
assert.equal(fs.readFileSync(path.join(host,'src/common/ime-layout.js'),'utf8'),'new related module')
|
||||
assert.ok(!fs.existsSync(path.join(host,'src/assets/ime-dict/2222.json')))
|
||||
assert.equal(fs.readFileSync(path.join(host,'src/assets/host-data.txt'),'utf8'),'preserve')
|
||||
const receipt=JSON.parse(fs.readFileSync(path.join(host,'src/assets/crab-input-source.json'),'utf8'))
|
||||
receipt.files['../outside']={};write(host,'src/assets/crab-input-source.json',JSON.stringify(receipt))
|
||||
assert.throws(()=>syncInput(host,source),/Invalid managed/)
|
||||
assert.ok(!fs.existsSync(path.join(host,'src/assets/crab-input-source.json')),'failed sync invalidates receipt')
|
||||
console.log(JSON.stringify({suite:'input-sync',status:'passed',watchEvents:events.length,checks:['incremental','tamper rejection','source drift rejection','watch component engine theme icon dictionary update','obsolete shard removal','host isolation','invalid receipt rejection']}))
|
||||
}finally{
|
||||
if(stop)stop()
|
||||
if(!scratch.startsWith(parent+path.sep+'sync-test-'))throw Error('Unexpected test directory')
|
||||
fs.rmSync(scratch,{recursive:true,force:true})
|
||||
}
|
||||
@@ -50,7 +50,7 @@ def main():
|
||||
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(1);emu.evaluate("var p=require('pkg://com.sydxky.rtos.inputmethod/common/ime-lifecycle.js').activeInputs().filter(input=>input.$page===r.getPage(1))[0];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')
|
||||
|
||||
在新工单中引用
屏蔽一个用户