#!/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)