- Add company module (3-tier org, CEO planning, parallel departments) - Add company orchestrator, knowledge extractor, presets, scheduler - Add company API endpoints, models, and frontend views - Add 今天吃啥 PWA app (69 dishes, real images, offline support) - Add team_projects output directory structure - Add unified manage.ps1 for service lifecycle - Add Windows startup guide v1.0 - Add TTS troubleshooting doc - Update frontend (AgentChat UX overhaul, new views) - Update backend (voice engine fix, multi-tenant, RBAC) - Remove deprecated startup scripts and old docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
586 lines
22 KiB
Python
586 lines
22 KiB
Python
# ============================================================================
|
||
# 家庭阅读激励工具 - 业务层单元测试
|
||
# ============================================================================
|
||
"""
|
||
Phase 2 业务逻辑层测试套件。
|
||
|
||
测试范围:
|
||
1. PointsConfig - 配置加载/保存/默认值
|
||
2. PointsEngine - 积分计算/打卡积分/奖励/调整/每日上限
|
||
3. RedemptionEngine - 兑换资格检查/兑换事务/取消退还/库存扣减
|
||
4. ChildContext - 切换孩子/数据隔离/统计概览
|
||
|
||
运行方式:
|
||
python 12-test_engine.py
|
||
|
||
前置条件:
|
||
需要先运行种子数据或手动初始化数据库
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import unittest
|
||
import tempfile
|
||
import shutil
|
||
from datetime import date, timedelta
|
||
|
||
# 确保导入路径正确
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
|
||
class PointsEngineTest(unittest.TestCase):
|
||
"""积分引擎测试。"""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""初始化测试数据库环境。"""
|
||
# 使用临时数据库,不污染开发数据
|
||
cls._tmpdir = tempfile.mkdtemp(prefix="reading_test_")
|
||
cls._orig_db_dir = os.environ.get("READING_INCENTIVE_DB_DIR", "")
|
||
|
||
os.environ["READING_INCENTIVE_DB_DIR"] = cls._tmpdir
|
||
|
||
# 重新加载模块以使用新路径
|
||
import importlib
|
||
import db_config
|
||
importlib.reload(db_config)
|
||
|
||
# 初始化 Schema
|
||
schema_path = os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)), "01-schema.sql"
|
||
)
|
||
conn = db_config.get_connection()
|
||
with open(schema_path, "r", encoding="utf-8") as f:
|
||
conn.executescript(f.read())
|
||
conn.commit()
|
||
|
||
# 导入 DAL 和引擎(在环境变量设置之后)
|
||
from dal import ChildrenDAO, BooksDAO, ReadingRecordsDAO, RewardsDAO
|
||
cls.ChildrenDAO = ChildrenDAO
|
||
cls.BooksDAO = BooksDAO
|
||
cls.ReadingRecordsDAO = ReadingRecordsDAO
|
||
cls.RewardsDAO = RewardsDAO
|
||
|
||
from points_config import PointsConfig
|
||
from points_engine import PointsEngine, PointsBreakdown
|
||
from redemption_engine import RedemptionEngine, EligibilityResult, RedemptionResult
|
||
from child_context import ChildContext, ChildStats, FamilyStats
|
||
|
||
cls.PointsConfig = PointsConfig
|
||
cls.PointsEngine = PointsEngine
|
||
cls.PointsBreakdown = PointsBreakdown
|
||
cls.RedemptionEngine = RedemptionEngine
|
||
cls.EligibilityResult = EligibilityResult
|
||
cls.RedemptionResult = RedemptionResult
|
||
cls.ChildContext = ChildContext
|
||
cls.ChildStats = ChildStats
|
||
cls.FamilyStats = FamilyStats
|
||
|
||
@classmethod
|
||
def tearDownClass(cls):
|
||
"""清理临时数据库。"""
|
||
# 恢复环境变量
|
||
if cls._orig_db_dir:
|
||
os.environ["READING_INCENTIVE_DB_DIR"] = cls._orig_db_dir
|
||
else:
|
||
os.environ.pop("READING_INCENTIVE_DB_DIR", None)
|
||
|
||
shutil.rmtree(cls._tmpdir, ignore_errors=True)
|
||
|
||
def setUp(self):
|
||
"""每个测试前准备:创建基础数据。"""
|
||
self.xiaoming = self.ChildrenDAO.create(name="小明", age=7)
|
||
self.xiaohong = self.ChildrenDAO.create(name="小红", age=9)
|
||
self.book1 = self.BooksDAO.create(title="好饿的毛毛虫", total_pages=32)
|
||
self.book2 = self.BooksDAO.create(title="小王子", total_pages=120)
|
||
self.book3 = self.BooksDAO.create(title="夏洛的网", total_pages=176)
|
||
|
||
self.config = self.PointsConfig.load()
|
||
self.engine = self.PointsEngine
|
||
self.redemption = self.RedemptionEngine
|
||
|
||
# ------------------------------------------------------------------
|
||
# PointsConfig 测试
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_config_load_defaults(self):
|
||
"""配置加载:默认值正确。"""
|
||
config = self.PointsConfig.load()
|
||
self.assertEqual(config.base_points, 10)
|
||
self.assertTrue(config.streak_enabled)
|
||
self.assertEqual(config.duration_unit_minutes, 10)
|
||
self.assertEqual(config.max_daily_points, 50)
|
||
|
||
def test_config_save_and_reload(self):
|
||
"""配置保存:保存后重载值一致。"""
|
||
config = self.PointsConfig.load()
|
||
config.base_points = 15
|
||
config.streak_enabled = False
|
||
config.save()
|
||
|
||
reloaded = self.PointsConfig.load()
|
||
self.assertEqual(reloaded.base_points, 15)
|
||
self.assertFalse(reloaded.streak_enabled)
|
||
|
||
def test_config_to_dict(self):
|
||
"""配置导出:to_dict 返回完整字段。"""
|
||
d = self.config.to_dict()
|
||
self.assertIn("base_points", d)
|
||
self.assertIn("streak_enabled", d)
|
||
self.assertIn("max_daily_points", d)
|
||
self.assertEqual(d["base_points"], 10)
|
||
|
||
# ------------------------------------------------------------------
|
||
# PointsEngine.calculate_expected_points 测试
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_calculate_basic(self):
|
||
"""积分计算:仅基础分。"""
|
||
result = self.engine.calculate_expected_points(
|
||
duration_minutes=0, pages_read=0, streak_days=0, config=self.config
|
||
)
|
||
self.assertEqual(result.base, 10)
|
||
self.assertEqual(result.total, 10)
|
||
self.assertFalse(result.capped)
|
||
|
||
def test_calculate_with_duration(self):
|
||
"""积分计算:含时长加成。"""
|
||
result = self.engine.calculate_expected_points(
|
||
duration_minutes=30, pages_read=0, streak_days=0, config=self.config
|
||
)
|
||
# 30分钟 / 10分钟每单位 * 2分 = 6分时长加成
|
||
self.assertEqual(result.duration_bonus, 6)
|
||
self.assertEqual(result.total, 16)
|
||
|
||
def test_calculate_duration_cap(self):
|
||
"""积分计算:时长加成不超过上限。"""
|
||
result = self.engine.calculate_expected_points(
|
||
duration_minutes=120, pages_read=0, streak_days=0, config=self.config
|
||
)
|
||
# 120分钟 → 12单位 * 2 = 24,但上限 20
|
||
self.assertEqual(result.duration_bonus, 20)
|
||
|
||
def test_calculate_with_pages(self):
|
||
"""积分计算:含页数加成。"""
|
||
result = self.engine.calculate_expected_points(
|
||
duration_minutes=0, pages_read=60, streak_days=0, config=self.config
|
||
)
|
||
# 60页 / 20页每分 = 3分
|
||
self.assertEqual(result.pages_bonus, 3)
|
||
self.assertEqual(result.total, 13)
|
||
|
||
def test_calculate_streak_7(self):
|
||
"""积分计算:连续7天加成。"""
|
||
# streak_days=6 表示打卡前连续6天,本次是第7天
|
||
result = self.engine.calculate_expected_points(
|
||
duration_minutes=0, pages_read=0, streak_days=6, config=self.config
|
||
)
|
||
self.assertEqual(result.streak_bonus, 5)
|
||
self.assertEqual(result.streak_days, 7)
|
||
|
||
def test_calculate_streak_30(self):
|
||
"""积分计算:连续30天加成。"""
|
||
result = self.engine.calculate_expected_points(
|
||
duration_minutes=0, pages_read=0, streak_days=29, config=self.config
|
||
)
|
||
self.assertEqual(result.streak_bonus, 20)
|
||
self.assertEqual(result.streak_days, 30)
|
||
|
||
def test_calculate_daily_cap(self):
|
||
"""积分计算:超过每日上限被截断。"""
|
||
result = self.engine.calculate_expected_points(
|
||
duration_minutes=120, pages_read=200, streak_days=29, config=self.config
|
||
)
|
||
# base=10 + streak=20 + duration=20(capped) + pages=10(capped) = 60
|
||
# 但 max_daily=50,最终 capped=True
|
||
self.assertTrue(result.capped)
|
||
self.assertEqual(result.total, 50)
|
||
|
||
def test_calculate_zero_duration(self):
|
||
"""积分计算:0分钟只拿基础分。"""
|
||
result = self.engine.calculate_expected_points(
|
||
duration_minutes=0, pages_read=0, streak_days=0, config=self.config
|
||
)
|
||
self.assertEqual(result.duration_bonus, 0)
|
||
self.assertEqual(result.pages_bonus, 0)
|
||
|
||
# ------------------------------------------------------------------
|
||
# PointsEngine.add_reading_points 测试
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_add_reading_points_basic(self):
|
||
"""打卡积分:第一次打卡获得基础分。"""
|
||
record = self.ReadingRecordsDAO.create(
|
||
child_id=self.xiaoming.id,
|
||
book_id=self.book1.id,
|
||
read_date=date.today().isoformat(),
|
||
duration_minutes=20,
|
||
pages_read=10,
|
||
)
|
||
|
||
result = self.engine.add_reading_points(self.xiaoming.id, record.id, self.config)
|
||
self.assertGreater(result.total, 0)
|
||
self.assertEqual(result.base, 10)
|
||
|
||
# 验证余额已更新
|
||
balance = self.engine.get_balance(self.xiaoming.id)
|
||
self.assertEqual(balance, result.total)
|
||
|
||
def test_add_reading_points_updates_balance(self):
|
||
"""打卡积分:两次打卡后余额累计正确。"""
|
||
record1 = self.ReadingRecordsDAO.create(
|
||
child_id=self.xiaoming.id,
|
||
book_id=self.book1.id,
|
||
read_date=date.today().isoformat(),
|
||
duration_minutes=20,
|
||
pages_read=10,
|
||
)
|
||
result1 = self.engine.add_reading_points(self.xiaoming.id, record1.id, self.config)
|
||
|
||
# 换一本书再次打卡(同一天允许不同书)
|
||
record2 = self.ReadingRecordsDAO.create(
|
||
child_id=self.xiaoming.id,
|
||
book_id=self.book2.id,
|
||
read_date=date.today().isoformat(),
|
||
duration_minutes=15,
|
||
pages_read=5,
|
||
)
|
||
result2 = self.engine.add_reading_points(self.xiaoming.id, record2.id, self.config)
|
||
|
||
balance = self.engine.get_balance(self.xiaoming.id)
|
||
self.assertEqual(balance, result1.total + result2.total)
|
||
|
||
def test_add_reading_points_daily_cap(self):
|
||
"""打卡积分:超过每日上限后不再发放。"""
|
||
# 使用已调整的配置突破上限更可控
|
||
self.config.max_daily_points = 10
|
||
self.config.base_points = 10
|
||
self.config.duration_enabled = False
|
||
self.config.pages_enabled = False
|
||
|
||
record1 = self.ReadingRecordsDAO.create(
|
||
child_id=self.xiaoming.id,
|
||
book_id=self.book1.id,
|
||
read_date=date.today().isoformat(),
|
||
duration_minutes=30,
|
||
)
|
||
result1 = self.engine.add_reading_points(self.xiaoming.id, record1.id, self.config)
|
||
self.assertEqual(result1.total, 10)
|
||
|
||
# 换书再打卡
|
||
record2 = self.ReadingRecordsDAO.create(
|
||
child_id=self.xiaoming.id,
|
||
book_id=self.book2.id,
|
||
read_date=date.today().isoformat(),
|
||
duration_minutes=30,
|
||
)
|
||
result2 = self.engine.add_reading_points(self.xiaoming.id, record2.id, self.config)
|
||
self.assertEqual(result2.total, 0)
|
||
self.assertTrue(result2.capped)
|
||
|
||
def test_add_reading_points_invalid_record(self):
|
||
"""打卡积分:记录不存在抛出异常。"""
|
||
with self.assertRaises(ValueError):
|
||
self.engine.add_reading_points(self.xiaoming.id, 99999, self.config)
|
||
|
||
def test_add_reading_points_wrong_child(self):
|
||
"""打卡积分:记录归属不匹配抛出异常。"""
|
||
record = self.ReadingRecordsDAO.create(
|
||
child_id=self.xiaoming.id,
|
||
book_id=self.book1.id,
|
||
read_date=date.today().isoformat(),
|
||
duration_minutes=20,
|
||
)
|
||
with self.assertRaises(ValueError):
|
||
self.engine.add_reading_points(self.xiaohong.id, record.id, self.config)
|
||
|
||
# ------------------------------------------------------------------
|
||
# PointsEngine.add_bonus_points 测试
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_bonus_points_positive(self):
|
||
"""奖励积分:正向奖励。"""
|
||
new_balance = self.engine.add_bonus_points(self.xiaoming.id, 50, "表现好")
|
||
self.assertEqual(new_balance, 50)
|
||
|
||
def test_bonus_points_invalid_amount(self):
|
||
"""奖励积分:负值/零值抛出异常。"""
|
||
with self.assertRaises(ValueError):
|
||
self.engine.add_bonus_points(self.xiaoming.id, -10)
|
||
|
||
# ------------------------------------------------------------------
|
||
# PointsEngine.adjust_points 测试
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_adjust_points_positive(self):
|
||
"""调整积分:正向调整。"""
|
||
self.engine.add_bonus_points(self.xiaoming.id, 30)
|
||
new_balance = self.engine.adjust_points(self.xiaoming.id, 20, "补发")
|
||
self.assertEqual(new_balance, 50)
|
||
|
||
def test_adjust_points_negative_below_zero(self):
|
||
"""调整积分:扣减不允许为负。"""
|
||
new_balance = self.engine.adjust_points(self.xiaoming.id, -100, "扣罚")
|
||
self.assertEqual(new_balance, 0)
|
||
|
||
# ------------------------------------------------------------------
|
||
# RedemptionEngine 测试
|
||
# ------------------------------------------------------------------
|
||
|
||
def _setup_rewards(self):
|
||
"""创建测试奖品。"""
|
||
self.reward_cheap = self.RewardsDAO.create(
|
||
name="多看15分钟动画片", points_required=20, stock=-1
|
||
)
|
||
self.reward_expensive = self.RewardsDAO.create(
|
||
name="去游乐场", points_required=100, stock=-1
|
||
)
|
||
self.reward_limited = self.RewardsDAO.create(
|
||
name="挑选一本新书", points_required=30, stock=2
|
||
)
|
||
|
||
def test_check_eligibility_insufficient_points(self):
|
||
"""兑换资格:积分不足。"""
|
||
self._setup_rewards()
|
||
result = self.redemption.check_eligibility(
|
||
self.xiaoming.id, self.reward_expensive.id
|
||
)
|
||
self.assertFalse(result.eligible)
|
||
self.assertIn("积分不足", result.reason)
|
||
|
||
def test_check_eligibility_sufficient_points(self):
|
||
"""兑换资格:积分充足。"""
|
||
self._setup_rewards()
|
||
self.engine.add_bonus_points(self.xiaoming.id, 50)
|
||
result = self.redemption.check_eligibility(
|
||
self.xiaoming.id, self.reward_cheap.id
|
||
)
|
||
self.assertTrue(result.eligible)
|
||
|
||
def test_check_eligibility_inactive_reward(self):
|
||
"""兑换资格:奖品已下架。"""
|
||
self._setup_rewards()
|
||
# 先给予足够积分
|
||
self.engine.add_bonus_points(self.xiaoming.id, 50)
|
||
# 下架奖品
|
||
from dal import RewardsDAO
|
||
RewardsDAO.update(self.reward_cheap.id, is_active=0)
|
||
|
||
result = self.redemption.check_eligibility(
|
||
self.xiaoming.id, self.reward_cheap.id
|
||
)
|
||
self.assertFalse(result.eligible)
|
||
|
||
def test_redeem_success(self):
|
||
"""兑换操作:成功兑换。"""
|
||
self._setup_rewards()
|
||
self.engine.add_bonus_points(self.xiaoming.id, 50)
|
||
|
||
result = self.redemption.redeem(
|
||
self.xiaoming.id, self.reward_cheap.id, notes="想要红色"
|
||
)
|
||
self.assertTrue(result.success)
|
||
self.assertIsNotNone(result.redemption_record)
|
||
self.assertEqual(result.new_balance, 30) # 50 - 20
|
||
|
||
# 验证积分已扣减
|
||
balance = self.engine.get_balance(self.xiaoming.id)
|
||
self.assertEqual(balance, 30)
|
||
|
||
def test_redeem_insufficient_points(self):
|
||
"""兑换操作:积分不足返回失败。"""
|
||
self._setup_rewards()
|
||
result = self.redemption.redeem(self.xiaoming.id, self.reward_cheap.id)
|
||
self.assertFalse(result.success)
|
||
self.assertIn("积分不足", result.error)
|
||
|
||
def test_redeem_limited_stock(self):
|
||
"""兑换操作:限量库存正常扣减。"""
|
||
self._setup_rewards()
|
||
self.engine.add_bonus_points(self.xiaoming.id, 100)
|
||
|
||
# 兑换第1次
|
||
result1 = self.redemption.redeem(
|
||
self.xiaoming.id, self.reward_limited.id
|
||
)
|
||
self.assertTrue(result1.success)
|
||
|
||
# 兑换第2次
|
||
result2 = self.redemption.redeem(
|
||
self.xiaoming.id, self.reward_limited.id
|
||
)
|
||
self.assertTrue(result2.success)
|
||
|
||
# 兑换第3次(库存已耗尽)
|
||
self.engine.add_bonus_points(self.xiaoming.id, 100)
|
||
# 用小红来兑换剩余库存(小明已用完)
|
||
from dal import RewardsDAO
|
||
available = RewardsDAO.get_available_stock(self.reward_limited.id)
|
||
# 两个pending占用了库存
|
||
self.assertEqual(available, 0)
|
||
|
||
def test_cancel_redemption_refunds_points(self):
|
||
"""取消兑换:退还积分。"""
|
||
self._setup_rewards()
|
||
self.engine.add_bonus_points(self.xiaoming.id, 50)
|
||
redeem_result = self.redemption.redeem(
|
||
self.xiaoming.id, self.reward_cheap.id
|
||
)
|
||
self.assertTrue(redeem_result.success)
|
||
|
||
# 取消兑换
|
||
cancel_result = self.redemption.cancel_redemption(
|
||
redeem_result.redemption_record.id
|
||
)
|
||
self.assertTrue(cancel_result.success)
|
||
self.assertEqual(cancel_result.new_balance, 50)
|
||
|
||
# 验证积分已退还
|
||
balance = self.engine.get_balance(self.xiaoming.id)
|
||
self.assertEqual(balance, 50)
|
||
|
||
def test_fulfill_redemption(self):
|
||
"""确认发放:pending → fulfilled。"""
|
||
self._setup_rewards()
|
||
self.engine.add_bonus_points(self.xiaoming.id, 50)
|
||
redeem_result = self.redemption.redeem(
|
||
self.xiaoming.id, self.reward_cheap.id
|
||
)
|
||
|
||
fulfill_result = self.redemption.fulfill(redeem_result.redemption_record.id)
|
||
self.assertTrue(fulfill_result.success)
|
||
self.assertEqual(fulfill_result.redemption_record.status, "fulfilled")
|
||
|
||
# ------------------------------------------------------------------
|
||
# ChildContext 测试
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_switch_to_valid_child(self):
|
||
"""上下文切换:切换到有效的孩子。"""
|
||
ctx = self.ChildContext()
|
||
child = ctx.switch_to(self.xiaoming.id)
|
||
self.assertEqual(child.id, self.xiaoming.id)
|
||
self.assertEqual(ctx.current_child_id, self.xiaoming.id)
|
||
|
||
def test_switch_to_invalid_child(self):
|
||
"""上下文切换:切换不存在的孩子抛出异常。"""
|
||
ctx = self.ChildContext()
|
||
with self.assertRaises(ValueError):
|
||
ctx.switch_to(99999)
|
||
|
||
def test_switch_to_archived_child(self):
|
||
"""上下文切换:切换已归档孩子抛出异常。"""
|
||
self.ChildrenDAO.archive(self.xiaoming.id)
|
||
ctx = self.ChildContext()
|
||
with self.assertRaises(ValueError):
|
||
ctx.switch_to(self.xiaoming.id)
|
||
|
||
def test_switch_to_first_active(self):
|
||
"""上下文切换:自动选择第一个活跃孩子。"""
|
||
ctx = self.ChildContext()
|
||
child = ctx.switch_to_first_active()
|
||
self.assertIsNotNone(child)
|
||
self.assertTrue(child.is_active)
|
||
|
||
def test_context_without_selection_raises(self):
|
||
"""上下文:未选择孩子时调用查询抛出异常。"""
|
||
ctx = self.ChildContext()
|
||
with self.assertRaises(RuntimeError):
|
||
ctx.get_reading_records()
|
||
|
||
def test_get_stats(self):
|
||
"""统计:ChildStats 返回正确数据。"""
|
||
# 创建一些阅读记录
|
||
record = self.ReadingRecordsDAO.create(
|
||
child_id=self.xiaoming.id,
|
||
book_id=self.book1.id,
|
||
read_date=date.today().isoformat(),
|
||
duration_minutes=30,
|
||
pages_read=10,
|
||
)
|
||
self.engine.add_reading_points(self.xiaoming.id, record.id, self.config)
|
||
|
||
stats = self.ChildContext.get_child_stats(self.xiaoming.id)
|
||
self.assertEqual(stats.child_name, "小明")
|
||
self.assertEqual(stats.total_readings, 1)
|
||
self.assertGreater(stats.total_points, 0)
|
||
self.assertTrue(stats.today_read)
|
||
self.assertGreaterEqual(stats.this_week_readings, 1)
|
||
|
||
def test_family_stats(self):
|
||
"""统计:FamilyStats 包含所有活跃孩子。"""
|
||
family = self.ChildContext.get_family_stats()
|
||
self.assertEqual(family.active_children, 2)
|
||
self.assertEqual(len(family.children_stats), 2)
|
||
names = [s.child_name for s in family.children_stats]
|
||
self.assertIn("小明", names)
|
||
self.assertIn("小红", names)
|
||
|
||
def test_data_isolation(self):
|
||
"""数据隔离:切换孩子后查询结果隔离。"""
|
||
# 小明打卡
|
||
record_m = self.ReadingRecordsDAO.create(
|
||
child_id=self.xiaoming.id,
|
||
book_id=self.book1.id,
|
||
read_date=date.today().isoformat(),
|
||
duration_minutes=20,
|
||
)
|
||
self.engine.add_reading_points(self.xiaoming.id, record_m.id, self.config)
|
||
|
||
# 小红打卡
|
||
record_h = self.ReadingRecordsDAO.create(
|
||
child_id=self.xiaohong.id,
|
||
book_id=self.book2.id,
|
||
read_date=date.today().isoformat(),
|
||
duration_minutes=30,
|
||
)
|
||
self.engine.add_reading_points(self.xiaohong.id, record_h.id, self.config)
|
||
|
||
ctx = self.ChildContext()
|
||
|
||
# 切换小明
|
||
ctx.switch_to(self.xiaoming.id)
|
||
xm_records = ctx.get_reading_records()
|
||
xm_book_ids = [r.book_id for r in xm_records]
|
||
self.assertIn(self.book1.id, xm_book_ids)
|
||
self.assertNotIn(self.book2.id, xm_book_ids)
|
||
|
||
# 切换小红
|
||
ctx.switch_to(self.xiaohong.id)
|
||
xh_records = ctx.get_reading_records()
|
||
xh_book_ids = [r.book_id for r in xh_records]
|
||
self.assertIn(self.book2.id, xh_book_ids)
|
||
self.assertNotIn(self.book1.id, xh_book_ids)
|
||
|
||
def test_list_active_children(self):
|
||
"""孩子列表:不包含已归档孩子。"""
|
||
self.ChildrenDAO.archive(self.xiaoming.id)
|
||
active = self.ChildContext.list_active_children()
|
||
names = [c.name for c in active]
|
||
self.assertNotIn("小明", names)
|
||
self.assertIn("小红", names)
|
||
|
||
def test_list_all_children_includes_archived(self):
|
||
"""孩子列表:包含已归档孩子。"""
|
||
self.ChildrenDAO.archive(self.xiaoming.id)
|
||
all_children = self.ChildContext.list_all_children()
|
||
names = [c.name for c in all_children]
|
||
self.assertIn("小明", names)
|
||
|
||
def test_redeem_through_context(self):
|
||
"""集成:通过 ChildContext 获取可兑换奖品。"""
|
||
self._setup_rewards()
|
||
self.engine.add_bonus_points(self.xiaoming.id, 50)
|
||
|
||
ctx = self.ChildContext(self.xiaoming.id)
|
||
rewards = ctx.get_available_rewards()
|
||
self.assertGreater(len(rewards), 0)
|
||
|
||
# 验证有可兑换的奖品
|
||
eligible = [r for r in rewards if r["can_redeem"]]
|
||
self.assertGreater(len(eligible), 0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main(verbosity=2)
|