@antprofuse/saddle-islands-tools 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antprofuse/saddle-islands-tools",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Independent Saddle–Islands engineering tools",
5
5
  "files": [
6
6
  "tools"
@@ -30,3 +30,15 @@ Eight is the public tool default, not a universal hardware optimum. Lower concur
30
30
  Both `batch-input.json` and `batch-summary.json` contain `concurrencyPolicy`: `defaultWorkers`, `requestedWorkers`, `actualConcurrency` (effective simultaneous worker upper bound, not a measured peak), `workersReason`, and truthful `shardSizes` including plugin tails. The summary's `concurrency` uses that effective upper bound; its worker records retain every actual shard and lifecycle receipt.
31
31
 
32
32
  Legacy component-host integrations must supply an explicit `repositoryRoot` to `component_runtime.main(prepare, sourceRoot, projectRoot, repositoryRoot)`. No directory-depth inference is performed. Current case packages use the public `run` or `batch` commands.
33
+
34
+ ### 区域视觉声明
35
+
36
+ `region` 且 `check: "visual"` 的断言由公共编译器及运行器校验。`visual-expectation.mjs` 公开 `validateVisualExpectation` 和纯函数 `assertVisualMetrics`。声明必须含正数 `designWidth` 和至少一项实际期望;空对象、仅设计宽度、未知键、非有限数值被拒绝,合法零值保留。
37
+
38
+ 支持 `text`(fontSize/fontWeight/color/lineHeight)、`lengths`(padding四边、margin四边、borderRadius、gap/rowGap/columnGap)、`dimensions`(width/height)、`backgroundColor`、`align`,以及 `layout`(position/display/flexDirection/alignItems/justifyContent/overflowX/overflowY/boxSizing)。数值长度以声明的设计宽度换算,字体容差0.02px,几何容差0.25px;布局与颜色按浏览器计算值比较。
39
+
40
+ 运行器在声明的语义载体上独立采集并判定;按压状态使用释放前冻结的原生快照。观察模块的自报成功不能替代公共判定。该合同不证明期望覆盖完整,也不为缺少语义身份的内部部件猜测选择器;这类观察能力须先明确,不能按期望尺寸或颜色挑选能通过的元素。其他断言类别不隐式套用本合同。
41
+
42
+ 可选 `carrier` 默认为 `semantic-root`。`source-content-group` 在语义root内对可见文字/图像取最低公共元素祖先;`owned-text-block` 在该组内要求唯一不属于其他语义目标的文字分支。选择与期待样式、class、原型HTML标签无关,候选缺失或歧义明确失败;不适用的拓扑不得借此猜测观察目标。`viewportInsets` 可声明 left/right/top/bottom 到业务frame视口的设计长度,返回采样记录包含原root盒及是否选中root。
43
+
44
+ 按压快照目前只支持 `semantic-root`,其他carrier在按下前拒绝,不能释放后采样冒充按住状态。这是明确能力限制,不能默默降级。内容分组不覆盖背景装饰/无文字无图像部件或Shadow DOM;相应责任另用适用的公开观察合同。
@@ -17,7 +17,7 @@ async function fixture(){
17
17
  await writeFile(path.join(project,'harness/regions/alternate.mjs'),"import {semanticLocator} from '@antprofuse/saddle-islands-tools/tools/modules/minifish/harness/semantic-anchor.mjs';export function locate(root,name){return semanticLocator(root,name)};export function assertTarget(){}");
18
18
  return {root,project,configFile,config,loaded:await loadProject(project,configFile)};
19
19
  }
20
- test('alternate project static closure records tool/adapter separately and copies no unrelated region',async()=>{const f=await fixture();try{const output=path.join(f.root,'artifact'),records=await packageRunner(f.loaded,output,'regions/alternate.mjs');assert(records.some(r=>r.kind==='tool'&&r.path==='harness/semantic-anchor.mjs'));assert(records.some(r=>r.kind==='adapter'&&r.path==='harness/regions/alternate.mjs'));assert(records.every(r=>/^[a-f0-9]{64}$/.test(r.sha256)));assert(!records.some(r=>r.path.includes('T1')));assert((await readFile(path.join(output,'adapters/harness/regions/alternate.mjs'),'utf8')).includes('../../../tools/harness/semantic-anchor.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
20
+ test('alternate project static closure records tool/adapter separately and copies no unrelated region',async()=>{const f=await fixture();try{const output=path.join(f.root,'artifact'),records=await packageRunner(f.loaded,output,'regions/alternate.mjs');assert(records.some(r=>r.kind==='tool'&&r.path==='harness/semantic-anchor.mjs'));for(const name of ['visual-expectation.mjs','visual-carrier.mjs']){assert(records.some(r=>r.kind==='tool'&&r.path==='harness/'+name));assert((await readFile(path.join(output,'tools/harness',name),'utf8')).length>0);}assert(records.some(r=>r.kind==='adapter'&&r.path==='harness/regions/alternate.mjs'));assert(records.every(r=>/^[a-f0-9]{64}$/.test(r.sha256)));assert(!records.some(r=>r.path.includes('T1')));assert((await readFile(path.join(output,'adapters/harness/regions/alternate.mjs'),'utf8')).includes('../../../tools/harness/semantic-anchor.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
21
21
  for(const [name,code] of [['unknown package',"import 'fs';"],['dependency traversal',"import '../../outside.mjs';"],['direct DOM',"export const read=root=>root.evaluate(()=>1);"],['dynamic import',"export const read=()=>import('./other.mjs');"]])test(name+' is rejected from the closed observation dependency graph',async()=>{const f=await fixture();try{await writeFile(path.join(f.project,'harness/regions/alternate.mjs'),code);await writeFile(path.join(f.project,'outside.mjs'),'export const leaked=true;');await assert.rejects(packageRunner(f.loaded,path.join(f.root,'artifact'),'regions/alternate.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
22
22
  test('symlink aliases, undeclared module, missing config, and package output are rejected',async()=>{const f=await fixture();try{const module=path.join(f.project,'harness/regions/alternate.mjs');await rm(module);await writeFile(path.join(f.project,'harness/other.mjs'),'export const safe=true;');await symlink('../other.mjs',module);await assert.rejects(packageRunner(f.loaded,path.join(f.root,'alias'),'regions/alternate.mjs'),/Symlink/);await assert.rejects(packageRunner(f.loaded,path.join(f.root,'unknown'),'regions/unknown.mjs'),/declared module/);await assert.rejects(loadProject(f.project,path.join(f.project,'missing.json')));await assert.rejects(packageRunner(f.loaded,path.join(f.project,'artifact'),'regions/alternate.mjs'),/outside/);}finally{await rm(f.root,{recursive:true,force:true});}});
23
23
  test('generic composition preserves exact preparation and original ID mappings without project names',()=>{const noop=()=>{},policy={validateSemanticTargets:noop,supplementalDefinition:()=>null,officialTitle:()=>false,validatePreparationOverrides:noop,validateFixtureTypes:noop,validateSpecialBoundaries:noop,validateIndependentFixture:noop,validatePacketPlan:noop,validatePluginEvents:noop,validatePluginAssertionIdentity:noop,validateObservationModuleName:noop};const catalog={cases:[{id:'alternate / page / value / content',title:'value'}]},definition={caseId:catalog.cases[0].id,ancestors:['open'],basis:[{path:'alternate/source.yaml'}],preparation:{},assertions:[{id:'value',kind:'text',target:'alternate / page / value',expected:'42',count:1}]},fragment={id:'open',inputs:{query:{key:'42'}},boundaries:[],actions:[{kind:'open-page'}]};const plan=compileCase(catalog,definition,[fragment],policy);assert.deepEqual(plan.inputs,fragment.inputs);assert.deepEqual(plan.assertions,definition.assertions);assert.equal(plan.case.id,definition.caseId);assert.throws(()=>compileCase(catalog,{...definition,ancestors:['unknown']},[fragment],policy),/missing preparation/);});
@@ -1,3 +1,4 @@
1
+ import {validateRegionVisualAssertions} from '../harness/visual-expectation.mjs';
1
2
  import assert from 'node:assert/strict';
2
3
  import {validateTargetActions} from '../harness/target-actions.mjs';
3
4
  function jsonValue(value, path = '$') {
@@ -34,6 +35,7 @@ export function compileCase(catalog, definition, fragments,policy) {
34
35
  assert(policy&&typeof policy.validateSemanticTargets==='function','Explicit project plan policy required');
35
36
  const {validateSemanticTargets,validateIndependentFixture,validatePacketPlan,validatePluginEvents,validatePluginAssertionIdentity,validateObservationModuleName}=policy;
36
37
  jsonValue(definition);
38
+ validateRegionVisualAssertions(definition.assertions);
37
39
  validateSemanticTargets(definition);
38
40
  jsonValue(fragments);
39
41
  for(const fragment of fragments)validateSemanticTargets(fragment);
@@ -170,6 +172,7 @@ export function compileCase(catalog, definition, fragments,policy) {
170
172
  export function renderCaseModule(plan,validateSemanticTargets) {
171
173
  assert.equal(typeof validateSemanticTargets,'function','Explicit project source validator required');
172
174
  jsonValue(plan);
175
+ validateRegionVisualAssertions(plan.assertions);
173
176
  validateSemanticTargets(plan);
174
177
  return `// Generated from miniapp case definitions. Do not edit.\nexport default ${JSON.stringify(plan, null, 2)};\n`;
175
178
  }
@@ -1,3 +1,4 @@
1
+ import {assertVisualMetrics} from './visual-expectation.mjs';
1
2
  import assert from 'node:assert/strict';
2
3
  import {sampleNativeMetrics,sampleNativeTextVisibility,sampleNativeStyle,sampleNativePressed,sampleHeldNativeState} from './native-dom-sampling.mjs';
3
4
  import {nativeMetricsFrameIdentity} from './independent-visual.mjs';
@@ -53,6 +54,7 @@ export function validateFrozenHeldAssertion(snapshot,a,validators){
53
54
  for(const member of group.members){assert(matchesNativeSemanticIdentity(member.identity,a.target,a.instance),'Original semantic identity and optional instance must match');// Preserve measured :active; declared assertions do not require CSS :active.
54
55
 
55
56
  if(a.enabled)assert.equal(member.state.enabled,true,'Original native enabled obligation');validators.validateObservation(member.metrics,a);
57
+ if(a.kind==='region'&&a.check==='visual'){assert(!a.visual.carrier||a.visual.carrier==='semantic-root','Held visual carrier not captured');assertVisualMetrics(member.metrics,a.visual);}
56
58
  if(a.textFits)validateNativeTextVisibility(member.textPaint,{selfOverflow:'contain'});observations.push({...member.metrics,...(a.textFits?{textPaint:member.textPaint}:{})});
57
59
  }
58
60
  return {count:group.count,visible:group.members.map(m=>m.state.visible),observations,snapshotVersion:snapshot.version,snapshotAt:snapshot.snapshotAt};
@@ -1,3 +1,4 @@
1
+ import {validateRegionVisualAssertions,assertVisualMetrics} from './visual-expectation.mjs';
1
2
  import {hostPolicy} from '@minifish/project-host';
2
3
  import {prepareHeldNativeInput,executeHeldSnapshot} from './held-snapshot.mjs';
3
4
  import {prepareHeldSnapshot,independentBoundary,assertNativeState,projectAssert,locateProject,queryBoundaryIds,validateReleasedPaymentRows} from '@minifish/project-runtime';
@@ -26,6 +27,7 @@ export const screenshotPolicyVersion = 1;
26
27
  function target(root, name) { return semanticLocator(root, name); }
27
28
 
28
29
  export async function runMiniappCase({page, plan, testInfo}) {
30
+ validateRegionVisualAssertions(plan.assertions);
29
31
  assert.equal(plan.resources.length, 0, 'resource handling is not implemented in this runner yet');
30
32
  const boundary = plan.boundaries.length ? independentBoundary(plan.boundaries) : zeroBusinessPlan();
31
33
  const images = installRendererImageEvidence(page);
@@ -92,6 +94,7 @@ export async function runMiniappCase({page, plan, testInfo}) {
92
94
  assert(app, 'missing real page opening');
93
95
  await trace?.attach();
94
96
  input=targetActions({page,locate:(name,instance)=>locate(app.root,name,{instance}),synchronize:()=>synchronizeAnchors(app.root),boundary,receipts:result.actions,beforePress:async()=>{if(artifactOnly)capturePreparation=await prepareMiniappCapture(page);},observeInput:async(stage,action)=>trace?.sample(stage,app.root,boundary.evidence(),action)});
97
+ if(heldSpec)for(const a of plan.assertions)if(a.kind==='region'&&a.check==='visual')assert(!a.visual.carrier||a.visual.carrier==='semantic-root','Held visual carrier requires a supported frozen snapshot contract');
95
98
  if(heldSpec)await prepareHeldNativeInput(app.root,heldSpec,locate(app.root,heldSpec.press.target,{instance:heldSpec.press.instance}));
96
99
  for(const action of plan.beforeTarget??[]){
97
100
  if(action.kind==='leave-return'){
@@ -114,7 +117,12 @@ export async function runMiniappCase({page, plan, testInfo}) {
114
117
  if(assertion.kind==='host-native-ui'){result.observations[assertion.id]=await assertHostNativeUi(page,app,assertion);
115
118
  }else if(assertion.kind==='carousel-frame'){result.observations[assertion.id]=await observeCarousel(app.root,assertion.contract,{urls:assertion.images,records:images.records});
116
119
  }else if(assertion.kind==='host-navigation'){result.observations[assertion.id]=await assertHostNavigation(page,app,assertion,plan.hostSystemPlan);
117
- }else if(assertion.kind==='region'){result.observations[assertion.id]=await observation.assertTarget({app,page,assertion,images});
120
+ }else if(assertion.kind==='region'){
121
+ result.observations[assertion.id]=await observation.assertTarget({app,page,assertion,images});
122
+ if(assertion.check==='visual') {
123
+ const metrics=await nativeMetrics(locator,{carrier:assertion.visual.carrier});
124
+ result.observations[assertion.id]={...result.observations[assertion.id],publicVisual:assertVisualMetrics(metrics,assertion.visual)};
125
+ }
118
126
  }else if(await projectAssert({root:app.root,assertion,locator,locate,images,result})){
119
127
  }else if(['hidden','visible'].includes(assertion.kind)){await assertNativeState(locator,assertion);
120
128
  }else if(assertion.kind==='text'){
@@ -1,3 +1,4 @@
1
+ import {resolveVisualCarrier} from './visual-carrier.mjs';
1
2
  import {sampleNativeMetrics} from './native-dom-sampling.mjs';
2
3
  import {rendererImageFrameId} from './renderer-image-evidence.mjs';
3
4
  import assert from 'node:assert/strict';
@@ -7,9 +8,9 @@ export async function nativeMetricsFrameIdentity(locator) {
7
8
  try{const frame=await handle?.ownerFrame();if(frame)frameId=rendererImageFrameId(locator.page(),frame);}finally{await handle?.dispose();}
8
9
  return frameId;
9
10
  }
10
- export async function nativeMetrics(locator) {
11
+ export async function nativeMetrics(locator, {carrier='semantic-root'}={}) {
11
12
  const frameId=await nativeMetricsFrameIdentity(locator);
12
- const metrics=await locator.evaluate(sampleNativeMetrics);
13
+ const metrics=carrier==='semantic-root'?await locator.evaluate(sampleNativeMetrics):await locator.evaluate(Function('return ((root,kind)=>{const resolve='+resolveVisualCarrier.toString()+';const sample='+sampleNativeMetrics.toString()+';const selected=resolve(root,kind);return {...sample(selected),carrierEvidence:{kind,rootBox:sample(root).box,selectedIsRoot:selected===root}};})')(),carrier);
13
14
  metrics.frameId=frameId;for(const paint of [...metrics.imagePaints,...metrics.imagePaintInventory])paint.frameId=frameId;return metrics;
14
15
  }
15
16
  export function near(actual,expected,label,tolerance=.02){assert(Math.abs(actual-expected)<=tolerance,`${label}: ${actual} != ${expected}`);}
@@ -1,7 +1,7 @@
1
1
  // Existing public browser collectors, extracted without changing measured fields.
2
2
  export function sampleNativeMetrics(el){
3
3
  const s=getComputedStyle(el),r=el.getBoundingClientRect();
4
- const styles=Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color','backgroundColor','borderRadius','paddingLeft','paddingRight','paddingTop','paddingBottom','marginTop','marginBottom','marginLeft','marginRight','backgroundImage','backgroundSize','backgroundRepeat','backgroundPosition','objectFit'].map(k=>[k,s[k]]));
4
+ const styles=Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color','backgroundColor','borderRadius','paddingLeft','paddingRight','paddingTop','paddingBottom','marginTop','marginBottom','marginLeft','marginRight','backgroundImage','backgroundSize','backgroundRepeat','backgroundPosition','objectFit','textAlign','position','display','flexDirection','alignItems','justifyContent','overflowX','overflowY','boxSizing','gap','rowGap','columnGap'].map(k=>[k,s[k]]));
5
5
  const texts=[],textStyles=[];
6
6
  function visit(node){if(node.nodeType===3 && node.textContent.trim()){const range=document.createRange();range.selectNodeContents(node);const ts=getComputedStyle(node.parentElement);textStyles.push(Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color'].map(k=>[k,ts[k]])));for(const b of range.getClientRects())if(b.width&&b.height)texts.push({x:b.x,y:b.y,width:b.width,height:b.height});}else{for(const c of node.childNodes)visit(c);if(node.shadowRoot)visit(node.shadowRoot);}}
7
7
  visit(el);
@@ -0,0 +1,44 @@
1
+ /** Browser-side, expectation-independent selection inside one semantic root. */
2
+ export function resolveVisualCarrier(root, kind = 'semantic-root') {
3
+ if(kind==='semantic-root')return root;
4
+ if(!['source-content-group','owned-text-block'].includes(kind))throw Error('Unsupported visual carrier');
5
+ const visible=element=>{
6
+ for(let p=element;p;p=p.parentElement){const s=getComputedStyle(p);if(s.display==='none'||s.visibility!=='visible'||Number(s.opacity)===0)return false;}
7
+ return true;
8
+ };
9
+ const content=[],texts=[];
10
+ const walk=node=>{
11
+ if(node.nodeType===3&&node.textContent.trim()&&visible(node.parentElement)) {
12
+ const range=node.ownerDocument.createRange();range.selectNodeContents(node);
13
+ if([...range.getClientRects()].some(r=>r.width>0&&r.height>0)){content.push(node.parentElement);texts.push(node);}
14
+ } else if(node.nodeType===1) {
15
+ if(node.shadowRoot)throw Error('Unsupported shadow content carrier topology');
16
+ if(node.tagName==='IMG'&&visible(node)){const r=node.getBoundingClientRect();if(r.width>0&&r.height>0)content.push(node);}
17
+ for(const child of node.childNodes)walk(child);
18
+ }
19
+ };
20
+ walk(root);
21
+ if(!content.length)throw Error('Content carrier requires actual painted text or image');
22
+ let group=content[0];
23
+ while(group&& !content.every(n=>group.contains(n)))group=group.parentElement;
24
+ if(!group||!root.contains(group))throw Error('Content group escaped semantic owner');
25
+ if(kind==='source-content-group')return group;
26
+ const branches=new Set();
27
+ for(const text of texts) {
28
+ let owned=true;
29
+ for(let p=text.parentElement;p&&p!==root;p=p.parentElement){
30
+ const path=p.$$dataset?.anchorId??p.getAttribute('data-anchor-id');
31
+ if(path){owned=false;break;}
32
+ }
33
+ if(!owned)continue;
34
+ let branch=text.parentElement;
35
+ if(branch===group)throw Error('Owned text has no distinct block');
36
+ while(branch.parentElement!==group)branch=branch.parentElement;
37
+ branches.add(branch);
38
+ }
39
+ if(branches.size!==1)throw Error('Owned text block missing or ambiguous');
40
+ const block=[...branches][0];
41
+ // Do not let an unanchored wrapper make text belonging to other targets owned.
42
+ for(const n of block.querySelectorAll('*'))if(n.$$dataset?.anchorId||n.getAttribute('data-anchor-id'))throw Error('Owned text block contains another semantic target');
43
+ return block;
44
+ }
@@ -0,0 +1,79 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ const lengths = new Set(['paddingLeft','paddingRight','paddingTop','paddingBottom','marginLeft','marginRight','marginTop','marginBottom','borderRadius','gap','rowGap','columnGap']);
4
+ const layout = new Set(['position','display','flexDirection','alignItems','justifyContent','overflowX','overflowY','boxSizing']);
5
+ const top = new Set(['designWidth','text','lengths','dimensions','backgroundColor','align','layout','carrier','viewportInsets']);
6
+ function object(value, name) {
7
+ assert(value && Object.getPrototypeOf(value) === Object.prototype, `${name}: object required`);
8
+ }
9
+ function number(value, name) { assert(typeof value === 'number' && Number.isFinite(value), `${name}: finite number required`); }
10
+ function string(value, name) { assert(typeof value === 'string' && value.trim(), `${name}: nonempty string required`); }
11
+ function keys(value, allowed, name, validate) {
12
+ object(value,name);
13
+ for (const [key,item] of Object.entries(value)) {
14
+ assert(allowed.has(key), `${name}: unsupported ${key}`);
15
+ validate(item, `${name}.${key}`);
16
+ }
17
+ return Object.keys(value).length;
18
+ }
19
+
20
+ /** Validates declarations, not whether the source obligations are sufficiently covered. */
21
+ export function validateVisualExpectation(value) {
22
+ object(value,'visual');
23
+ for (const key of Object.keys(value)) assert(top.has(key), `visual: unsupported ${key}`);
24
+ number(value.designWidth,'visual.designWidth');
25
+ assert(value.designWidth > 0,'visual.designWidth must be positive');
26
+ if(value.carrier!==undefined)assert(['semantic-root','source-content-group','owned-text-block'].includes(value.carrier),'visual: unsupported carrier');
27
+ let checks = 0;
28
+ if(value.viewportInsets!==undefined)checks+=keys(value.viewportInsets,new Set(['left','right','top','bottom']),'visual.viewportInsets',number);
29
+ if (value.text !== undefined) {
30
+ checks += keys(value.text,new Set(['fontSize','fontWeight','color','lineHeight']),'visual.text',(v,k)=>k.endsWith('.color')?string(v,k):number(v,k));
31
+ }
32
+ if (value.lengths !== undefined) checks += keys(value.lengths,lengths,'visual.lengths',number);
33
+ if (value.dimensions !== undefined) checks += keys(value.dimensions,new Set(['width','height']),'visual.dimensions',number);
34
+ if (value.layout !== undefined) checks += keys(value.layout,layout,'visual.layout',string);
35
+ for (const key of ['backgroundColor','align']) if (value[key] !== undefined) { string(value[key],`visual.${key}`); checks++; }
36
+ assert(checks > 0,'visual: at least one substantive expectation required');
37
+ return value;
38
+ }
39
+
40
+ function near(actual,expected,tolerance,label) {
41
+ number(actual,label);
42
+ assert(Math.abs(actual-expected)<=tolerance,`${label}: ${actual} != ${expected}`);
43
+ }
44
+ function pixels(value,label) {
45
+ assert(typeof value==='string' && /^-?(?:\d+(?:\.\d*)?|\.\d+)px$/.test(value),`${label}: resolved px required`);
46
+ return Number(value.slice(0,-2));
47
+ }
48
+
49
+ /** Pure public predicate. Metrics must be sampled from the declared semantic carrier. */
50
+ export function assertVisualMetrics(metrics, expectation) {
51
+ const v=validateVisualExpectation(expectation), m=metrics;
52
+ assert(m?.box && m?.viewport && m?.styles,'visual: native metrics required');
53
+ number(m.viewport.width,'viewport.width');assert(m.viewport.width>0,'positive viewport width required');
54
+ number(m.box.width,'box.width');number(m.box.height,'box.height');
55
+ assert(m.box.width>0&&m.box.height>0,'visual: painted carrier required');
56
+ const unit=m.viewport.width/v.designWidth;
57
+ for(const [side,value] of Object.entries(v.viewportInsets??{})) {
58
+ const actual=side==='left'?m.box.x:side==='top'?m.box.y:side==='right'?m.viewport.width-m.box.x-m.box.width:m.viewport.height-m.box.y-m.box.height;
59
+ near(actual,value*unit,.25,`visual.viewportInsets.${side}`);
60
+ }
61
+ if(v.text && Object.keys(v.text).length) {
62
+ assert(m.texts?.length&&m.textStyles?.length,'visual: actual text paint required');
63
+ for(const style of m.textStyles) for(const [key,value] of Object.entries(v.text)) {
64
+ if(key==='color') assert.equal(style.color,value,'visual.text.color');
65
+ else if(key==='fontWeight') {assert(style.fontWeight!==''&&style.fontWeight!=null,'fontWeight required');assert.equal(Number(style.fontWeight),value,'visual.text.fontWeight');}
66
+ else near(pixels(style[key],key),value*unit,.02,`visual.text.${key}`);
67
+ }
68
+ }
69
+ for(const [key,value] of Object.entries(v.lengths??{})) near(pixels(m.styles[key],key),value*unit,.25,`visual.lengths.${key}`);
70
+ for(const [key,value] of Object.entries(v.dimensions??{})) near(m.box[key],value*unit,.25,`visual.dimensions.${key}`);
71
+ for(const [key,value] of Object.entries(v.layout??{})) assert.equal(m.styles[key],value,`visual.layout.${key}`);
72
+ if(v.backgroundColor!==undefined) assert.equal(m.styles.backgroundColor,v.backgroundColor,'visual.backgroundColor');
73
+ if(v.align!==undefined) assert.equal(m.styles.textAlign,v.align,'visual.align');
74
+ return {checks:'passed',expectation:v,metrics:m};
75
+ }
76
+
77
+ export function validateRegionVisualAssertions(assertions) {
78
+ for(const a of assertions??[]) if(a.kind==='region'&&a.check==='visual') validateVisualExpectation(a.visual);
79
+ }