feat(flutter): 설정 › 앱 정보에 업데이트 내역(목록 → 글)을 더한다

앱 버전 다음 줄에서 목록(제목 · 날짜, [더 보기])으로 들어가 줄을 누르면 글(날짜 · 제목 · 묶음, 다음 글 · 이전 글 줄)이 열린다.
없는 글 · 받지 못함 · 글 없음 상태를 두고, 날짜는 달력 날짜 글이라 시각으로 바꾸지 않는다(CalendarDate). 다른 글 줄은 지금 글을 바꿔 끼워 뒤로는 늘 목록이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 19:37:00 +09:00
parent 1b3f9ba7fe
commit 1d01130c9d
22 changed files with 2916 additions and 45 deletions
+11 -8
View File
@@ -226,12 +226,22 @@ void main() {
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, SettingsTexts.home), findsOneWidget);
final Finder settingsScroll = find.descendant(
of: find.byType(SettingsPage),
matching: find.byType(Scrollable),
);
// 아래쪽 절(문의)은 화면 밖이면 아직 그리지 않았으니 끌어 올려 찾는다(앱 정보에 업데이트 내역 줄이 늘었다)
for (final String item in <String>[
SettingsTexts.account,
SettingsTexts.notifications,
SettingsTexts.about,
SettingsTexts.support,
]) {
await tester.scrollUntilVisible(
page(item),
200,
scrollable: settingsScroll,
);
expect(page(item), findsOneWidget, reason: item);
}
@@ -240,14 +250,7 @@ void main() {
OutlinedButton,
SettingsTexts.signOut,
);
await tester.scrollUntilVisible(
signOut,
200,
scrollable: find.descendant(
of: find.byType(SettingsPage),
matching: find.byType(Scrollable),
),
);
await tester.scrollUntilVisible(signOut, 200, scrollable: settingsScroll);
await tester.pumpAndSettle();
await tester.tap(signOut);
await tester.pumpAndSettle();
@@ -0,0 +1,64 @@
import 'package:arasystem_app/core/utils/calendar_date.dart';
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:flutter_test/flutter_test.dart';
/// 달력 날짜 — 시각이 아닌 "그날"(업데이트 내역 글의 쓴 날 — 계약 7절). 시간대를 바꾸지 않는다
void main() {
group('CalendarDate.tryParse', () {
test('`YYYY-MM-DD` 를 연 · 월 · 일로 읽는다', () {
expect(
CalendarDate.tryParse('2026-11-18'),
const CalendarDate(2026, 11, 18),
);
expect(
CalendarDate.tryParse('2027-01-05'),
const CalendarDate(2027, 1, 5),
);
expect(
CalendarDate.tryParse('2028-02-29'),
const CalendarDate(2028, 2, 29),
reason: '윤년',
);
});
test('없는 날 · 모양이 다른 글은 null', () {
for (final Object? value in <Object?>[
'2026-02-29',
'2026-02-30',
'2026-04-31',
'2026-13-01',
'2026-00-10',
'2026-11-00',
'2026-1-5',
'2026-11-18-2',
'2026-11-18T00:00:00Z',
' 2026-11-18',
'2026-11-18',
'',
null,
20261118,
]) {
expect(CalendarDate.tryParse(value), isNull, reason: '$value');
}
});
test('글로 되돌리면 서버가 준 모양 그대로', () {
expect(CalendarDate.tryParse('2026-01-05').toString(), '2026-01-05');
});
});
group('koreanCalendarDate — 웹 { year · month: long · day } 와 같은 글', () {
test('"2026년 11월 18일" — 앞자리 0 없이', () {
expect(
koreanCalendarDate(const CalendarDate(2026, 11, 18)),
'2026년 11월 18일',
);
expect(koreanCalendarDate(const CalendarDate(2027, 1, 5)), '2027년 1월 5일');
});
test('시간대와 상관없이 그날 — 시각으로 바꾸지 않는다(UTC 자정이면 음수 시간대에서 하루 앞이 된다)', () {
final CalendarDate date = CalendarDate.tryParse('2026-11-01')!;
expect(koreanCalendarDate(date), '2026년 11월 1일');
});
});
}
@@ -0,0 +1,477 @@
import 'dart:async';
import 'package:arasystem_app/core/api/error_lexicon.dart';
import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart';
import 'package:arasystem_app/features/settings/presentation/pages/release_note_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/release_notes_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_notes_list_controller.dart';
import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart';
import 'package:arasystem_app/features/settings/settings_routes.dart';
import 'package:arasystem_app/shared/widgets/load_state_texts.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
import '../../support/mail_harness.dart';
import '../../support/push_screen_harness.dart';
import '../../support/release_notes_fakes.dart';
/// 업데이트 내역 — 보드 `ReleaseNotes` B(목록 · 글 · 상태). 로그인한 앱 전체를 가짜 서버로 띄워 본다
void main() {
late MailAppHarness h;
late FakeReleaseNotesBackend notes;
setUp(() {
h = MailAppHarness();
notes = FakeReleaseNotesBackend(h.server)..install();
});
/// 앱을 띄워 [location] 까지 그리고 응답이 모두 올 때까지
Future<void> open(WidgetTester tester, String location) async {
await h.pump(tester, location: location);
await settle(tester);
}
Future<void> openList(WidgetTester tester) =>
open(tester, SettingsRoutes.releaseNotes);
ReleaseNotesListState listState() =>
h.container.read(releaseNotesListProvider);
/// 글 화면의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
String noteLocation(WidgetTester tester) => GoRouterState.of(
tester.element(find.byType(ReleaseNotePage)),
).uri.toString();
Finder moreButton() =>
find.widgetWithText(OutlinedButton, ReleaseNotesTexts.more);
Finder neighbor(String label, String date) =>
find.text(ReleaseNotesTexts.neighborLine(label, date));
Future<void> scrollTo(WidgetTester tester, Finder finder) async {
await tester.scrollUntilVisible(
finder,
500,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
}
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pumpAndSettle();
await tester.tap(finder);
await settle(tester);
}
Future<void> back(WidgetTester tester) async {
await tester.tap(find.byTooltip(ReleaseNotesTexts.back));
await settle(tester);
}
group('목록 — 보드 B2', () {
testWidgets('최근 글부터 줄마다 제목 · 날짜 · ›, 한 쪽(20개)에 다 들면 [더 보기]가 없다', (
WidgetTester tester,
) async {
await openList(tester);
expect(find.byType(ReleaseNotesPage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing, reason: '탭 막대 없는 전체 화면');
expect(find.text(ReleaseNotesTexts.title), findsOneWidget);
final List<double> tops = <double>[
for (final FakeReleaseNote note in kBoardReleaseNotes)
tester.getTopLeft(findCopy(note.title)).dy,
];
for (int index = 1; index < tops.length; index++) {
expect(tops[index - 1], lessThan(tops[index]), reason: '최근 글부터');
}
for (final String date in <String>[
'2026년 11월 18일',
'2026년 11월 5일',
'2026년 11월 4일',
'2026년 11월 3일',
]) {
expect(find.text(date), findsOneWidget, reason: date);
}
expect(find.byIcon(Icons.chevron_right), findsNWidgets(4));
expect(moreButton(), findsNothing);
final RequestOptions request = notes.listRequests.single;
expect(request.queryParameters['limit'], kReleaseNotesPageSize);
expect(request.queryParameters.containsKey('cursor'), isFalse);
expect(h.notifier.shownCodes, isEmpty);
});
testWidgets('줄은 최소 68 이고 화면 낭독기는 제목 · 날짜를 단추 하나로 읽는다', (
WidgetTester tester,
) async {
await openList(tester);
final Finder row = find.ancestor(
of: findCopy(kBoardReleaseNotes.first.title),
matching: find.byType(InkWell),
);
expect(tester.getSize(row).height, greaterThanOrEqualTo(68));
expect(
tester.getSemantics(row),
isSemantics(
label: '${kBoardReleaseNotes.first.title}\n2026년 11월 18일',
isButton: true,
),
);
});
testWidgets('[더 보기] — 다음 쪽을 뒤에 붙인다. 받는 동안은 그 단추만 바쁨 표시이고 다시 누르지 못한다', (
WidgetTester tester,
) async {
notes.seed(25);
await openList(tester);
expect(listState().items, hasLength(20));
await scrollTo(tester, moreButton());
final Completer<void> gate = Completer<void>();
notes.listGate = gate;
await tester.tap(moreButton());
// 요청이 가짜 서버에 닿을 때까지(시계가 가야 온다) — 도는 표시가 있어 pumpAndSettle 은 쓰지 않는다
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(notes.listRequests, hasLength(2));
expect(
notes.listRequests.last.queryParameters['cursor'],
'${FakeReleaseNotesBackend.cursorPrefix}20',
);
expect(
find.descendant(
of: moreButton(),
matching: find.byType(CircularProgressIndicator),
),
findsOneWidget,
);
expect(
tester.getSemantics(find.bySemanticsLabel(ReleaseNotesTexts.more)),
isSemantics(
label: ReleaseNotesTexts.more,
value: ReleaseNotesTexts.moreBusy,
isButton: true,
hasEnabledState: true,
isEnabled: false,
),
);
// 받는 동안 다시 눌러도 겹쳐 보내지 않는다
await tester.tap(moreButton());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(notes.listRequests, hasLength(2));
expect(listState().loadingMore, isTrue);
notes.listGate = null;
gate.complete();
await settle(tester);
expect(listState().items, hasLength(25));
await scrollTo(tester, findCopy('업데이트 1'));
expect(moreButton(), findsNothing, reason: '마지막 쪽');
expect(h.notifier.shownCodes, isEmpty);
});
testWidgets('[더 보기]를 받지 못하면 알리고 받은 줄은 그대로 — 다시 누르면 받는다', (
WidgetTester tester,
) async {
notes.seed(25);
await openList(tester);
await scrollTo(tester, moreButton());
notes.nextListResponse = FakeResponse.fail(500, 'SYS_001');
await tester.tap(moreButton());
await settle(tester);
expect(h.notifier.shownCodes, <String>['SYS_001']);
expect(listState().items, hasLength(20));
expect(listState().loadingMore, isFalse);
expect(
find.descendant(
of: moreButton(),
matching: find.byType(CircularProgressIndicator),
),
findsNothing,
);
await tester.tap(moreButton());
await settle(tester);
expect(listState().items, hasLength(25));
});
testWidgets('[더 보기] 응답의 모양이 틀리면 화면이 알린다(SYS_001) — 받은 줄은 그대로', (
WidgetTester tester,
) async {
notes.seed(25);
await openList(tester);
await scrollTo(tester, moreButton());
notes.nextListResponse = FakeResponse.ok(<String, Object?>{
'items': '모양이 틀림',
});
await tester.tap(moreButton());
await settle(tester);
expect(h.notifier.shownCodes, <String>['SYS_001']);
expect(listState().items, hasLength(20));
expect(moreButton(), findsOneWidget);
});
testWidgets('뒤로 — 설정 탭으로 돌아온다', (WidgetTester tester) async {
await openList(tester);
await back(tester);
expect(find.byType(ReleaseNotesPage), findsNothing);
expect(find.byType(SettingsPage), findsOneWidget);
expect(find.byType(NavigationBar), findsOneWidget);
expect(h.location(), SettingsRoutes.home);
});
});
group('목록의 상태 — 보드 B7 · B8 · B9', () {
testWidgets('받는 동안 줄 자리를 막대로 잡아 둔다 — 화면 낭독기는 "업데이트 내역 불러오는 중"', (
WidgetTester tester,
) async {
final Completer<void> gate = Completer<void>();
notes.listGate = gate;
await h.pump(tester, location: SettingsRoutes.releaseNotes);
expect(
find.bySemanticsLabel(ReleaseNotesTexts.listLoading),
findsOneWidget,
);
expect(findCopy(kBoardReleaseNotes.first.title), findsNothing);
notes.listGate = null;
gate.complete();
await settle(tester);
expect(
find.bySemanticsLabel(ReleaseNotesTexts.listLoading),
findsNothing,
);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
});
testWidgets(
'받지 못하면 그 자리에 사전 문구 · 문의 코드 · [다시 시도] — 알리지 않고, [다시 시도]면 다시 받는다',
(WidgetTester tester) async {
notes.nextListResponse = FakeResponse.fail(500, 'SYS_001');
await openList(tester);
expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget);
expect(find.text(LoadStateTexts.traceCode('trace-SY')), findsOneWidget);
expect(find.text(LoadStateTexts.retry), findsOneWidget);
expect(h.notifier.shownCodes, isEmpty);
await tester.tap(find.text(LoadStateTexts.retry));
await settle(tester);
expect(notes.listRequests, hasLength(2));
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
expect(h.notifier.shownCodes, isEmpty);
},
);
testWidgets('목록은 빠져나갈 곳이 없어 없는 경로(404)여도 [다시 시도]다', (
WidgetTester tester,
) async {
notes.nextListResponse = FakeResponse.fail(404, 'RES_001');
await openList(tester);
expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget);
expect(find.text(LoadStateTexts.retry), findsOneWidget);
expect(find.text(ReleaseNotesTexts.toList), findsNothing);
});
testWidgets('글이 없으면 한 줄 — "아직 알려 드릴 업데이트가 없습니다."', (
WidgetTester tester,
) async {
notes.notes = const <FakeReleaseNote>[];
await openList(tester);
expect(findCopy(ReleaseNotesTexts.empty), findsOneWidget);
expect(find.byIcon(Icons.description_outlined), findsOneWidget);
expect(moreButton(), findsNothing);
expect(find.text(LoadStateTexts.retry), findsNothing);
});
});
group('글 — 보드 B3 · B4', () {
testWidgets('줄을 누르면 글 — 날짜 · 제목 · 묶음(머리 · 점 목록), 맨 위 글은 이전 글 줄만', (
WidgetTester tester,
) async {
await openList(tester);
await tapVisible(tester, findCopy(kBoardReleaseNotes.first.title));
expect(find.byType(ReleaseNotePage), findsOneWidget);
expect(noteLocation(tester), SettingsRoutes.releaseNote('2026-11-18'));
expect(
find.byType(ReleaseNotesPage, skipOffstage: false),
findsOneWidget,
reason: '글은 목록 위에 하나만 쌓인다',
);
expect(find.byType(NavigationBar), findsNothing);
expect(notes.noteRequests, <String>['2026-11-18']);
expect(find.text('2026년 11월 18일'), findsOneWidget);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
expect(findCopy('휴대폰 앱'), findsOneWidget);
expect(findCopy('메일 검색'), findsOneWidget);
expect(
findCopy('안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.'),
findsOneWidget,
);
expect(findCopy('찾은 말에는 노란 표시가 붙습니다.'), findsOneWidget);
expect(
tester.getTopLeft(findCopy('휴대폰 앱')).dy,
lessThan(tester.getTopLeft(findCopy('메일 검색')).dy),
);
await tester.ensureVisible(
neighbor(ReleaseNotesTexts.older, '2026년 11월 5일'),
);
await tester.pumpAndSettle();
expect(neighbor(ReleaseNotesTexts.older, '2026년 11월 5일'), findsOneWidget);
expect(find.textContaining(ReleaseNotesTexts.newer), findsNothing);
expect(h.notifier.shownCodes, isEmpty);
});
testWidgets('가운데 글 — 다음 글(더 새 글)이 위 · 이전 글(더 옛 글)이 아래, 머리 없는 묶음은 줄만', (
WidgetTester tester,
) async {
await open(tester, SettingsRoutes.releaseNote('2026-11-05'));
expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget);
expect(findCopy('단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.'), findsOneWidget);
final Finder newer = neighbor(ReleaseNotesTexts.newer, '2026년 11월 18일');
final Finder older = neighbor(ReleaseNotesTexts.older, '2026년 11월 4일');
expect(newer, findsOneWidget);
expect(older, findsOneWidget);
expect(
tester.getTopLeft(newer).dy,
lessThan(tester.getTopLeft(older).dy),
);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
expect(findCopy('왼쪽 메뉴를 정리했습니다'), findsOneWidget);
final Finder row = find.ancestor(
of: newer,
matching: find.byType(InkWell),
);
expect(tester.getSize(row).height, greaterThanOrEqualTo(64));
expect(
tester.getSemantics(row),
isSemantics(
label:
'${ReleaseNotesTexts.newer}, 2026년 11월 18일\n'
'${kBoardReleaseNotes.first.title}',
isButton: true,
),
);
});
testWidgets('다른 글 줄을 누르면 그 글로 바꿔 끼운다 — 뒤로는 늘 목록', (
WidgetTester tester,
) async {
await openList(tester);
await tapVisible(tester, findCopy(kBoardReleaseNotes.first.title));
await tapVisible(
tester,
neighbor(ReleaseNotesTexts.older, '2026년 11월 5일'),
);
expect(find.byType(ReleaseNotePage, skipOffstage: false), findsOneWidget);
expect(noteLocation(tester), SettingsRoutes.releaseNote('2026-11-05'));
expect(findCopy('단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.'), findsOneWidget);
expect(notes.noteRequests, <String>['2026-11-18', '2026-11-05']);
await back(tester);
expect(find.byType(ReleaseNotePage), findsNothing);
expect(find.byType(ReleaseNotesPage), findsOneWidget);
expect(findCopy(kBoardReleaseNotes.last.title), findsOneWidget);
});
testWidgets('주소로 바로 연 글도 뒤로는 목록이다', (WidgetTester tester) async {
await open(tester, SettingsRoutes.releaseNote('2026-11-04'));
expect(findCopy('왼쪽 메뉴를 정리했습니다'), findsOneWidget);
await back(tester);
expect(find.byType(ReleaseNotesPage), findsOneWidget);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
});
});
group('글의 상태 — 보드 B5 · B6', () {
testWidgets('없는 글(404 RES_001)은 알리지 않고 사전 문구 · 문의 코드 · [목록으로] — 누르면 목록', (
WidgetTester tester,
) async {
await open(tester, SettingsRoutes.releaseNote('2026-01-01'));
expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget);
expect(find.text(LoadStateTexts.traceCode('trace-RE')), findsOneWidget);
expect(find.text(ReleaseNotesTexts.toList), findsOneWidget);
expect(find.text(LoadStateTexts.retry), findsNothing);
expect(h.notifier.shownCodes, isEmpty);
await tester.tap(find.text(ReleaseNotesTexts.toList));
await settle(tester);
expect(find.byType(ReleaseNotePage), findsNothing);
expect(find.byType(ReleaseNotesPage), findsOneWidget);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
});
testWidgets('그 밖의 실패는 사전 문구 · 문의 코드 · [다시 시도] — 다시 받으면 글', (
WidgetTester tester,
) async {
notes.nextNoteResponse = FakeResponse.fail(500, 'SYS_001');
await open(tester, SettingsRoutes.releaseNote('2026-11-05'));
expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget);
expect(find.text(LoadStateTexts.traceCode('trace-SY')), findsOneWidget);
expect(find.text(ReleaseNotesTexts.toList), findsNothing);
expect(h.notifier.shownCodes, isEmpty);
await tester.tap(find.text(LoadStateTexts.retry));
await settle(tester);
expect(notes.noteRequests, <String>['2026-11-05', '2026-11-05']);
expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget);
});
testWidgets('받는 동안 글 자리를 막대로 잡아 둔다 — 화면 낭독기는 "글 불러오는 중"', (
WidgetTester tester,
) async {
final Completer<void> gate = Completer<void>();
notes.noteGate = gate;
await h.pump(tester, location: SettingsRoutes.releaseNote('2026-11-05'));
expect(
find.bySemanticsLabel(ReleaseNotesTexts.noteLoading),
findsOneWidget,
);
notes.noteGate = null;
gate.complete();
await settle(tester);
expect(
find.bySemanticsLabel(ReleaseNotesTexts.noteLoading),
findsNothing,
);
expect(findCopy('화면의 안내 글을 다듬었습니다'), findsOneWidget);
});
});
}
@@ -0,0 +1,483 @@
import 'dart:async';
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/utils/calendar_date.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:arasystem_app/features/settings/data/models/release_note.dart';
import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_note_controller.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_notes_list_controller.dart';
import 'package:arasystem_app/features/settings/presentation/providers/release_notes_providers.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/provider_harness.dart';
import '../../support/release_notes_fakes.dart';
/// 업데이트 내역을 보는 직원 — 시험이 바꾼다(세션이 바뀜)
class _Account extends Notifier<String?> {
@override
String? build() => 'ara.kim@arasystem.it.kr';
void set(String? value) => state = value;
}
final NotifierProvider<_Account, String?> _accountProvider =
NotifierProvider<_Account, String?>(_Account.new);
/// 끝난 요청을 세는 저장소 — 늦은 응답이 컨트롤러에 닿은 뒤를 기다리려고(고정 대기 없이)
class _RecordingRepository extends ReleaseNotesRepository {
_RecordingRepository(super.dio);
int listsFinished = 0;
int notesFinished = 0;
@override
Future<ReleaseNotesPage> listNotes({
String? cursor,
int limit = kReleaseNotesPageSize,
bool silent = false,
}) async {
try {
return await super.listNotes(
cursor: cursor,
limit: limit,
silent: silent,
);
} finally {
listsFinished++;
}
}
@override
Future<ReleaseNote> getNote(String id) async {
try {
return await super.getNote(id);
} finally {
notesFinished++;
}
}
}
/// 업데이트 내역 — 모델 읽기 · 저장소 · 목록 · 글 컨트롤러 (계약 7절)
void main() {
group('모델 읽기', () {
Map<String, Object?> summary(String id, {String? date, String? title}) =>
<String, Object?>{
'id': id,
'date': date ?? id.substring(0, 10),
'title': title ?? '제목 $id',
};
test('목록 한 쪽 — 줄 · 다음 쪽 · 글 수', () {
final ReleaseNotesPage page = ReleaseNotesPage.tryParse(<String, Object?>{
'items': <Object?>[summary('2026-11-18-2'), summary('2026-11-18')],
'pageInfo': <String, Object?>{
'hasNext': true,
'nextCursor': 'abc',
'total': 41,
},
})!;
expect(page.items.map((ReleaseNoteSummary n) => n.id), <String>[
'2026-11-18-2',
'2026-11-18',
]);
expect(page.items.first.date, const CalendarDate(2026, 11, 18));
expect(page.items.first.title, '제목 2026-11-18-2');
expect(page.hasMore, isTrue);
expect(page.total, 41);
});
test('마지막 쪽 — 커서가 없으면 더 없다(hasNext 여도) · 글 수는 없어도 된다', () {
final ReleaseNotesPage last = ReleaseNotesPage.tryParse(<String, Object?>{
'items': <Object?>[],
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
})!;
expect(last.items, isEmpty);
expect(last.hasMore, isFalse);
expect(last.total, isNull);
final ReleaseNotesPage noCursor = ReleaseNotesPage.tryParse(
<String, Object?>{
'items': <Object?>[],
'pageInfo': <String, Object?>{'hasNext': true, 'nextCursor': ''},
},
)!;
expect(noCursor.hasMore, isFalse);
});
test('모양이 틀리면 null — 줄 하나라도(없는 날 · 빈 id · 숫자 제목)', () {
Map<String, Object?> pageOf(List<Object?> items) => <String, Object?>{
'items': items,
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
};
expect(
ReleaseNotesPage.tryParse(
pageOf(<Object?>[summary('2026-11-18'), summary('2026-02-30')]),
),
isNull,
);
expect(
ReleaseNotesPage.tryParse(
pageOf(<Object?>[summary('', date: '2026-11-18')]),
),
isNull,
);
expect(
ReleaseNotesPage.tryParse(
pageOf(<Object?>[
<String, Object?>{
'id': '2026-11-18',
'date': '2026-11-18',
'title': 3,
},
]),
),
isNull,
);
expect(
ReleaseNotesPage.tryParse(<String, Object?>{'items': <Object?>[]}),
isNull,
);
expect(ReleaseNotesPage.tryParse('목록'), isNull);
});
test('글 — 묶음(머리 없는 묶음 포함) · 바로 더 옛 글 · 바로 더 새 글', () {
final ReleaseNote note = ReleaseNote.tryParse(<String, Object?>{
...summary('2026-11-05', title: '화면의 안내 글을 다듬었습니다'),
'sections': <Object?>[
<String, Object?>{
'heading': '메일 · 메일함',
'items': <Object?>['한 줄', '두 줄'],
},
<String, Object?>{
'heading': null,
'items': <Object?>['머리 없는 줄'],
},
],
'previous': summary('2026-11-04'),
'next': summary('2026-11-18'),
})!;
expect(note.id, '2026-11-05');
expect(note.date, const CalendarDate(2026, 11, 5));
expect(note.title, '화면의 안내 글을 다듬었습니다');
expect(note.sections, const <ReleaseNoteSection>[
ReleaseNoteSection(heading: '메일 · 메일함', items: <String>['한 줄', '두 줄']),
ReleaseNoteSection(heading: null, items: <String>['머리 없는 줄']),
]);
expect(note.previous?.id, '2026-11-04');
expect(note.next?.id, '2026-11-18');
});
test('다른 글이 없으면(null) · 칸이 아예 없으면 없는 것', () {
final ReleaseNote first = ReleaseNote.tryParse(<String, Object?>{
...summary('2026-11-18'),
'sections': <Object?>[],
'previous': null,
'next': null,
})!;
expect(first.previous, isNull);
expect(first.next, isNull);
final ReleaseNote old = ReleaseNote.tryParse(<String, Object?>{
...summary('2026-11-18'),
'sections': <Object?>[],
})!;
expect(old.previous, isNull);
expect(old.next, isNull);
});
test('글의 모양이 틀리면 null — 묶음 · 다른 글 하나라도', () {
Map<String, Object?> noteWith(Map<String, Object?> extra) =>
<String, Object?>{
...summary('2026-11-18'),
'sections': <Object?>[],
...extra,
};
expect(
ReleaseNote.tryParse(
noteWith(<String, Object?>{
'sections': <Object?>[
<String, Object?>{
'heading': 1,
'items': <Object?>['줄'],
},
],
}),
),
isNull,
);
expect(
ReleaseNote.tryParse(
noteWith(<String, Object?>{
'sections': <Object?>[
<String, Object?>{
'heading': null,
'items': <Object?>['줄', null],
},
],
}),
),
isNull,
);
expect(
ReleaseNote.tryParse(
noteWith(<String, Object?>{
'previous': <String, Object?>{'id': '2026-11-04'},
}),
),
isNull,
);
expect(
ReleaseNote.tryParse(noteWith(<String, Object?>{'next': '2026-11-19'})),
isNull,
);
expect(
ReleaseNote.tryParse(summary('2026-11-18')),
isNull,
reason: '묶음 없음',
);
});
});
group('저장소 · 컨트롤러', () {
late ProviderHarness h;
late FakeReleaseNotesBackend notes;
late ProviderContainer container;
late _RecordingRepository recording;
setUp(() async {
h = ProviderHarness();
notes = FakeReleaseNotesBackend(h.server)..install();
await h.prepare();
await h.store.save(tokensFor(1, now: h.clock.now));
container = ProviderContainer(
overrides: <Override>[
...h.overrides,
releaseNotesAccountProvider.overrideWith(
(Ref ref) => ref.watch(_accountProvider),
),
releaseNotesRepositoryProvider.overrideWith(
(Ref ref) =>
recording = _RecordingRepository(ref.watch(dioProvider)),
),
],
retry: noAutomaticRetry,
);
addTearDown(container.dispose);
});
ReleaseNotesRepository repository() =>
container.read(releaseNotesRepositoryProvider);
/// 목록을 열고(화면이 보는 것처럼 구독한다) 첫 쪽이 끝날 때까지 기다린다
Future<void> openList() async {
final ProviderSubscription<ReleaseNotesListState> subscription = container
.listen(
releaseNotesListProvider,
(ReleaseNotesListState? previous, ReleaseNotesListState next) {},
);
addTearDown(subscription.close);
await pumpEventsUntil(
() =>
container.read(releaseNotesListProvider).status !=
ReleaseNotesListStatus.loading,
reason: '첫 쪽',
);
}
ReleaseNotesListState listState() =>
container.read(releaseNotesListProvider);
/// [condition] 이 된 뒤 한 차례 더 — 응답을 받은 컨트롤러가 상태를 바꿀 자리까지
Future<void> settleAfter(bool Function() condition) async {
await pumpEventsUntil(condition);
await Future<void>.delayed(Duration.zero);
}
test(
'목록 — `GET /release-notes?limit=20`, 다음 쪽은 받은 커서 그대로 · id 는 경로 칸으로 인코딩',
() async {
notes.seed(3);
final ReleaseNotesPage first = await repository().listNotes();
final ReleaseNotesPage next = await repository().listNotes(
cursor: 'n2',
);
await repository().getNote('2026-11-30');
expect(first.items, hasLength(3));
expect(next.items.single.id, '2026-11-28');
expect(notes.listRequests.first.queryParameters, <String, Object?>{
'limit': kReleaseNotesPageSize,
});
expect(notes.listRequests.last.queryParameters['cursor'], 'n2');
expect(
ReleaseNotesApiPaths.releaseNote('2026/11 18'),
'/release-notes/2026%2F11%2018',
);
expect(notes.noteRequests, <String>['2026-11-30']);
},
);
test(
'응답 모양이 틀리면 SYS_001 — 인터셉터를 다 지난 뒤라 따로 가른다(ReleaseNotesMalformedResponse)',
() async {
notes.nextListResponse = FakeResponse.ok(<String, Object?>{'items': 1});
final Object error = await errorOf(repository().listNotes());
expect(error, isA<ReleaseNotesMalformedResponse>());
expect(apiErrorOf(error).code, 'SYS_001');
expect(h.notifier.shownCodes, isEmpty, reason: '저장소는 알리지 않는다');
},
);
test('글 — 없는 글은 404 RES_001 이고 알리지 않는다', () async {
final Object error = await errorOf(repository().getNote('2026-01-01'));
expect(apiErrorOf(error).code, 'RES_001');
expect(h.notifier.shownCodes, isEmpty);
});
test('첫 쪽은 알리지 않는다 — 받지 못함을 상태에 두고 [다시 시도]로 다시 받는다', () async {
notes.nextListResponse = FakeResponse.fail(503, 'MAIL_001');
await openList();
expect(listState().status, ReleaseNotesListStatus.failed);
expect(listState().error?.code, 'MAIL_001');
expect(h.notifier.shownCodes, isEmpty);
await container.read(releaseNotesListProvider.notifier).retry();
expect(listState().status, ReleaseNotesListStatus.ready);
expect(listState().items, hasLength(kBoardReleaseNotes.length));
});
test('[더 보기]의 겹친 글은 건너뛴다 — 그사이 글이 더해져 자리가 밀렸을 때', () async {
notes.seed(21);
await openList();
// 새 글이 맨 위에 더해졌다 — 다음 쪽(21번째 자리부터)에 이미 받은 마지막 줄이 다시 온다
notes.notes = <FakeReleaseNote>[
const FakeReleaseNote('2026-12-01', '업데이트 22'),
...notes.notes,
];
await container.read(releaseNotesListProvider.notifier).loadMore();
final List<String> ids = <String>[
for (final ReleaseNoteSummary note in listState().items) note.id,
];
expect(ids, hasLength(21));
expect(ids.toSet(), hasLength(21));
expect(listState().hasMore, isFalse);
});
test('세션이 바뀌면 처음부터 — 앞 세션의 늦은 응답은 버린다', () async {
notes.seed(2);
final Completer<void> late = Completer<void>();
int calls = 0;
notes.onList = (RequestOptions options) async {
calls++;
if (calls == 1) {
await late.future;
return FakeResponse.ok(<String, Object?>{
'items': <Object?>[
const FakeReleaseNote('2020-01-01', '앞 세션의 글').summaryJson(),
],
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
});
}
return notes.listPage(options);
};
final ProviderSubscription<ReleaseNotesListState> subscription = container
.listen(
releaseNotesListProvider,
(ReleaseNotesListState? previous, ReleaseNotesListState next) {},
);
addTearDown(subscription.close);
await pumpEventsUntil(() => notes.listRequests.length == 1);
container.read(_accountProvider.notifier).set('other@arasystem.it.kr');
await pumpEventsUntil(
() => listState().status == ReleaseNotesListStatus.ready,
reason: '새 세션의 첫 쪽',
);
expect(notes.listRequests, hasLength(2));
expect(listState().items.map((ReleaseNoteSummary n) => n.title), <String>[
'업데이트 2',
'업데이트 1',
]);
late.complete();
await settleAfter(() => recording.listsFinished == 2);
expect(listState().status, ReleaseNotesListStatus.ready);
expect(listState().items.map((ReleaseNoteSummary n) => n.title), <String>[
'업데이트 2',
'업데이트 1',
]);
});
test('로그인하지 않았으면(세션 없음) 묻지 않는다', () async {
container.read(_accountProvider.notifier).set(null);
final ProviderSubscription<ReleaseNotesListState> list = container.listen(
releaseNotesListProvider,
(ReleaseNotesListState? previous, ReleaseNotesListState next) {},
);
addTearDown(list.close);
final ProviderSubscription<ReleaseNoteState> note = container.listen(
releaseNoteProvider('2026-11-18'),
(ReleaseNoteState? previous, ReleaseNoteState next) {},
);
addTearDown(note.close);
await Future<void>.delayed(Duration.zero);
expect(listState().status, ReleaseNotesListStatus.loading);
expect(notes.listRequests, isEmpty);
expect(notes.noteRequests, isEmpty);
});
test('글 — 세션이 바뀌면 다시 받고, 앞 세션의 늦은 응답(실패)은 버린다', () async {
final Completer<void> late = Completer<void>();
int calls = 0;
notes.onNote = (RequestOptions options) async {
calls++;
if (calls == 1) {
await late.future;
return FakeResponse.fail(500, 'SYS_001');
}
return notes.noteDetail('2026-11-05');
};
final ProviderSubscription<ReleaseNoteState> subscription = container
.listen(
releaseNoteProvider('2026-11-05'),
(ReleaseNoteState? previous, ReleaseNoteState next) {},
);
addTearDown(subscription.close);
await pumpEventsUntil(() => notes.noteRequests.length == 1);
container.read(_accountProvider.notifier).set('other@arasystem.it.kr');
await pumpEventsUntil(
() =>
container.read(releaseNoteProvider('2026-11-05')).status ==
ReleaseNoteStatus.ready,
reason: '새 세션의 글',
);
expect(notes.noteRequests, hasLength(2));
late.complete();
await settleAfter(() => recording.notesFinished == 2);
final ReleaseNoteState state = container.read(
releaseNoteProvider('2026-11-05'),
);
expect(state.status, ReleaseNoteStatus.ready);
expect(state.note?.title, '화면의 안내 글을 다듬었습니다');
expect(state.error, isNull);
});
});
}
@@ -8,6 +8,9 @@ import 'package:arasystem_app/core/push/push_messaging.dart';
import 'package:arasystem_app/core/push/push_preferences.dart';
import 'package:arasystem_app/core/push/push_texts.dart';
import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/release_notes_page.dart';
import 'package:arasystem_app/features/settings/presentation/pages/settings_page.dart';
import 'package:arasystem_app/features/settings/presentation/release_notes_texts.dart';
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -16,6 +19,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../support/fakes.dart';
import '../../support/login_fakes.dart';
import '../../support/push_screen_harness.dart';
import '../../support/release_notes_fakes.dart';
/// 설정 탭 — 앱 보드 `App1Shell`(설정 한 화면 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄)
void main() {
@@ -102,6 +106,31 @@ void main() {
expect(find.byType(LicensePage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
});
testWidgets(
'[업데이트 내역]은 탭 막대 없이 전체 화면 목록으로 연다 — 뒤로 오면 설정 (보드 ReleaseNotes B1)',
(WidgetTester tester) async {
final FakeReleaseNotesBackend notes = FakeReleaseNotesBackend(h.server)
..install();
await h.pump(tester);
await h.openSettings(tester);
await tapVisible(tester, find.text(ReleaseNotesTexts.title));
expect(find.byType(ReleaseNotesPage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
expect(findCopy(kBoardReleaseNotes.first.title), findsOneWidget);
expect(notes.listRequests, hasLength(1));
await tester.tap(find.byTooltip(ReleaseNotesTexts.back));
await settle(tester);
expect(find.byType(ReleaseNotesPage), findsNothing);
expect(find.byType(SettingsPage), findsOneWidget);
expect(find.byType(NavigationBar), findsOneWidget);
expect(h.app.location(), '/settings');
},
);
});
group('설정 › 앱 정보 › [새 버전 받기] — 앱 보드 App1Push ④', () {
@@ -139,42 +168,63 @@ void main() {
Finder snack(String text) =>
find.descendant(of: find.byType(SnackBar), matching: findCopy(text));
testWidgets('줄 차례 — 앱 버전 · 새 버전 받기 · 오픈소스 라이선스, 끝 아이콘은 바깥으로 여는 아이콘', (
WidgetTester tester,
) async {
await h.pump(tester);
await h.openSettings(tester);
testWidgets(
'줄 차례 — 앱 버전 · 업데이트 내역 · 새 버전 받기 · 오픈소스 라이선스, 끝 아이콘은 바깥으로 여는 아이콘',
(WidgetTester tester) async {
await h.pump(tester);
await h.openSettings(tester);
final double version = tester
.getTopLeft(find.text(SettingsTexts.appVersion))
.dy;
final double download = tester
.getTopLeft(find.text(AppDownloadTexts.getNewVersion))
.dy;
final double licenses = tester
.getTopLeft(find.text(SettingsTexts.licenses))
.dy;
expect(version, lessThan(download));
expect(download, lessThan(licenses));
expect(
rowIcon(AppDownloadTexts.getNewVersion, Icons.open_in_new),
findsOneWidget,
);
expect(
rowIcon(AppDownloadTexts.getNewVersion, Icons.chevron_right),
findsNothing,
);
expect(
rowIcon(SettingsTexts.licenses, Icons.chevron_right),
findsOneWidget,
);
expect(
tester.getSize(downloadRow()).height,
greaterThanOrEqualTo(52),
reason: '한 줄 칸 52 — 앱 버전 · 오픈소스 라이선스와 같다',
);
expect(launched, isEmpty);
});
final double version = tester
.getTopLeft(find.text(SettingsTexts.appVersion))
.dy;
final double releaseNotes = tester
.getTopLeft(find.text(ReleaseNotesTexts.title))
.dy;
final double download = tester
.getTopLeft(find.text(AppDownloadTexts.getNewVersion))
.dy;
final double licenses = tester
.getTopLeft(find.text(SettingsTexts.licenses))
.dy;
expect(version, lessThan(releaseNotes));
expect(releaseNotes, lessThan(download));
expect(download, lessThan(licenses));
expect(
rowIcon(ReleaseNotesTexts.title, Icons.chevron_right),
findsOneWidget,
reason: '앱 안의 화면 — 오픈소스 라이선스 줄과 같은 꼴',
);
expect(
tester
.getSize(
find.ancestor(
of: find.text(ReleaseNotesTexts.title),
matching: find.byType(InkWell),
),
)
.height,
greaterThanOrEqualTo(52),
);
expect(
rowIcon(AppDownloadTexts.getNewVersion, Icons.open_in_new),
findsOneWidget,
);
expect(
rowIcon(AppDownloadTexts.getNewVersion, Icons.chevron_right),
findsNothing,
);
expect(
rowIcon(SettingsTexts.licenses, Icons.chevron_right),
findsOneWidget,
);
expect(
tester.getSize(downloadRow()).height,
greaterThanOrEqualTo(52),
reason: '한 줄 칸 52 — 앱 버전 · 오픈소스 라이선스와 같다',
);
expect(launched, isEmpty);
},
);
testWidgets('누르면 업데이트 화면과 같은 받기 화면을 바깥 브라우저로 연다 — 설정에 머문다', (
WidgetTester tester,
@@ -0,0 +1,203 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:arasystem_app/features/settings/data/repositories/release_notes_repository.dart';
import 'package:dio/dio.dart';
import 'fakes.dart';
/// 가짜 업데이트 내역 글 하나 — 서버가 읽어 둔 글(계약 7절의 응답 칸)
class FakeReleaseNote {
const FakeReleaseNote(this.id, this.title, {String? date, this.sections})
: _date = date;
final String id;
final String title;
final String? _date;
/// 묶음 — 없으면 머리 없는 묶음 하나에 줄 하나
final List<Map<String, Object?>>? sections;
/// 쓴 날 — 없으면 id 의 날짜 칸(`2026-11-18-2` → `2026-11-18`)
String get date => _date ?? id.substring(0, _dateLength);
static const int _dateLength = 10;
/// 목록 줄 · 다른 글 줄의 꼴 `{ id, date, title }`
Map<String, Object?> summaryJson() => <String, Object?>{
'id': id,
'date': date,
'title': title,
};
/// 글 화면의 묶음
List<Map<String, Object?>> sectionsJson() =>
sections ??
<Map<String, Object?>>[
<String, Object?>{
'heading': null,
'items': <String>['$title — 본문'],
},
];
}
/// 묶음 하나의 꼴
Map<String, Object?> releaseNoteSectionJson(
String? heading,
List<String> items,
) => <String, Object?>{'heading': heading, 'items': items};
/// 보드 `ReleaseNotes` 의 네 글(맨 위 글은 다음 배포의 첫 글 초안 — 최근 글부터)
const List<FakeReleaseNote> kBoardReleaseNotes = <FakeReleaseNote>[
FakeReleaseNote(
'2026-11-18',
'메일 검색이 빨라지고 휴대폰 앱이 나왔습니다',
sections: <Map<String, Object?>>[
<String, Object?>{
'heading': '휴대폰 앱',
'items': <String>[
'안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.',
'앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다. 알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.',
],
},
<String, Object?>{
'heading': '메일 검색',
'items': <String>['찾은 말에는 노란 표시가 붙습니다.'],
},
],
),
FakeReleaseNote(
'2026-11-05',
'화면의 안내 글을 다듬었습니다',
sections: <Map<String, Object?>>[
<String, Object?>{
'heading': null,
'items': <String>[
'단추와 안내 글을 더 알기 쉬운 말로 고쳤습니다.',
'브라우저 탭과 휴대폰 홈 화면의 아이콘을 ARA SYSTEM 마크로 바꿨습니다.',
],
},
],
),
FakeReleaseNote('2026-11-04', '왼쪽 메뉴를 정리했습니다'),
FakeReleaseNote('2026-11-03', '2단계 인증과 앱 비밀번호를 쓸 수 있습니다'),
];
/// 가짜 업데이트 내역 서버 — `GET /release-notes`(커서 = 다음 글의 자리) · `GET /release-notes/{id}` (계약 7절)
class FakeReleaseNotesBackend {
FakeReleaseNotesBackend(this.server);
final FakeServer server;
/// 글 — 최근 글부터(서버 차례)
List<FakeReleaseNote> notes = kBoardReleaseNotes;
/// 받은 목록 요청 · 글 요청의 id (차례대로)
final List<RequestOptions> listRequests = <RequestOptions>[];
final List<String> noteRequests = <String>[];
/// 두면 목록 · 글 요청이 이것이 끝날 때까지 기다린다(불러오는 중 · [더 보기]를 받는 중)
Completer<void>? listGate;
Completer<void>? noteGate;
/// 두면 다음 목록 · 글 요청 하나에 이것으로 답한다(한 번 쓰고 지운다) — 실패 · 모양이 틀린 응답
FakeResponse? nextListResponse;
FakeResponse? nextNoteResponse;
/// 두면 목록 · 글 요청에 이것으로 답한다(기본 답은 [listPage] · [noteDetail]) — 요청마다 다른 때에 답하는 시험
FakeHandler? onList;
FakeHandler? onNote;
/// 글 [count] 개 — 2026-11-30 부터 하루씩 거슬러(최근 글부터), 제목 "업데이트 N"(가장 새 글이 N = count)
void seed(int count) {
final DateTime newest = DateTime.utc(2026, 11, 30);
notes = <FakeReleaseNote>[
for (int index = 0; index < count; index++)
FakeReleaseNote(
_dateOf(newest.subtract(Duration(days: index))),
'업데이트 ${count - index}',
),
];
}
static String _dateOf(DateTime day) =>
'${day.year}-${day.month.toString().padLeft(2, '0')}-'
'${day.day.toString().padLeft(2, '0')}';
/// 커서의 머리 — 커서는 다음 글의 자리(`n20`)
static const String cursorPrefix = 'n';
static final RegExp _notePath = RegExp(r'^/release-notes/([^/]+)$');
/// 목록 한 쪽의 기본 답 — `limit`(없으면 20)개씩, 커서 자리부터
FakeResponse listPage(RequestOptions options) {
final Object? rawLimit = options.queryParameters['limit'];
final int limit = rawLimit is int ? rawLimit : kReleaseNotesPageSize;
final Object? cursor = options.queryParameters['cursor'];
final int start = cursor is String
? int.parse(cursor.substring(cursorPrefix.length))
: 0;
final int end = math.min(start + limit, notes.length);
final bool hasNext = end < notes.length;
return FakeResponse.ok(<String, Object?>{
'items': <Map<String, Object?>>[
for (final FakeReleaseNote note in notes.sublist(start, end))
note.summaryJson(),
],
'pageInfo': <String, Object?>{
'hasNext': hasNext,
'nextCursor': hasNext ? '$cursorPrefix$end' : null,
'total': notes.length,
},
});
}
/// 글 하나의 기본 답 — 없는 글은 404 `RES_001`, 다른 글은 목록 차례의 위(next) · 아래(previous)
FakeResponse noteDetail(String id) {
final int index = notes.indexWhere((FakeReleaseNote n) => n.id == id);
if (index < 0) return FakeResponse.fail(404, 'RES_001');
final FakeReleaseNote note = notes[index];
return FakeResponse.ok(<String, Object?>{
...note.summaryJson(),
'sections': note.sectionsJson(),
'previous': index + 1 < notes.length
? notes[index + 1].summaryJson()
: null,
'next': index > 0 ? notes[index - 1].summaryJson() : null,
});
}
void install() {
server.on('GET', ReleaseNotesApiPaths.releaseNotes, (
RequestOptions options,
) async {
listRequests.add(options);
final Completer<void>? gate = listGate;
if (gate != null) await gate.future;
final FakeResponse? injected = nextListResponse;
if (injected != null) {
nextListResponse = null;
return injected;
}
final FakeHandler? handler = onList;
if (handler != null) return handler(options);
return listPage(options);
});
server.onPattern('GET', _notePath, (RequestOptions options) async {
final String id = Uri.decodeComponent(
_notePath.firstMatch(options.path)!.group(1)!,
);
noteRequests.add(id);
final Completer<void>? gate = noteGate;
if (gate != null) await gate.future;
final FakeResponse? injected = nextNoteResponse;
if (injected != null) {
nextNoteResponse = null;
return injected;
}
final FakeHandler? handler = onNote;
if (handler != null) return handler(options);
return noteDetail(id);
});
}
}