一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。
二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。
三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
181 lines
10 KiB
Python
181 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""从同一测量基线生成浏览器采集表达式与验收配置,不启动浏览器。"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
from PIL import Image
|
|
|
|
|
|
def digest(path):
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def read_baseline(path, expected_digest=None):
|
|
actual_digest = digest(path)
|
|
if expected_digest is not None and actual_digest != expected_digest:
|
|
raise ValueError('测量基线已改动;为新基线创建新轮次,不能沿用旧采集')
|
|
data = json.loads(path.read_text(encoding='utf-8'))
|
|
source = Path(data['source'])
|
|
if not source.is_absolute(): source = path.parent / source
|
|
if digest(source) != data['source_sha256']:
|
|
raise ValueError('参考图已改动,与测量基线不符')
|
|
with Image.open(source) as image:
|
|
if list(image.size) != data['source_size']: raise ValueError('参考图尺寸与测量基线不符')
|
|
return data, source, actual_digest
|
|
|
|
|
|
def validate_expected(data):
|
|
expected = data.get('expected')
|
|
if not isinstance(expected, dict): raise ValueError('先在区域清单填写 expected 并重新测量,不能后补截图基线')
|
|
if not isinstance(expected.get('title'), str) or not expected['title'].strip(): raise ValueError('缺少页面标题')
|
|
url = urlparse(expected.get('url', ''))
|
|
if url.scheme not in ('http', 'https') or not url.hostname: raise ValueError('页面 URL 必须为 http(s) 地址')
|
|
for value in [expected['viewport']['width'], expected['viewport']['height'], expected['dpr']]:
|
|
if isinstance(value, bool) or not isinstance(value, (int,float)) or not math.isfinite(value) or value <= 0:
|
|
raise ValueError('视口与 DPR 必须为有限正数')
|
|
if type(expected.get('full_page')) is not bool or expected.get('screenshot_scale') not in ('css','device'):
|
|
raise ValueError('明确 full_page 布尔值与 screenshot_scale: css/device')
|
|
if data['css_mapping']['assumed_viewport_width'] != expected['viewport']['width']:
|
|
raise ValueError('测量的 CSS 映射与截图视口不一致')
|
|
factor = expected['viewport']['width']/data['source_size'][0]
|
|
if not math.isclose(data['css_mapping']['css_per_image_pixel'], factor):
|
|
raise ValueError('测量基线的比例无效')
|
|
scale = expected['dpr'] if expected['screenshot_scale'] == 'device' else 1
|
|
if abs(expected['viewport']['width']*scale - data['source_size'][0]) >= 1:
|
|
raise ValueError('截图输出宽度与参考图不等;调整截图像素模式或另建基线,不自动缩放')
|
|
if not expected['full_page'] and abs(expected['viewport']['height']*scale - data['source_size'][1]) >= 1:
|
|
raise ValueError('视口截图高度与参考图不等;长图应明确 full_page')
|
|
return expected
|
|
|
|
|
|
def targets_for(data):
|
|
names = set()
|
|
targets = []
|
|
for region in data['regions']:
|
|
name = region['name']
|
|
if name in names: raise ValueError('测量区域重名')
|
|
names.add(name)
|
|
if 'selector' not in region: continue
|
|
if not isinstance(region['selector'], str) or not region['selector'].strip(): raise ValueError('selector 必须为非空 CSS 选择器')
|
|
targets.append({'name': name, 'selector': region['selector']})
|
|
if not targets: raise ValueError('至少为一个标注提供 selector,才能生成采集计划')
|
|
return targets
|
|
|
|
|
|
def prepare(path, out):
|
|
path, out = path.resolve(), out.resolve()
|
|
data, source, baseline_digest = read_baseline(path)
|
|
expected = validate_expected(data)
|
|
targets = targets_for(data)
|
|
plan = {'measurement_sha256':baseline_digest, 'targets':targets}
|
|
collector = Path(__file__).with_name('collect_browser.js').read_text(encoding='utf-8')
|
|
expression = f'({collector})({json.dumps(plan,ensure_ascii=False)})'
|
|
run = {'reference':os.path.relpath(source, out), 'candidate':'candidate.png', 'capture_metadata':'capture.json',
|
|
'expected':expected, 'measurement_baseline':os.path.relpath(path.resolve(),out),
|
|
'measurement_sha256':baseline_digest, 'allow_height_difference':expected['full_page'],
|
|
'expected_assets':data.get('expected_assets',{}),
|
|
'clips':[f"region-{i:03d}:"+','.join(str(v) for v in r['box']) for i,r in enumerate(data['regions'])]}
|
|
out.mkdir(parents=True, exist_ok=False)
|
|
for filename, obj in [('run.json',run),('capture-plan.json',plan)]:
|
|
(out/filename).write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8')
|
|
(out/'collect.js').write_text(expression,encoding='utf-8')
|
|
(out/'round-notes.md').write_text('# 本轮校准记录\n\n填写本轮目标区域、依据、调整的变量,以及报告产生后确认的收益与退步;区分测量事实与视觉判断。\n',encoding='utf-8')
|
|
return run
|
|
|
|
|
|
def compare_geometry(run, capture, base, previous=None):
|
|
path = (base / run['measurement_baseline']).resolve()
|
|
data, source, baseline_digest = read_baseline(path, run['measurement_sha256'])
|
|
expected = validate_expected(data)
|
|
if run['expected'] != expected or (base/run['reference']).resolve() != source.resolve():
|
|
raise ValueError('验收配置与测量基线的参考图或截图条件不一致')
|
|
if run.get('expected_assets',{}) != data.get('expected_assets',{}):
|
|
raise ValueError('关键素材约定与测量基线不一致')
|
|
if capture.get('measurement_sha256') != baseline_digest:
|
|
raise ValueError('采集脚本来自其他测量基线;重新准备并采集')
|
|
targets = targets_for(data)
|
|
rows = capture.get('elements')
|
|
if not isinstance(rows,list): raise ValueError('缺少生成采集脚本产生的 elements')
|
|
actual = {}
|
|
for row in rows:
|
|
if row['name'] in actual: raise ValueError('采集结果包含重复区域')
|
|
actual[row['name']] = row
|
|
if set(actual) != {r['name'] for r in targets}: raise ValueError('采集区域与测量清单不一致')
|
|
regions = []
|
|
for r in data['regions']:
|
|
if 'selector' not in r:
|
|
regions.append({'name':r['name'],'status':'reference_only','reason':'仅测量参考图,没有 DOM 选择器'})
|
|
continue
|
|
row = actual[r['name']]
|
|
if row['selector'] != r['selector']: raise ValueError('采集选择器与测量清单不一致')
|
|
status = row['status']
|
|
if status not in ('measured','missing','ambiguous','hidden','unmeasurable','invalid_selector'):
|
|
raise ValueError('未知采集状态')
|
|
item = {'name':r['name'],'selector':r['selector'],'status':status,'box_type':r['box_type']}
|
|
if status == 'measured':
|
|
if row.get('count') != 1: raise ValueError('可测量元素必须唯一匹配')
|
|
box = row.get('box')
|
|
if not isinstance(box,list) or len(box)!=4 or any(isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) for v in box) or min(box[2:])<=0:
|
|
raise ValueError('采集元素框无效')
|
|
item['actual_css_box'] = box
|
|
item['computed'] = row.get('computed',{})
|
|
if r['box_type'] != 'element':
|
|
item['status'] = 'visual_only'
|
|
item['reason'] = '字形框或采样区不与 DOM 元素外框比较'
|
|
else:
|
|
factor=data['css_mapping']['css_per_image_pixel']
|
|
ref=[v*factor for v in r['box']]
|
|
item['reference_css_box']=[round(v,3) for v in ref]
|
|
item['delta_css']=[round(a-b,3) for a,b in zip(box,ref)]
|
|
item['max_abs_delta_css']=round(max(abs(a-b) for a,b in zip(box,ref)),3)
|
|
regions.append(item)
|
|
incomplete = [r['name'] for r in regions if r['status'] not in ('measured','visual_only','reference_only')]
|
|
result = {'measurement_sha256':baseline_digest,'complete':not incomplete,'unavailable_regions':incomplete,
|
|
'regions':regions,'note':'偏差是 CSS 像素,不是还原率;布局可测量不证明未遮挡、字体正确或交互可用。'}
|
|
if previous is not None:
|
|
previous_report=json.loads(previous.read_text(encoding='utf-8'))
|
|
before=previous_report.get('geometry',{})
|
|
if before.get('measurement_sha256') != baseline_digest:
|
|
raise ValueError('前轮报告不是同一测量基线,不能声称前后改善')
|
|
for key in ('viewport','dpr','full_page','screenshot_scale','title','url'):
|
|
if previous_report['capture'].get(key) != capture.get(key):
|
|
raise ValueError(f'前后采集条件不同:{key}')
|
|
old={r['name']:r for r in before['regions']}
|
|
changes=[]
|
|
for item in regions:
|
|
prev=old.get(item['name'],{})
|
|
a,b=prev.get('max_abs_delta_css'),item.get('max_abs_delta_css')
|
|
changes.append({'name':item['name'],'before':a,'after':b,'delta':round(b-a,3) if a is not None and b is not None else None,
|
|
'change':'unavailable' if a is None or b is None else 'reduced' if b<a else 'increased' if b>a else 'unchanged'})
|
|
result['changes']=changes
|
|
return result
|
|
|
|
|
|
def write_geometry_markdown(out, geometry):
|
|
def cell(value): return str(value).replace('|','\\|').replace('\n',' ')
|
|
lines=['# 元素几何偏差','',geometry['note'],'', '| 区域 | 状态 | Δx | Δy | Δ宽 | Δ高 |','| --- | --- | --- | --- | --- | --- |']
|
|
for r in geometry['regions']:
|
|
lines.append('| '+' | '.join(cell(v) for v in [r['name'],r['status'],*r.get('delta_css',['—']*4)])+' |')
|
|
if 'changes' in geometry:
|
|
lines+=['','## 相对前轮的最大绝对偏差','','| 区域 | 前轮 | 本轮 | 变化 |','| --- | --- | --- | --- |']
|
|
for r in geometry['changes']: lines.append('| '+' | '.join(cell(v) for v in [r['name'],r['before'],r['after'],r['change']])+' |')
|
|
(out/'geometry.md').write_text('\n'.join(lines)+'\n',encoding='utf-8')
|
|
|
|
|
|
def main():
|
|
p=argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--measurements',type=Path,required=True)
|
|
p.add_argument('--out-dir',type=Path,required=True)
|
|
args=p.parse_args()
|
|
try:
|
|
prepare(args.measurements,args.out_dir)
|
|
print(json.dumps({'status':'采集计划已生成;在已授权浏览器执行 collect.js 并保存真实截图','output':str(args.out_dir)},ensure_ascii=False))
|
|
except (OSError,ValueError,KeyError,TypeError) as error:
|
|
p.exit(2,f'准备失败:{error}\n')
|
|
|
|
if __name__=='__main__':main()
|