verify-industry-result-tabs.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. """Real-browser acceptance for product decisions, independent evidence and live interaction."""
  2. import argparse
  3. import base64
  4. from datetime import datetime, timezone
  5. import json
  6. from pathlib import Path
  7. import subprocess
  8. import sys
  9. import tempfile
  10. import time
  11. from urllib.request import Request, urlopen
  12. import websocket
  13. ROOT = Path(__file__).resolve().parents[1]
  14. def main():
  15. parser = argparse.ArgumentParser(description=__doc__)
  16. parser.add_argument('--url', default='http://127.0.0.1:4183')
  17. parser.add_argument('--out', default='work/industry-result-tabs-20261107-r1/browser')
  18. args = parser.parse_args()
  19. out = (ROOT / args.out).resolve()
  20. out.mkdir(parents=True, exist_ok=True)
  21. profile = Path(tempfile.mkdtemp(prefix='innovation-edge-', dir=ROOT / 'work'))
  22. browser = subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '--headless=new',
  23. '--disable-gpu', '--disable-background-networking', '--disable-component-update', '--disable-extensions',
  24. '--no-first-run', '--remote-debugging-port=0', '--user-data-dir=' + str(profile), 'about:blank'],
  25. stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
  26. serial, ws, events = 0, None, []
  27. report = {'ok': False, 'checkedAt': datetime.now(timezone.utc).isoformat(), 'url': args.url,
  28. 'checks': [], 'layouts': [], 'responseOverrides': 0, 'paidCalls': 0, 'productionWrites': 0}
  29. try:
  30. end = time.monotonic() + 20
  31. while not (profile / 'DevToolsActivePort').exists():
  32. assert time.monotonic() < end and browser.poll() is None
  33. time.sleep(.1)
  34. port = (profile / 'DevToolsActivePort').read_text().splitlines()[0]
  35. page = next(p for p in json.load(urlopen('http://127.0.0.1:' + port + '/json/list')) if p['type'] == 'page')
  36. ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=45, suppress_origin=True, http_no_proxy=['127.0.0.1'])
  37. def call(method, params=None):
  38. nonlocal serial
  39. serial += 1
  40. ws.send(json.dumps({'id': serial, 'method': method, 'params': params or {}}))
  41. while True:
  42. d = json.loads(ws.recv())
  43. if d.get('id') == serial:
  44. assert 'error' not in d, d
  45. return d.get('result', {})
  46. events.append(d)
  47. def ev(code):
  48. d = call('Runtime.evaluate', {'expression': code, 'returnByValue': True, 'awaitPromise': True})
  49. assert 'exceptionDetails' not in d, d
  50. return d['result'].get('value')
  51. view = 'document.querySelector("kf-app")?.renderRoot?.querySelector("view-industry")'
  52. def action(code): return ev('(()=>{const p=' + view + ';' + code + '})()')
  53. def ready(tab='insights'):
  54. ev('new Promise((resolve,reject)=>{let end=Date.now()+30000;function tick(){const p=' + view +
  55. ';if(p && p._tab===' + json.dumps(tab) + ' && !p._loading && p._data){p.updateComplete.then(()=>resolve(true));return}'
  56. 'if(p?._error)reject(Error(p._error));else if(Date.now()>end)reject(Error("timeout"));else setTimeout(tick,70)}tick()})')
  57. def navigate(path, tab='insights'):
  58. call('Page.navigate', {'url': args.url + path})
  59. ready(tab)
  60. def change(field, value):
  61. action('const s=p.renderRoot.querySelector(' + json.dumps('select[data-filter=' + field + ']') + ');s.value=' + json.dumps(value) + ';s.dispatchEvent(new Event("change"));return true')
  62. ev('new Promise(r=>setTimeout(r,80))')
  63. ready()
  64. def shot(name):
  65. (out / name).write_bytes(base64.b64decode(call('Page.captureScreenshot', {'format': 'png', 'captureBeyondViewport': False})['data']))
  66. def check(name):
  67. report['checks'].append(name)
  68. (out/'in-progress.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  69. print('PASS '+name,flush=True)
  70. def settle(): ev('new Promise(r=>setTimeout(r,120))')
  71. def click(selector, reload=True):
  72. 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}')
  73. for kind in ['mousePressed','mouseReleased']:
  74. call('Input.dispatchMouseEvent',{'type':kind,**point,'button':'left','clickCount':1})
  75. settle()
  76. if reload:ready()
  77. def data():return action('return p._data')
  78. def dimensions():return action('return p._data.choices')
  79. def search(text):
  80. action('p.renderRoot.querySelector("[data-w-search]").value='+json.dumps(text)+';p.renderRoot.querySelector(".ws-product-search").requestSubmit();return true')
  81. settle();ready()
  82. def layout(section,width,height):
  83. action('p.renderRoot.querySelector("#workshop-'+section+'").scrollIntoView({block:"start"});return true');settle()
  84. metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
  85. assert metrics['innerWidth']==width,metrics
  86. assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
  87. 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)}))')
  88. assert not bad,bad
  89. report['layouts'].append({'section':section,'width':width,'height':height,**metrics,'outOfBounds':len(bad)})
  90. # Independent readback of snapshot identifiers, raw values and provenance.
  91. snapshot=json.loads((ROOT/'work/industry-monitoring-20261107-r1/snapshot.json').read_text(encoding='utf-8'))
  92. entities=snapshot['entities'];products={p['record_id']:p for p in entities['products']};reviews={r['record_id']:r for r in entities['reviews']}
  93. sys.path.insert(0,str(ROOT/'server/voc-library'))
  94. from industry_research import analysis_eligible
  95. def verify_scope(result, selected):
  96. selected=set(selected);rs=[r for r in reviews.values() if r['product_id'] in selected]
  97. s=result['scope']
  98. assert s['products']==len(selected)
  99. assert s['reviewRows']==len(rs)
  100. assert s['reviewedProducts']==len({r['product_id'] for r in rs})
  101. assert sum(b['products'] for b in result['brands'])==len(selected)
  102. assert sum(b['reviewRows'] for b in result['brands'])==len(rs)
  103. assert s['eligibleRows']==sum(analysis_eligible(r,True) for r in rs)
  104. for p in result['products']['rows']:
  105. assert p['productId'] in selected and p['title']==products[p['productId']]['title']
  106. assert p['reviewRows']==sum(r['product_id']==p['productId'] for r in rs)
  107. for r in result['reviewEvidence']['rows']:
  108. original=reviews[r['reviewId']]
  109. assert r['productId'] in selected and r['productId']==original['product_id']
  110. assert r['content']==original.get('content','') and r['appendContent']==original.get('append_content','')
  111. assert r['sourceRecordId']==original.get('source_record_id')
  112. assert r['analysisEligible']==analysis_eligible(original,True)
  113. for group in [*result['motives'],*result['audiences'],*result['scenarios']]:
  114. for r in group['examples']:
  115. original=reviews[r['reviewId']]
  116. for hit in r['hits']:
  117. assert hit['quote'] in original.get('content' if hit['field']=='main' else 'append_content','')
  118. return result
  119. call('Runtime.enable');call('Page.enable');call('Network.enable')
  120. call('Emulation.setFocusEmulationEnabled',{'enabled':True})
  121. call('Emulation.setDeviceMetricsOverride',{'width':1440,'height':900,'deviceScaleFactor':1,'mobile':False})
  122. navigate('/industry');settle()
  123. base={pid for pid,p in products.items() if p['category']=='banmian'}
  124. baseline=verify_scope(data(),base);report['scope']=baseline['scope']
  125. assert baseline['generation']==snapshot['generation']
  126. decisions=baseline['decisions']
  127. 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,""))]))')
  128. assert metrics=={'products':baseline['scope']['products'],'reviews':baseline['scope']['reviewRows'],'readable':baseline['scope']['evidenceUnits'],'qualified':decisions['eligibleUnits']},metrics
  129. assert action('return p.renderRoot.querySelector("[data-decision-result]").dataset.decisionResult')=='products'
  130. assert action('return p.renderRoot.querySelectorAll(".ws-product").length')==12
  131. assert action('return p.renderRoot.querySelector(".wd-range-label").textContent')=='资料范围'
  132. assert action('return p.renderRoot.querySelector(".wd-filters").getBoundingClientRect().bottom < p.renderRoot.querySelector(".wr-tabs").getBoundingClientRect().top')
  133. labels=action('return [...p.renderRoot.querySelectorAll("[data-decision-view]")].map(e=>e.dataset.decisionView)')
  134. assert labels==['products','pains','motives','selling','audience','directions','trend','reviews']
  135. assert not action('return p.renderRoot.querySelector(".wd-hero")')
  136. assert action('return p.renderRoot.querySelector(".ws-product").getBoundingClientRect().height')<270
  137. 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}')
  138. shot('products-initial-1440.png')
  139. check('research-direction-above-eight-compact-result-tabs-default-products-and-live-scope-match-snapshot')
  140. def request_count():
  141. return sum(e['method']=='Network.requestWillBeSent' and '/api/industry' in e['params']['request']['url'] for e in events)
  142. count=request_count()
  143. click('[data-decision-view="pains"]')
  144. assert action('return p.renderRoot.querySelectorAll("[data-rank-kind=pains]").length')==3
  145. assert not action('return p.renderRoot.querySelector(".ws-product")')
  146. for o in decisions['directions']:
  147. if not o['supportUnits']:continue
  148. click('[data-rank-kind="pains"][data-rank-id="'+o['id']+'"]')
  149. assert action('return p.renderRoot.querySelector('+json.dumps('[data-pain-card="'+o['id']+'"]')+').textContent.includes('+json.dumps(o['question'])+')')
  150. assert action('return Number(p.renderRoot.querySelector('+json.dumps('[data-rank-kind="pains"][data-rank-id="'+o['id']+'"]')+').dataset.rankCount)')==o['supportUnits']
  151. for r in o['evidence']:
  152. original=reviews[r['reviewId']]
  153. assert r['analysisEligible']==analysis_eligible(original,True)
  154. for hit in r['hits']:
  155. assert hit['quote'] in original.get('content' if hit['field']=='main' else 'append_content','')
  156. click('[data-decision-view="motives"]')
  157. assert action('return p.renderRoot.querySelectorAll("[data-rank-kind=motives]").length')==sum(bool(r['units']) for r in baseline['motives'])
  158. for r in baseline['motives']:
  159. if not r['units']:continue
  160. click('[data-rank-kind="motives"][data-rank-id="'+r['id']+'"]')
  161. assert action('return p.renderRoot.querySelector("[data-motive-card]").dataset.motiveCard')==r['id']
  162. 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']]
  163. assert r['examples'][0]['reviewId'] in action('return decodeURIComponent(p.renderRoot.querySelector("#vd-detail-motives .wd-quote footer a").getAttribute("href"))')
  164. click('[data-decision-view="selling"]')
  165. assert action('return p.renderRoot.querySelectorAll("[data-selling-claim]").length')==len(baseline['claims'])
  166. assert action('return p.renderRoot.querySelectorAll("[data-selling-benefit]").length')==len(decisions['strengths'])
  167. for c in baseline['claims']:
  168. assert all(pid in products for pid in c['examples'])
  169. click('[data-rank-kind="claims"][data-rank-id="'+c['label']+'"]')
  170. assert action('return p.renderRoot.querySelector("#vd-detail-selling h3").textContent')==c['label']
  171. 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'])]
  172. for r in decisions['strengths']:
  173. click('[data-rank-kind="benefits"][data-rank-id="'+r['id']+'"]')
  174. assert action('return p.renderRoot.querySelector("#vd-detail-selling h3").textContent')==r['name']
  175. assert r['examples'][0]['reviewId'] in action('return decodeURIComponent(p.renderRoot.querySelector("#vd-detail-selling .wd-quote footer a").getAttribute("href"))')
  176. click('[data-decision-view="audience"]')
  177. assert action('return p.renderRoot.querySelectorAll("[data-rank-kind=audience]").length')==sum(bool(r['units']) for r in [*baseline['audiences'],*baseline['scenarios']])
  178. for kind,rows in [('audience',baseline['audiences']),('scenario',baseline['scenarios'])]:
  179. for r in rows:
  180. if not r['units']:continue
  181. click('[data-rank-kind="audience"][data-rank-id="'+kind+':'+r['id']+'"]')
  182. assert action('return p.renderRoot.querySelector("[data-audience-card]").dataset.audienceCard')==kind+':'+r['id']
  183. assert r['examples'][0]['reviewId'] in action('return decodeURIComponent(p.renderRoot.querySelector("#vd-detail-audience .wd-quote footer a").getAttribute("href"))')
  184. click('[data-decision-view="directions"]')
  185. click('[data-direction-select="value"]')
  186. assert action('return p.routeQuery.w_focus')=='value'
  187. click('[data-decision-view="trend"]')
  188. assert action('return p.renderRoot.querySelector("#workshop-trend").textContent.includes("不是销量")')
  189. click('[data-decision-view="reviews"]')
  190. assert action('return p.renderRoot.querySelectorAll(".ws-review").length')==8
  191. assert request_count()==count,(request_count(),count)
  192. assert data()['decisions']==decisions
  193. check('all-eight-tabs-render-own-analysis-with-exact-evidence-and-no-statistical-reload-or-scope-change')
  194. click('[data-decision-view="pains"]');call('Page.reload');ready();settle()
  195. assert action('return p.routeQuery.w_view')=='pains'
  196. click('[data-decision-view="motives"]');ev('history.back()');settle();ready()
  197. assert action('return p.routeQuery.w_view')=='pains'
  198. ev('history.forward()');settle();ready()
  199. assert action('return p.routeQuery.w_view')=='motives'
  200. click('[data-decision-view="products"]');click('[data-product-mode="competition"]')
  201. assert action('return p.renderRoot.querySelectorAll(".wd-brand-row").length')==8
  202. first=action('return [...p.renderRoot.querySelectorAll(".wd-brand-row")].map(e=>e.textContent)')
  203. count=request_count();click('[data-brands-next]')
  204. assert action('return p.routeQuery.w_brands')=='2'
  205. assert first!=action('return [...p.renderRoot.querySelectorAll(".wd-brand-row")].map(e=>e.textContent)')
  206. call('Page.reload');ready();settle()
  207. assert action('return p.routeQuery.w_brands')=='2'
  208. count=request_count()
  209. action('const s=p.renderRoot.querySelector("[data-competition-theme]");s.value="health";s.dispatchEvent(new Event("change"));return true');settle();ready()
  210. assert not action('return p.routeQuery.w_brands')
  211. assert action('return p.renderRoot.querySelector("[data-competition-theme]").value')=='health'
  212. assert request_count()==count
  213. click('[data-product-mode="products"]')
  214. click('[data-w-products-next]')
  215. assert data()['products']['page']==2 and data()['scope']==baseline['scope']
  216. assert data()['decisions']==decisions
  217. click('[data-w-products-prev]')
  218. pid=data()['products']['rows'][0]['productId']
  219. click('[data-w-product-reviews="'+pid+'"]')
  220. assert action('return p.routeQuery.w_view')=='reviews'
  221. assert all(r['productId']==pid for r in data()['reviewEvidence']['rows'])
  222. check('refresh-history-competitor-theme-and-pagination-products-and-direct-review-navigation-work-with-stable-scope')
  223. click('[data-decision-view="pains"]');click('[data-rank-kind="pains"][data-rank-id="seasoning"]');click('[data-pain-evidence="seasoning"]')
  224. assert action('return p.routeQuery.w_view')=='reviews'
  225. assert action('return p.renderRoot.querySelector("[data-w-review-theme]").value')=='seasoning'
  226. assert data()['scope']==baseline['scope'] and data()['decisions']==decisions
  227. click('[data-decision-view="selling"]');click('[data-rank-kind="benefits"][data-rank-id="texture"]');click('[data-benefit-evidence="texture"]')
  228. assert action('return p.routeQuery.w_kind')=='benefit'
  229. assert data()['reviewEvidence']['total']==66
  230. click('[data-w-reviews-next]')
  231. assert data()['reviewEvidence']['page']==2 and data()['decisions']==decisions
  232. click('[data-decision-view="pains"]');click('[data-rank-kind="pains"][data-rank-id="seasoning"]');click('[data-pain-direction="seasoning"]')
  233. assert action('return p.routeQuery.w_view')=='directions'
  234. assert action('return p.routeQuery.w_focus')=='seasoning'
  235. assert action('return p.renderRoot.querySelector(".rd-validation").textContent.includes('+json.dumps(data()['decisions']['directions'][0]['test']['decisionRule'])+')')
  236. check('pain-benefit-and-validation-actions-open-related-comments-or-direction-with-identical-aggregate-analysis')
  237. click('[data-w-reset]')
  238. assert action('return p.renderRoot.querySelector("[data-filter-section-panel=product]").getBoundingClientRect().height>0')
  239. click('[data-w-choice="format:bag"]');click('[data-w-choice="format:bowl"]')
  240. import re
  241. packaging={pid for pid in base if re.search('袋|连包|碗|桶',products[pid]['title'])}
  242. verify_scope(data(),packaging)
  243. click('[data-w-choice="flavor:spicy"]')
  244. combined={pid for pid in packaging if re.search('麻辣|香辣|火鸡|超辣|小龙虾|红油',products[pid]['title'])}
  245. verify_scope(data(),combined)
  246. click('[data-decision-view="pains"]');assert data()['scope']['products']==len(combined)
  247. call('Page.reload');ready();settle()
  248. assert dimensions()['format']==['bag','bowl'] and dimensions()['flavor']==['spicy']
  249. assert action('return p.routeQuery.w_view')=='pains'
  250. click('[data-w-choice="format:all"]');click('[data-w-choice="flavor:all"]')
  251. assert action('return p.renderRoot.querySelector("[data-filter-section-panel=product]").getBoundingClientRect().height>0')
  252. click('[data-filter-section="people"]')
  253. click('[data-w-choice="audience:office"]')
  254. office=data();assert 0<office['scope']['products']<len(base)
  255. click('[data-w-choice="scenario:lunch"]');assert 0<data()['scope']['products']<=office['scope']['products']
  256. click('[data-w-reset]');change('brand','ksf')
  257. ksf={pid for pid in base if products[pid]['brand']=='ksf'};verify_scope(data(),ksf)
  258. change('shopId','1000084104');assert action('return p._filters.platform')=='jd'
  259. primary={pid for pid in ksf if products[pid]['shop_id']=='1000084104' and products[pid]['platform']=='jd'}
  260. verify_scope(data(),primary)
  261. change('platform','tmall');assert not action('return p.routeQuery.shopId')
  262. click('[data-w-reset]')
  263. check('upper-research-filters-multiselect-and-brand-shop-really-update-all-result-panels-and-survive-refresh')
  264. search('根本不存在的商品XYZ')
  265. assert data()['scope']['products']==0 and data()['decisions']['directions']==[]
  266. for tab in ['pains','motives','selling','audience','directions','trend','reviews']:
  267. click('[data-decision-view="'+tab+'"]')
  268. assert not action('return /NaN|undefined|jd:jd:/.test(p.renderRoot.querySelector(".wr-compact").textContent)')
  269. click('[data-w-reset]')
  270. check('empty-filter-scope-gives-clear-empty-states-in-all-eight-tabs-with-no-invented-result')
  271. call('Browser.grantPermissions',{'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
  272. click('[data-copy-agent]',False)
  273. text=ev('navigator.clipboard.readText()')
  274. assert snapshot['generation'] in text and '预期业务作用(待验证)' in text and '决策条件:' in text
  275. assert '商品标题卖点' in text and '评价覆盖:39/837件' in text
  276. refs=[json.loads(line) for line in text.splitlines() if line.startswith('{') and '"database"' in line]
  277. for r in refs:
  278. assert any(row['record_id']==r['id'] for row in entities[r['entity']]),r
  279. if r['entity']=='reviews' and 'source_record_id' in r:
  280. assert r['source_record_id']==reviews[r['id']].get('source_record_id'),r
  281. for c in data()['claims']:
  282. for pid in c['examples']:assert any(r['id']==pid and r['entity']=='products' for r in refs)
  283. report['copy']={'bytes':len(text.encode()),'references':len(refs),'exactReferencesVerified':len(refs),'generation':snapshot['generation']}
  284. click('[data-decision-view="pains"]')
  285. rid=data()['decisions']['directions'][0]['evidence'][0]['reviewId']
  286. click('.wr-insight .wd-quote footer a',False)
  287. 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()})')
  288. assert '/library/reviews/'+rid in ev('decodeURIComponent(location.pathname)')
  289. ev('history.back()');settle();ready()
  290. assert action('return p.routeQuery.w_view')=='pains'
  291. check('clipboard-includes-visible-title-examples-and-all-references-verify-and-original-link-opens-real-library-detail')
  292. # Old links continue to open the intended data, never a blank result.
  293. 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')]:
  294. navigate(path);settle()
  295. assert action('return p.renderRoot.querySelector("[data-decision-result]").dataset.decisionResult')==result
  296. navigate('/industry?tab=reviews&category=banmian','reviews');assert action('return !!p._data.corpus')
  297. navigate('/industry?tab=listings&category=banmian','listings');assert action('return !!p._data.inventory')
  298. check('old-result-links-and-existing-data-maintenance-still-open-correctly')
  299. navigate('/industry');settle()
  300. tabs=['products','pains','motives','selling','audience','directions','trend','reviews']
  301. for width,height in [(1440,900),(1024,900),(768,1024),(390,844),(320,568)]:
  302. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
  303. for tab in tabs:
  304. click('[data-decision-view="'+tab+'"]')
  305. if tab=='products':click('[data-product-mode="products"]')
  306. metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
  307. assert metrics['innerWidth']==width and metrics['scrollWidth']<=metrics['clientWidth'],metrics
  308. 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)}))')
  309. assert not bad,bad
  310. report['layouts'].append({'section':tab,'width':width,'height':height,**metrics,'outOfBounds':0})
  311. if width in (1440,390,320):shot(tab+'-'+str(width)+'.png')
  312. click('[data-decision-view="products"]');click('[data-product-mode="competition"]')
  313. metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
  314. assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
  315. 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)})')
  316. 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}}))')
  317. # Each column uses the same absolute group scale; zero must paint no bar.
  318. assert all(b['width']<=.1 if b['count']==0 else b['width']>0 for b in bars),bars
  319. ratios=[b['width']/b['track']/b['count'] for b in bars if b['count'] and b['track']]
  320. assert not ratios or max(ratios)-min(ratios)<.005,ratios
  321. report['layouts'].append({'section':'competition','width':width,'height':height,**metrics,'outOfBounds':0})
  322. if width in (1440,320):shot('competition-'+str(width)+'.png')
  323. ev('scrollTo(0,document.documentElement.scrollHeight)');settle()
  324. nav=action('const r=p.renderRoot.querySelector(".wr-tabs").getBoundingClientRect();return {top:r.top,bottom:r.bottom}')
  325. assert abs(nav['top'])<2,nav
  326. action('p.renderRoot.querySelector(".copy-menu").open=true;return true')
  327. click('[data-preview-agent]',False)
  328. assert action('return p.renderRoot.querySelector(".agent-copy-dialog").matches(":modal")')
  329. 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}')
  330. assert d['right']<=width and d['top']>=0 and d['bottom']<=height and d['scrollWidth']<=d['clientWidth'],d
  331. report.setdefault('copyDialogs',[]).append({'viewport':width,**d})
  332. click('.copy-dialog-head>button',False)
  333. action('p.renderRoot.querySelector(".copy-menu").open=false;return true')
  334. check('eight-panels-plus-competitors-and-copy-dialog-fit-five-real-viewports-with-sticky-visible-tabs')
  335. for width,height in [(390,844),(320,568)]:
  336. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
  337. click('[data-decision-view="pains"]');click('[data-rank-kind="pains"][data-rank-id="value"]')
  338. 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}')
  339. assert point['id']=='value' and point['top']>=point['navBottom'] and point['top']<height/2,point
  340. click('[data-back-ranks="pains"]')
  341. r=action('const r=p.renderRoot.querySelector("[data-rank-kind=pains][aria-pressed=true]").getBoundingClientRect();return {top:r.top,bottom:r.bottom}')
  342. assert r['top']>=0 and r['bottom']<height,r
  343. click('[data-decision-view="selling"]');click('[data-rank-kind="benefits"][data-rank-id="texture"]')
  344. assert action('return p.renderRoot.querySelector("#vd-detail-selling h3").textContent')=='面体与口感'
  345. action('p.renderRoot.querySelector("#vd-detail-selling .vd-related").open=true;return true');settle()
  346. metrics=ev('({scrollWidth:document.documentElement.scrollWidth,clientWidth:document.documentElement.clientWidth})')
  347. assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
  348. shot('benefit-selected-expanded-'+str(width)+'.png')
  349. check('mobile-chart-selection-reveals-matching-evidence-below-sticky-tabs-and-returns-to-selected-theme')
  350. action('p.renderRoot.querySelector("[data-decision-view=pains]").focus();return true')
  351. call('Input.dispatchKeyEvent',{'type':'keyDown','key':'Enter','code':'Enter','windowsVirtualKeyCode':13,'text':'\r'})
  352. call('Input.dispatchKeyEvent',{'type':'keyUp','key':'Enter','code':'Enter','windowsVirtualKeyCode':13})
  353. settle();ready();assert action('return p.routeQuery.w_view')=='pains'
  354. errors=[e for e in events if e['method']=='Runtime.exceptionThrown'];assert not errors,errors
  355. report['javascriptExceptions']=0
  356. check('native-keyboard-activation-no-uncaught-exceptions-no-response-overrides-paid-calls-or-production-writes')
  357. report['ok']=True
  358. except Exception:
  359. if ws:shot('failure.png')
  360. raise
  361. finally:
  362. if ws:ws.close()
  363. browser.terminate()
  364. try:browser.wait(timeout=10)
  365. except subprocess.TimeoutExpired:browser.kill();browser.wait(timeout=10)
  366. (out/'verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  367. 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))
  368. if __name__=='__main__':main()