[03-Developer] #226 Catalog Gallery 구현
- Drift schema v2: Protocols.category CHECK 6→7 (light_circadian/sleep/movement/
nutrition/focus_cognition/recovery_stress/emotion_relationship). schemaVersion
1→2 + onUpgrade migrateV1ToV2 (DROP+CREATE+reseed flag 클리어).
- protocols.json 34 항목 v2 재분류 (1차 효과 기준). emotion_relationship 0 매핑.
- 도메인: DisplayCategory enum (8) + CatalogItem sealed (Protocol/Break/Diet).
- 데이터: CatalogRepository.all/byId/referencesByIds (3 source 통합).
- 상태: catalog_providers.dart (catalogItems / groupedByCategory / refsByIds).
- UI: ProtocolGalleryScreen (카테고리 칩 + 카드 그리드) + ProtocolPreviewScreen
(모든 필드 + reference 펼치기 + "내 습관으로" disabled placeholder) +
CatalogCard / CategoryChipRow / ReferenceExpandCard. HabitListScreen 빈
상태 CTA + AppBar 액션.
- 테스트: migration_v1_to_v2 3건 + display_category 5건 + catalog_repository
9건 + gallery widget 3건 + preview widget 3건 = 23 신규. 기존 88 회귀 0,
flutter analyze 0 issues. 110 passed / 1 skipped.
설계서: docs/design/226-catalog-gallery/{README, fn-catalog_repository,
fn-migration_v1_to_v2}.md + ADR-0004.
Refs #226
This commit is contained in:
155
app/test/data/db/migration_v1_to_v2_test.dart
Normal file
155
app/test/data/db/migration_v1_to_v2_test.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/core/constants.dart';
|
||||
import 'package:life_helper/core/time.dart';
|
||||
import 'package:life_helper/data/db/app_database.dart';
|
||||
|
||||
/// v1 schema 로 in-memory DB 를 raw SQL 로 부트스트랩.
|
||||
///
|
||||
/// Phase 1 (#204) 시점의 protocols 테이블 정의를 모사 — 그 시점 CHECK 는 6 값.
|
||||
Future<AppDatabase> _buildV1Database() async {
|
||||
// schemaVersion=1 흉내. v2 schema 로 createAll 후, protocols 만 v1 CHECK 로 재생성.
|
||||
// 이렇게 하면 migrateV1ToV2 가 호출 가능 (생성된 DB 는 항상 v2 코드 베이스).
|
||||
// 본 테스트의 핵심은 "DROP+CREATE+flag 클리어가 user 테이블에 영향 주지 않음" + "v2 CHECK 적용 확인".
|
||||
final db = AppDatabase.memory();
|
||||
|
||||
// v1 의 protocols 테이블을 시뮬레이트: v2 테이블을 DROP 하고 v1 정의로 다시 만든다.
|
||||
await db.customStatement('DROP TABLE IF EXISTS protocols');
|
||||
await db.customStatement('DROP INDEX IF EXISTS IDX_protocols_category');
|
||||
await db.customStatement('''
|
||||
CREATE TABLE protocols (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
category TEXT NOT NULL CHECK (category IN ('health','meditation','motivation','habit','learning','diet')),
|
||||
title TEXT NOT NULL,
|
||||
title_en TEXT,
|
||||
what TEXT NOT NULL,
|
||||
when_text TEXT NOT NULL,
|
||||
dose TEXT NOT NULL,
|
||||
why TEXT NOT NULL,
|
||||
how_json TEXT NOT NULL,
|
||||
check_text TEXT NOT NULL,
|
||||
caution TEXT,
|
||||
default_anchor_json TEXT,
|
||||
min_dose_for_start TEXT,
|
||||
reference_ids_json TEXT,
|
||||
evidence_strength TEXT CHECK (evidence_strength IS NULL OR evidence_strength IN ('strong_rct','meta_analysis','observational','mechanistic','expert_opinion')),
|
||||
source_doc TEXT CHECK (source_doc IS NULL OR source_doc IN ('huberman-protocols.md','diet-protocols.md'))
|
||||
)
|
||||
''');
|
||||
await db.customStatement(
|
||||
'CREATE INDEX IDX_protocols_category ON protocols(category)');
|
||||
return db;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('migrateV1ToV2', () {
|
||||
test('v1 → v2: CHECK 갱신 + 인덱스 재생성 + flag 클리어', () async {
|
||||
final db = await _buildV1Database();
|
||||
addTearDown(db.close);
|
||||
|
||||
// v1 row 1개 insert (raw SQL — v1 CHECK 통과).
|
||||
await db.customStatement('''
|
||||
INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text)
|
||||
VALUES ('legacy', 'health', '레거시', '뭐', '언제', '도즈', '왜', '[]', '체크')
|
||||
''');
|
||||
// 시드 flag pre-set.
|
||||
await db.into(db.metaKv).insert(
|
||||
MetaKvCompanion.insert(key: kSeededV1Flag, value: 'true'));
|
||||
|
||||
// Migrate.
|
||||
await db.transaction(() async {
|
||||
final m = Migrator(db);
|
||||
await migrateV1ToV2(m, db);
|
||||
});
|
||||
|
||||
// 1. Protocols 테이블 비어있음 (DROP 으로 row 손실 — reseed 가 채울 책임).
|
||||
final rows = await db.select(db.protocols).get();
|
||||
expect(rows, isEmpty);
|
||||
|
||||
// 2. v1 카테고리 'health' insert 는 이제 CHECK 위배.
|
||||
Future<void> insertHealth() async {
|
||||
await db.customStatement('''
|
||||
INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text)
|
||||
VALUES ('new', 'health', 't', 'w', 'wn', 'd', 'w', '[]', 'c')
|
||||
''');
|
||||
}
|
||||
await expectLater(insertHealth(), throwsA(isA<Object>()));
|
||||
|
||||
// 3. v2 카테고리 'light_circadian' insert 는 통과.
|
||||
await db.customStatement('''
|
||||
INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text)
|
||||
VALUES ('new', 'light_circadian', 't', 'w', 'wn', 'd', 'w', '[]', 'c')
|
||||
''');
|
||||
final after = await db.select(db.protocols).get();
|
||||
expect(after.length, 1);
|
||||
|
||||
// 4. 시드 flag 클리어 — 다음 부팅이 reseed 트리거.
|
||||
final marker = await (db.select(db.metaKv)
|
||||
..where((t) => t.key.equals(kSeededV1Flag)))
|
||||
.getSingleOrNull();
|
||||
expect(marker, isNull);
|
||||
|
||||
// 5. 인덱스 재생성 확인 — sqlite_master 조회.
|
||||
final indexCheck = await db.customSelect(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='IDX_protocols_category'",
|
||||
).get();
|
||||
expect(indexCheck.length, 1);
|
||||
});
|
||||
|
||||
test('user 테이블 (Users / Phases / Habits) 무변화', () async {
|
||||
final db = await _buildV1Database();
|
||||
addTearDown(db.close);
|
||||
|
||||
// user 데이터 사전 insert.
|
||||
await db.into(db.users).insert(UsersCompanion.insert(
|
||||
id: 'u1',
|
||||
displayName: const Value('Alice'),
|
||||
createdAt: nowKst().toIso8601String()));
|
||||
await db.into(db.phases).insert(PhasesCompanion.insert(
|
||||
id: 'ph1',
|
||||
userId: 'u1',
|
||||
status: 'active',
|
||||
startedAt: nowKst().toIso8601String()));
|
||||
await db.into(db.habits).insert(HabitsCompanion.insert(
|
||||
id: 'h1',
|
||||
userId: 'u1',
|
||||
type: 'build',
|
||||
status: 'active',
|
||||
title: 'My Habit',
|
||||
protocolId: const Value('legacy'),
|
||||
frameLevel: 'L2',
|
||||
frameFramedText: '저녁엔 무알콜',
|
||||
startedAt: nowKst().toIso8601String()));
|
||||
|
||||
// Migrate.
|
||||
await db.transaction(() async {
|
||||
final m = Migrator(db);
|
||||
await migrateV1ToV2(m, db);
|
||||
});
|
||||
|
||||
// user 테이블 무변화.
|
||||
final users = await db.select(db.users).get();
|
||||
final phases = await db.select(db.phases).get();
|
||||
final habits = await db.select(db.habits).get();
|
||||
expect(users.length, 1);
|
||||
expect(phases.length, 1);
|
||||
expect(habits.length, 1);
|
||||
expect(users.first.id, 'u1');
|
||||
expect(habits.first.title, 'My Habit');
|
||||
});
|
||||
|
||||
test('idempotent: 두 번째 호출도 성공 (no row 차이)', () async {
|
||||
final db = await _buildV1Database();
|
||||
addTearDown(db.close);
|
||||
|
||||
await db.transaction(() async {
|
||||
final m = Migrator(db);
|
||||
await migrateV1ToV2(m, db);
|
||||
await migrateV1ToV2(m, db);
|
||||
});
|
||||
|
||||
final rows = await db.select(db.protocols).get();
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user