Files
aiagent/team_projects/b9b14925-f054-43d0-b2f0-6ed6b38dbe4b/Company-context-SQLiteFlutter-React-Nati/12-test_engine.py
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- 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>
2026-07-10 22:37:56 +08:00

586 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ============================================================================
# 家庭阅读激励工具 - 业务层单元测试
# ============================================================================
"""
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)