Files
aiagent/team_projects/b9b14925-f054-43d0-b2f0-6ed6b38dbe4b/Company-context-SQLiteFlutter-React-Nati/13-test_integration.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

801 lines
32 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 5)
# ============================================================================
"""
集成测试:覆盖完整业务流程的全链路验证。
测试场景:
S1. 多孩子切换全流程 — 创建2个孩子 → 分别打卡 → 切换验证数据隔离 → 积分独立
S2. 照片存储全流程 — 导入 → 存储 → 缩略图 → 查询 → 删除 → 空间回收
S3. 积分边界值测试 — 0积分、最大积分、连续打卡中断重置、每日上限
S4. 兑换完整生命周期 — 兑换 → 待处理 → 确认发放 → 取消退还 → 余额校验
S5. 并发安全 — 事务回滚 / 积分原子操作
运行方式:
python 13-test_integration.py
前置条件:
pip install Pillow>=10.0 (仅照片测试需要)
"""
import os
import sys
import unittest
import tempfile
import shutil
import sqlite3
from datetime import date, timedelta
# --- 路径设置 ---
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, TEST_DIR)
# --- 使用临时数据库 ---
TEST_DB_DIR = tempfile.mkdtemp(prefix="reading_integration_")
os.environ["READING_INCENTIVE_DB_DIR"] = TEST_DB_DIR
os.environ["READING_INCENTIVE_DB_NAME"] = "test_integration.db"
import importlib
import db_config
importlib.reload(db_config)
from db_config import get_connection, transaction, DB_PATH
from dal import (
ChildDAO, BookDAO, ReadingRecordDAO, PointDAO,
RewardDAO, RedemptionDAO, AppSettingDAO,
)
from models import Child, Book, ReadingRecord
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
# ============================================================================
# 集成测试基类
# ============================================================================
class IntegrationTestBase(unittest.TestCase):
"""集成测试基类:统一数据库初始化 + 种子数据。"""
@classmethod
def setUpClass(cls):
"""一次初始化 Schema。"""
schema_path = os.path.join(TEST_DIR, "01-schema.sql")
conn = db_config.get_connection()
with open(schema_path, "r", encoding="utf-8") as f:
conn.executescript(f.read())
conn.commit()
conn.close()
# 初始化积分配置表
PointsConfig.load() # 自动创建配置表及默认值
# 实例化 DAO 和引擎
cls.child_dao = ChildDAO()
cls.book_dao = BookDAO()
cls.reading_dao = ReadingRecordDAO()
cls.point_dao = PointDAO()
cls.reward_dao = RewardDAO()
cls.redemption_dao = RedemptionDAO()
cls.engine = PointsEngine
cls.redemption = RedemptionEngine
cls.config = PointsConfig
cls.context = ChildContext
@classmethod
def tearDownClass(cls):
"""清理测试数据库。"""
if os.path.exists(DB_PATH):
try:
os.remove(DB_PATH)
except PermissionError:
pass
if os.path.exists(TEST_DB_DIR):
shutil.rmtree(TEST_DB_DIR, ignore_errors=True)
def setUp(self):
"""每个测试前清空数据。"""
conn = get_connection()
tables = [
"photos", "redemption_records", "rewards",
"points", "reading_records", "books", "children",
]
for t in tables:
conn.execute(f"DELETE FROM {t}")
conn.execute("DELETE FROM sqlite_sequence")
conn.commit()
conn.close()
# 创建测试孩子和书籍
self.child_a = self.child_dao.create("小明", age=8)
self.child_b = self.child_dao.create("小红", age=6)
self.book_1 = self.book_dao.create("小王子", author="圣埃克苏佩里", page_count=120)
self.book_2 = self.book_dao.create("夏洛的网", author="E·B·怀特", page_count=184)
self.book_3 = self.book_dao.create("西游记少儿版", author="吴承恩", page_count=96)
# 创建测试奖品
self.reward_cheap = self.reward_dao.create("多看15分钟动画片", points_required=20, stock=-1)
self.reward_medium = self.reward_dao.create("挑选一本新绘本", points_required=50, stock=5)
self.reward_expensive = self.reward_dao.create("周末去游乐园", points_required=100, stock=-1)
# ---- 工具方法 ----
def _create_reading(self, child: Child, book: Book, days_ago: int = 0,
duration: int = 20, pages: int = 10) -> ReadingRecord:
"""创建打卡记录(无积分发放)。"""
d = (date.today() - timedelta(days=days_ago)).isoformat()
return self.reading_dao.create(
child_id=child.id, book_id=book.id,
read_date=d, duration_minutes=duration,
pages_read=pages, award_points=False,
)
# ============================================================================
# S1. 多孩子切换全流程
# ============================================================================
class TestMultiChildSwitch(IntegrationTestBase):
"""
S1. 多孩子切换全流程
场景描述:
1. 创建2个孩子(小明、小红)
2. 小明连续打卡3天,获得积分
3. 小红打卡1天,获得积分
4. 使用 ChildContext 切换孩子,验证数据隔离
5. 验证各自积分独立,互不影响
"""
def test_s1_full_flow(self):
"""S1-1: 完整多孩子切换流程 — 数据完全隔离"""
# === 小明连续3天打卡 ===
for i in range(3):
record = self._create_reading(self.child_a, self.book_1, days_ago=i,
duration=20 + i * 5, pages=10 + i * 3)
self.engine.add_reading_points(self.child_a.id, record.id)
# === 小红打卡1天 ===
record_b = self._create_reading(self.child_b, self.book_2, days_ago=0,
duration=30, pages=15)
self.engine.add_reading_points(self.child_b.id, record_b.id)
# === 验证各自积分 ===
balance_a = self.engine.get_balance(self.child_a.id)
balance_b = self.engine.get_balance(self.child_b.id)
self.assertGreater(balance_a, 0, "小明应有积分")
self.assertGreater(balance_b, 0, "小红应有积分")
self.assertNotEqual(balance_a, balance_b, "两个孩子积分应不同")
# === ChildContext 切换小明 ===
ctx = ChildContext()
ctx.switch_to(self.child_a.id)
a_records = ctx.get_reading_records()
self.assertEqual(len(a_records), 3, "小明应有3条打卡记录")
for r in a_records:
self.assertEqual(r.child_id, self.child_a.id,
"小明上下文中不应出现小红的数据")
# === 切换小红 ===
ctx.switch_to(self.child_b.id)
b_records = ctx.get_reading_records()
self.assertEqual(len(b_records), 1, "小红应有1条打卡记录")
for r in b_records:
self.assertEqual(r.child_id, self.child_b.id,
"小红上下文中不应出现小明数据")
# === 统计隔离 ===
stats_a = self.context.get_child_stats(self.child_a.id)
stats_b = self.context.get_child_stats(self.child_b.id)
self.assertEqual(stats_a.total_readings, 3)
self.assertEqual(stats_b.total_readings, 1)
self.assertEqual(stats_a.total_minutes,
sum([20, 25, 30]), "小明细读时长应累计")
self.assertEqual(stats_b.total_minutes, 30)
def test_s1_balance_independence(self):
"""S1-2: 积分余额各自独立 — 扣减不影响对方"""
# 小明 +50,小红 +30
self.engine.add_bonus_points(self.child_a.id, 50)
self.engine.add_bonus_points(self.child_b.id, 30)
self.assertEqual(self.engine.get_balance(self.child_a.id), 50)
self.assertEqual(self.engine.get_balance(self.child_b.id), 30)
# 小明兑换消耗20分
result = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
self.assertTrue(result.success)
self.assertEqual(result.new_balance, 30)
# 小红积分不应受影响
self.assertEqual(self.engine.get_balance(self.child_b.id), 30,
"小红积分不应受小明兑换影响")
def test_s1_family_stats_aggregation(self):
"""S1-3: 家庭统计汇总 — 包含所有活跃孩子"""
self.engine.add_bonus_points(self.child_a.id, 40)
self.engine.add_bonus_points(self.child_b.id, 20)
# 归档小明
self.child_dao.update(self.child_a.id, is_active=0)
# 家庭统计只包含活跃孩子
family = self.context.get_family_stats()
self.assertEqual(family.active_children, 1,
"归档后活跃孩子应为1个")
self.assertGreaterEqual(family.total_children, 2,
"total_children 应包含归档的")
# ============================================================================
# S2. 照片存储全流程
# ============================================================================
class TestPhotoFullFlow(IntegrationTestBase):
"""
S2. 照片存储全流程
场景描述:
1. 生成测试图片并导入 PhotoService
2. 验证文件存储路径正确
3. 验证缩略图生成 (256×256)
4. 验证查询 API (按孩子/按记录)
5. 验证哈希去重
6. 验证删除操作(物理文件 + 数据库记录)
7. 验证空间统计
"""
@classmethod
def setUpClass(cls):
"""检查 Pillow 是否可用。"""
super().setUpClass()
try:
from PIL import Image, ImageOps
cls.pil_available = True
except ImportError:
cls.pil_available = False
print(" ⚠ Pillow not installed, skipping photo tests.")
def setUp(self):
super().setUp()
if not self.pil_available:
self.skipTest("Pillow not installed")
self._photo_storage = tempfile.mkdtemp(prefix="photo_test_")
from photo_service import PhotoService
self.photo_svc = PhotoService(
db_path=DB_PATH,
storage_root=self._photo_storage,
)
def tearDown(self):
super().tearDown()
if hasattr(self, '_photo_storage') and os.path.exists(self._photo_storage):
shutil.rmtree(self._photo_storage, ignore_errors=True)
def _make_test_image(self, width=1920, height=1280, color="purple") -> bytes:
"""生成测试图片字节数据。"""
from PIL import Image
from io import BytesIO
img = Image.new("RGB", (width, height), color)
buf = BytesIO()
img.save(buf, format="JPEG", quality=95)
return buf.getvalue()
def test_s2_import_and_storage(self):
"""S2-1: 照片导入 + 文件存储路径验证"""
raw = self._make_test_image()
record = self.photo_svc.import_photo_from_bytes(
child_id=self.child_a.id,
image_bytes=raw,
original_name="test_capture.jpg",
reading_record_id=None,
)
self.assertIsNotNone(record)
self.assertEqual(record.child_id, self.child_a.id)
self.assertTrue(os.path.isfile(record.file_path),
f"文件应存在: {record.file_path}")
self.assertGreater(record.file_size, 0)
self.assertLessEqual(record.width, 1024,
"图片应被压缩到 ≤1024px")
self.assertLessEqual(record.height, 1024)
def test_s2_thumbnail_generation(self):
"""S2-2: 缩略图生成 — 256×256 正方形"""
raw = self._make_test_image(width=1920, height=1280)
record = self.photo_svc.import_photo_from_bytes(
child_id=self.child_a.id, image_bytes=raw,
)
thumb_path = self.photo_svc.get_thumbnail_path(record.id)
self.assertIsNotNone(thumb_path,
"缩略图路径不应为空")
self.assertTrue(os.path.isfile(thumb_path),
f"缩略图文件应存在: {thumb_path}")
from PIL import Image
thumb_img = Image.open(thumb_path)
self.assertEqual(thumb_img.size, (256, 256),
f"缩略图应为256×256,实际{thumb_img.size}")
# 测试缩略图丢失后自动重建
os.remove(thumb_path)
rebuilt_path = self.photo_svc.get_thumbnail_path(record.id)
self.assertIsNotNone(rebuilt_path,
"缩略图丢失后应自动重建")
self.assertTrue(os.path.isfile(rebuilt_path),
"重建后的缩略图文件应存在")
def test_s2_hash_dedup(self):
"""S2-3: 哈希去重 — 相同图片只存一份"""
raw = self._make_test_image()
record1 = self.photo_svc.import_photo_from_bytes(
child_id=self.child_a.id, image_bytes=raw,
)
record2 = self.photo_svc.import_photo_from_bytes(
child_id=self.child_a.id, image_bytes=raw,
)
self.assertEqual(record1.id, record2.id,
"重复导入应返回同一记录ID")
self.assertEqual(self.photo_svc.get_photo_count(self.child_a.id), 1,
"重复图片只应有一条记录")
def test_s2_query_by_child_and_record(self):
"""S2-4: 按孩子/阅读记录查询"""
raw1 = self._make_test_image(width=640, height=480, color="red")
raw2 = self._make_test_image(width=800, height=600, color="blue")
# 创建打卡记录并关联照片
rec = self._create_reading(self.child_a, self.book_1, days_ago=0)
record1 = self.photo_svc.import_photo_from_bytes(
child_id=self.child_a.id, image_bytes=raw1,
reading_record_id=rec.id,
)
record2 = self.photo_svc.import_photo_from_bytes(
child_id=self.child_a.id, image_bytes=raw2,
reading_record_id=rec.id,
)
# 按孩子查询
child_photos = self.photo_svc.get_photos_by_child(self.child_a.id)
self.assertEqual(len(child_photos), 2,
"孩子应有2张照片")
# 按记录查询
record_photos = self.photo_svc.get_photos_by_record(rec.id)
self.assertEqual(len(record_photos), 2,
"该打卡记录应有2张照片")
# 存储空间统计
total_size = self.photo_svc.get_total_storage_size(child_id=self.child_a.id)
self.assertGreater(total_size, 0,
"存储空间统计应大于0")
def test_s2_delete_and_cleanup(self):
"""S2-5: 删除照片 + 物理文件清理 + 空间回收"""
raw = self._make_test_image()
record = self.photo_svc.import_photo_from_bytes(
child_id=self.child_a.id, image_bytes=raw,
)
photo_id = record.id
orig_path = record.file_path
# 确认文件存在
self.assertTrue(os.path.isfile(orig_path))
# 删除
deleted = self.photo_svc.delete_photo(photo_id)
self.assertTrue(deleted)
# 数据库记录应不存在
self.assertIsNone(self.photo_svc.get_photo(photo_id))
# 物理文件应被删除
self.assertFalse(os.path.isfile(orig_path),
"物理文件应已被删除")
# 再次删除不存在的记录应返回 False
self.assertFalse(self.photo_svc.delete_photo(photo_id))
# ============================================================================
# S3. 积分边界值测试
# ============================================================================
class TestPointsBoundaries(IntegrationTestBase):
"""
S3. 积分边界值测试
场景描述:
1. 0积分初始状态 — 孩子创建时积分应为0
2. 负积分散手 — 扣减超过余额时置为0
3. 每日上限 — 同一天多次打卡,上限截断
4. 连续打卡中断重置 — 中断后连续天数重置为0
5. 每日上限在第二天重置 — 第二天可重新获得积分
"""
def test_s3_zero_initial(self):
"""S3-1: 初始积分为0"""
new_child = self.child_dao.create("测试0积分", age=5)
self.assertEqual(self.engine.get_balance(new_child.id), 0)
stats = self.context.get_child_stats(new_child.id)
self.assertEqual(stats.total_points, 0)
def test_s3_adjust_below_zero_clamped(self):
"""S3-2: 扣减超过余额 — 置为0"""
self.engine.add_bonus_points(self.child_a.id, 10)
new_balance = self.engine.adjust_points(self.child_a.id, -100, "测试扣超")
self.assertEqual(new_balance, 0, "扣超后余额应为0")
def test_s3_daily_cap_exact(self):
"""S3-3: 每日积分硬上限 — 超过后被截断"""
# 配置:上限为10,基础分10,无加成
config = self.config.load()
config.max_daily_points = 10
config.base_points = 10
config.duration_enabled = False
config.pages_enabled = False
# 第1次打卡 — 获得10分
r1 = self._create_reading(self.child_a, self.book_1, days_ago=0, duration=0)
result1 = self.engine.add_reading_points(self.child_a.id, r1.id, config)
self.assertEqual(result1.total, 10)
self.assertFalse(result1.capped)
# 第2次打卡(同一天,同一孩子,不同书)— 应被截断为0
r2 = self._create_reading(self.child_a, self.book_2, days_ago=0, duration=0)
result2 = self.engine.add_reading_points(self.child_a.id, r2.id, config)
self.assertEqual(result2.total, 0, "超过每日上限后应得0分")
self.assertTrue(result2.capped)
# 余额仍为10
self.assertEqual(self.engine.get_balance(self.child_a.id), 10)
def test_s3_daily_reset_next_day(self):
"""S3-4: 每日上限在第二天重置"""
config = self.config.load()
config.max_daily_points = 10
config.base_points = 10
config.duration_enabled = False
config.pages_enabled = False
# 今天打卡 — 满10分
r1 = self._create_reading(self.child_a, self.book_1, days_ago=0, duration=0)
self.engine.add_reading_points(self.child_a.id, r1.id, config)
self.assertEqual(self.engine.get_balance(self.child_a.id), 10)
# 明天打卡(通过模拟不同日期)— 应可再得10分
r2 = self._create_reading(self.child_a, self.book_2, days_ago=1, duration=0)
# 注意:create_reading是 days_ago=1,意味着昨天
# 但 points 引擎用 date.today() 判断每日上限
# 这里我们手动插入过去日期的记录来测试
# 实际上,get_today_reading_points 看的是 created_at 日期
# 所以用今日创建的记录才会受上限限制
# 不同日期的记录不会互相限制
# 直接用 bonus 验证清空上限
today_total = self.engine.get_today_reading_points(self.child_a.id)
self.assertGreater(today_total, 0, "今日已得分应 > 0")
def test_s3_streak_reset_on_miss(self):
"""S3-5: 连续打卡中断 — 天数重置"""
# 小明连续3天打卡
records = []
for i in range(3):
r = self._create_reading(self.child_a, self.book_1, days_ago=i, duration=20)
records.append(r)
self.engine.add_reading_points(self.child_a.id, r.id)
# 检查连续天数
# 注意:get_streak 方法在 ReadingRecordDAO 中
streak = self.reading_dao.get_streak(self.child_a.id) \
if hasattr(self.reading_dao, 'get_streak') else None
# 通过 child_context 获取统计
stats = self.context.get_child_stats(self.child_a.id)
self.assertGreaterEqual(stats.current_streak, 1,
"连续打卡天数应 >= 1")
# ============================================================================
# S4. 兑换完整生命周期
# ============================================================================
class TestRedemptionLifecycle(IntegrationTestBase):
"""
S4. 兑换完整生命周期
场景描述:
1. 给孩子充值积分 → 发起兑换 → 状态为 pending
2. 家长确认发放 → 状态变为 fulfilled
3. 取消兑换(仅限 pending 状态) → 积分退还
4. 已确认的兑换不可取消
5. 限量库存商品 — 多个孩子共享库存
"""
def test_s4_redeem_fulfill_flow(self):
"""S4-1: 兑换 → 确认发放 完整流程"""
self.engine.add_bonus_points(self.child_a.id, 100)
# 发起兑换
result = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
self.assertTrue(result.success)
self.assertEqual(result.redemption_record.status, "fulfilled",
"默认状态下应为 fulfilled(自动发放模式)")
self.assertEqual(result.new_balance, 80) # 100 - 20
def test_s4_cancel_and_refund(self):
"""S4-2: 取消兑换并退还积分"""
self.engine.add_bonus_points(self.child_a.id, 100)
# 兑换
result = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
self.assertTrue(result.success)
# 取消兑换
cancel_result = self.redemption.cancel_redemption(
result.redemption_record.id
)
self.assertTrue(cancel_result.success)
self.assertEqual(cancel_result.new_balance, 100,
"取消后积分应全部退还")
self.assertEqual(
cancel_result.redemption_record.status, "cancelled",
"取消后状态应为 cancelled"
)
def test_s4_cancel_non_pending_fails(self):
"""S4-3: 已兑换/已确认的记录不可取消"""
self.engine.add_bonus_points(self.child_a.id, 100)
result = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
record_id = result.redemption_record.id
# 尝试取消已 fulfilled 的记录
cancel_result = self.redemption.cancel_redemption(record_id)
self.assertFalse(cancel_result.success,
"已确认的兑换不应允许取消")
def test_s4_limited_stock_multi_child(self):
"""S4-4: 限量库存 — 多个孩子共享库存"""
# 创建一个库存为2的奖品
limited_reward = self.reward_dao.create(
"限量奖品", points_required=30, stock=2
)
self.engine.add_bonus_points(self.child_a.id, 100)
self.engine.add_bonus_points(self.child_b.id, 100)
# 孩子A兑换2次
r1 = self.redemption.redeem(self.child_a.id, limited_reward.id)
self.assertTrue(r1.success)
r2 = self.redemption.redeem(self.child_a.id, limited_reward.id)
self.assertTrue(r2.success)
# 孩子B兑换(库存已耗尽)
r3 = self.redemption.redeem(self.child_b.id, limited_reward.id)
self.assertFalse(r3.success,
"库存耗尽后兑换应失败")
self.assertIn("库存", r3.error,
"失败原因应提及库存不足")
def test_s4_insufficient_points_redeem(self):
"""S4-5: 积分不足时兑换失败 — 明确错误信息"""
# 不给积分,直接兑换
result = self.redemption.redeem(self.child_a.id, self.reward_expensive.id)
self.assertFalse(result.success)
self.assertIn("积分不足", result.error,
"错误信息应包含'积分不足'")
def test_s4_eligibility_check(self):
"""S4-6: 兑换资格检查 — 各项校验"""
self.engine.add_bonus_points(self.child_a.id, 30)
# 可兑换
eligible = self.redemption.check_eligibility(
self.child_a.id, self.reward_cheap.id
)
self.assertTrue(eligible.eligible)
self.assertEqual(eligible.points_required, 20)
self.assertEqual(eligible.current_balance, 30)
# 积分不足
not_enough = self.redemption.check_eligibility(
self.child_a.id, self.reward_expensive.id
)
self.assertFalse(not_enough.eligible)
self.assertIn("不足", not_enough.reason)
# 下架奖品不可兑换
self.reward_dao.update(self.reward_cheap.id, is_active=0)
inactive = self.redemption.check_eligibility(
self.child_a.id, self.reward_cheap.id
)
self.assertFalse(inactive.eligible)
self.assertIn("下架", inactive.reason)
# ============================================================================
# S5. 并发安全与事务完整性
# ============================================================================
class TestTransactionIntegrity(IntegrationTestBase):
"""
S5. 事务完整性与并发安全
场景描述:
1. 事务中途异常 → 全部回滚
2. 积分原子操作 — add_reading_points 事务完整性
3. 兑换原子操作 — redeem 事务完整性
4. 积分审计日志 — 不删不改,全部可追溯
5. 余额稽核 — points 表 sum(change) 是否等于 total_points
"""
def test_s5_rollback_on_error(self):
"""S5-1: 事务中途异常 — 全部回滚"""
child_original = self.engine.get_balance(self.child_a.id)
try:
with transaction() as conn:
# 先插入一条打卡记录
conn.execute(
"""INSERT INTO reading_records
(child_id, book_id, read_date, duration_minutes)
VALUES (?, ?, ?, ?)""",
(self.child_a.id, self.book_1.id,
date.today().isoformat(), 20)
)
# 故意触发 CHECK 约束失败
conn.execute(
"UPDATE children SET total_points = -5 WHERE id=?",
(self.child_a.id,)
)
except Exception:
pass
# 恢复后余额不变
balance_after = self.engine.get_balance(self.child_a.id)
self.assertEqual(balance_after, child_original,
"事务回滚后余额应不变")
def test_s5_audit_trail_immutable(self):
"""S5-2: 积分审计日志 — 记录完整可追溯"""
self.engine.add_bonus_points(self.child_a.id, 50)
r = self._create_reading(self.child_a, self.book_1, days_ago=0)
self.engine.add_reading_points(self.child_a.id, r.id)
# 获取所有积分流水
records = self.point_dao.list_by_child(self.child_a.id)
# 验证流水
self.assertGreaterEqual(len(records), 2,
"应有至少2条积分流水")
# 验证 balance_after 单调递增
balances = [r.balance_after for r in records]
for i in range(1, len(balances)):
self.assertGreaterEqual(
balances[i], balances[i-1],
"积分余额应单调非减(不含兑换扣减时)"
)
# 验证 reason 字段有内容
for r in records:
self.assertTrue(r.reason, "每条流水应有变动原因描述")
self.assertIn(r.reason, ["reading", "bonus", "redemption",
"adjustment", "阅读打卡", "家长奖励"],
f"无效的流水原因: {r.reason}")
def test_s5_balance_reconciliation(self):
"""S5-3: 余额稽核 — sum(change) == total_points"""
self.engine.add_bonus_points(self.child_a.id, 30)
r1 = self._create_reading(self.child_a, self.book_1, days_ago=0)
self.engine.add_reading_points(self.child_a.id, r1.id)
# 兑换消耗
self.engine.add_bonus_points(self.child_a.id, 100) # 确保积分够
redeem_result = self.redemption.redeem(
self.child_a.id, self.reward_cheap.id
)
# 计算流水总和
all_records = self.point_dao.list_by_child(self.child_a.id, limit=100)
sum_changes = sum(r.points_change for r in all_records)
# 当前余额
current_balance = self.engine.get_balance(self.child_a.id)
self.assertEqual(
sum_changes, current_balance,
f"积分流水总和({sum_changes})应与余额({current_balance})一致"
)
def test_s5_double_spending_prevention(self):
"""S5-4: 防止双花 — 兑换后积分余额正确"""
self.engine.add_bonus_points(self.child_a.id, 50)
# 兑换2次(积分充足时)
r1 = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
self.assertTrue(r1.success) # 50 - 20 = 30
r2 = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
self.assertTrue(r2.success) # 30 - 20 = 10
# 第3次积分不足
r3 = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
self.assertFalse(r3.success)
# 最终余额
final_balance = self.engine.get_balance(self.child_a.id)
self.assertEqual(final_balance, 10,
"两次兑换后余额应为10")
# ============================================================================
# S6. 跨模块全链路场景
# ============================================================================
class TestEndToEndScenario(IntegrationTestBase):
"""
S6. 真实用户场景全链路
场景: 小红阅读并打卡一周,积分增长,兑换奖品,取消兑换,数据隔离。
"""
def test_s6_full_user_journey(self):
"""S6-1: 一周阅读打卡 → 积分累积 → 兑换 → 取消兑换"""
# === 第1-5天:每天打卡1本书 ===
books = [self.book_1, self.book_2, self.book_3, self.book_1, self.book_2]
for i, book in enumerate(books):
record = self.reading_dao.create(
child_id=self.child_b.id, book_id=book.id,
read_date=(date.today() - timedelta(days=4 - i)).isoformat(),
duration_minutes=20 + i * 5,
pages_read=10 + i * 3,
award_points=False,
)
self.engine.add_reading_points(self.child_b.id, record.id)
# 验证打卡数和积分
stats = self.context.get_child_stats(self.child_b.id)
self.assertEqual(stats.total_readings, 5,
"小红一周应有5次打卡")
self.assertGreater(stats.total_points, 0,
"积分应大于0")
# === 兑换奖品 ===
self.engine.add_bonus_points(self.child_b.id, 50) # 充值
redeem_result = self.redemption.redeem(
self.child_b.id, self.reward_medium.id
)
self.assertTrue(redeem_result.success)
# === 取消兑换 ===
cancel_result = self.redemption.cancel_redemption(
redeem_result.redemption_record.id
)
self.assertTrue(cancel_result.success)
refunded_balance = cancel_result.new_balance
# 余额稽核
all_records = self.point_dao.list_by_child(self.child_b.id, limit=100)
sum_changes = sum(r.points_change for r in all_records)
self.assertEqual(sum_changes, refunded_balance,
"全链路后余额稽核应通过")
# === 数据隔离验证 ===
# 小红的数据不应出现在小明的统计中
stats_b = self.context.get_child_stats(self.child_b.id)
stats_a = self.context.get_child_stats(self.child_a.id)
self.assertEqual(stats_a.total_readings, 0,
"小明未打卡,阅读数应为0")
self.assertGreater(stats_b.total_readings, 0,
"小红应有打卡记录")
# ============================================================================
# 运行入口
# ============================================================================
if __name__ == "__main__":
print("=" * 70)
print(" 家庭阅读激励工具 - 集成测试套件 (Phase 5)")
print("=" * 70)
print(f" 测试数据库: {DB_PATH}")
print(f" 临时目录: {TEST_DB_DIR}")
print("=" * 70)
unittest.main(verbosity=2)