assistant/tools/learning/test-viewport-seek.mjs

105 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
// All pages and mutations here are isolated fixtures. The ordinary fixed root
// deliberately has no scrolling overflow; only the document viewport can move.
export function viewportFixture(url,request,response){
if(url.pathname!=='/fixture/viewport-seek') return false;
const mode=url.searchParams.get('mode') || 'offscreen';
const initialTarget=['visible','obscured'].includes(mode) ? 80 : 1100;
const anchor=(top=initialTarget)=>`<a class="viewport-title" style="top:${top}px" href="/fixture/viewport-seek?selected=1">目标笔记</a>`;
const noTarget=['missing','time','lazy'].includes(mode);
const change=mode==='source' ? `history.replaceState({},'',location.pathname+'?changed=1')` :
mode==='root' ? `root.replaceWith(root.cloneNode(true))` :
mode==='scroller' ? `Object.defineProperty(document,'scrollingElement',{configurable:true,get:()=>document.body})` :
mode==='disappear' ? `root.querySelector('.viewport-title').remove()` :
mode==='blocked' ? `fetch('/fixture/write/viewport-seek',{method:'POST'}).catch(()=>{})` :
mode==='lazy' ? `setTimeout(()=>root.insertAdjacentHTML('beforeend',${JSON.stringify(anchor(500))}),250)` : '';
response.setHeader('Content-Type','text/html; charset=utf-8');
response.end(`<!doctype html><html><head><title>固定根内的视口有界定位</title><style>
body{margin:12px}#viewport-status{height:32px;margin:0}#viewport-root{position:relative;width:500px;height:2400px;overflow:visible}
.viewport-title{position:absolute;display:block;left:0;width:400px;height:30px;box-sizing:border-box}
#viewport-outside{position:fixed;top:12px;left:700px;width:200px;height:30px}
#viewport-cover{position:fixed;top:120px;left:12px;width:500px;height:100px;background:#eee;z-index:10}
</style></head><body><p id="viewport-status">实际视口定位样本</p><main id="viewport-global"><section id="viewport-root"><p>普通已加载内容,根容器不独立滚动</p>${noTarget ? '' : anchor()}${mode==='duplicate' ? anchor(1140) : ''}</section><a id="viewport-outside" class="viewport-title" href="/fixture/viewport-seek?outside=1">目标笔记</a></main>${mode==='obscured' ? '<div id="viewport-cover">遮挡目标</div>' : ''}<script>
const root=document.querySelector('#viewport-root');let started=false;
addEventListener('scroll',()=>{if(started)return;started=true;${change}});
</script></body></html>`);
return true;
}
function checkSummary(result,{passed,reason,code,movement=false,zero=false}){
assert.equal(result.passed,passed,JSON.stringify(result.errors));
assert.deepEqual(result.outputs,{status:[{text:'实际视口定位样本'}]},'the real read prefix is retained even when the later seek fails');
assert.equal(result.seek_results.length,1);
const summary=result.seek_results[0];
assert.deepEqual(Object.keys(summary).sort(),['step_position','scrolls','movements','target_reached','stop_reason'].sort());
assert.equal(summary.step_position,2);assert.equal(summary.target_reached,passed);assert.equal(summary.stop_reason,reason);
assert.equal(summary.scrolls,summary.movements.length);
assert.ok(summary.movements.every(move=>Object.keys(move).sort().join(',')==='after,before,client_height,scroll_height' &&
Object.values(move).every(Number.isFinite) && move.before>=0 && move.after>=move.before && move.client_height>180 && move.scroll_height>move.client_height));
for(let index=1;index<summary.movements.length;index+=1) assert.equal(summary.movements[index].before,summary.movements[index-1].after,'actual document movements are consecutive');
if(movement){assert.ok(summary.scrolls>0);assert.ok(summary.movements.some(move=>move.after>move.before));}
if(zero){assert.equal(summary.scrolls,0);assert.deepEqual(summary.movements,[]);}
assert.equal(result.verify_budget_ms,undefined,'explicit viewport scope does not expand the ordinary verify budget');
assert.deepEqual(result.error_codes,code ? [code] : [],JSON.stringify(result.errors));
assert.equal(result.step_timings[0].status,'passed');
assert.equal(result.step_timings[1].status,passed ? 'passed' : 'failed');
assert.ok(result.evidence.length>=(passed ? 3 : 4),'real entry, prefix and final/failed captures are retained');
return summary;
}
export async function viewportRegression(client,entryUrl,passed){
const task='viewport-seek-regression',base=new URL('/fixture/viewport-seek',entryUrl).href;
await client.call('open',{task_id:task,account_key:'test-account',entry_url:base});
await client.call('ready',{task_id:task,identity_label:'隔离视口定位测试'});
const seek={kind:'scroll',selector:'#viewport-root',scroll_scope:'viewport',direction:'down',pixels:320,max_scrolls:5,timeout_ms:3000,
until_target:{selector:'#viewport-global a.viewport-title',field:'text',value:'目标笔记'}};
// A required output is read before seeking, since after a genuine document
// movement this first-screen status is intentionally no longer in view.
const run=(mode,extra={})=>client.call('verify',{task_id:task,entry_url:base+'?mode='+mode,parameters:{},
steps:[{kind:'read',selector:'#viewport-status',output_name:'status'},{...seek,...extra}],expected_outputs:{status:'实际视口定位样本'}});
try{
const actual=await run('offscreen');
const summary=checkSummary(actual,{passed:true,reason:'target_reached',movement:true});
assert.ok(summary.scrolls<=seek.max_scrolls);
assert.equal(summary.movements[0].before,0);assert.equal(summary.movements[0].after,seek.pixels);
assert.ok(summary.movements.every(move=>move.after-move.before<=seek.pixels),'fixed viewport movements cannot jump directly to a target');
const final=actual.evidence.at(-1);
assert.equal(final.viewport.scroll_y,summary.movements.at(-1).after,'evidence records the same actual window displacement');
assert.equal(final.elements.find(element=>element.selector==='#viewport-root')?.scroll_y,undefined,'ordinary root never becomes an implicit inner scroll container');
checkSummary(await run('visible'),{passed:true,reason:'target_reached',zero:true});
checkSummary(await run('offscreen',{scroll_scope:undefined}),{passed:false,code:'UNOBSERVED_CURRENT_SELECTOR',reason:'target_changed',zero:true});
passed('显式viewport在固定普通根内匹配并实际固定步长滚document,证据与真实metrics一致;已可见零移动,旧未声明scope仍拒绝非滚动根');
for(const [mode,code,reason,extra] of [
['missing','SCROLL_BUDGET_EXHAUSTED','scroll_budget',{max_scrolls:1}],
['missing','SCROLL_BOUNDARY_BEFORE_TARGET','boundary',{pixels:1600,max_scrolls:3}],
['disappear','SCROLL_BUDGET_EXHAUSTED','scroll_budget',{max_scrolls:1}],
['time','SCROLL_TARGET_TIMEOUT','time_budget',{timeout_ms:1}],
]){
const failure=await run(mode,extra);
const item=checkSummary(failure,{passed:false,code,reason,movement:['missing','disappear'].includes(mode)});
if(reason==='boundary') assert.equal(item.movements.at(-1).after,item.movements.at(-1).before,'boundary summary records the real no-movement attempt');
}
const lazy=await run('lazy',{max_scrolls:1,timeout_ms:2000});
const lazySummary=checkSummary(lazy,{passed:true,reason:'target_reached',movement:true});
assert.equal(lazySummary.scrolls,1);
passed('视口目标缺失或消失不接受根外可见同名链接,滚动/边界/时间预算分别停止;短等允许根内自然出现且不重复滚动');
for(const [mode,code,reason,movement] of [
['duplicate','NON_UNIQUE_TARGET','ambiguous',false],
['obscured','TARGET_OBSCURED','obscured',false],
['source','SCROLL_SOURCE_CHANGED','source_changed',true],
['root','STALE_TARGET','target_changed',true],
['scroller','STALE_TARGET','target_changed',true],
['blocked','WRITE_REQUEST_BLOCKED','blocked_request',true],
]){
const failure=await run(mode);
checkSummary(failure,{passed:false,code,reason,movement,zero:!movement});
}
passed('视口重名/遮挡不滚动,来源/固定根/实际scrollingElement身份变化和未知POST中断;失败保留真实阅读前缀、位移与截图');
}finally{
await client.call('close',{task_id:task});
}
}