test_board_library.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import sys
  2. sys.dont_write_bytecode=True
  3. import hashlib
  4. import json
  5. import os
  6. from pathlib import Path
  7. import shutil
  8. import sqlite3
  9. import tempfile
  10. import unittest
  11. import board_library as board
  12. import retrieval
  13. import update_library as updater
  14. import voc_db
  15. ROOT=Path(__file__).resolve().parents[2]
  16. DB=Path(os.environ.get('KSF_TEST_VOC_DB',str(ROOT/'artifacts/康师傅VOC结构化入库-20261105/ksf-voc-library-20261105-r2/library/library.sqlite')))
  17. @unittest.skipUnless(DB.is_file(),'requires private VOC corpus; set KSF_TEST_VOC_DB')
  18. class BoardLibraryTests(unittest.TestCase):
  19. def setUp(self):self.config={'db':str(DB)}
  20. def test_real_totals_and_read_only(self):
  21. before=updater.digest(DB)
  22. r=board.run(self.config,{'op':'summary'})
  23. self.assertEqual(r['counts']['products'],4639);self.assertEqual(r['counts']['policy_documents'],25)
  24. self.assertEqual(r['counts']['policy_clauses'],31);self.assertEqual(r['analysisCounts']['reviews'],771)
  25. self.assertEqual(updater.digest(DB),before);self.assertEqual(r['databaseWrites'],0)
  26. def test_exact_scope_facets_and_text(self):
  27. p={'op':'browse','entity':'products','filters':{'platform':'jd','brand':'ksf','category':'banmian'}}
  28. r=board.run(self.config,p);self.assertGreater(r['totalMatched'],0)
  29. self.assertTrue(all(x['platform']=='jd' and x['brand']=='ksf' and x['category']=='banmian' for x in r['records']))
  30. p['keyword']='不可能命中的独特测试文字81743';self.assertEqual(board.run(self.config,p)['totalMatched'],0)
  31. def test_cursor_bound_to_query_and_version(self):
  32. r=board.run(self.config,{'op':'browse','entity':'products'})
  33. with self.assertRaises(voc_db.LibraryError):board.run(self.config,{'op':'browse','entity':'products','filters':{'brand':'ksf'},'cursor':r['nextCursor']})
  34. with self.assertRaises(voc_db.LibraryError):board.run(self.config,{'op':'summary','generation':'stale'})
  35. def test_every_entity_and_source_projection(self):
  36. for entity in board.LABELS:
  37. with self.subTest(entity=entity):
  38. r=board.run(self.config,{'op':'browse','entity':entity})
  39. self.assertGreater(r['totalMatched'],0);self.assertNotIn('citation',r['records'][0])
  40. d=board.run(self.config,{'op':'detail','entity':entity,'id':r['records'][0]['record_id']})
  41. self.assertNotIn('sourcePayload',d);self.assertNotIn('upstreamSources',d['record']['source'])
  42. self.assertNotIn('/',d['record']['source']['asset_path'])
  43. def test_reader_product_preview_and_clause_source(self):
  44. r=board.run(self.config,{'op':'browse','entity':'products'})
  45. self.assertTrue(all('分类依据' not in x['preview'] and '分类依据' not in (x.get('shop_name') or '') for x in r['records']))
  46. clause=board.run(self.config,{'op':'browse','entity':'clauses'})['records'][0]
  47. detail=board.run(self.config,{'op':'detail','entity':'clauses','id':clause['record_id']})['record']
  48. self.assertEqual(detail['parentDocument']['id'],detail['document_id'])
  49. self.assertTrue(detail['parentDocument']['title']);self.assertTrue(detail['parentDocument']['source_url'])
  50. def test_review_quality_and_unsupported_conditions(self):
  51. a=board.run(self.config,{'op':'browse','entity':'reviews'})
  52. raw=board.run(self.config,{'op':'browse','entity':'reviews','quality':'audit'})
  53. self.assertEqual(raw['totalMatched'],2360);self.assertEqual(a['pendingCount'],2360-771)
  54. with self.assertRaises(voc_db.LibraryError):board.run(self.config,{'op':'browse','entity':'documents','filters':{'brand':'ksf'}})
  55. with self.assertRaises(voc_db.LibraryError):board.run(self.config,{'op':'source','id':'anything'})
  56. def test_related_product_counts_are_exact(self):
  57. r=board.run(self.config,{'op':'browse','entity':'products','filters':{'brand':'ksf'}})['records'][0]
  58. d=board.run(self.config,{'op':'detail','entity':'products','id':r['record_id']})
  59. for relation in d['relations']:
  60. rows=board.run(self.config,{'op':'browse','entity':relation['entity'],'filters':{'productId':r['record_id']},'quality':'audit'})
  61. self.assertEqual(relation['count'],rows['totalMatched'])
  62. def test_product_sort_matches_full_database_across_page_boundary(self):
  63. before=updater.digest(DB)
  64. with voc_db.readonly(DB) as c:
  65. counts=dict(c.execute('SELECT product_id,count(*) FROM reviews GROUP BY product_id'))
  66. prices=dict(c.execute('SELECT product_id,min(amount_minor) FROM prices GROUP BY product_id'))
  67. products=[(r['record_id'],r['doc_id']) for r in c.execute("SELECT record_id,doc_id FROM retrieval_documents WHERE entity='products'")]
  68. for sort in ('reviews_asc','reviews_desc','price_asc','price_desc'):
  69. metric=counts if sort.startswith('reviews_') else prices
  70. def key(row):
  71. val=metric.get(row[0],0 if sort.startswith('reviews_') else None)
  72. return (val is None,(-val if sort.endswith('desc') else val) if val is not None else 0,row[1])
  73. expected=[x[0] for x in sorted(products,key=key)][:40]
  74. request={'op':'browse','entity':'products','sort':sort}
  75. first=board.run(self.config,request)
  76. second=board.run(self.config,{**request,'cursor':first['nextCursor']})
  77. self.assertEqual(first['totalMatched'],4639);self.assertEqual(second['totalMatched'],4639)
  78. self.assertEqual([r['record_id'] for r in first['records']+second['records']],expected)
  79. scoped=board.run(self.config,{'op':'browse','entity':'products','sort':'reviews_desc','filters':{'platform':'jd','brand':'ksf','category':'banmian'}})
  80. self.assertTrue(all((r['platform'],r['brand'],r['category'])==('jd','ksf','banmian') for r in scoped['records']))
  81. self.assertEqual(updater.digest(DB),before)
  82. def test_product_cards_counts_and_inline_details_keep_exact_product_scope(self):
  83. before=updater.digest(DB)
  84. cards=board.run(self.config,{'op':'browse','entity':'products','filters':{'brand':'ksf','category':'banmian'}})
  85. for card in cards['records'][:3]:
  86. detail=board.run(self.config,{'op':'detail','entity':'products','id':card['record_id']})
  87. for relation in detail['relations']:
  88. self.assertEqual(card['relatedCounts'].get(relation['entity'],0),relation['count'])
  89. with voc_db.readonly(DB) as c:
  90. pid=c.execute('SELECT product_id FROM reviews WHERE product_id IS NOT NULL GROUP BY product_id ORDER BY count(*) DESC LIMIT 1').fetchone()[0]
  91. for entity in ('reviews','specs','prices','rankings'):
  92. p={'op':'browse','entity':entity,'filters':{'productId':pid},'quality':'audit'}
  93. result=board.run(self.config,p)
  94. self.assertTrue(all(row['product_id']==pid for row in result['records']))
  95. if entity=='reviews':
  96. self.assertGreater(result['totalMatched'],20)
  97. row=result['records'][0]
  98. detail=board.run(self.config,{'op':'detail','entity':entity,'id':row['record_id']})['record']
  99. self.assertEqual(row['content'],detail['content'])
  100. self.assertIn('purchased_sku_text',row)
  101. if result['nextCursor']:
  102. second=board.run(self.config,{**p,'cursor':result['nextCursor']})
  103. self.assertTrue(all(row['product_id']==pid for row in second['records']))
  104. self.assertFalse({r['record_id'] for r in result['records']} & {r['record_id'] for r in second['records']})
  105. with self.assertRaises(voc_db.LibraryError):board.run(self.config,{**p,'filters':{'productId':'jd:100000283767'},'cursor':result['nextCursor']})
  106. self.assertEqual(updater.digest(DB),before)
  107. @unittest.skipUnless(DB.is_file(),'requires private VOC corpus; set KSF_TEST_VOC_DB')
  108. class UpdateTests(unittest.TestCase):
  109. def setUp(self):self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name);self.registry=self.root/'registry'
  110. def tearDown(self):self.tmp.cleanup()
  111. def incoming(self):
  112. p=self.root/'incoming.sqlite';shutil.copyfile(DB,p)
  113. c=sqlite3.connect(p);c.row_factory=sqlite3.Row
  114. c.execute("UPDATE meta SET value='new-generation-test' WHERE key='generation'")
  115. c.execute("UPDATE meta SET value=? WHERE key='archive_sha256'",('a'*64,))
  116. c.execute("DELETE FROM batches")
  117. c.execute('INSERT INTO batches VALUES(?,?,?,?,?)',('a'*64,'新增测试批次.zip','a'*64,'2026-11-06T01:00:00Z','test'))
  118. product=dict(c.execute('SELECT * FROM products LIMIT 1').fetchone());product.update(id='jd:test-added',item_id='test-added',title='新增拌面测试商品',marketplace='jd')
  119. c.execute('INSERT INTO products('+','.join(product)+') VALUES('+','.join('?' for _ in product)+')',list(product.values()))
  120. c.execute("UPDATE reviews SET content='更新后的评价正文' WHERE id=(SELECT id FROM reviews LIMIT 1)")
  121. price=dict(c.execute('SELECT * FROM prices WHERE source_time_raw IS NOT NULL LIMIT 1').fetchone())
  122. for pid,observed in [('test-reexport-price',price['source_time_raw']),('test-new-price','2026-11-06T10:00:00+08:00')]:
  123. row={**price,'id':pid,'source_time_raw':observed}
  124. if pid=='test-new-price':row.update(source_time_utc='2026-11-06T02:00:00Z',source_calendar_date='2026-11-06')
  125. c.execute('INSERT INTO prices('+','.join(row)+') VALUES('+','.join('?' for _ in row)+')',list(row.values()))
  126. for table in ('retrieval_fts','retrieval_documents'):c.execute('DROP TABLE '+table)
  127. retrieval.create_index(c);c.commit();c.close();return p
  128. def test_dry_run_persistence_merge_provenance_and_rollback(self):
  129. updater.publish(DB,self.registry)
  130. self.assertFalse(self.registry.exists())
  131. first=updater.publish(DB,self.registry,True,'首批');before=(self.registry/first['file']).read_bytes()
  132. repeat=updater.publish(DB,self.registry,True);self.assertEqual(repeat['operation'],'already-imported')
  133. incoming=self.incoming();second=updater.publish(incoming,self.registry,True,'新增一批')
  134. self.assertNotEqual(first['generation'],second['generation']);self.assertEqual((self.registry/first['file']).read_bytes(),before)
  135. cfg={'registry':str(self.registry)};s=board.run(cfg,{'op':'summary'})
  136. self.assertEqual(voc_db.registry_database(self.registry),self.registry/second['file'])
  137. self.assertEqual(s['counts']['products'],4640);self.assertEqual(s['counts']['reviews'],2360);self.assertEqual(len(s['updates']),2)
  138. self.assertEqual(s['counts']['prices'],4071)
  139. self.assertEqual(second['delta']['added']['products'],1);self.assertEqual(second['delta']['changed']['reviews'],1)
  140. with voc_db.readonly(self.registry/second['file']) as c:
  141. self.assertGreater(c.execute('SELECT count(*) FROM record_versions').fetchone()[0],0)
  142. origin=voc_db.citation(c,c.execute("SELECT source_record_id FROM products WHERE id='jd:test-added'").fetchone()[0])
  143. self.assertTrue(origin['archiveSha256s'])
  144. updater.publish(None,self.registry,True,rollback=first['generation'])
  145. self.assertEqual(board.run(cfg,{'op':'summary'})['counts']['products'],4639)
  146. def test_invalid_batch_keeps_current(self):
  147. first=updater.publish(DB,self.registry,True)
  148. pointer=(self.registry/'current.json').read_bytes()
  149. bad=self.root/'bad.sqlite';bad.write_text('bad',encoding='utf-8')
  150. with self.assertRaises(sqlite3.DatabaseError):updater.publish(bad,self.registry,True)
  151. self.assertEqual((self.registry/'current.json').read_bytes(),pointer)
  152. self.assertFalse((self.registry/'.update.lock').exists())
  153. self.assertEqual(board.run({'registry':str(self.registry)},{'op':'version'})['generation'],first['generation'])
  154. if __name__=='__main__':unittest.main()