| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- """Real-browser acceptance for product decisions, independent evidence and live interaction."""
- import argparse
- import base64
- from datetime import datetime, timezone
- import json
- from pathlib import Path
- import subprocess
- import sys
- import tempfile
- import time
- from urllib.request import Request, urlopen
- import websocket
- ROOT = Path(__file__).resolve().parents[1]
- def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--url', default='http://127.0.0.1:4183')
- parser.add_argument('--out', default='work/industry-result-tabs-20261107-r1/browser')
- args = parser.parse_args()
- out = (ROOT / args.out).resolve()
- out.mkdir(parents=True, exist_ok=True)
- profile = Path(tempfile.mkdtemp(prefix='innovation-edge-', dir=ROOT / 'work'))
- browser = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
- '--disable-gpu', '--disable-background-networking', '--disable-component-update', '--disable-extensions',
- '--no-first-run', '--remote-debugging-port=0', '--user-data-dir=' + str(profile), 'about:blank'],
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
- serial, ws, events = 0, None, []
- report = {'ok': False, 'checkedAt': datetime.now(timezone.utc).isoformat(), 'url': args.url,
- 'checks': [], 'layouts': [], 'responseOverrides': 0, 'paidCalls': 0, 'productionWrites': 0}
- try:
- end = time.monotonic() + 20
- while not (profile / 'DevToolsActivePort').exists():
- assert time.monotonic() < end and browser.poll() is None
- time.sleep(.1)
- port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
- page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list')) if p['type'] == 'page')
- ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=45, suppress_origin=True, http_no_proxy=['127.0.0.1'])
- def call(method, params=None):
- nonlocal serial
- serial += 1
- ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
- while True:
- d = json.loads(ws.recv())
- if d.get('id') == serial:
- assert 'error' not in d, d
- return d.get('result', {})
- events.append(d)
- def ev(code):
- d = call('Runtime.evaluate', {'expression': code, 'returnByValue': True, 'awaitPromise': True})
- assert 'exceptionDetails' not in d, d
- return d['result'].get('value')
- view = 'document.querySelector("kf-app")?.renderRoot?.querySelector("view-industry")'
- def action(code): return ev('(()=>{const p=' + view + ';' + code + '})()')
- def ready(tab='insights'):
- ev('new Promise((resolve,reject)=>{let end=Date.now()+30000;function tick(){const p=' + view +
- ';if(p && p._tab===' + json.dumps(tab) + ' && !p._loading && p._data){p.updateComplete.then(()=>resolve(true));return}'
- 'if(p?._error)reject(Error(p._error));else if(Date.now()>end)reject(Error("timeout"));else setTimeout(tick,70)}tick()})')
- def navigate(path, tab='insights'):
- call('Page.navigate', {'url': args.url + path})
- ready(tab)
- def change(field, value):
- action('const s=p.renderRoot.querySelector(' + json.dumps('select[data-filter=' + field + ']') + ');s.value=' + json.dumps(value) + ';s.dispatchEvent(new Event("change"));return true')
- ev('new Promise(r=>setTimeout(r,80))')
- ready()
- def shot(name):
- (out / name).write_bytes(base64.b64decode(call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})['data']))
- def check(name):
- report['checks'].append(name)
- (out/'in-progress.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
- print('PASS '+name,flush=True)
- def settle(): ev('new Promise(r=>setTimeout(r,120))')
- def click(selector, reload=True):
- point=action('const b=p.renderRoot.querySelector('+json.dumps(selector)+');if(!b)throw Error("missing selector");b.scrollIntoView({block:"center"});const r=b.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}')
- for kind in ['mousePressed','mouseReleased']:
- call('Input.dispatchMouseEvent',{'type':kind,**point,'button':'left','clickCount':1})
- settle()
- if reload:ready()
- def data():return action('return p._data')
- def dimensions():return action('return p._data.choices')
- def search(text):
- action('p.renderRoot.querySelector("[data-w-search]").value='+json.dumps(text)+';p.renderRoot.querySelector(".ws-product-search").requestSubmit();return true')
- settle();ready()
- def layout(section,width,height):
- action('p.renderRoot.querySelector("#workshop-'+section+'").scrollIntoView({block:"start"});return true');settle()
- metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
- assert metrics['innerWidth']==width,metrics
- assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
- bad=action('return [...p.renderRoot.querySelectorAll(".workshop-analysis *")].filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&(r.left < -1 || r.right>document.documentElement.clientWidth+1)}).slice(0,8).map(e=>({tag:e.tagName,cls:e.className,text:e.textContent.slice(0,80)}))')
- assert not bad,bad
- report['layouts'].append({'section':section,'width':width,'height':height,**metrics,'outOfBounds':len(bad)})
- # Independent readback of snapshot identifiers, raw values and provenance.
- snapshot=json.loads((ROOT/'work/industry-monitoring-20261107-r1/snapshot.json').read_text(encoding='utf-8'))
- entities=snapshot['entities'];products={p['record_id']:p for p in entities['products']};reviews={r['record_id']:r for r in entities['reviews']}
- sys.path.insert(0,str(ROOT/'server/voc-library'))
- from industry_research import analysis_eligible
- def verify_scope(result, selected):
- selected=set(selected);rs=[r for r in reviews.values() if r['product_id'] in selected]
- s=result['scope']
- assert s['products']==len(selected)
- assert s['reviewRows']==len(rs)
- assert s['reviewedProducts']==len({r['product_id'] for r in rs})
- assert sum(b['products'] for b in result['brands'])==len(selected)
- assert sum(b['reviewRows'] for b in result['brands'])==len(rs)
- assert s['eligibleRows']==sum(analysis_eligible(r,True) for r in rs)
- for p in result['products']['rows']:
- assert p['productId'] in selected and p['title']==products[p['productId']]['title']
- assert p['reviewRows']==sum(r['product_id']==p['productId'] for r in rs)
- for r in result['reviewEvidence']['rows']:
- original=reviews[r['reviewId']]
- assert r['productId'] in selected and r['productId']==original['product_id']
- assert r['content']==original.get('content','') and r['appendContent']==original.get('append_content','')
- assert r['sourceRecordId']==original.get('source_record_id')
- assert r['analysisEligible']==analysis_eligible(original,True)
- for group in [*result['motives'],*result['audiences'],*result['scenarios']]:
- for r in group['examples']:
- original=reviews[r['reviewId']]
- for hit in r['hits']:
- assert hit['quote'] in original.get('content' if hit['field']=='main' else 'append_content','')
- return result
- call('Runtime.enable');call('Page.enable');call('Network.enable')
- call('Emulation.setFocusEmulationEnabled',{'enabled':True})
- call('Emulation.setDeviceMetricsOverride',{'width':1440,'height':900,'deviceScaleFactor':1,'mobile':False})
- navigate('/industry');settle()
- base={pid for pid,p in products.items() if p['category']=='banmian'}
- baseline=verify_scope(data(),base);report['scope']=baseline['scope']
- assert baseline['generation']==snapshot['generation']
- decisions=baseline['decisions']
- metrics=action('return Object.fromEntries([...p.renderRoot.querySelectorAll("[data-dashboard-metric]")].map(e=>[e.dataset.dashboardMetric,Number(e.querySelector("strong").textContent.replace(/[^0-9]/g,""))]))')
- assert metrics=={'products':baseline['scope']['products'],'reviews':baseline['scope']['reviewRows'],'readable':baseline['scope']['evidenceUnits'],'qualified':decisions['eligibleUnits']},metrics
- assert action('return p.renderRoot.querySelector("[data-decision-result]").dataset.decisionResult')=='products'
- assert action('return p.renderRoot.querySelectorAll(".ws-product").length')==12
- assert action('return p.renderRoot.querySelector(".wd-range-label").textContent')=='资料范围'
- assert action('return p.renderRoot.querySelector(".wd-filters").getBoundingClientRect().bottom < p.renderRoot.querySelector(".wr-tabs").getBoundingClientRect().top')
- labels=action('return [...p.renderRoot.querySelectorAll("[data-decision-view]")].map(e=>e.dataset.decisionView)')
- assert labels==['products','pains','motives','selling','audience','directions','trend','reviews']
- assert not action('return p.renderRoot.querySelector(".wd-hero")')
- assert action('return p.renderRoot.querySelector(".ws-product").getBoundingClientRect().height')<270
- report['density']=action('return {firstCardTop:p.renderRoot.querySelector(".ws-product").getBoundingClientRect().top,cardHeight:p.renderRoot.querySelector(".ws-product").getBoundingClientRect().height,filtersHeight:p.renderRoot.querySelector(".wd-filters").getBoundingClientRect().height,fullCards:[...p.renderRoot.querySelectorAll(".ws-product")].filter(e=>e.getBoundingClientRect().bottom<=900).length,bodyFont:getComputedStyle(p.renderRoot.querySelector(".ws-product h4")).fontSize}')
- shot('products-initial-1440.png')
- check('research-direction-above-eight-compact-result-tabs-default-products-and-live-scope-match-snapshot')
- def request_count():
- return sum(e['method']=='Network.requestWillBeSent' and '/api/industry' in e['params']['request']['url'] for e in events)
- count=request_count()
- click('[data-decision-view="pains"]')
- assert action('return p.renderRoot.querySelectorAll("[data-rank-kind=pains]").length')==3
- assert not action('return p.renderRoot.querySelector(".ws-product")')
- for o in decisions['directions']:
- if not o['supportUnits']:continue
- click('[data-rank-kind="pains"][data-rank-id="'+o['id']+'"]')
- assert action('return p.renderRoot.querySelector('+json.dumps('[data-pain-card="'+o['id']+'"]')+').textContent.includes('+json.dumps(o['question'])+')')
- assert action('return Number(p.renderRoot.querySelector('+json.dumps('[data-rank-kind="pains"][data-rank-id="'+o['id']+'"]')+').dataset.rankCount)')==o['supportUnits']
- for r in o['evidence']:
- original=reviews[r['reviewId']]
- assert r['analysisEligible']==analysis_eligible(original,True)
- for hit in r['hits']:
- assert hit['quote'] in original.get('content' if hit['field']=='main' else 'append_content','')
- click('[data-decision-view="motives"]')
- assert action('return p.renderRoot.querySelectorAll("[data-rank-kind=motives]").length')==sum(bool(r['units']) for r in baseline['motives'])
- for r in baseline['motives']:
- if not r['units']:continue
- click('[data-rank-kind="motives"][data-rank-id="'+r['id']+'"]')
- assert action('return p.renderRoot.querySelector("[data-motive-card]").dataset.motiveCard')==r['id']
- assert action('return [...p.renderRoot.querySelectorAll("#vd-detail-motives .vd-detail-stats b")].map(e=>Number(e.textContent.replaceAll(",","")))')==[r['units'],r['products'],r['qualifiedUnits']]
- assert r['examples'][0]['reviewId'] in action('return decodeURIComponent(p.renderRoot.querySelector("#vd-detail-motives .wd-quote footer a").getAttribute("href"))')
- click('[data-decision-view="selling"]')
- assert action('return p.renderRoot.querySelectorAll("[data-selling-claim]").length')==len(baseline['claims'])
- assert action('return p.renderRoot.querySelectorAll("[data-selling-benefit]").length')==len(decisions['strengths'])
- for c in baseline['claims']:
- assert all(pid in products for pid in c['examples'])
- click('[data-rank-kind="claims"][data-rank-id="'+c['label']+'"]')
- assert action('return p.renderRoot.querySelector("#vd-detail-selling h3").textContent')==c['label']
- assert action('return [...p.renderRoot.querySelectorAll("#vd-detail-selling .vd-detail-stats b")].map(e=>Number(e.textContent.replaceAll(",","")))')==[c['products'],baseline['scope']['products'],len(c['examples'])]
- for r in decisions['strengths']:
- click('[data-rank-kind="benefits"][data-rank-id="'+r['id']+'"]')
- assert action('return p.renderRoot.querySelector("#vd-detail-selling h3").textContent')==r['name']
- assert r['examples'][0]['reviewId'] in action('return decodeURIComponent(p.renderRoot.querySelector("#vd-detail-selling .wd-quote footer a").getAttribute("href"))')
- click('[data-decision-view="audience"]')
- assert action('return p.renderRoot.querySelectorAll("[data-rank-kind=audience]").length')==sum(bool(r['units']) for r in [*baseline['audiences'],*baseline['scenarios']])
- for kind,rows in [('audience',baseline['audiences']),('scenario',baseline['scenarios'])]:
- for r in rows:
- if not r['units']:continue
- click('[data-rank-kind="audience"][data-rank-id="'+kind+':'+r['id']+'"]')
- assert action('return p.renderRoot.querySelector("[data-audience-card]").dataset.audienceCard')==kind+':'+r['id']
- assert r['examples'][0]['reviewId'] in action('return decodeURIComponent(p.renderRoot.querySelector("#vd-detail-audience .wd-quote footer a").getAttribute("href"))')
- click('[data-decision-view="directions"]')
- click('[data-direction-select="value"]')
- assert action('return p.routeQuery.w_focus')=='value'
- click('[data-decision-view="trend"]')
- assert action('return p.renderRoot.querySelector("#workshop-trend").textContent.includes("不是销量")')
- click('[data-decision-view="reviews"]')
- assert action('return p.renderRoot.querySelectorAll(".ws-review").length')==8
- assert request_count()==count,(request_count(),count)
- assert data()['decisions']==decisions
- check('all-eight-tabs-render-own-analysis-with-exact-evidence-and-no-statistical-reload-or-scope-change')
- click('[data-decision-view="pains"]');call('Page.reload');ready();settle()
- assert action('return p.routeQuery.w_view')=='pains'
- click('[data-decision-view="motives"]');ev('history.back()');settle();ready()
- assert action('return p.routeQuery.w_view')=='pains'
- ev('history.forward()');settle();ready()
- assert action('return p.routeQuery.w_view')=='motives'
- click('[data-decision-view="products"]');click('[data-product-mode="competition"]')
- assert action('return p.renderRoot.querySelectorAll(".wd-brand-row").length')==8
- first=action('return [...p.renderRoot.querySelectorAll(".wd-brand-row")].map(e=>e.textContent)')
- count=request_count();click('[data-brands-next]')
- assert action('return p.routeQuery.w_brands')=='2'
- assert first!=action('return [...p.renderRoot.querySelectorAll(".wd-brand-row")].map(e=>e.textContent)')
- call('Page.reload');ready();settle()
- assert action('return p.routeQuery.w_brands')=='2'
- count=request_count()
- action('const s=p.renderRoot.querySelector("[data-competition-theme]");s.value="health";s.dispatchEvent(new Event("change"));return true');settle();ready()
- assert not action('return p.routeQuery.w_brands')
- assert action('return p.renderRoot.querySelector("[data-competition-theme]").value')=='health'
- assert request_count()==count
- click('[data-product-mode="products"]')
- click('[data-w-products-next]')
- assert data()['products']['page']==2 and data()['scope']==baseline['scope']
- assert data()['decisions']==decisions
- click('[data-w-products-prev]')
- pid=data()['products']['rows'][0]['productId']
- click('[data-w-product-reviews="'+pid+'"]')
- assert action('return p.routeQuery.w_view')=='reviews'
- assert all(r['productId']==pid for r in data()['reviewEvidence']['rows'])
- check('refresh-history-competitor-theme-and-pagination-products-and-direct-review-navigation-work-with-stable-scope')
- click('[data-decision-view="pains"]');click('[data-rank-kind="pains"][data-rank-id="seasoning"]');click('[data-pain-evidence="seasoning"]')
- assert action('return p.routeQuery.w_view')=='reviews'
- assert action('return p.renderRoot.querySelector("[data-w-review-theme]").value')=='seasoning'
- assert data()['scope']==baseline['scope'] and data()['decisions']==decisions
- click('[data-decision-view="selling"]');click('[data-rank-kind="benefits"][data-rank-id="texture"]');click('[data-benefit-evidence="texture"]')
- assert action('return p.routeQuery.w_kind')=='benefit'
- assert data()['reviewEvidence']['total']==66
- click('[data-w-reviews-next]')
- assert data()['reviewEvidence']['page']==2 and data()['decisions']==decisions
- click('[data-decision-view="pains"]');click('[data-rank-kind="pains"][data-rank-id="seasoning"]');click('[data-pain-direction="seasoning"]')
- assert action('return p.routeQuery.w_view')=='directions'
- assert action('return p.routeQuery.w_focus')=='seasoning'
- assert action('return p.renderRoot.querySelector(".rd-validation").textContent.includes('+json.dumps(data()['decisions']['directions'][0]['test']['decisionRule'])+')')
- check('pain-benefit-and-validation-actions-open-related-comments-or-direction-with-identical-aggregate-analysis')
- click('[data-w-reset]')
- assert action('return p.renderRoot.querySelector("[data-filter-section-panel=product]").getBoundingClientRect().height>0')
- click('[data-w-choice="format:bag"]');click('[data-w-choice="format:bowl"]')
- import re
- packaging={pid for pid in base if re.search('袋|连包|碗|桶',products[pid]['title'])}
- verify_scope(data(),packaging)
- click('[data-w-choice="flavor:spicy"]')
- combined={pid for pid in packaging if re.search('麻辣|香辣|火鸡|超辣|小龙虾|红油',products[pid]['title'])}
- verify_scope(data(),combined)
- click('[data-decision-view="pains"]');assert data()['scope']['products']==len(combined)
- call('Page.reload');ready();settle()
- assert dimensions()['format']==['bag','bowl'] and dimensions()['flavor']==['spicy']
- assert action('return p.routeQuery.w_view')=='pains'
- click('[data-w-choice="format:all"]');click('[data-w-choice="flavor:all"]')
- assert action('return p.renderRoot.querySelector("[data-filter-section-panel=product]").getBoundingClientRect().height>0')
- click('[data-filter-section="people"]')
- click('[data-w-choice="audience:office"]')
- office=data();assert 0<office['scope']['products']<len(base)
- click('[data-w-choice="scenario:lunch"]');assert 0<data()['scope']['products']<=office['scope']['products']
- click('[data-w-reset]');change('brand','ksf')
- ksf={pid for pid in base if products[pid]['brand']=='ksf'};verify_scope(data(),ksf)
- change('shopId','1000084104');assert action('return p._filters.platform')=='jd'
- primary={pid for pid in ksf if products[pid]['shop_id']=='1000084104' and products[pid]['platform']=='jd'}
- verify_scope(data(),primary)
- change('platform','tmall');assert not action('return p.routeQuery.shopId')
- click('[data-w-reset]')
- check('upper-research-filters-multiselect-and-brand-shop-really-update-all-result-panels-and-survive-refresh')
- search('根本不存在的商品XYZ')
- assert data()['scope']['products']==0 and data()['decisions']['directions']==[]
- for tab in ['pains','motives','selling','audience','directions','trend','reviews']:
- click('[data-decision-view="'+tab+'"]')
- assert not action('return /NaN|undefined|jd:jd:/.test(p.renderRoot.querySelector(".wr-compact").textContent)')
- click('[data-w-reset]')
- check('empty-filter-scope-gives-clear-empty-states-in-all-eight-tabs-with-no-invented-result')
- call('Browser.grantPermissions',{'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
- click('[data-copy-agent]',False)
- text=ev('navigator.clipboard.readText()')
- assert snapshot['generation'] in text and '预期业务作用(待验证)' in text and '决策条件:' in text
- assert '商品标题卖点' in text and '评价覆盖:39/837件' in text
- refs=[json.loads(line) for line in text.splitlines() if line.startswith('{') and '"database"' in line]
- for r in refs:
- assert any(row['record_id']==r['id'] for row in entities[r['entity']]),r
- if r['entity']=='reviews' and 'source_record_id' in r:
- assert r['source_record_id']==reviews[r['id']].get('source_record_id'),r
- for c in data()['claims']:
- for pid in c['examples']:assert any(r['id']==pid and r['entity']=='products' for r in refs)
- report['copy']={'bytes':len(text.encode()),'references':len(refs),'exactReferencesVerified':len(refs),'generation':snapshot['generation']}
- click('[data-decision-view="pains"]')
- rid=data()['decisions']['directions'][0]['evidence'][0]['reviewId']
- click('.wr-insight .wd-quote footer a',False)
- ev('new Promise((resolve,reject)=>{const end=Date.now()+30000;function tick(){const p=document.querySelector("kf-app")?.renderRoot?.querySelector("view-library");if(p?._detail&&!p._loading)resolve(true);else if(p?._error)reject(Error(p._error));else if(Date.now()>end)reject(Error("library timeout"));else setTimeout(tick,70)}tick()})')
- assert '/library/reviews/'+rid in ev('decodeURIComponent(location.pathname)')
- ev('history.back()');settle();ready()
- assert action('return p.routeQuery.w_view')=='pains'
- check('clipboard-includes-visible-title-examples-and-all-references-verify-and-original-link-opens-real-library-detail')
- # Old links continue to open the intended data, never a blank result.
- for path,result in [('/industry?w_view=evidence&w_data=products','products'),('/industry?w_view=evidence','reviews'),('/industry?w_view=market&w_market=people','pains'),('/industry?w_view=market&w_market=trend','trend')]:
- navigate(path);settle()
- assert action('return p.renderRoot.querySelector("[data-decision-result]").dataset.decisionResult')==result
- navigate('/industry?tab=reviews&category=banmian','reviews');assert action('return !!p._data.corpus')
- navigate('/industry?tab=listings&category=banmian','listings');assert action('return !!p._data.inventory')
- check('old-result-links-and-existing-data-maintenance-still-open-correctly')
- navigate('/industry');settle()
- tabs=['products','pains','motives','selling','audience','directions','trend','reviews']
- for width,height in [(1440,900),(1024,900),(768,1024),(390,844),(320,568)]:
- call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
- for tab in tabs:
- click('[data-decision-view="'+tab+'"]')
- if tab=='products':click('[data-product-mode="products"]')
- metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
- assert metrics['innerWidth']==width and metrics['scrollWidth']<=metrics['clientWidth'],metrics
- bad=action('return [...p.renderRoot.querySelectorAll(".workshop-analysis *")].filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&(r.left < -1 || r.right>document.documentElement.clientWidth+1)}).slice(0,8).map(e=>({tag:e.tagName,cls:e.className,text:e.textContent.slice(0,80)}))')
- assert not bad,bad
- report['layouts'].append({'section':tab,'width':width,'height':height,**metrics,'outOfBounds':0})
- if width in (1440,390,320):shot(tab+'-'+str(width)+'.png')
- click('[data-decision-view="products"]');click('[data-product-mode="competition"]')
- metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
- assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
- assert not action('return [...p.renderRoot.querySelectorAll(".wd-brand-matrix *")].some(e=>{const r=e.getBoundingClientRect();return r.width>0&&(r.left< -1||r.right>document.documentElement.clientWidth+1)})')
- bars=action('return [...p.renderRoot.querySelectorAll(".wd-brand-row")].flatMap(e=>[2,3].map(i=>{const c=e.children[i],bar=c.querySelector(".vd-brand-track>i");return {count:Number(c.querySelector("strong").textContent),width:bar.getBoundingClientRect().width,track:bar.parentElement.getBoundingClientRect().width}}))')
- # Each column uses the same absolute group scale; zero must paint no bar.
- assert all(b['width']<=.1 if b['count']==0 else b['width']>0 for b in bars),bars
- ratios=[b['width']/b['track']/b['count'] for b in bars if b['count'] and b['track']]
- assert not ratios or max(ratios)-min(ratios)<.005,ratios
- report['layouts'].append({'section':'competition','width':width,'height':height,**metrics,'outOfBounds':0})
- if width in (1440,320):shot('competition-'+str(width)+'.png')
- ev('scrollTo(0,document.documentElement.scrollHeight)');settle()
- nav=action('const r=p.renderRoot.querySelector(".wr-tabs").getBoundingClientRect();return {top:r.top,bottom:r.bottom}')
- assert abs(nav['top'])<2,nav
- action('p.renderRoot.querySelector(".copy-menu").open=true;return true')
- click('[data-preview-agent]',False)
- assert action('return p.renderRoot.querySelector(".agent-copy-dialog").matches(":modal")')
- d=action('const x=p.renderRoot.querySelector(".agent-copy-dialog"),r=x.getBoundingClientRect();return {width:r.width,right:r.right,bottom:r.bottom,top:r.top,scrollWidth:x.scrollWidth,clientWidth:x.clientWidth}')
- assert d['right']<=width and d['top']>=0 and d['bottom']<=height and d['scrollWidth']<=d['clientWidth'],d
- report.setdefault('copyDialogs',[]).append({'viewport':width,**d})
- click('.copy-dialog-head>button',False)
- action('p.renderRoot.querySelector(".copy-menu").open=false;return true')
- check('eight-panels-plus-competitors-and-copy-dialog-fit-five-real-viewports-with-sticky-visible-tabs')
- for width,height in [(390,844),(320,568)]:
- call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
- click('[data-decision-view="pains"]');click('[data-rank-kind="pains"][data-rank-id="value"]')
- point=action('const r=p.renderRoot.querySelector("#vd-detail-pains").getBoundingClientRect(),n=p.renderRoot.querySelector(".wr-tabs").getBoundingClientRect();return {top:r.top,navBottom:n.bottom,id:p.renderRoot.querySelector("[data-pain-card]").dataset.painCard}')
- assert point['id']=='value' and point['top']>=point['navBottom'] and point['top']<height/2,point
- click('[data-back-ranks="pains"]')
- r=action('const r=p.renderRoot.querySelector("[data-rank-kind=pains][aria-pressed=true]").getBoundingClientRect();return {top:r.top,bottom:r.bottom}')
- assert r['top']>=0 and r['bottom']<height,r
- click('[data-decision-view="selling"]');click('[data-rank-kind="benefits"][data-rank-id="texture"]')
- assert action('return p.renderRoot.querySelector("#vd-detail-selling h3").textContent')=='面体与口感'
- action('p.renderRoot.querySelector("#vd-detail-selling .vd-related").open=true;return true');settle()
- metrics=ev('({scrollWidth:document.documentElement.scrollWidth,clientWidth:document.documentElement.clientWidth})')
- assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
- shot('benefit-selected-expanded-'+str(width)+'.png')
- check('mobile-chart-selection-reveals-matching-evidence-below-sticky-tabs-and-returns-to-selected-theme')
- action('p.renderRoot.querySelector("[data-decision-view=pains]").focus();return true')
- call('Input.dispatchKeyEvent',{'type':'keyDown','key':'Enter','code':'Enter','windowsVirtualKeyCode':13,'text':'\r'})
- call('Input.dispatchKeyEvent',{'type':'keyUp','key':'Enter','code':'Enter','windowsVirtualKeyCode':13})
- settle();ready();assert action('return p.routeQuery.w_view')=='pains'
- errors=[e for e in events if e['method']=='Runtime.exceptionThrown'];assert not errors,errors
- report['javascriptExceptions']=0
- check('native-keyboard-activation-no-uncaught-exceptions-no-response-overrides-paid-calls-or-production-writes')
- report['ok']=True
- except Exception:
- if ws:shot('failure.png')
- raise
- finally:
- if ws:ws.close()
- browser.terminate()
- try:browser.wait(timeout=10)
- except subprocess.TimeoutExpired:browser.kill();browser.wait(timeout=10)
- (out/'verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
- print(json.dumps({'ok':report['ok'],'checks':report['checks'],'layouts':len(report['layouts']),'copyDialogs':len(report.get('copyDialogs',[])),'receipt':str(out/'verification.json')},ensure_ascii=False,indent=2))
- if __name__=='__main__':main()
|