verify-industry-decision.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  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-decision-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'];report['decisions']={k:decisions[k] for k in ['headline','reason','eligibleUnits']}
  127. assert decisions['headline']=='优先研究:调味更可控'
  128. assert decisions['eligibleUnits']==47
  129. lead=next(o for o in decisions['directions'] if o['id']=='seasoning')
  130. assert (lead['supportUnits'],lead['products'],lead['qualifiedUnits'])==(3,3,0)
  131. assert decisions['strengths'][0]['units']==66
  132. assert action('return p.renderRoot.querySelectorAll("[data-decision-card]").length')==3
  133. assert not action('return p.renderRoot.querySelector("#workshop-products")')
  134. assert action('return p.renderRoot.querySelector(".wd-hero").getBoundingClientRect().bottom')<900
  135. shot('summary-1440.png')
  136. for o in decisions['directions']:
  137. for r in [*o['evidence'],*o['benefits'],*o['adjustments'],*[r for g in o['segments'] for r in g['examples']]]:
  138. original=reviews[r['reviewId']]
  139. assert r['sourceRecordId']==original.get('source_record_id')
  140. assert r['analysisEligible']==analysis_eligible(original,True)
  141. assert r['content']==str(original.get('content') or '')[:10000]
  142. assert r['appendContent']==str(original.get('append_content') or '')[:10000]
  143. for hit in r['hits']:
  144. assert hit['quote'] in original.get('content' if hit['field']=='main' else 'append_content','')
  145. for row in o['representatives']:
  146. assert row['title']==products[row['productId']]['title']
  147. check('result-first-real-scope-three-directions-and-exact-decision-quotes-identifiers-provenance-verified')
  148. def request_count():
  149. return sum(e['method']=='Network.requestWillBeSent' and '/api/industry' in e['params']['request']['url'] for e in events)
  150. count=request_count()
  151. click('[data-decision-lead]')
  152. assert action('return p.routeQuery.w_view')=='directions'
  153. assert action('return p.routeQuery.w_focus')=='seasoning'
  154. assert action('return p.renderRoot.querySelectorAll(".wd-quote").length')>=3
  155. assert action('return p.renderRoot.querySelector(".wd-test").textContent.includes("看到结果后怎么决策")')
  156. ev('scrollTo(0,document.documentElement.scrollHeight)');settle()
  157. assert abs(action('return p.renderRoot.querySelector(".wd-nav").getBoundingClientRect().top'))<2
  158. click('[data-decision-view="market"]')
  159. assert action('return p.renderRoot.querySelectorAll(".wd-brand-row").length')==len(baseline['brands'])
  160. action('const s=p.renderRoot.querySelector("[data-competition-theme]");s.value="health";s.dispatchEvent(new Event("change"));return true')
  161. settle();ready()
  162. assert action('return p.routeQuery.w_market_theme')=='health'
  163. assert action('return p.renderRoot.querySelector("[data-competition-theme]").value')=='health'
  164. click('[data-decision-view="directions"]')
  165. assert action('return p.routeQuery.w_focus')=='seasoning'
  166. click('[data-decision-view="evidence"]');click('[data-data-kind="products"]')
  167. assert action('return p.renderRoot.querySelectorAll(".ws-product").length')==12
  168. click('[data-decision-view="summary"]')
  169. assert request_count()==count,(count,request_count())
  170. check('result-navigation-direction-and-brand-theme-switch-in-place-without-requerying-data-or-losing-selected-direction')
  171. click('[data-decision-lead]');call('Page.reload');ready();settle()
  172. assert action('return p.routeQuery.w_focus')=='seasoning'
  173. assert action('return p.renderRoot.querySelector("[data-direction-select=seasoning]").getAttribute("aria-pressed")')=='true'
  174. click('[data-direction-select="value"]')
  175. ev('history.back()');settle();ready()
  176. assert action('return p.routeQuery.w_focus')=='seasoning'
  177. ev('history.forward()');settle();ready()
  178. assert action('return p.routeQuery.w_focus')=='value'
  179. click('[data-direction-evidence="value"]')
  180. assert data()['decisions']==decisions and data()['scope']==baseline['scope']
  181. assert action('return p.routeQuery.w_view')=='evidence'
  182. assert action('return p.renderRoot.querySelector("[data-w-review-theme]").value')=='value'
  183. check('selected-direction-survives-refresh-history-and-drills-to-own-originals-with-full-scope-conclusions-unchanged')
  184. click('[data-decision-view="summary"]')
  185. click('[data-decision-strength]')
  186. assert action('return p.routeQuery.w_theme')=='texture'
  187. assert action('return p.routeQuery.w_kind')=='benefit'
  188. assert data()['reviewEvidence']['total']==66
  189. assert all(any(t['id']=='texture' and any(h['kind']=='benefit' for h in t['hits']) for t in r['themes']) for r in data()['reviewEvidence']['rows'])
  190. click('[data-w-reviews-next]')
  191. assert data()['reviewEvidence']['page']==2 and data()['decisions']==decisions
  192. click('[data-w-reviews-prev]')
  193. click('[data-data-kind="products"]')
  194. click('[data-w-products-next]')
  195. assert data()['products']['page']==2 and data()['decisions']==decisions
  196. assert 55<action('return p.renderRoot.querySelector("#workshop-products").getBoundingClientRect().top')<85
  197. click('[data-w-products-prev]')
  198. pid=data()['products']['rows'][0]['productId']
  199. click('[data-w-product-reviews="'+pid+'"]')
  200. assert all(r['productId']==pid for r in data()['reviewEvidence']['rows'])
  201. assert data()['decisions']==decisions
  202. check('strengths-products-and-independent-page-controls-open-their-own-evidence-without-changing-summary')
  203. click('[data-w-reset]')
  204. action('p.renderRoot.querySelector(".wd-extra-filters").open=true;return true')
  205. click('[data-w-choice="format:bag"]')
  206. import re
  207. bag={pid for pid in base if re.search('袋|连包',products[pid]['title'])}
  208. verify_scope(data(),bag)
  209. click('[data-w-choice="format:bowl"]')
  210. packaging={pid for pid in base if re.search('袋|连包|碗|桶',products[pid]['title'])}
  211. verify_scope(data(),packaging)
  212. click('[data-w-choice="flavor:spicy"]')
  213. combined={pid for pid in packaging if re.search('麻辣|香辣|火鸡|超辣|小龙虾|红油',products[pid]['title'])}
  214. verify_scope(data(),combined)
  215. assert dimensions()['format']==['bag','bowl']
  216. call('Page.reload');ready();settle()
  217. assert dimensions()['format']==['bag','bowl'] and dimensions()['flavor']==['spicy']
  218. assert action('return p.renderRoot.querySelector(".wd-extra-filters").open')
  219. click('[data-w-choice="format:all"]');click('[data-w-choice="flavor:all"]')
  220. assert action('return p.renderRoot.querySelector(".wd-extra-filters").open')
  221. click('[data-w-choice="audience:office"]')
  222. office=data();assert 0<office['scope']['products']<len(base)
  223. click('[data-w-choice="scenario:lunch"]')
  224. assert 0<data()['scope']['products']<=office['scope']['products']
  225. assert all('audience' in p['matchEvidence'] and 'scenario' in p['matchEvidence'] for p in data()['products']['rows'])
  226. click('[data-w-reset]')
  227. change('brand','ksf')
  228. ksf={pid for pid in base if products[pid]['brand']=='ksf'}
  229. verify_scope(data(),ksf)
  230. change('shopId','1000084104')
  231. assert action('return p._filters.platform')=='jd'
  232. primary={pid for pid in ksf if products[pid]['shop_id']=='1000084104' and products[pid]['platform']=='jd'}
  233. verify_scope(data(),primary)
  234. change('platform','tmall');assert not action('return p.routeQuery.shopId')
  235. verify_scope(data(),{pid for pid in ksf if products[pid]['platform']=='tmall'})
  236. click('[data-w-reset]')
  237. check('real-multiselects-reset-refresh-audience-scenario-brand-shop-and-platform-update-all-analysis-by-exact-product-scope')
  238. click('[data-decision-view="evidence"]');click('[data-data-kind="products"]')
  239. search('根本不存在的商品XYZ')
  240. assert data()['scope']['products']==0 and data()['decisions']['directions']==[]
  241. assert data()['decisions']['headline']=='当前条件没有匹配商品'
  242. click('[data-decision-view="summary"]')
  243. assert action('return p.renderRoot.querySelectorAll("[data-decision-card]").length')==0
  244. assert action('return p.renderRoot.querySelector(".wd-hero").textContent.includes("没有匹配商品")')
  245. click('[data-w-reset]')
  246. check('empty-matching-range-has-no-invented-direction-and-explicit-empty-result')
  247. call('Browser.grantPermissions',{'origin':args.url,'permissions':['clipboardReadWrite','clipboardSanitizedWrite']})
  248. click('[data-copy-agent]',False)
  249. text=ev('navigator.clipboard.readText()')
  250. assert snapshot['generation'] in text and '预期业务作用(待验证)' in text and '决策条件:' in text
  251. assert '评价覆盖:39/837件' in text and '去重可读依据:47组' in text
  252. refs=[json.loads(line) for line in text.splitlines() if line.startswith('{') and '"database"' in line]
  253. for r in refs:
  254. assert any(row['record_id']==r['id'] for row in entities[r['entity']]),r
  255. if r['entity']=='reviews' and 'source_record_id' in r:
  256. assert r['source_record_id']==reviews[r['id']].get('source_record_id'),r
  257. for o in data()['decisions']['directions']:
  258. assert o['name'] in text and o['test']['decisionRule'] in text
  259. for r in o['evidence']:
  260. assert any(ref['id']==r['reviewId'] for ref in refs)
  261. report['copy']={'bytes':len(text.encode()),'references':len(refs),'exactReferencesVerified':len(refs),'generation':snapshot['generation']}
  262. check('real-clipboard-decision-hypotheses-tests-and-database-references-all-verified-against-original-snapshot')
  263. click('[data-decision-lead]')
  264. rid=action('return p._data.decisions.directions.find(r=>r.id==="seasoning").evidence[0].reviewId')
  265. click('.wd-quote footer a',False)
  266. 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()})')
  267. assert '/library/reviews/'+rid in ev('decodeURIComponent(location.pathname)')
  268. ev('history.back()');settle();ready()
  269. assert action('return p.routeQuery.w_focus')=='seasoning'
  270. check('decision-quote-opens-actual-library-original-with-generation-guard-and-history-restores-analysis')
  271. for tab,field in [('reviews','corpus'),('listings','inventory')]:
  272. navigate('/industry?tab='+tab+'&category=banmian',tab)
  273. assert action('return !!p._data.'+field)
  274. check('existing-inventory-and-review-maintenance-remain-available')
  275. navigate('/industry');settle()
  276. for width,height in [(1440,900),(1024,900),(768,1024),(390,844),(320,568)]:
  277. call('Emulation.setDeviceMetricsOverride',{'width':width,'height':height,'deviceScaleFactor':1,'mobile':False});settle()
  278. for name,selector in [('summary','[data-decision-view="summary"]'),('directions','[data-decision-view="directions"]'),('competition','[data-decision-view="market"]'),('people','[data-market-view="people"]'),('trend','[data-market-view="trend"]'),('reviews','[data-decision-view="evidence"]'),('products','[data-data-kind="products"]')]:
  279. click(selector)
  280. if name=='reviews':click('[data-data-kind="reviews"]')
  281. if name=='competition':click('[data-market-view="competition"]')
  282. metrics=ev('({innerWidth,clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth})')
  283. assert metrics['innerWidth']==width,metrics
  284. assert metrics['scrollWidth']<=metrics['clientWidth'],metrics
  285. 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)}))')
  286. assert not bad,bad
  287. report['layouts'].append({'section':name,'width':width,'height':height,**metrics,'outOfBounds':0})
  288. if width in (1440,390,320):shot(name+'-'+str(width)+'.png')
  289. action('p.renderRoot.querySelector(".copy-menu").open=true;return true')
  290. click('[data-preview-agent]',False)
  291. assert action('return p.renderRoot.querySelector(".agent-copy-dialog").matches(":modal")')
  292. 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}')
  293. assert d['right']<=width and d['top']>=0 and d['bottom']<=height and d['scrollWidth']<=d['clientWidth'],d
  294. report.setdefault('copyDialogs',[]).append({'viewport':width,**d})
  295. if width==390:shot('copy-390.png')
  296. click('.copy-dialog-head>button',False)
  297. action('p.renderRoot.querySelector(".copy-menu").open=false;return true')
  298. check('seven-result-views-and-native-copy-dialog-fit-five-real-viewports-with-no-horizontal-overflow')
  299. # Native keyboard activation works through the rendered button.
  300. action('p.renderRoot.querySelector("[data-decision-view=summary]").focus();return true')
  301. call('Input.dispatchKeyEvent',{'type':'keyDown','key':'Enter','code':'Enter','windowsVirtualKeyCode':13,'text':'\r'})
  302. call('Input.dispatchKeyEvent',{'type':'keyUp','key':'Enter','code':'Enter','windowsVirtualKeyCode':13})
  303. settle();ready()
  304. assert action('return p.routeQuery.w_view')=='summary'
  305. assert action('return p.renderRoot.querySelector(".p-head h2").textContent')=='产品创新决策'
  306. assert not action('return /NaN|undefined|jd:jd:/.test(p.renderRoot.querySelector(".wd-analysis").textContent)')
  307. errors=[e for e in events if e['method']=='Runtime.exceptionThrown']
  308. assert not errors,errors
  309. report['javascriptExceptions']=0
  310. check('keyboard-activation-no-bad-values-or-uncaught-exceptions-no-response-overrides-and-no-paid-or-production-writes')
  311. report['ok']=True
  312. except Exception:
  313. if ws:shot('failure.png')
  314. raise
  315. finally:
  316. if ws:ws.close()
  317. browser.terminate()
  318. try:browser.wait(timeout=10)
  319. except subprocess.TimeoutExpired:browser.kill();browser.wait(timeout=10)
  320. (out/'verification.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
  321. 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))
  322. if __name__=='__main__':main()