feat(BIZ-105): 记录-比对 中奖金额展示 v1.2 实施

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 <github@multica.ai>
This commit is contained in:
徐聪
2026-08-31 04:45:06 +08:00
parent e4e66f3826
commit 5d459b4505
4 changed files with 541 additions and 2 deletions
+344
View File
@@ -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.3PRIZE_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=0has_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):
"""验收 #16total_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)