Files
sentinel-home-ai/fam-core/tests/test_db_layer.py
ericwyuan 1964e976f4 refactor: 全量迁云——fam-core 直读甲骨文 SQLite,NAS 只剩推送进程
前一天刚把登录迁到甲骨文,隔天 NAS 上的 fam-core 又挂了导致数据接口 502。
盘点后确认:甲骨文的 SQLite 才是权威数据源(videos 3113 / events 16159 /
people 60 / model_calls 9876),NAS 的 MariaDB 全是它的镜像——前端读的数据
本来就产自甲骨文,绕了一圈回家又绕回来。

改动:
- db_layer.py 从 725 行重写成 377 行:MySQL 镜像查询改为直读 fam-edge 的
  SQLite。5 个 upsert_sync_*(约 300 行去重逻辑,8/29 和 9/3 两次 1062 事故的
  发源地)连同 oracle_sync.py 整个删除。SQL 方言:JSON_CONTAINS -> json_each
  (前置 json_valid,历史脏数据不会把查询搞崩)、LEFT() -> substr()、%s -> ?。
  函数名 get_sync_* 一并改掉——已经没有 sync 这回事了
- 新增 edge_client.py:写操作(改名/删除)、帧图头像、服务状态都打给同机
  fam-edge,全走 127.0.0.1
- 新增 fam-notifier/:motion_notifier 从 fam-core 拆出独立成服务,游标从
  MariaDB 换成本地 JSON 文件。NAS 上从此没有 Flask、没有数据库、没有监听端口
- fam-core 移到甲骨文 /opt/fam-core(systemd,gunicorn -w 2,只绑
  127.0.0.1:5401——5400 被 chat-relay 占了)。Caddy 的 /api/* 从"frp 隧道
  回源 NAS"改成同机反代,forward_auth 闸门不变
- 前端删掉侧边栏同步面板、统计页同步状态、服务状态页的"NAS 同步"卡片与
  "立即同步"按钮(背后的镜像层已不存在);换成"NAS 运动推送"卡片,读
  fam-edge activity 新增的 motion 段(心跳年龄 + 最近事件)
- 顺带修掉一个隐蔽 bug:镜像表为保外键稳定用的是 NAS 本地自增 id,而帧图接口
  要的是甲骨文的 id,两边在 9/3 那次 id 重排后就对不上了。现在只有一套 id

测试:fam-core 21(新增 12 个 db_layer 用例:脏 JSON 不崩、人物精确匹配不误伤
"人物B"、日期过滤、统计口径、chat_history 懒建表)、fam-notifier 6、
fam-edge 157,全绿。

生产验证:甲骨文 /api/ui/stats 返回 videos 2965 / events 16159 / people 59;
NAS 侧 fam-notifier 已推送成功(事件 33070-33072 落库,心跳新鲜);
chat_history 19 条经 scripts/import_chat_history.py 迁移完成。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:03:32 +08:00

156 lines
7.4 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.
"""db_layer 直读 SQLite 的单测2026-09-13 迁云重写后新增)。
重点验证从 MySQL 翻译过来的几处方言json_each 取代 JSON_CONTAINS、
substr 取代 LEFT、以及历史脏数据person_list_json 不是合法 JSON不能
把整条查询搞崩——这在 MySQL 下 JSON_CONTAINS 会直接报错SQLite 下
json_each 同样会,所以查询里加了 json_valid 前置判断。
"""
import sqlite3
import pytest
from fam_core import db_layer
# fam-edge 建表语句的最小子集(列名与 oracle_db.py 保持一致)
_SCHEMA = """
CREATE TABLE videos (
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE, camera_name TEXT,
duration_sec REAL, event_start_time TEXT, status TEXT DEFAULT 'pending',
summary_json TEXT, events_json TEXT, people_json TEXT, compute_provider TEXT,
created_at TEXT, updated_at TEXT, processed_at TEXT);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER, ts TEXT, description TEXT,
person_list_json TEXT, person_appearances_json TEXT, is_attention_event INTEGER DEFAULT 0);
CREATE TABLE people (
id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT UNIQUE, canonical_name TEXT,
first_seen TEXT, appearances INTEGER DEFAULT 0, source TEXT, features_json TEXT,
display_uid TEXT, updated_at TEXT);
CREATE TABLE model_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT, model TEXT, video_id INTEGER,
filename TEXT, started_at TEXT, duration_sec REAL, success INTEGER, error TEXT,
created_at TEXT);
"""
@pytest.fixture
def db(tmp_path, monkeypatch):
path = tmp_path / "oracle.db"
conn = sqlite3.connect(path)
conn.executescript(_SCHEMA)
conn.executescript("""
INSERT INTO videos (id, filename, camera_name, event_start_time, status, processed_at)
VALUES (1, 'motion_20260913_080000.mp4', '客厅', '2026-09-13 08:00:00', 'done', '2026-09-13 08:10:00'),
(2, 'whole_20260912_090000.mp4', '客厅', '2026-09-12 09:00:00', 'done', '2026-09-12 09:10:00'),
(3, 'whole_20260911_090000.mp4', '客厅', '2026-09-11 09:00:00', 'done', '2026-09-11 09:10:00'),
(4, 'motion_20260910_070000.mp4', '客厅', '2026-09-10 07:00:00', 'pending', NULL);
-- video 2 有事件应展示video 3 没有事件且不是 motion_应被过滤掉
INSERT INTO events (video_id, ts, description, person_list_json, is_attention_event)
VALUES (1, '00:00:05', '汤圆在客厅玩耍', '["汤圆"]', 0),
(1, '00:00:20', '有人靠近门口', '["人物B"]', 1),
(2, '00:01:00', '汤圆和奶奶', '["汤圆","奶奶"]', 0),
(2, '00:02:00', '脏数据事件', '不是合法JSON', 0);
INSERT INTO people (id, label, canonical_name, appearances, source)
VALUES (1, '人物A', '汤圆', 12, 'manual'),
(2, '人物B', NULL, 3, 'llm'),
(3, '汤圆', '汤圆', 5, 'manual');
INSERT INTO model_calls (provider, model, success, duration_sec, created_at)
VALUES ('gemini', 'flash', 1, 10.0, '2026-09-13 08:10:00'),
('gemini', 'flash', 0, 20.0, '2026-09-13 08:20:00'),
('nvidia', 'vila', 1, 5.0, '2026-09-12 08:10:00');
""")
conn.commit()
conn.close()
monkeypatch.setattr(db_layer, '_db_path', str(path))
monkeypatch.setattr(db_layer, '_chat_schema_ready', False)
return path
# ---------------------------------------------------------------- videos
def test_get_videos_only_returns_content_sessions(db):
"""只展示运动片段或含事件的会话:分割 0 段的整段素材不该淹没时间轴。"""
ids = [v['id'] for v in db_layer.get_videos()]
assert ids == [1, 2] # 3 无事件且非 motion_4 未完成
assert db_layer.get_videos()[0]['event_count'] == 2
def test_get_videos_date_filter_and_paging(db):
assert [v['id'] for v in db_layer.get_videos(date_filter='2026-09-12')] == [2]
assert [v['id'] for v in db_layer.get_videos(limit=1, offset=1)] == [2]
def test_get_video_and_events(db):
assert db_layer.get_video(1)['filename'].startswith('motion_')
assert db_layer.get_video(999) is None
evs = db_layer.get_events_for_video(1)
assert [e['ts'] for e in evs] == ['00:00:05', '00:00:20']
def test_get_attention_events(db):
rows = db_layer.get_attention_events()
assert len(rows) == 1 and rows[0]['ev_date'].startswith('2026-09-13')
# ---------------------------------------------------------------- 人物命中JSON
def test_people_clips_matches_label_and_its_aliases(db):
"""'人物A' 的规范名是"汤圆",而事件里记的是"汤圆"——要能顺着别名找到。"""
clips = db_layer.get_people_clips('人物A')
assert {c['video_id'] for c in clips} == {1, 2}
assert clips[0]['video_id'] == 1 # 按录制时间倒序
assert clips[0]['first_ts'] == '00:00:05'
assert clips[0]['clip_events'] == 1
def test_person_hit_is_exact_not_substring(db):
"""json_each 是精确匹配:查"人物"不该命中"人物B""""
assert db_layer.get_people_clips('人物') == []
assert {c['video_id'] for c in db_layer.get_people_clips('人物B')} == {1}
def test_malformed_person_json_does_not_break_queries(db):
"""video 2 里混了一条非 JSON 的脏数据,查询必须照常返回而不是整条报错。"""
rows = db_layer.query_events_for_person_date('汤圆', '2026-09-12')
assert len(rows) == 1 and rows[0]['ts'] == '00:01:00'
def test_query_events_for_person_date(db):
assert db_layer.query_events_for_person_date('汤圆', '2026-09-13')[0]['description'] == '汤圆在客厅玩耍'
assert db_layer.query_events_for_person_date('汤圆', '2026-09-01') == []
# ---------------------------------------------------------------- people / model_calls
def test_people_and_named_members(db):
assert len(db_layer.get_people()) == 3
assert db_layer.get_named_members() == ['汤圆'] # distinct 且非空
ctx = db_layer.get_known_members_context()
assert '- 汤圆标识人物A' in ctx and '- 人物B' in ctx
def test_model_calls_stats(db):
stats = {s['model']: s for s in db_layer.get_model_calls_stats()}
assert stats['flash']['ok_cnt'] == 1 and stats['flash']['fail_cnt'] == 1
assert stats['flash']['avg_duration'] == 15.0
assert len(db_layer.get_model_calls(limit=2)) == 2
# ---------------------------------------------------------------- 统计
def test_stats_全量与按日(db):
total = db_layer.get_stats()
assert total['videos'] == 2 and total['events'] == 4 and total['attention'] == 1
day = db_layer.get_stats('2026-09-13')
assert day['videos'] == 1 and day['events'] == 2
# 人物数:汤圆(含 label 人物A/汤圆两行都归一到"汤圆"+ 人物B
assert total['people'] >= 2
# ---------------------------------------------------------------- chat_history
def test_chat_history_roundtrip_creates_table_on_demand(db):
"""chat_history 是本模块唯一写的表,建表是懒加载的(库文件属于 fam-edge"""
chat_id = db_layer.insert_chat_history('今天有人来吗', '08:00 有人靠近门口',
'上下文', '2026-09-13', '汤圆')
assert chat_id == 1
rows = db_layer.get_chat_history(limit=10)
assert rows[0]['user_question'] == '今天有人来吗'
assert rows[0]['created_at']
assert db_layer.get_chat_history(person_filter='不存在的人') == []
assert len(db_layer.get_chat_history(date_filter='2026-09-13')) == 1