generated from ttipo/project-base
feat(flutter): 일정을 서버에 저장 (ApiScheduleRepository)
일정 저장소가 "목록 전체 저장" 이라 서버에 맞지 않아 건별 계약으로 바꿨다. - 저장소: 날짜 일정 여러 건 만들기·내용 바꾸기·삭제, 반복 규칙 만들기·바꾸기·삭제, 완료 표시, 대상 떼기. 돌려받은 결과(id 가 붙은 일정·규칙)를 알림기가 ScheduleBook 에 반영한다 - 일반↔반복 전환은 알림기가 저장소 호출로 풀고, 새 것을 먼저 만든 뒤 옛것을 지운다(중간 실패에도 일정이 남는다). ScheduleBook.update 는 지우고 같은 규칙을 알림기 테스트로 옮겼다 - 서버 저장소: 불러올 때 날짜 일정·반복 규칙·완료 표시를 합치고, 기간 설정은 500건씩 나눠 보낸다. 아이 삭제·보관 때의 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다 - 완료 체크는 화면에 먼저 반영하고 저장에 실패하면 되돌린다 - 일정·아이 저장 화면은 실패하면(알림은 인터셉터) 입력을 둔 채 머문다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/utils/hour_minute.dart';
|
||||
import 'package:tma_app/features/schedule/data/api_schedule_repository.dart';
|
||||
import 'package:tma_app/features/schedule/domain/event_category.dart';
|
||||
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
|
||||
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
|
||||
|
||||
import 'support/fake_api.dart';
|
||||
|
||||
const EventDetails _details = EventDetails(
|
||||
title: '병원',
|
||||
kind: EventKind.reservation,
|
||||
category: EventCategory.hospital,
|
||||
targetIds: ['p1', 'self'],
|
||||
start: HourMinute(10, 30),
|
||||
end: HourMinute(11, 0),
|
||||
place: '동물병원',
|
||||
labelColor: Color(0xFFFF6B6B),
|
||||
);
|
||||
|
||||
Map<String, Object?> _detailsJson() => {
|
||||
'title': '병원',
|
||||
'kind': 'reservation',
|
||||
'category': 'hospital',
|
||||
'targetIds': ['p1', 'self'],
|
||||
'start': '10:30',
|
||||
'end': '11:00',
|
||||
'place': '동물병원',
|
||||
'labelColor': '#FF6B6B',
|
||||
};
|
||||
|
||||
Map<String, Object?> _eventJson(String id, String date, {bool isDone = false}) =>
|
||||
{'id': id, 'date': date, 'isDone': isDone, ..._detailsJson()};
|
||||
|
||||
void main() {
|
||||
late FakeApi api;
|
||||
late ApiScheduleRepository repo;
|
||||
|
||||
setUp(() {
|
||||
api = FakeApi();
|
||||
repo = ApiScheduleRepository(dio: api.dio, session: api.session);
|
||||
});
|
||||
|
||||
test('일정 내용은 서버 형식(HH:mm · #RRGGBB)으로 오가도 같다', () {
|
||||
expect(eventDetailsToApi(_details), _detailsJson());
|
||||
|
||||
final back = eventDetailsFromApi(_detailsJson());
|
||||
expect(back.start, const HourMinute(10, 30));
|
||||
expect(back.labelColor, const Color(0xFFFF6B6B));
|
||||
expect(back.targetIds, ['p1', 'self']);
|
||||
});
|
||||
|
||||
test('불러오기는 날짜 일정·반복 규칙·완료 표시를 하나의 일정 장부로 합친다', () async {
|
||||
api.on('GET', '/schedule-events', (r) => ok(page([
|
||||
_eventJson('e1', '2026-10-12', isDone: true),
|
||||
_eventJson('e2', '2026-10-13'),
|
||||
])));
|
||||
api.on('GET', '/repeat-rules', (r) => ok(page([
|
||||
{
|
||||
'id': 'r1',
|
||||
'from': '2026-10-11',
|
||||
'spec': {'mode': 'every', 'unit': 'week', 'n': 2},
|
||||
'details': _detailsJson(),
|
||||
},
|
||||
])));
|
||||
api.on('GET', '/repeat-rule-completions', (r) => ok(page([
|
||||
{'ruleId': 'r1', 'date': '2026-10-11'},
|
||||
])));
|
||||
|
||||
final book = await repo.fetch();
|
||||
|
||||
expect(book.events.map((e) => e.id), ['e1', 'e2']);
|
||||
expect(book.rules.single.spec, const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.week, n: 2));
|
||||
expect(book.doneIds, {'e1', 'r1@2026-10-11'});
|
||||
expect(book.eventsOn(DateTime(2026, 10, 25)).single.ruleId, 'r1');
|
||||
});
|
||||
|
||||
test('기간 설정처럼 많은 일정은 500건씩 나눠 보낸다', () async {
|
||||
final sizes = <int>[];
|
||||
api.on('POST', '/schedule-events', (r) {
|
||||
final events = r.json!['events'] as List<dynamic>;
|
||||
sizes.add(events.length);
|
||||
return ok([
|
||||
for (final (i, e) in events.indexed) _eventJson('e${sizes.length}-$i', (e as Map<String, dynamic>)['date'] as String),
|
||||
], status: 201);
|
||||
});
|
||||
|
||||
final drafts = [
|
||||
for (var i = 0; i < 1001; i++) (date: DateTime(2026, 1, 1).add(Duration(days: i)), details: _details),
|
||||
];
|
||||
final created = await repo.createEvents(drafts);
|
||||
|
||||
expect(sizes, [500, 500, 1]);
|
||||
expect(created, hasLength(1001));
|
||||
expect(created.first.date, DateTime(2026, 1, 1));
|
||||
});
|
||||
|
||||
test('완료 표시는 날짜 일정과 반복 일정의 경로가 다르다', () async {
|
||||
api.on('PUT', '/schedule-events/:id/completion', (r) => noContent);
|
||||
api.on('DELETE', '/repeat-rules/:id/completions/:date', (r) {
|
||||
expect(r.params, {'id': 'r1', 'date': '2026-10-11'});
|
||||
return noContent;
|
||||
});
|
||||
|
||||
await repo.setDone('e1', done: true);
|
||||
await repo.setDone(ScheduleEvent.instanceId('r1', DateTime(2026, 10, 11)), done: false);
|
||||
|
||||
expect(api.sent('PUT', '/schedule-events/e1/completion'), hasLength(1));
|
||||
expect(api.sent('DELETE', '/repeat-rules/r1/completions/2026-10-11'), hasLength(1));
|
||||
});
|
||||
|
||||
test('반복 주기·내용 바꾸기는 시작한 날을 보내지 않는다', () async {
|
||||
api.on('PUT', '/repeat-rules/:id', (r) {
|
||||
expect(r.json!.keys, unorderedEquals(['spec', 'details']));
|
||||
return ok({
|
||||
'id': 'r1',
|
||||
'from': '2026-10-11',
|
||||
'spec': {'mode': 'daily', 'unit': 'week', 'n': 1},
|
||||
'details': _detailsJson(),
|
||||
});
|
||||
});
|
||||
final rule = RepeatRule(
|
||||
id: 'r1',
|
||||
spec: const RepeatSpec(RepeatPattern.every),
|
||||
from: DateTime(2026, 10, 11),
|
||||
details: _details,
|
||||
);
|
||||
|
||||
final updated = await repo.updateRule(rule, const RepeatSpec(RepeatPattern.daily), _details);
|
||||
|
||||
expect(updated.spec.mode, RepeatPattern.daily);
|
||||
});
|
||||
|
||||
test('아이를 지울 때 일정 정리는 서버가 이미 했다 — 아무것도 보내지 않는다', () async {
|
||||
await repo.dropTarget('p1');
|
||||
|
||||
expect(api.requests, isEmpty);
|
||||
});
|
||||
|
||||
test('로그인 전에는 불러오지 않는다', () async {
|
||||
final signedOut = FakeApi(tokens: null);
|
||||
|
||||
final book = await ApiScheduleRepository(dio: signedOut.dio, session: signedOut.session).fetch();
|
||||
|
||||
expect(book.events, isEmpty);
|
||||
expect(signedOut.requests, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -105,33 +105,7 @@ void main() {
|
||||
expect(found?.isRepeat, isTrue);
|
||||
});
|
||||
|
||||
test('일반 일정을 반복으로 바꾸면 그 날부터 규칙이 된다', () {
|
||||
final target = _event('a', day, _details('산책'));
|
||||
final book = ScheduleBook(events: [target]).update(
|
||||
target,
|
||||
_details('산책'),
|
||||
spec: const RepeatSpec(RepeatPattern.daily),
|
||||
newEventId: () => 'e-new',
|
||||
newRuleId: () => 'r-new',
|
||||
);
|
||||
expect(book.events, isEmpty);
|
||||
expect(book.rules.single.from, day);
|
||||
expect(book.eventsOn(DateTime(2026, 9, 30)).single.details.title, '산책');
|
||||
});
|
||||
|
||||
test('반복을 끄면 보고 있던 날짜의 일정 하나만 남고 그날 완료 표시는 지운다', () {
|
||||
final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책'));
|
||||
final instance = rule.instanceOn(DateTime(2026, 10, 1));
|
||||
final book = ScheduleBook(rules: [rule], doneIds: {instance.id}).update(
|
||||
instance,
|
||||
_details('산책'),
|
||||
newEventId: () => 'e-new',
|
||||
newRuleId: () => 'r-new',
|
||||
);
|
||||
expect(book.rules, isEmpty);
|
||||
expect(book.events.single.date, DateTime(2026, 10, 1));
|
||||
expect(book.doneIds, isEmpty);
|
||||
});
|
||||
// 일반 ↔ 반복 전환 규칙은 저장소를 거치므로 schedule_notifier_test 가 검사한다
|
||||
|
||||
test('반복 일정을 지우면 규칙 전체가 지워진다', () {
|
||||
final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책'));
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/features/schedule/data/schedule_repository.dart';
|
||||
import 'package:tma_app/features/schedule/domain/event_category.dart';
|
||||
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
|
||||
import 'package:tma_app/features/schedule/domain/schedule_book.dart';
|
||||
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
|
||||
import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart';
|
||||
|
||||
const EventDetails _walk = EventDetails(
|
||||
title: '산책',
|
||||
kind: EventKind.general,
|
||||
category: EventCategory.walk,
|
||||
targetIds: ['pet-1'],
|
||||
);
|
||||
|
||||
final DateTime _day = DateTime(2026, 9, 30);
|
||||
|
||||
/// 저장소에 들어온 호출 순서를 기록한다 — 기기 저장소를 감싼다
|
||||
class _RecordingRepository implements ScheduleRepository {
|
||||
_RecordingRepository(this._inner);
|
||||
|
||||
final ScheduleRepository _inner;
|
||||
final List<String> calls = [];
|
||||
bool failSetDone = false;
|
||||
|
||||
@override
|
||||
Future<ScheduleBook> fetch() => _inner.fetch();
|
||||
|
||||
@override
|
||||
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) {
|
||||
calls.add('createEvents');
|
||||
return _inner.createEvents(drafts);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ScheduleEvent> updateEvent(ScheduleEvent event, EventDetails details) {
|
||||
calls.add('updateEvent');
|
||||
return _inner.updateEvent(event, details);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteEvent(String eventId) {
|
||||
calls.add('deleteEvent');
|
||||
return _inner.deleteEvent(eventId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RepeatRule> createRule(DateTime from, RepeatSpec spec, EventDetails details) {
|
||||
calls.add('createRule');
|
||||
return _inner.createRule(from, spec, details);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RepeatRule> updateRule(RepeatRule rule, RepeatSpec spec, EventDetails details) {
|
||||
calls.add('updateRule');
|
||||
return _inner.updateRule(rule, spec, details);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteRule(String ruleId) {
|
||||
calls.add('deleteRule');
|
||||
return _inner.deleteRule(ruleId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setDone(String occurrenceId, {required bool done}) {
|
||||
if (failSetDone) throw Exception('저장 실패');
|
||||
return _inner.setDone(occurrenceId, done: done);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dropTarget(String targetId) => _inner.dropTarget(targetId);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late ProviderContainer container;
|
||||
late _RecordingRepository repo;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
repo = _RecordingRepository(LocalScheduleRepository(prefs));
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
dataModeProvider.overrideWithValue(DataMode.local),
|
||||
scheduleRepositoryProvider.overrideWithValue(repo),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(scheduleProvider.future);
|
||||
});
|
||||
|
||||
ScheduleNotifier notifier() => container.read(scheduleProvider.notifier);
|
||||
ScheduleBook book() => container.read(scheduleBookProvider);
|
||||
|
||||
test('일반 일정을 반복으로 바꾸면 그 날부터 규칙이 된다 — 규칙을 먼저 만들고 일정을 지운다', () async {
|
||||
await notifier().addEvents([(date: _day, details: _walk)]);
|
||||
final target = book().events.single;
|
||||
repo.calls.clear();
|
||||
|
||||
await notifier().edit(target, _walk, spec: const RepeatSpec(RepeatPattern.daily));
|
||||
|
||||
expect(book().events, isEmpty);
|
||||
expect(book().rules.single.from, _day);
|
||||
expect(book().eventsOn(DateTime(2026, 10, 3)).single.details.title, '산책');
|
||||
expect(repo.calls, ['createRule', 'deleteEvent']);
|
||||
// 저장소에도 같은 결과가 남는다
|
||||
final saved = await repo.fetch();
|
||||
expect(saved.events, isEmpty);
|
||||
expect(saved.rules, hasLength(1));
|
||||
});
|
||||
|
||||
test('반복을 끄면 보고 있던 날짜의 일정 하나만 남고 그 규칙의 완료 표시는 지운다', () async {
|
||||
await notifier().addRule(_day, const RepeatSpec(RepeatPattern.daily), _walk);
|
||||
final instance = book().eventsOn(DateTime(2026, 10, 1)).single;
|
||||
await notifier().toggleDone(instance.id);
|
||||
expect(book().isDone(instance.id), isTrue);
|
||||
repo.calls.clear();
|
||||
|
||||
await notifier().edit(instance, _walk);
|
||||
|
||||
expect(book().rules, isEmpty);
|
||||
expect(book().events.single.date, DateTime(2026, 10, 1));
|
||||
expect(book().doneIds, isEmpty);
|
||||
expect(repo.calls, ['createEvents', 'deleteRule']);
|
||||
});
|
||||
|
||||
test('반복 주기만 바꾸면 같은 규칙이 남고 시작한 날도 그대로다', () async {
|
||||
await notifier().addRule(_day, const RepeatSpec(RepeatPattern.daily), _walk);
|
||||
final instance = book().eventsOn(DateTime(2026, 10, 2)).single;
|
||||
|
||||
await notifier().edit(instance, _walk, spec: const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.day, n: 2));
|
||||
|
||||
final rule = book().rules.single;
|
||||
expect(rule.id, instance.ruleId);
|
||||
expect(rule.from, _day);
|
||||
expect(rule.spec.label, '2일 마다');
|
||||
});
|
||||
|
||||
test('완료 체크를 저장하지 못하면 화면의 체크도 되돌린다', () async {
|
||||
await notifier().addEvents([(date: _day, details: _walk)]);
|
||||
final id = book().events.single.id;
|
||||
repo.failSetDone = true;
|
||||
|
||||
await expectLater(notifier().toggleDone(id), throwsException);
|
||||
|
||||
expect(book().isDone(id), isFalse);
|
||||
});
|
||||
|
||||
test('기간 설정으로 바꾸면 새 일정을 먼저 만들고 원래 일정을 지운다', () async {
|
||||
await notifier().addEvents([(date: _day, details: _walk)]);
|
||||
final target = book().events.single;
|
||||
repo.calls.clear();
|
||||
|
||||
await notifier().replace(target, [
|
||||
(date: DateTime(2026, 10, 1), details: _walk),
|
||||
(date: DateTime(2026, 10, 2), details: _walk),
|
||||
]);
|
||||
|
||||
expect(book().events.map((e) => e.date.day), [1, 2]);
|
||||
expect(repo.calls, ['createEvents', 'deleteEvent']);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user