From 5d459b450525063ecdbbfb5694aff30aba5fb0bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E8=81=AA?= Date: Mon, 31 Aug 2026 04:45:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(BIZ-105):=20=E8=AE=B0=E5=BD=95-=E6=AF=94?= =?UTF-8?q?=E5=AF=B9=20=E4=B8=AD=E5=A5=96=E9=87=91=E9=A2=9D=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=20v1.2=20=E5=AE=9E=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD v1.2 §4.3 落地,参考 BIZ-102 PRD 与 UI 原型。 后端(app.py): - 新增 PRIZE_AMOUNT / PRIZE_KEY_MAP 常量双表(PRD §4.3) - 新增 format_amount 千分位格式化(PRD §3.2:紧邻「元」不空格) - 新增 compute_prize_summary 聚合函数 - api_compare_record 接口扩展: - 单注:prize_amount + prize_amount_display 字段 - 顶层:total_prize / total_prize_display / has_variable_prize / prize_summary / prize_display 字段 - 一/二等奖标记为浮动奖(历史数据无奖池字段),不计入 total_prize 前端(index.html): - 总中奖金额字段(橙色「含浮动奖」角标) - 中奖奖项汇总表(不含浮动奖金额) - 浮动奖橙色提示条(区别于固定奖绿色 #1e8e4a) - 每注金额 badge(固定奖绿 / 浮动奖橙) 测试(tests/test_compare_prize.py): - 31 个用例,100% 通过 - 覆盖:6 档奖金档 / 未中奖 / waiting 契约 / 0 注 / 全中奖 / 仅有浮动奖 / 混合 / Mock 一致性(验收 #16)/ 响应结构契约 - running: pytest tests/test_compare_prize.py -v → 31 passed 不变项:比对算法 / 归期规则 / Excel 解析 / 认证 — 全部不动。 Co-authored-by: multica-agent --- app.py | 110 +++++++++++- index.html | 89 +++++++++- tests/__init__.py | 0 tests/test_compare_prize.py | 344 ++++++++++++++++++++++++++++++++++++ 4 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_compare_prize.py diff --git a/app.py b/app.py index 407cc3c..d4d895d 100644 --- a/app.py +++ b/app.py @@ -31,6 +31,100 @@ from lottery import DoubleColorBallGenerator app = Flask(__name__) +# ============================================================ +# 中奖金额常量表(PRD v1.2 BIZ-105 §4.3) +# ============================================================ +# PRIZE_AMOUNT: 奖项 → (单注金额, 是否浮动奖) +# 一/二等奖金额官方按奖池浮动,历史数据无奖池字段,标记为浮动, +# 不参与 total_prize 汇总,仅在 UI 用橙色角标展示。 +PRIZE_AMOUNT = { + '一等奖': (5000000, True), # 浮动:实际金额按奖池浮动 + '二等奖': (200000, True), # 浮动:实际金额按奖池浮动 + '三等奖': (3000, False), # 固定:3,000 元 + '四等奖': (200, False), # 固定:200 元 + '五等奖': (10, False), # 固定:10 元 + '六等奖': (5, False), # 固定:5 元 + '未中奖': (0, False), +} + +# PRIZE_KEY_MAP: 中文奖级 → JSON key(便于前端按固定 key 访问) +PRIZE_KEY_MAP = { + '一等奖': 'first', + '二等奖': 'second', + '三等奖': 'third', + '四等奖': 'fourth', + '五等奖': 'fifth', + '六等奖': 'sixth', +} + + +def format_amount(amount): + """金额千分位格式化(PRD v1.2 §3.2:数字紧邻「元」不空格)。 + + >>> format_amount(3000) + '3,000元' + >>> format_amount(5000000) + '5,000,000元' + >>> format_amount(0) + '0元' + """ + return f'{int(amount):,}元' + + +def compute_prize_summary(results): + """按 PRD v1.2 §4.3 计算中奖汇总。 + + 入参:results — 比对结果列表,每项含 'prize_level' 字段。 + 返回:dict,含 total_prize / has_variable_prize / prize_summary / prize_display + - total_prize: 固定奖级总金额(不含浮动) + - has_variable_prize: 是否存在一/二等奖命中 + - prize_summary: {key: {count, amount}} 仅含 count > 0 的奖项 + - prize_display: [{level, count, amount}] 浮动奖不进入展示列表 + """ + # 1. 统计各奖级命中数 + prize_counts = {} + for r in results: + level = r.get('prize_level', '未中奖') + prize_counts[level] = prize_counts.get(level, 0) + 1 + + # 2. 聚合 + total_prize = 0 + has_variable_prize = False + prize_summary = {} + prize_display = [] + + for level, count in prize_counts.items(): + if level == '未中奖' or count <= 0: + continue + amount, is_float = PRIZE_AMOUNT.get(level, (0, False)) + key = PRIZE_KEY_MAP.get(level, '') + + if is_float: + # 浮动奖:仅标记,不计入 total_prize + has_variable_prize = True + prize_summary[key] = {'count': count, 'amount': 0} + else: + # 固定奖:计入 total_prize + total_prize += amount * count + prize_summary[key] = {'count': count, 'amount': amount * count} + + # 展示列表:浮动奖不进入(按 PRD v1.2 决策 9) + if not is_float: + prize_display.append({ + 'level': level, + 'count': count, + 'amount': format_amount(amount), + }) + + return { + 'total_prize': total_prize, + 'total_prize_display': format_amount(total_prize), + 'has_variable_prize': has_variable_prize, + 'prize_summary': prize_summary, + 'prize_display': prize_display, + } + + # ============================================================ # 配置 # ============================================================ @@ -562,6 +656,9 @@ def api_compare_record(record_id): prize_level = '六等奖' prize_desc = f'{red_matches}红+1蓝' + # 该注中奖金额(PRD v1.2 §后端扩展 1) + # 浮动奖实际金额未知,按占位金额展示(前端用橙色角标) + ticket_amount, _is_float = PRIZE_AMOUNT.get(prize_level, (0, False)) results.append({ 'index': i + 1, 'reds': t['reds'], @@ -570,11 +667,16 @@ def api_compare_record(record_id): 'blue_match': blue_match, 'prize_level': prize_level, 'prize_desc': prize_desc, + 'prize_amount': ticket_amount, # 该注金额(元,整数) + 'prize_amount_display': format_amount(ticket_amount), # 该注金额(千分位字符串) 'is_win': prize_level != '未中奖' }) win_count = sum(1 for r in results if r['is_win']) + # 中奖金额汇总(PRD v1.2 §4.3 + §后端扩展 2) + prize_info = compute_prize_summary(results) + return jsonify({ 'success': True, 'data': { @@ -591,7 +693,13 @@ def api_compare_record(record_id): 'results': results, 'total': len(results), 'win_count': win_count, - 'win_tickets': [r for r in results if r['is_win']] + 'win_tickets': [r for r in results if r['is_win']], + # PRD v1.2 新增字段 + 'total_prize': prize_info['total_prize'], + 'total_prize_display': prize_info['total_prize_display'], + 'has_variable_prize': prize_info['has_variable_prize'], + 'prize_summary': prize_info['prize_summary'], + 'prize_display': prize_info['prize_display'], } }) except Exception as e: diff --git a/index.html b/index.html index 610f4f9..3e7fdc3 100644 --- a/index.html +++ b/index.html @@ -600,6 +600,64 @@ } .prize-badge.win { background: #27ae60; color: white; } .prize-badge.lose { background: #f0f0f0; color: var(--text-light); } + /* PRD v1.2 BIZ-105:每注中奖金额(紧邻「元」不空格) */ + .ticket-amount { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + margin-left: 4px; + background: rgba(30, 142, 74, 0.1); + color: #1e8e4a; + } + .ticket-amount.floating { + background: rgba(230, 126, 34, 0.1); + color: #e67e22; + } + /* PRD v1.2 BIZ-105:浮动奖顶部提示条 */ + .variable-prize-tip { + background: #fff3e0; + color: #e67e22; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + margin-top: 10px; + line-height: 1.6; + } + /* PRD v1.2 BIZ-105:奖项汇总表 */ + .prize-table-wrap { + background: #f8f9fa; + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 12px; + } + .prize-table-title { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-bottom: 8px; + } + .prize-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + .prize-table th, .prize-table td { + padding: 6px 4px; + text-align: center; + border-bottom: 1px solid #e8e8e8; + } + .prize-table th { + font-size: 12px; + color: var(--text-light); + font-weight: 500; + } + .prize-table tr:last-child td { border-bottom: none; } + .prize-table .col-level { text-align: left; color: var(--text); font-weight: 500; } + .prize-table .col-count { color: var(--text-light); } + .prize-table .col-amount { font-weight: 600; color: #1e8e4a; } + .prize-table .col-amount.floating { color: #e67e22; } .compare-ticket-info { font-size: 12px; color: var(--text-light); @@ -1400,14 +1458,36 @@ async function compareRecord(id) { const draw = d.draw; const results = d.results; const winCount = d.win_count; + // PRD v1.2 BIZ-105:中奖金额汇总字段 + const totalPrize = d.total_prize || 0; + const totalPrizeDisplay = d.total_prize_display || '0元'; + const hasVariablePrize = !!d.has_variable_prize; + const prizeDisplay = d.prize_display || []; let html = '
'; html += `
生成时间
${d.gen_time}
`; html += `
比对开奖期
${draw.period} · ${draw.date}
`; html += `
总注数
${d.total}
`; html += `
中奖注数
${winCount}
`; + html += `
总中奖金额${hasVariablePrize ? '含浮动奖' : ''}
${totalPrizeDisplay}
`; html += '
'; + // PRD v1.2 BIZ-105:按奖项分行汇总(不展示浮动奖金额) + if (prizeDisplay.length > 0) { + html += '
'; + html += '
中奖奖项汇总
'; + html += ''; + prizeDisplay.forEach(row => { + html += ``; + }); + html += '
奖级中奖注数单注金额
${row.level}${row.count} 注${row.amount}
'; + } + + // PRD v1.2 BIZ-105:浮动奖提示(一/二等奖金额仅参考,不计入总金额) + if (hasVariablePrize) { + html += '
说明:一/二等奖金额为占位值,实际按奖池浮动,不计入「总中奖金额」。
'; + } + // Show draw result html += `
开奖号码: @@ -1428,12 +1508,19 @@ async function compareRecord(id) { const prizeBadge = r.is_win ? `${r.prize_level}` : `未中奖`; + // PRD v1.2 BIZ-105:每注中奖金额(未中奖不展示) + let amountBadge = ''; + if (r.is_win) { + const isFloating = r.prize_level === '一等奖' || r.prize_level === '二等奖'; + const amountDisplay = r.prize_amount_display || `${r.prize_amount || 0}元`; + amountBadge = `${amountDisplay}`; + } const matchInfo = `红${r.red_matches} 蓝${r.blue_match ? '✓' : '✗'}`; html += `
${String(r.index).padStart(3,'0')} ${redBalls}${blueBall} - ${prizeBadge} + ${prizeBadge}${amountBadge} ${matchInfo} ${r.prize_desc || ''}
`; } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_compare_prize.py b/tests/test_compare_prize.py new file mode 100644 index 0000000..0ba14cb --- /dev/null +++ b/tests/test_compare_prize.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +BIZ-105 单测:中奖金额计算与展示(PRD v1.2) + +覆盖: +- 6 档奖金档(一/二/三/四/五/六) +- 未中奖 +- waiting 状态(接口不返回金额字段) +- 0 注 +- 全中奖 +- 仅有浮动奖 +- Mock 一致性(验收 #16):total_prize = 固定奖级 amount × count 求和 +- 边界 case:边界数字、混合、固定 + 浮动 +- format_amount 千分位 + 无空格(PRD v1.2 §3.2) +- PRIZE_AMOUNT / PRIZE_KEY_MAP 表结构(PRD v1.2 §4.3) + +运行: + cd Lottery && python3 -m pytest tests/test_compare_prize.py -v + cd Lottery && python3 tests/test_compare_prize.py +""" +import os +import sys +import unittest + +# 把项目目录加入 path,以便导入 app +HERE = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.dirname(HERE) +sys.path.insert(0, PROJECT_ROOT) + +import app # noqa: E402 + + +class TestConstants(unittest.TestCase): + """PRD v1.2 §4.3:PRIZE_AMOUNT + PRIZE_KEY_MAP 双表结构验证""" + + def test_prize_amount_keys(self): + """PRIZE_AMOUNT 必须包含全部 7 个 key(含「未中奖」)""" + expected = {'一等奖', '二等奖', '三等奖', '四等奖', '五等奖', '六等奖', '未中奖'} + self.assertEqual(set(app.PRIZE_AMOUNT.keys()), expected) + + def test_prize_amount_values_are_tuples(self): + """每个 value 必须是 (amount: int, is_float: bool) 元组""" + for level, val in app.PRIZE_AMOUNT.items(): + self.assertIsInstance(val, tuple, f'{level} value 必须是元组') + self.assertEqual(len(val), 2, f'{level} 元组必须是 2 元素') + amount, is_float = val + self.assertIsInstance(amount, int, f'{level} amount 必须是 int') + self.assertIsInstance(is_float, bool, f'{level} is_float 必须是 bool') + + def test_variable_prize_markers(self): + """一/二等奖必须标记为浮动奖(True)""" + self.assertTrue(app.PRIZE_AMOUNT['一等奖'][1]) + self.assertTrue(app.PRIZE_AMOUNT['二等奖'][1]) + + def test_fixed_prize_markers(self): + """三/四/五/六等奖 + 未中奖必须标记为固定(False)""" + for level in ('三等奖', '四等奖', '五等奖', '六等奖', '未中奖'): + self.assertFalse(app.PRIZE_AMOUNT[level][1], f'{level} 应为固定奖') + + def test_fixed_prize_amounts_match_official(self): + """三/四/五/六等奖官方金额""" + self.assertEqual(app.PRIZE_AMOUNT['三等奖'][0], 3000) + self.assertEqual(app.PRIZE_AMOUNT['四等奖'][0], 200) + self.assertEqual(app.PRIZE_AMOUNT['五等奖'][0], 10) + self.assertEqual(app.PRIZE_AMOUNT['六等奖'][0], 5) + + def test_prize_key_map_covers_all_paid_levels(self): + """PRIZE_KEY_MAP 必须覆盖 6 个有奖级(含一/二等奖)""" + expected = {'一等奖', '二等奖', '三等奖', '四等奖', '五等奖', '六等奖'} + self.assertEqual(set(app.PRIZE_KEY_MAP.keys()), expected) + + def test_prize_key_map_values_unique(self): + """PRIZE_KEY_MAP 的 value 必须唯一""" + values = list(app.PRIZE_KEY_MAP.values()) + self.assertEqual(len(values), len(set(values))) + + +class TestFormatAmount(unittest.TestCase): + """PRD v1.2 §3.2:千分位 + 「元」紧邻不空格""" + + def test_thousands_separator(self): + self.assertEqual(app.format_amount(3000), '3,000元') + self.assertEqual(app.format_amount(5000000), '5,000,000元') + self.assertEqual(app.format_amount(200), '200元') + + def test_no_space_before_yuan(self): + """数字和「元」之间不能有空格(苏锦绘建议 #1)""" + result = app.format_amount(1234) + self.assertNotIn(' 元', result) + self.assertTrue(result.endswith('元')) + + def test_zero(self): + self.assertEqual(app.format_amount(0), '0元') + + def test_small_amounts(self): + self.assertEqual(app.format_amount(5), '5元') + self.assertEqual(app.format_amount(10), '10元') + + def test_large_amounts(self): + self.assertEqual(app.format_amount(100000000), '100,000,000元') + + +class TestComputePrizeSummary(unittest.TestCase): + """compute_prize_summary 主逻辑:覆盖 PRD 验收的所有边界 case""" + + # ---------- 单奖档覆盖 ---------- + + def test_first_prize_only(self): + """仅一等奖:total_prize=0(浮动),has_variable_prize=True""" + results = [{'prize_level': '一等奖'}] * 2 + s = app.compute_prize_summary(results) + self.assertEqual(s['total_prize'], 0, '浮动奖不计入 total_prize') + self.assertTrue(s['has_variable_prize']) + self.assertEqual(s['prize_summary']['first']['count'], 2) + self.assertEqual(s['prize_display'], [], '浮动奖不进 prize_display') + + def test_second_prize_only(self): + """仅二等奖:同上逻辑""" + results = [{'prize_level': '二等奖'}] * 1 + s = app.compute_prize_summary(results) + self.assertEqual(s['total_prize'], 0) + self.assertTrue(s['has_variable_prize']) + self.assertEqual(s['prize_summary']['second']['count'], 1) + + def test_third_prize(self): + """仅三等奖:3000 × N""" + results = [{'prize_level': '三等奖'}] * 2 + s = app.compute_prize_summary(results) + self.assertEqual(s['total_prize'], 6000) + self.assertFalse(s['has_variable_prize']) + self.assertEqual(s['prize_summary']['third']['count'], 2) + self.assertEqual(s['prize_summary']['third']['amount'], 6000) + + def test_fourth_prize(self): + """仅四等奖:200 × N""" + results = [{'prize_level': '四等奖'}] * 5 + s = app.compute_prize_summary(results) + self.assertEqual(s['total_prize'], 1000) + self.assertEqual(s['prize_summary']['fourth']['count'], 5) + + def test_fifth_prize(self): + """仅五等奖:10 × N""" + results = [{'prize_level': '五等奖'}] * 3 + s = app.compute_prize_summary(results) + self.assertEqual(s['total_prize'], 30) + self.assertEqual(s['prize_summary']['fifth']['count'], 3) + + def test_sixth_prize(self): + """仅六等奖:5 × N""" + results = [{'prize_level': '六等奖'}] * 7 + s = app.compute_prize_summary(results) + self.assertEqual(s['total_prize'], 35) + self.assertEqual(s['prize_summary']['sixth']['count'], 7) + + # ---------- 未中奖 ---------- + + def test_no_win(self): + """全部未中奖:total_prize=0,无 prize_summary""" + results = [{'prize_level': '未中奖'}] * 10 + s = app.compute_prize_summary(results) + self.assertEqual(s['total_prize'], 0) + self.assertEqual(s['total_prize_display'], '0元') + self.assertFalse(s['has_variable_prize']) + self.assertEqual(s['prize_summary'], {}) + self.assertEqual(s['prize_display'], []) + + # ---------- 0 注 ---------- + + def test_zero_tickets(self): + """0 注:空 results""" + s = app.compute_prize_summary([]) + self.assertEqual(s['total_prize'], 0) + self.assertEqual(s['prize_summary'], {}) + self.assertEqual(s['prize_display'], []) + self.assertFalse(s['has_variable_prize']) + + # ---------- 全中奖 ---------- + + def test_all_win_levels(self): + """全 6 档都有命中:固定奖全计,浮动奖仅标记""" + results = ( + [{'prize_level': '一等奖'}] * 1 + + [{'prize_level': '二等奖'}] * 1 + + [{'prize_level': '三等奖'}] * 1 + + [{'prize_level': '四等奖'}] * 1 + + [{'prize_level': '五等奖'}] * 1 + + [{'prize_level': '六等奖'}] * 1 + ) + s = app.compute_prize_summary(results) + # total_prize = 3000 + 200 + 10 + 5 = 3215 + self.assertEqual(s['total_prize'], 3215) + self.assertEqual(s['total_prize_display'], '3,215元') + self.assertTrue(s['has_variable_prize']) + # prize_display 应有 4 行(无浮动) + self.assertEqual(len(s['prize_display']), 4) + levels = [r['level'] for r in s['prize_display']] + self.assertNotIn('一等奖', levels) + self.assertNotIn('二等奖', levels) + + # ---------- 仅浮动奖 ---------- + + def test_variable_prize_only(self): + """仅命中浮动奖:total_prize=0,has_variable_prize=True""" + results = [{'prize_level': '一等奖'}] * 2 + [{'prize_level': '二等奖'}] * 1 + s = app.compute_prize_summary(results) + self.assertEqual(s['total_prize'], 0) + self.assertEqual(s['total_prize_display'], '0元') + self.assertTrue(s['has_variable_prize']) + self.assertEqual(s['prize_summary']['first']['count'], 2) + self.assertEqual(s['prize_summary']['second']['count'], 1) + self.assertEqual(s['prize_display'], []) + + # ---------- Mock 一致性(验收 #16) ---------- + + def test_mock_consistency_total_prize_matches_sum(self): + """验收 #16:total_prize 必须等于所有固定奖级 amount × count 求和 + + PRD v1.2 Mock 数据笔误修正示例: + 一等奖 1 注 + 五等奖 1 注 + 六等奖 1 注 + 一等奖浮动不计 → total_prize = 10 + 5 = 15 元 + """ + results = ( + [{'prize_level': '一等奖'}] * 1 + + [{'prize_level': '五等奖'}] * 1 + + [{'prize_level': '六等奖'}] * 1 + ) + s = app.compute_prize_summary(results) + # 手工核算固定奖 + expected = 10 * 1 + 5 * 1 + self.assertEqual(s['total_prize'], expected, + f'total_prize 应等于固定奖求和:{expected}') + self.assertEqual(s['total_prize'], 15) + + def test_mock_prize_summary_amount_is_total_per_level(self): + """prize_summary[x].amount 应是该奖级 count × 单注金额(汇总值)""" + results = [{'prize_level': '五等奖'}] * 5 + [{'prize_level': '六等奖'}] * 3 + s = app.compute_prize_summary(results) + self.assertEqual(s['prize_summary']['fifth']['amount'], 50) + self.assertEqual(s['prize_summary']['sixth']['amount'], 15) + # total_prize 应等于各 level amount 之和(固定奖) + self.assertEqual(s['total_prize'], 65) + + # ---------- 混合 + 边界 ---------- + + def test_mixed_with_lots_of_tickets(self): + """大量混合注数""" + results = ( + [{'prize_level': '六等奖'}] * 100 + + [{'prize_level': '五等奖'}] * 20 + + [{'prize_level': '四等奖'}] * 5 + + [{'prize_level': '三等奖'}] * 2 + + [{'prize_level': '二等奖'}] * 1 + ) + s = app.compute_prize_summary(results) + expected = 100 * 5 + 20 * 10 + 5 * 200 + 2 * 3000 # = 500 + 200 + 1000 + 6000 = 7700 + self.assertEqual(s['total_prize'], expected) + self.assertEqual(s['total_prize_display'], '7,700元') + self.assertTrue(s['has_variable_prize']) + + def test_prize_display_excludes_variable(self): + """prize_display 一定不包含浮动奖级""" + results = [{'prize_level': '一等奖'}] * 3 + [{'prize_level': '五等奖'}] * 2 + s = app.compute_prize_summary(results) + levels = {r['level'] for r in s['prize_display']} + self.assertNotIn('一等奖', levels) + self.assertNotIn('二等奖', levels) + self.assertIn('五等奖', levels) + + def test_prize_display_amounts_are_formatted(self): + """prize_display 内 amount 是千分位字符串(非 int)""" + results = [{'prize_level': '三等奖'}] * 1 + s = app.compute_prize_summary(results) + self.assertEqual(len(s['prize_display']), 1) + self.assertEqual(s['prize_display'][0]['amount'], '3,000元') + + def test_count_zero_excluded(self): + """即使 prize_counts 含 count=0 也不进入 prize_summary""" + # 模拟异常:直接构造 + results = [{'prize_level': '未中奖'}] * 5 + s = app.compute_prize_summary(results) + # 未中奖已在 compute_prize_summary 里被显式跳过 + self.assertEqual(s['prize_summary'], {}) + + +class TestWaitingStateContract(unittest.TestCase): + """waiting 状态:接口不返回金额字段(PRD v1.2 决策 #8) + + 这是契约测试:后端代码层面 waiting 分支直接 return jsonify, + 不应包含 total_prize / prize_summary / prize_display / has_variable_prize。 + """ + + def test_waiting_branch_in_app_py(self): + """app.py 中 waiting 分支必须不含金额字段""" + with open(os.path.join(PROJECT_ROOT, 'app.py'), 'r', encoding='utf-8') as f: + src = f.read() + # 找到 waiting 早返回 + idx = src.find("compare_type == 'waiting'") + self.assertGreater(idx, 0, '未找到 waiting 分支') + # 找下一个 return jsonify 的右括号位置 + ret_idx = src.find("'compare_rule': '生成时间 ≥ 开奖日 20:00", idx) + self.assertGreater(ret_idx, idx) + waiting_block = src[idx:ret_idx + 200] + # 不应包含新字段 + for forbidden in ('total_prize', 'prize_summary', 'prize_display', + 'has_variable_prize', 'prize_amount'): + self.assertNotIn(forbidden, waiting_block, + f'waiting 分支不应包含 {forbidden}') + + +class TestResponseShapeContract(unittest.TestCase): + """响应结构契约:compared 分支必须包含所有新字段(向后兼容)""" + + def test_compared_response_has_all_new_fields(self): + """app.py 中 compared return dict 必须含 5 个新顶层字段""" + with open(os.path.join(PROJECT_ROOT, 'app.py'), 'r', encoding='utf-8') as f: + src = f.read() + # 找到 'status': 'compared' 块 + idx = src.find("'status': 'compared'") + self.assertGreater(idx, 0) + # 取到下一个空行 + 下个函数(或本 return 结束)前的所有内容 + # 用下一个 '}\n })' 形式判断 return jsonify 结束 + ret_idx = src.find("'has_variable_prize': prize_info", idx) + self.assertGreater(ret_idx, idx) + # 取 has_variable_prize 之后 200 字符(足够覆盖 prize_display 行) + block = src[idx:ret_idx + 300] + for required in ('total_prize', 'total_prize_display', + 'has_variable_prize', 'prize_summary', 'prize_display'): + self.assertIn(required, block, + f'compared 响应缺少 {required}') + + def test_per_result_has_prize_amount(self): + """每条 result 必须含 prize_amount + prize_amount_display""" + with open(os.path.join(PROJECT_ROOT, 'app.py'), 'r', encoding='utf-8') as f: + src = f.read() + idx = src.find("'prize_desc': prize_desc") + self.assertGreater(idx, 0) + block = src[idx:idx + 500] + self.assertIn('prize_amount', block) + self.assertIn('prize_amount_display', block) + + +if __name__ == '__main__': + unittest.main(verbosity=2)