Files
reimburse/native-engine/tests/test_match_explanation.py
T
2026-09-17 11:12:57 +08:00

187 lines
9.6 KiB
Python

import copy
import json
import os
import subprocess
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from domain import auto_match, enrich
from match_explanation import explain_match, single_checks
from test_domain import material, train, workspace
class MatchExplanationTests(unittest.TestCase):
def pair(self, dates=None, merchants=None, content=''):
return workspace(
[material('invoice', 'invoice', '100.00', content, dates, merchants)],
[material('payment', 'payment', '100.00', content, dates, merchants)])
def explained(self, state):
return enrich(auto_match(state))['matches'][0]
def assert_scores(self, match, score, evidence_score=None):
explanation = match['explanation']
self.assertEqual(match['score'], score)
self.assertTrue(explanation['checks'])
self.assertEqual(sum(item['points'] for item in explanation['checks']), explanation['rawScore'])
self.assertEqual(explanation['version'], 2)
self.assertEqual(sum(item['maximum'] for item in explanation['checks']), 100)
self.assertEqual(explanation['rawScore'], score if evidence_score is None else evidence_score)
self.assertTrue(all(0 <= item['points'] <= item['maximum'] for item in explanation['checks']))
self.assertEqual(sum(item['maximum'] - item['points'] for item in explanation['checks']), 100 - explanation['rawScore'])
def test_90_missing_merchant_and_order_not_amount(self):
match = self.explained(self.pair(dates=['2026-09-01']))
self.assert_scores(match, 90, 65)
checks = match['explanation']['checks']
self.assertEqual([item['points'] for item in checks], [40, 0, 10, 0, 15])
self.assertEqual([item['maximum'] for item in checks], [40, 20, 10, 15, 15])
self.assertEqual(checks[1]['issue'], '商户未识别')
self.assertEqual(checks[3]['issue'], '单号未识别')
self.assertIn('不是金额', match['explanation']['note'])
def test_75_missing_dates_and_merchants(self):
match = self.explained(self.pair())
self.assert_scores(match, 75, 55)
self.assertEqual(match['explanation']['checks'][2]['issue'], '日期未识别')
self.assertIn('发票和付款截图', match['explanation']['checks'][2]['detail'])
def test_70_nonunique_with_rejected_alternative(self):
state = self.pair(dates=['2026-09-01'])
state['payments'].append(material('other', 'payment', '100.00'))
match = self.explained(state)
self.assert_scores(match, 70, 50)
self.assertEqual(match['explanation']['checks'][-1]['points'], 0)
self.assertIn('其他', match['explanation']['checks'][-1]['detail'])
def test_old_capped_100_is_now_75_when_date_and_order_missing(self):
match = self.explained(self.pair(merchants=['海棠餐厅']))
self.assert_scores(match, 100, 75)
self.assertEqual(match['explanation']['checks'][2]['points'], 0)
self.assertIn('每项', match['explanation']['note'])
def test_all_fields_total_exactly_100_without_cap(self):
match = self.explained(self.pair(['2026-09-01'], ['海棠餐厅'], '订单号 ORDER12345678'))
self.assert_scores(match, 100)
self.assertEqual(match['explanation']['rawScore'], 100)
def test_date_tiers_and_one_sided_missing(self):
for dates, points, score in [(['2026-09-04'], 10, 90), (['2026-09-05'], 5, 83),
(['2026-09-09'], 0, 75), ([], 0, 75)]:
with self.subTest(dates=dates):
state = self.pair(['2026-09-01'])
state['payments'][0]['ocr']['dates'] = dates
match = self.explained(state)
self.assert_scores(match, score, 55 + points)
self.assertEqual(match['explanation']['checks'][2]['points'], points)
if not dates:
self.assertIn('付款截图未识别', match['explanation']['checks'][2]['detail'])
def test_merchant_partial_and_below_threshold(self):
for merchant, old_points, points in [('海棠餐饮', 13, 10), ('其他商户', 0, 0)]:
state = self.pair(merchants=['海棠餐厅'])
state['payments'][0]['ocr']['merchants'] = [merchant]
match = self.explained(state)
self.assert_scores(match, 75 + old_points, 55 + points)
self.assertIn('35%', match['explanation']['checks'][1]['detail'])
self.assertIn(merchant, match['explanation']['checks'][1]['detail'])
def test_different_order_numbers(self):
state = self.pair(content='订单号 ORDER12345678')
state['payments'][0]['ocr']['orderNumbers'] = ['OTHER12345678']
match = self.explained(state)
self.assert_scores(match, 75, 55)
self.assertEqual(match['explanation']['checks'][3]['issue'], '单号不一致')
def test_shared_trip_95_and_100(self):
for count in [1, 2]:
state = workspace([train('first', '张三'), train('second', '李四')],
[material(f'payment-{index}', 'payment', '2116.00', '12306 支付成功', ['2026-06-24'])
for index in range(count)])
match = self.explained(state)
self.assert_scores(match, 95 if count == 1 else 100)
self.assertEqual(match['explanation']['checks'][1]['points'], 0 if count == 1 else 5)
def test_combined_invoices_90_nontravel(self):
state = workspace(
[material('first', 'invoice', '30.00', dates=['2026-09-01']),
material('second', 'invoice', '70.00', dates=['2026-09-01'])],
[material('pay', 'payment', '100.00', '订单号 ORDER12345678', ['2026-09-01']),
material('order', 'payment', '100.00', '订单号 ORDER12345678', ['2026-09-01'])])
match = self.explained(state)
self.assert_scores(match, 90)
self.assertEqual([item['points'] for item in match['explanation']['checks']], [70, 10, 10, 0])
self.assertIn('不适用', match['explanation']['checks'][-1]['detail'])
def test_split_payment_fixed_95_is_not_fabricated_deduction(self):
state = workspace([material('invoice', 'invoice', '100.00')],
[material('first', 'payment', '30.00'), material('second', 'payment', '70.00')])
match = self.explained(state)
self.assert_scores(match, 95)
self.assertEqual(match['explanation']['checks'][-1]['title'], '规则保留分')
self.assertIn('固定', match['explanation']['checks'][-1]['detail'])
def test_legacy_refresh_preserves_pairing_and_historical_uniqueness(self):
state = auto_match(self.pair(['2026-09-01']))
original = copy.deepcopy(state['matches'][0])
state['invoices'].append(material('later', 'invoice', '100.00'))
enriched = enrich(state)
match = enriched['matches'][0]
self.assert_scores(match, 90, 65)
for field in ['id', 'score', 'reasons', 'category']:
self.assertEqual(match[field], original[field])
self.assertEqual([item['id'] for item in match['invoices']], [item['id'] for item in original['invoices']])
self.assertEqual(enrich(enriched), enriched)
def test_previous_explanations_upgrade_without_rematching(self):
for dates, merchants, content, expected in [
([], [], '', 55),
(['2026-09-01'], ['海棠餐厅'], '订单号 ORDER12345678', 100)
]:
state = auto_match(self.pair(dates, merchants, content))
match = state['matches'][0]
checks = single_checks(match)
match['explanation'] = dict(title='旧版评分', rawScore=sum(item['points'] for item in checks),
checks=checks, note='旧版封顶说明')
previous_id, previous_score = match['id'], match['score']
enrich(state)
self.assert_scores(match, previous_score, expected)
self.assertEqual(match['id'], previous_id)
previous = copy.deepcopy(state)
self.assertEqual(enrich(state), previous)
def test_manual_and_unknown_records(self):
match = self.explained(self.pair())
match['matchType'] = 'manual'
self.assertIsNone(explain_match(match))
match['matchType'] = 'auto'
match['reasons'] = ['历史匹配']
self.assertEqual(explain_match(match)['checks'], [])
self.assertIn('不推测', explain_match(match)['note'])
def test_changed_ocr_does_not_invent_historical_scores(self):
match = self.explained(self.pair())
match['invoices'][0]['ocr']['dates'] = ['2026-09-01']
match['payments'][0]['ocr']['dates'] = ['2026-09-01']
self.assertEqual(explain_match(match)['checks'], [])
self.assertIn('不一致', explain_match(match)['note'])
def test_engine_process_backfills_existing_workspace(self):
state = auto_match(self.pair(['2026-09-01']))
engine = Path(__file__).resolve().parents[1] / 'engine.py'
command = [os.environ['RECEIPT_ENGINE_BINARY']] if os.environ.get('RECEIPT_ENGINE_BINARY') else [sys.executable, str(engine)]
result = subprocess.run(command, input=json.dumps(dict(operation='refresh', state=state)),
capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr)
response = json.loads(result.stdout.splitlines()[-1])
self.assertEqual(response['event'], 'result')
match = response['result']['matches'][0]
self.assert_scores(match, 90, 65)
self.assertEqual(match['id'], state['matches'][0]['id'])
if __name__ == '__main__':
unittest.main()