[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:
114
app/test/data/catalog/catalog_repository_test.dart
Normal file
114
app/test/data/catalog/catalog_repository_test.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/data/catalog/catalog_repository.dart';
|
||||
import 'package:life_helper/data/db/app_database.dart';
|
||||
import 'package:life_helper/data/seed/seed_importer.dart';
|
||||
import 'package:life_helper/domain/catalog/catalog_item.dart';
|
||||
import 'package:life_helper/domain/catalog/display_category.dart';
|
||||
|
||||
import '../seed/test_seeds.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late CatalogRepository repo;
|
||||
|
||||
setUp(() async {
|
||||
db = AppDatabase.memory();
|
||||
repo = CatalogRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
Future<void> seed() async {
|
||||
final importer = SeedImporter(db, loadAsset: testStubLoader);
|
||||
await importer.importIfNeeded();
|
||||
}
|
||||
|
||||
test('빈 DB: all() 가 빈 리스트 (throw 안 함)', () async {
|
||||
final items = await repo.all();
|
||||
expect(items, isEmpty);
|
||||
});
|
||||
|
||||
test('정상: seed 후 all() 반환 + displayCategory 매핑', () async {
|
||||
await seed();
|
||||
final items = await repo.all();
|
||||
// test_seeds.dart 가 정의한 stub: protocols 1 + break 1 + diet 1 = 3.
|
||||
expect(items.length, 3);
|
||||
|
||||
final p = items.whereType<ProtocolCatalogItem>().single;
|
||||
expect(p.displayCategory, DisplayCategory.lightCircadian);
|
||||
|
||||
final b = items.whereType<BreakCatalogItem>().single;
|
||||
expect(b.displayCategory, DisplayCategory.breakHabit);
|
||||
|
||||
final d = items.whereType<DietCatalogItem>().single;
|
||||
expect(d.displayCategory, DisplayCategory.nutrition);
|
||||
});
|
||||
|
||||
test('정렬: displayCategory.index → id', () async {
|
||||
await seed();
|
||||
final items = await repo.all();
|
||||
for (var i = 1; i < items.length; i++) {
|
||||
final a = items[i - 1];
|
||||
final b = items[i];
|
||||
expect(a.displayCategory.index <= b.displayCategory.index, true);
|
||||
if (a.displayCategory.index == b.displayCategory.index) {
|
||||
expect(a.id.compareTo(b.id) <= 0, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('summary 길이 ≤ 60자', () async {
|
||||
await seed();
|
||||
final items = await repo.all();
|
||||
for (final item in items) {
|
||||
expect(item.summary.length, lessThanOrEqualTo(60),
|
||||
reason: 'id=${item.id} summary=${item.summary}');
|
||||
}
|
||||
});
|
||||
|
||||
test('byId: 정상 + 미존재', () async {
|
||||
await seed();
|
||||
final p = await repo.byId('morning_sunlight');
|
||||
expect(p, isNotNull);
|
||||
expect(p!.title, '아침 햇빛');
|
||||
|
||||
final none = await repo.byId('no_such_id');
|
||||
expect(none, isNull);
|
||||
});
|
||||
|
||||
test('referencesByIds: 일부 매칭 + 미매칭', () async {
|
||||
await seed();
|
||||
final ok = await repo.referencesByIds(['ref_x']);
|
||||
expect(ok.length, 1);
|
||||
final none = await repo.referencesByIds(['no_ref']);
|
||||
expect(none, isEmpty);
|
||||
final mixed = await repo.referencesByIds(['ref_x', 'no_ref']);
|
||||
expect(mixed.length, 1);
|
||||
});
|
||||
|
||||
test('referencesByIds: 빈 리스트 → 빈 결과 (DB 호출 안 함)', () async {
|
||||
final empty = await repo.referencesByIds(const []);
|
||||
expect(empty, isEmpty);
|
||||
});
|
||||
|
||||
test('손상된 category → StateError', () async {
|
||||
await seed();
|
||||
// CHECK 우회 위해 raw SQL — 외래로 손상 시뮬레이트.
|
||||
await db.customStatement(
|
||||
"INSERT INTO protocols (id, category, title, what, when_text, dose, why, how_json, check_text) "
|
||||
"VALUES ('bad', 'unknown_cat_xxx', 'x', 'x', 'x', 'x', 'x', '[]', 'x')",
|
||||
[]);
|
||||
// 위 insert 가 CHECK 로 실패하면 손상 시뮬레이션 불가 — skip 처리.
|
||||
// 우리는 v2 CHECK 가 있으므로 어차피 throw 됨. 이 시나리오는 raw injection 시에만 발생 — 이 케이스는 명세화로 충분.
|
||||
}, skip: 'v2 CHECK 가 모든 값 차단 — raw injection 시나리오는 명세 검증만');
|
||||
|
||||
test('groupByCategory: 빈 카테고리 key 미포함', () async {
|
||||
await seed();
|
||||
final items = await repo.all();
|
||||
final grouped = groupByCategory(items);
|
||||
// stub 데이터는 lightCircadian + breakHabit + nutrition 만.
|
||||
expect(grouped.keys.toSet(),
|
||||
{DisplayCategory.lightCircadian, DisplayCategory.breakHabit, DisplayCategory.nutrition});
|
||||
expect(grouped.containsKey(DisplayCategory.emotionRelationship), false);
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const _protocols = '''
|
||||
[
|
||||
{
|
||||
"id": "morning_sunlight",
|
||||
"category": "health",
|
||||
"category": "light_circadian",
|
||||
"title": "아침 햇빛",
|
||||
"what": "기상 후 햇빛.",
|
||||
"when": "기상 후 30~60분.",
|
||||
|
||||
126
app/test/data/seed/test_seeds.dart
Normal file
126
app/test/data/seed/test_seeds.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
// Stub seed loader used by both seed_importer_test and catalog_repository_test.
|
||||
// 1 row per catalog (minimal but schema-valid).
|
||||
|
||||
const protocolsStub = '''
|
||||
[
|
||||
{
|
||||
"id": "morning_sunlight",
|
||||
"category": "light_circadian",
|
||||
"title": "아침 햇빛",
|
||||
"what": "기상 후 햇빛.",
|
||||
"when": "기상 후 30~60분.",
|
||||
"dose": "5~10분.",
|
||||
"why": "ipRGC 자극.",
|
||||
"how": ["나간다", "쳐다본다"],
|
||||
"check": "60분 이내 외출",
|
||||
"reference_ids": ["ref_x"],
|
||||
"source_doc": "huberman-protocols.md"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const breakProtocolsStub = '''
|
||||
[
|
||||
{
|
||||
"id": "alcohol",
|
||||
"category": "alcohol",
|
||||
"title": "음주",
|
||||
"huberman_summary": "ep 86",
|
||||
"phases": [{"week": 1, "what": "환경 정리"}],
|
||||
"default_common_frames": ["dopamine_reset"]
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const commonFramesStub = '''
|
||||
[
|
||||
{
|
||||
"id": "dopamine_reset",
|
||||
"title": "도파민 리셋",
|
||||
"what": "30일 절제",
|
||||
"why": "수용체 회복",
|
||||
"check": "30일 무자극"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const methodologiesStub = '''
|
||||
[
|
||||
{
|
||||
"id": "atomic_habits",
|
||||
"name": "Atomic Habits",
|
||||
"originator": "James Clear",
|
||||
"one_line_definition": "1% 개선",
|
||||
"core_unit": "1회 행동",
|
||||
"huberman_fit_score": 5,
|
||||
"is_core_engine": true
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const framePatternsStub = '''
|
||||
[
|
||||
{
|
||||
"id": "fp_alcohol",
|
||||
"domain": "drink",
|
||||
"avoidance_keyword": "술 끊기",
|
||||
"l0_example": "술 끊기",
|
||||
"l2_suggestion": "저녁엔 무알콜",
|
||||
"l3_identity": "맑은 정신 추구"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const rewardMenuItemsStub = '''
|
||||
[
|
||||
{
|
||||
"id": "rmi_walk",
|
||||
"tier_recommended": "T1",
|
||||
"title": "산책 30분"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const referencesStub = '''
|
||||
[
|
||||
{
|
||||
"id": "ref_x",
|
||||
"kind": "url",
|
||||
"title": "Sample",
|
||||
"url": "https://example.com"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
const dietPatternsStub = '''
|
||||
[
|
||||
{
|
||||
"id": "med",
|
||||
"name": "지중해 식단",
|
||||
"core": "올리브유 + 채소 + 생선 위주의 전통 식단.",
|
||||
"evidence_strength": "strong"
|
||||
}
|
||||
]
|
||||
''';
|
||||
|
||||
Future<String> testStubLoader(String path) async {
|
||||
switch (path) {
|
||||
case 'assets/seed/protocols.json':
|
||||
return protocolsStub;
|
||||
case 'assets/seed/break_protocols.json':
|
||||
return breakProtocolsStub;
|
||||
case 'assets/seed/common_frames.json':
|
||||
return commonFramesStub;
|
||||
case 'assets/seed/methodologies.json':
|
||||
return methodologiesStub;
|
||||
case 'assets/seed/frame_patterns.json':
|
||||
return framePatternsStub;
|
||||
case 'assets/seed/reward_menu_items.json':
|
||||
return rewardMenuItemsStub;
|
||||
case 'assets/seed/references.json':
|
||||
return referencesStub;
|
||||
case 'assets/seed/diet_patterns.json':
|
||||
return dietPatternsStub;
|
||||
}
|
||||
throw StateError('unexpected asset: $path');
|
||||
}
|
||||
59
app/test/domain/catalog/display_category_test.dart
Normal file
59
app/test/domain/catalog/display_category_test.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/domain/catalog/display_category.dart';
|
||||
|
||||
void main() {
|
||||
test('fromProtocolCategory maps all 7 protocol categories', () {
|
||||
expect(DisplayCategory.fromProtocolCategory('light_circadian'),
|
||||
DisplayCategory.lightCircadian);
|
||||
expect(DisplayCategory.fromProtocolCategory('sleep'),
|
||||
DisplayCategory.sleep);
|
||||
expect(DisplayCategory.fromProtocolCategory('movement'),
|
||||
DisplayCategory.movement);
|
||||
expect(DisplayCategory.fromProtocolCategory('nutrition'),
|
||||
DisplayCategory.nutrition);
|
||||
expect(DisplayCategory.fromProtocolCategory('focus_cognition'),
|
||||
DisplayCategory.focusCognition);
|
||||
expect(DisplayCategory.fromProtocolCategory('recovery_stress'),
|
||||
DisplayCategory.recoveryStress);
|
||||
expect(DisplayCategory.fromProtocolCategory('emotion_relationship'),
|
||||
DisplayCategory.emotionRelationship);
|
||||
});
|
||||
|
||||
test('fromProtocolCategory returns null for unknown / break_habit', () {
|
||||
expect(DisplayCategory.fromProtocolCategory('break_habit'), isNull);
|
||||
expect(DisplayCategory.fromProtocolCategory('health'), isNull);
|
||||
expect(DisplayCategory.fromProtocolCategory(''), isNull);
|
||||
});
|
||||
|
||||
test('breakHabit enum has null protocolKey (별도 source)', () {
|
||||
expect(DisplayCategory.breakHabit.protocolKey, isNull);
|
||||
});
|
||||
|
||||
test('all 8 enum values have label + icon', () {
|
||||
for (final c in DisplayCategory.values) {
|
||||
expect(c.label.isNotEmpty, true, reason: '${c.name} label');
|
||||
}
|
||||
expect(DisplayCategory.values.length, 8);
|
||||
});
|
||||
|
||||
test('real seed: 모든 protocols.json id 가 정확히 1 DisplayCategory 에 매핑', () {
|
||||
// Load from disk (test runs from app/).
|
||||
final file = File('assets/seed/protocols.json');
|
||||
final rows = json.decode(file.readAsStringSync()) as List;
|
||||
expect(rows.length, 34, reason: 'protocols.json count');
|
||||
final seenCategories = <DisplayCategory>{};
|
||||
for (final r in rows.cast<Map<String, dynamic>>()) {
|
||||
final cat = r['category'] as String;
|
||||
final dc = DisplayCategory.fromProtocolCategory(cat);
|
||||
expect(dc, isNotNull,
|
||||
reason: 'id=${r['id']} category=$cat not mapped');
|
||||
seenCategories.add(dc!);
|
||||
}
|
||||
// 매핑 누락은 없어야 함, 단 emotion_relationship 은 0 매핑 OK.
|
||||
expect(seenCategories.contains(DisplayCategory.breakHabit), false,
|
||||
reason: 'protocols 는 breakHabit 와 직접 매핑 X');
|
||||
});
|
||||
}
|
||||
83
app/test/ui/protocol_gallery_screen_test.dart
Normal file
83
app/test/ui/protocol_gallery_screen_test.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/domain/catalog/catalog_item.dart';
|
||||
import 'package:life_helper/domain/catalog/display_category.dart';
|
||||
import 'package:life_helper/state/catalog_providers.dart';
|
||||
import 'package:life_helper/ui/screens/protocol_gallery_screen.dart';
|
||||
|
||||
ProtocolCatalogItem _protocol(
|
||||
{required String id,
|
||||
required String title,
|
||||
required DisplayCategory dc}) =>
|
||||
ProtocolCatalogItem(
|
||||
id: id,
|
||||
title: title,
|
||||
titleEn: null,
|
||||
summary: '$title 요약',
|
||||
displayCategory: dc,
|
||||
evidenceStrength: 'strong_rct',
|
||||
referenceIds: const [],
|
||||
what: 'w',
|
||||
whenText: 'wn',
|
||||
dose: 'd',
|
||||
why: 'y',
|
||||
how: const [],
|
||||
checkText: 'c',
|
||||
caution: null,
|
||||
defaultAnchor: null,
|
||||
minDoseForStart: null,
|
||||
sourceDoc: null,
|
||||
);
|
||||
|
||||
void main() {
|
||||
final fakeItems = [
|
||||
_protocol(id: 'a1', title: '아침 햇빛', dc: DisplayCategory.lightCircadian),
|
||||
_protocol(id: 's1', title: '수면 스택', dc: DisplayCategory.sleep),
|
||||
_protocol(id: 's2', title: '카페인', dc: DisplayCategory.sleep),
|
||||
];
|
||||
|
||||
Widget buildHarness(List<CatalogItem> items) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
catalogItemsProvider.overrideWith((ref) async => items),
|
||||
],
|
||||
child: const MaterialApp(home: ProtocolGalleryScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('카테고리 칩 + 카드 표시', (tester) async {
|
||||
await tester.pumpWidget(buildHarness(fakeItems));
|
||||
await tester.pump(); // resolve future
|
||||
|
||||
// 칩: 전체 + lightCircadian + sleep (2 카테고리만 — 빈 카테고리 미표시)
|
||||
expect(find.text('전체'), findsOneWidget);
|
||||
expect(find.text('빛/일주기'), findsOneWidget);
|
||||
expect(find.text('수면'), findsOneWidget);
|
||||
expect(find.text('영양'), findsNothing); // 빈 카테고리는 안 보임
|
||||
|
||||
// 카드 3개
|
||||
expect(find.text('아침 햇빛'), findsOneWidget);
|
||||
expect(find.text('수면 스택'), findsOneWidget);
|
||||
expect(find.text('카페인'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('칩 선택 시 필터링', (tester) async {
|
||||
await tester.pumpWidget(buildHarness(fakeItems));
|
||||
await tester.pump();
|
||||
|
||||
// 수면 칩 선택
|
||||
await tester.tap(find.text('수면'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('아침 햇빛'), findsNothing);
|
||||
expect(find.text('수면 스택'), findsOneWidget);
|
||||
expect(find.text('카페인'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('빈 결과 → "항목이 없습니다"', (tester) async {
|
||||
await tester.pumpWidget(buildHarness(const []));
|
||||
await tester.pump();
|
||||
expect(find.text('항목이 없습니다.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
81
app/test/ui/protocol_preview_screen_test.dart
Normal file
81
app/test/ui/protocol_preview_screen_test.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/domain/catalog/catalog_item.dart';
|
||||
import 'package:life_helper/domain/catalog/display_category.dart';
|
||||
import 'package:life_helper/state/catalog_providers.dart';
|
||||
import 'package:life_helper/ui/screens/protocol_preview_screen.dart';
|
||||
|
||||
ProtocolCatalogItem _sampleProtocol({List<String> refIds = const []}) =>
|
||||
ProtocolCatalogItem(
|
||||
id: 'morning_sunlight',
|
||||
title: '아침 햇빛',
|
||||
titleEn: 'Morning Sunlight',
|
||||
summary: '기상 후 햇빛',
|
||||
displayCategory: DisplayCategory.lightCircadian,
|
||||
evidenceStrength: 'strong_rct',
|
||||
referenceIds: refIds,
|
||||
what: '기상 후 햇빛 노출.',
|
||||
whenText: '기상 후 30~60분.',
|
||||
dose: '5~10분.',
|
||||
why: 'ipRGC 자극으로 일주기 리셋.',
|
||||
how: const ['밖으로 나간다', '하늘을 쳐다본다'],
|
||||
checkText: '60분 이내 외출',
|
||||
caution: '직사 응시 금지',
|
||||
defaultAnchor: const {'when': '기상 후', 'after_what': '세수'},
|
||||
minDoseForStart: '2분',
|
||||
sourceDoc: 'huberman-protocols.md',
|
||||
);
|
||||
|
||||
void main() {
|
||||
Widget buildHarness(CatalogItem item, {List<dynamic> refs = const []}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
referencesByIdsProvider.overrideWith((ref, ids) async => []),
|
||||
],
|
||||
child: MaterialApp(home: ProtocolPreviewScreen(item: item)),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('Protocol: 모든 핵심 필드 표시', (tester) async {
|
||||
final item = _sampleProtocol();
|
||||
await tester.pumpWidget(buildHarness(item));
|
||||
await tester.pump();
|
||||
|
||||
// Header + 상단 가시 필드.
|
||||
expect(find.text('아침 햇빛'), findsAtLeastNWidgets(1));
|
||||
expect(find.text('Morning Sunlight'), findsOneWidget);
|
||||
expect(find.text('빛/일주기'), findsOneWidget);
|
||||
|
||||
// ListView lazy-builds — 하위 필드는 명시적 스크롤로 가져온다.
|
||||
final scrollable = find.byType(Scrollable).first;
|
||||
for (final t in [
|
||||
'ipRGC 자극으로 일주기 리셋.',
|
||||
'1. 밖으로 나간다',
|
||||
'2. 하늘을 쳐다본다',
|
||||
'60분 이내 외출',
|
||||
'직사 응시 금지',
|
||||
'2분',
|
||||
'huberman-protocols.md',
|
||||
]) {
|
||||
await tester.scrollUntilVisible(find.text(t), 100,
|
||||
scrollable: scrollable);
|
||||
expect(find.text(t), findsOneWidget, reason: 'missing: $t');
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('"내 습관으로" 버튼 disabled + tooltip', (tester) async {
|
||||
await tester.pumpWidget(buildHarness(_sampleProtocol()));
|
||||
await tester.pump();
|
||||
|
||||
final button = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
expect(button.onPressed, isNull);
|
||||
expect(find.byType(Tooltip), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('reference 없으면 References 섹션 숨김', (tester) async {
|
||||
await tester.pumpWidget(buildHarness(_sampleProtocol(refIds: const [])));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('참고 ('), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user