合并完成
This commit is contained in:
@@ -1,56 +0,0 @@
|
||||
"""Offline numerical comparison; timings are local measurements, not API latency."""
|
||||
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
import sys
|
||||
from time import perf_counter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from geometry import Measurement, solve, smallest_enclosing_circle, first_bearing_candidate_disk
|
||||
from geometry.localization import _enclosing_circle_reference
|
||||
|
||||
|
||||
def timed(function, points, repeats):
|
||||
durations = []
|
||||
for _ in range(repeats):
|
||||
start = perf_counter()
|
||||
result = function(points)
|
||||
durations.append(perf_counter()-start)
|
||||
return result, median(durations)*1000
|
||||
|
||||
|
||||
def main():
|
||||
timings = []
|
||||
for size in [4, 20, 60]:
|
||||
points = [(20*math.cos(i*math.tau/size), 20*math.sin(i*math.tau/size)) for i in range(size)]
|
||||
old, old_ms = timed(_enclosing_circle_reference, points, 3)
|
||||
new, new_ms = timed(smallest_enclosing_circle, points, 11)
|
||||
timings.append(dict(vertices=size, reference_ms=old_ms, incremental_ms=new_ms,
|
||||
speedup=old_ms/new_ms, radius_difference_m=abs(old[1]-new[1])))
|
||||
source = next((ROOT/'output/q1/practice').glob('*_observations.json'))
|
||||
rows = json.loads(source.read_text(encoding='utf-8'))['measurements']
|
||||
observations = [Measurement(**m) for m in rows]
|
||||
sensitivity = []
|
||||
for margin in [0, 0.005]:
|
||||
ms = [replace(m, half_angle_deg=m.half_angle_deg+margin) for m in observations]
|
||||
result, duration = timed(solve, ms, 31)
|
||||
sensitivity.append(dict(additional_half_angle_deg=margin, solve_ms=duration,
|
||||
diameter_m=result['diameter_m'],
|
||||
mec_radius_m=result['minimum_enclosing_circle']['radius_m'],
|
||||
diameter_circle_excess_m=result['diameter_circle']['max_excess_m'],
|
||||
clearance_margin_m=result['clearance_certificate']['remaining_margin_m']))
|
||||
report = dict(python=sys.version.split()[0], timing_method='median perf_counter; warm local process',
|
||||
timing_scope='MEC only; not whole solver speedup or simulator/network performance',
|
||||
circle_benchmarks=timings, practice_sensitivity=sensitivity,
|
||||
q2_first_bearing_certificate=first_bearing_candidate_disk(Measurement(0, 0, 0, 1.005)))
|
||||
output = ROOT/'output/q1/optimization_report.json'
|
||||
output.write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False)+'\n', encoding='utf-8')
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Build reproducible examples and the interactive visualization from Python results."""
|
||||
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT))
|
||||
from geometry import Measurement, solve
|
||||
|
||||
|
||||
def measured(stations, target, errors):
|
||||
return [Measurement(x,y,(math.degrees(math.atan2(target[1]-y,target[0]-x))+e)%360)
|
||||
for (x,y),e in zip(stations,errors)]
|
||||
|
||||
|
||||
def scenarios():
|
||||
target=(80,90)
|
||||
normal=measured([(-600,-250),(650,-100),(100,700)],target,[.65,-.55,.35])
|
||||
h=20*math.sqrt(3)
|
||||
triangle=[Measurement(-1000,0,1),Measurement(540,-500*math.sqrt(3),121),
|
||||
Measurement(520,h+500*math.sqrt(3),241)]
|
||||
return [
|
||||
dict(id='normal',name='三点交会',source='synthetic',target=target,measurements=normal),
|
||||
dict(id='triangle',name='直径圆反例',source='synthetic',target=(20,h/3),measurements=triangle),
|
||||
dict(id='poor',name='近共线检测点',source='synthetic',target=(900,50),
|
||||
measurements=measured([(0,0),(100,0),(200,0)],(900,50),[0,0,0])),
|
||||
dict(id='empty',name='不相容观测',source='synthetic',target=None,
|
||||
measurements=[Measurement(-100,0,180),Measurement(100,0,0)]),
|
||||
dict(id='segment',name='零误差退化线段',source='synthetic',target=(40,0),
|
||||
measurements=[Measurement(0,0,0,0),Measurement(100,0,180,0)]),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
cases=scenarios()
|
||||
practice=ROOT/'output/q1/practice'
|
||||
for path in sorted(practice.glob('*_observations.json')):
|
||||
doc=json.loads(path.read_text(encoding='utf-8'))
|
||||
cases.append(dict(id='practice-'+doc['case_code'],name='官方演练 '+doc['case_code'],
|
||||
source='official_practice',target=None,
|
||||
measurements=[Measurement(**m) for m in doc['measurements']]))
|
||||
examples=ROOT/'examples/q1'
|
||||
results=ROOT/'output/q1'
|
||||
examples.mkdir(parents=True,exist_ok=True)
|
||||
results.mkdir(parents=True,exist_ok=True)
|
||||
dataset=[]
|
||||
for case in cases:
|
||||
ms=case['measurements']
|
||||
frames=[solve(ms[:n]) for n in range(1,len(ms)+1)]
|
||||
data={**case,'measurements':[asdict(m) for m in ms],'frames':frames}
|
||||
dataset.append(data)
|
||||
if case['source']=='synthetic':
|
||||
(examples/(case['id']+'.json')).write_text(json.dumps({k:v for k,v in data.items() if k!='frames'},ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
(results/(case['id']+'_result.json')).write_text(json.dumps(frames[-1],ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8')
|
||||
(results/'demo_data.json').write_text(json.dumps(dataset,ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8')
|
||||
template=(ROOT/'visualization/q1.template.html').read_text(encoding='utf-8')
|
||||
fragment=template.replace('/*__Q1_DATA__*/',json.dumps(dataset,ensure_ascii=False,allow_nan=False))
|
||||
(ROOT/'visualization/question1.html').write_text(fragment,encoding='utf-8')
|
||||
# A standalone companion uses the same fragment and computation results.
|
||||
theme=(ROOT/'visualization/standalone.css').read_text(encoding='utf-8')
|
||||
page='<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>B题问题一交会定位</title><style>'+theme+'</style><body>'+fragment+'</body></html>'
|
||||
(results/'question1.html').write_text(page,encoding='utf-8')
|
||||
print(json.dumps({'cases':len(dataset),'fragment_bytes':len(fragment.encode('utf-8')),
|
||||
'standalone':str(results/'question1.html')},ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Collect a few bearings for Q1 from an ALREADY OPEN practice session.
|
||||
|
||||
There is deliberately no test-start API, formal mode, or /clear call here.
|
||||
The documented robot API does not expose mode: verify the UI before running.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from geometry import Measurement, solve
|
||||
|
||||
|
||||
class PracticeClient:
|
||||
def __init__(self, robot_id, output, base_url='http://127.0.0.1:2026'):
|
||||
self.robot_id = robot_id
|
||||
self.output = output
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.prefix = uuid.uuid4().hex[:12]
|
||||
self.count = 0
|
||||
self.events = []
|
||||
self.deadline = None
|
||||
|
||||
def action(self, path, **extra):
|
||||
if path not in ('/enter', '/measure', '/exit'):
|
||||
raise ValueError('采集器只允许进入、检测、退出')
|
||||
if self.deadline is not None and path != '/exit' and time.monotonic() > self.deadline-15:
|
||||
raise TimeoutError('剩余时间不足,预留退出时间')
|
||||
self.count += 1
|
||||
payload = dict(arena_id='default', robot_id=self.robot_id,
|
||||
request_id=f'q1-{self.prefix}-{self.count}', **extra)
|
||||
encoded = json.dumps(payload, allow_nan=False).encode('utf-8')
|
||||
for attempt in range(3):
|
||||
request = Request(self.base_url+path, data=encoded,
|
||||
headers={'Content-Type':'application/json'}, method='POST')
|
||||
try:
|
||||
with urlopen(request, timeout=5) as response:
|
||||
status = response.status
|
||||
body = json.load(response)
|
||||
# A retried action is recorded/consumed only once.
|
||||
self.events.append(dict(path=path, request=payload, response=body, retries=attempt))
|
||||
self.output.write_text(json.dumps(self.events,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
if status != 200 or body.get('accepted') is not True:
|
||||
raise RuntimeError(f'动作未被接受: {status}, {body}')
|
||||
if path == '/enter':
|
||||
self.deadline = time.monotonic()+body['remaining_real_duration_s']
|
||||
return body
|
||||
except URLError:
|
||||
if attempt == 2:
|
||||
raise
|
||||
time.sleep(.25)
|
||||
|
||||
def measure(self, p, channel):
|
||||
return self.action('/measure',position={'x':p[0],'y':p[1]},channel=channel)
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--robot-id',required=True)
|
||||
parser.add_argument('--practice-confirmed',action='store_true',required=True,
|
||||
help='仅在界面确认为问题3演练且接口已就绪时提供')
|
||||
parser.add_argument('--case-code',required=True,help='界面显示的演练案例编码')
|
||||
parser.add_argument('--output',type=Path,default=Path('output/q1/practice'))
|
||||
args=parser.parse_args()
|
||||
args.output.mkdir(parents=True,exist_ok=True)
|
||||
log_path=args.output/f'{args.case_code}_actions.json'
|
||||
if log_path.exists():
|
||||
parser.error('同名采集日志已存在,请使用新的输出目录,避免覆盖证据')
|
||||
client=PracticeClient(args.robot_id,log_path)
|
||||
observations=[]
|
||||
channel=None
|
||||
first=None
|
||||
entered=False
|
||||
repeat=None
|
||||
try:
|
||||
client.action('/enter')
|
||||
entered=True
|
||||
for p in [(0,0),(800,0),(-800,0),(0,800),(0,-800)]:
|
||||
for c in range(1,21):
|
||||
response=client.measure(p,c)
|
||||
if response['measure_result']=='direction':
|
||||
first=(p,response['svd_deg'])
|
||||
observations.append(Measurement(*p,response['svd_deg']))
|
||||
channel=c
|
||||
break
|
||||
if channel is not None:
|
||||
break
|
||||
if channel is None:
|
||||
raise RuntimeError('预设采样点未取得direction;本次不扩展为全域搜索')
|
||||
p,theta=first
|
||||
repeat=client.measure(p,channel)
|
||||
a=math.radians(theta)
|
||||
# For an omnidirectional source seen initially at <=1500m, these
|
||||
# 700m-forward, +/-200m-lateral points remain within 1000m of its wedge.
|
||||
for lateral in [200,-200,300,-300]:
|
||||
q=(p[0]+700*math.cos(a)-lateral*math.sin(a),
|
||||
p[1]+700*math.sin(a)+lateral*math.cos(a))
|
||||
response=client.measure(q,channel)
|
||||
if response['measure_result']=='direction':
|
||||
observations.append(Measurement(*q,response['svd_deg']))
|
||||
if len(observations)>=3:
|
||||
break
|
||||
data={'name':'官方问题3演练观测','source':'official_practice','case_code':args.case_code,
|
||||
'channel':channel,'ground_truth':None,'measurements':[m.__dict__ for m in observations],
|
||||
'repeated_station_response':repeat,'result':solve(observations),
|
||||
'scope':'仅采集问题一观测,不执行问题三全清除任务;没有调用clear'}
|
||||
path=args.output/f'{args.case_code}_observations.json'
|
||||
path.write_text(json.dumps(data,ensure_ascii=False,indent=2,allow_nan=False),encoding='utf-8')
|
||||
print(json.dumps({'observations':str(path),'count':len(observations),'result':data['result']},ensure_ascii=False,indent=2))
|
||||
finally:
|
||||
if entered:
|
||||
client.action('/exit')
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Rasterize our restricted SVG primitives with Pillow, without a browser.
|
||||
|
||||
This inspects geometry/labels at both widths/themes. It does not emulate HTML/CSS.
|
||||
"""
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from PIL import Image,ImageDraw,ImageFont
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
S=2
|
||||
FONT=ImageFont.truetype('C:/Windows/Fonts/msyh.ttc',12*S)
|
||||
|
||||
|
||||
def render(path):
|
||||
root=ET.fromstring(path.read_text(encoding='utf-8'))
|
||||
width,height=map(float,(root.get('width'),root.get('height')))
|
||||
bg='#191c21' if 'dark' in path.stem else '#ffffff'
|
||||
image=Image.new('RGBA',(round(width*S),round(height*S)),bg)
|
||||
clip=next(el for el in root.iter() if el.tag.endswith('clipPath'))[0]
|
||||
clipbox=tuple(float(clip.get(k))*S for k in ('x','y','width','height'))
|
||||
|
||||
def color(value,opacity=1):
|
||||
if value in (None,'none'): return None
|
||||
value=value.lstrip('#')
|
||||
return tuple(int(value[i:i+2],16) for i in (0,2,4))+(round(opacity*255),)
|
||||
|
||||
def draw(el,clipped=False):
|
||||
nonlocal image
|
||||
tag=el.tag.rsplit('}',1)[-1]
|
||||
if tag in ('defs','title','desc'):return
|
||||
if tag in ('svg','g'):
|
||||
for child in el:draw(child,clipped or bool(el.get('clip-path')))
|
||||
return
|
||||
layer=Image.new('RGBA',image.size,(0,0,0,0));d=ImageDraw.Draw(layer)
|
||||
num=lambda key,default=0:float(el.get(key,default))*S
|
||||
fill=color(el.get('fill'),float(el.get('fill-opacity',1)))
|
||||
stroke=color(el.get('stroke'),float(el.get('stroke-opacity',1)))
|
||||
sw=max(1,round(num('stroke-width',1)))
|
||||
if tag=='line':
|
||||
xy=[(num('x1'),num('y1')),(num('x2'),num('y2'))]
|
||||
dash=el.get('stroke-dasharray','none')
|
||||
if dash=='none': d.line(xy,fill=stroke,width=sw)
|
||||
else:
|
||||
a,b=xy;dist=math.dist(a,b);unit=((b[0]-a[0])/max(dist,1e-15),(b[1]-a[1])/max(dist,1e-15))
|
||||
on,off=[float(v)*S for v in dash.split()]
|
||||
# Bound offscreen ray work to the finite exported viewport vicinity.
|
||||
segments=min(100000,int(dist/(on+off))+1)
|
||||
for i in range(segments):
|
||||
lo=i*(on+off);hi=min(lo+on,dist)
|
||||
d.line([(a[0]+lo*unit[0],a[1]+lo*unit[1]),(a[0]+hi*unit[0],a[1]+hi*unit[1])],fill=stroke,width=sw)
|
||||
elif tag=='rect':
|
||||
box=(num('x'),num('y'),num('x')+num('width'),num('y')+num('height'))
|
||||
d.rectangle(box,fill=fill,outline=stroke,width=sw)
|
||||
elif tag=='circle':
|
||||
x,y,r=num('cx'),num('cy'),num('r');box=(x-r,y-r,x+r,y+r)
|
||||
if el.get('stroke-dasharray') not in (None,'none'):
|
||||
for a in range(0,360,18):d.arc(box,a,a+10,fill=stroke,width=sw)
|
||||
else:d.ellipse(box,fill=fill,outline=stroke,width=sw)
|
||||
elif tag=='polygon':
|
||||
points=[tuple(float(v)*S for v in pair.split(',')) for pair in el.get('points').split()]
|
||||
d.polygon(points,fill=fill)
|
||||
if stroke:d.line(points+[points[0]],fill=stroke,width=sw,joint='curve')
|
||||
elif tag=='path':
|
||||
for x,y,axis,length in re.findall(r'M([\d.e+-]+),([\d.e+-]+)([hv])([\d.e+-]+)',el.get('d')):
|
||||
x,y,length=float(x)*S,float(y)*S,float(length)*S
|
||||
d.line([(x,y),(x+length,y) if axis=='h' else (x,y+length)],fill=stroke,width=sw)
|
||||
elif tag=='text':
|
||||
value=el.text or '';x,y=num('x'),num('y');anchor=el.get('text-anchor','start')
|
||||
length=d.textlength(value,font=FONT)
|
||||
if el.get('transform'):
|
||||
tile=Image.new('RGBA',(math.ceil(length+8*S),20*S));td=ImageDraw.Draw(tile)
|
||||
td.text((4*S,0),value,font=FONT,fill=fill)
|
||||
tile=tile.rotate(90,expand=True)
|
||||
layer.alpha_composite(tile,(round(x-tile.width/2),round(y-tile.height/2)))
|
||||
else:
|
||||
x-=length/2 if anchor=='middle' else length if anchor=='end' else 0
|
||||
d.text((x,y),value,font=FONT,fill=fill,anchor='ls')
|
||||
else:raise ValueError('unsupported SVG primitive '+tag)
|
||||
if clipped:
|
||||
x,y,w,h=clipbox
|
||||
mask=Image.new('L',image.size);ImageDraw.Draw(mask).rectangle((x,y,x+w,y+h),fill=255)
|
||||
transparent=Image.new('RGBA',image.size)
|
||||
layer=Image.composite(layer,transparent,mask)
|
||||
image=Image.alpha_composite(image,layer)
|
||||
|
||||
draw(root)
|
||||
output=path.with_suffix('.png');image.convert('RGB').save(output)
|
||||
return str(output.relative_to(ROOT))
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for path in sorted((ROOT/'output/q1/figures').glob('*.svg')):print(render(path))
|
||||
@@ -1,61 +0,0 @@
|
||||
/* Run only our generated local script in a minimal DOM, with no browser or network.
|
||||
* This checks event handlers and serializes the produced SVG for static inspection.
|
||||
* It is not a replacement for a native browser layout test.
|
||||
*/
|
||||
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),assert=require('node:assert');
|
||||
const rootDir=path.resolve(__dirname,'..');
|
||||
const source=fs.readFileSync(path.join(rootDir,'visualization/question1.html'),'utf8');
|
||||
const script=source.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||
class Element {
|
||||
constructor(tag) {this.tag=tag;this.attrs={};this.children=[];this.style={};this.events={};this.value='';this.max='3';this.textContent='';}
|
||||
setAttribute(k,v){this.attrs[k]=String(v);}
|
||||
appendChild(x){this.children.push(x);return x;}
|
||||
replaceChildren(...x){this.children=x;this.textContent='';}
|
||||
addEventListener(k,f){this.events[k]=f;}
|
||||
add(o){this.children.push(o);}
|
||||
getBoundingClientRect(){return {width:width};}
|
||||
}
|
||||
const esc=s=>String(s).replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
|
||||
const palette={light:{'--foreground':'#24282e','--border':'#d9dfe5','--viz-series-1':'#146e9a','--viz-series-2':'#c6503a','--viz-series-3':'#198064'},dark:{'--foreground':'#eceff3','--border':'#424a55','--viz-series-1':'#6cc8ed','--viz-series-2':'#ff947d','--viz-series-3':'#64d6ac'}};
|
||||
function serialize(el,theme){
|
||||
if(el.tag==='text')Object.assign(el.attrs,{fill:palette[theme]['--foreground'],'font-size':12,'font-family':'Microsoft YaHei, sans-serif'});
|
||||
const attrs=Object.entries(el.attrs).map(([k,v])=>`${k}="${esc(String(v).replace(/var\(([^)]+)\)/g,(_,n)=>palette[theme][n]||n))}"`).join(' ');
|
||||
return `<${el.tag} ${attrs}>${esc(el.textContent)}${el.children.map(x=>serialize(x,theme)).join('')}</${el.tag}>`;
|
||||
}
|
||||
function text(el){return el.textContent+el.children.map(text).join(' ');}
|
||||
const elements={};
|
||||
for(const key of ['case','step','view','plot','step-label','stats','status','rows'])elements[key]=new Element(key==='plot'?'svg':'div');
|
||||
elements.view.value='region';
|
||||
const root=new Element('div');
|
||||
root.querySelector=s=>{const m=s.match(/data-q1="([^"]+)"/);assert(m&&elements[m[1]],'missing selector '+s);return elements[m[1]];};
|
||||
const document={getElementById:id=>{assert.equal(id,'q1-localization');return root;},createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)};
|
||||
let width=736;
|
||||
vm.runInNewContext(script,{document,Option:function(label,value){this.label=label;this.value=String(value);},ResizeObserver:class{constructor(f){this.f=f;}observe(){}},Number,Math,String,Object,console},{timeout:5000});
|
||||
const output=path.join(rootDir,'output/q1/figures');fs.mkdirSync(output,{recursive:true});
|
||||
let checks=0;
|
||||
for(let i=0;i<elements.case.children.length;i++) {
|
||||
elements.case.value=String(i);elements.case.events.change();
|
||||
for(let n=1;n<=+elements.step.max;n++) {
|
||||
elements.step.value=String(n);elements.step.events.input();
|
||||
assert.equal(elements['step-label'].textContent,`${n} / ${elements.step.max}`);
|
||||
for(const v of ['global','region']) {
|
||||
elements.view.value=v;elements.view.events.change();
|
||||
const svg=serialize(elements.plot,'light');
|
||||
assert(!/NaN|undefined|Infinity/.test(svg));
|
||||
assert(svg.includes('data-axis="x"')&&svg.includes('data-axis="y"'));
|
||||
checks++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for(const w of [360,736])for(const theme of ['light','dark'])for(const index of [1,elements.case.children.length-1]) {
|
||||
width=w;elements.case.value=String(index);elements.case.events.change();
|
||||
elements.view.value='region';elements.view.events.change();
|
||||
const height=elements.plot.attrs.viewBox.split(' ')[3];
|
||||
const svg=serialize(elements.plot,theme).replace('<svg ',`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" `);
|
||||
const filename=(index===1?'counterexample':'practice')+`-${w}-${theme}.svg`;
|
||||
fs.writeFileSync(path.join(output,filename),svg,'utf8');
|
||||
}
|
||||
const report={interaction_states_checked:checks,missing_selectors:0,nonfinite_svg_values:0,browser_layout_verified:false,
|
||||
note:'Browser local-file navigation blocked by browser URL policy. Event handlers checked in minimal DOM; SVG statically inspected separately.'};
|
||||
fs.writeFileSync(path.join(rootDir,'output/q1/visualization_checks.json'),JSON.stringify(report,null,2));
|
||||
console.log(report);
|
||||
Reference in New Issue
Block a user