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 += `
`;
html += `
比对开奖期
${draw.period} · ${draw.date}
`;
html += `
`;
html += `
`;
+ html += `
总中奖金额${hasVariablePrize ? '含浮动奖' : ''}
${totalPrizeDisplay}
`;
html += '
';
+ // PRD v1.2 BIZ-105:按奖项分行汇总(不展示浮动奖金额)
+ if (prizeDisplay.length > 0) {
+ html += '';
+ html += '
中奖奖项汇总
';
+ html += '
| 奖级 | 中奖注数 | 单注金额 |
';
+ prizeDisplay.forEach(row => {
+ html += `| ${row.level} | ${row.count} 注 | ${row.amount} |
`;
+ });
+ html += '
';
+ }
+
+ // 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)