164 lines
5.7 KiB
Dart
164 lines
5.7 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:qiangbao_accounting/api/note_api.dart';
|
|
import 'package:qiangbao_accounting/models/note.dart';
|
|
import 'package:qiangbao_accounting/providers/note_provider.dart';
|
|
|
|
/// 记录每次 list() 的调用,用来断言「有没有真的重新请求」。
|
|
class FakeNoteApi extends NoteApi {
|
|
FakeNoteApi(this.pages) : super(Dio());
|
|
|
|
/// 依次返回的结果:第 1 次调用返回 pages[0],第 2 次返回 pages[1]……
|
|
/// 模拟「服务端数据被外部改过」的场景。
|
|
final List<NotePage> pages;
|
|
|
|
int listCalls = 0;
|
|
int lastRequestedPage = 0;
|
|
|
|
@override
|
|
Future<NotePage> list({
|
|
int page = 1,
|
|
int size = 20,
|
|
String scope = 'all',
|
|
int? folderId,
|
|
String sort = 'updated',
|
|
String? keyword,
|
|
String? tag,
|
|
}) async {
|
|
listCalls++;
|
|
lastRequestedPage = page;
|
|
final i = (listCalls - 1).clamp(0, pages.length - 1);
|
|
return pages[i];
|
|
}
|
|
|
|
@override
|
|
Future<List<String>> tags() async => const [];
|
|
|
|
@override
|
|
Future<FolderTree> folderTree() async => FolderTree.empty;
|
|
}
|
|
|
|
NotePage pageOf(List<int> ids) => NotePage(
|
|
items: ids
|
|
.map((id) => NoteListItem(
|
|
id: id,
|
|
folderId: null,
|
|
title: '笔记 $id',
|
|
summary: '',
|
|
tags: const [],
|
|
pinned: false,
|
|
updateTime: '2026-09-24 10:00',
|
|
))
|
|
.toList(),
|
|
total: ids.length,
|
|
hasMore: false,
|
|
);
|
|
|
|
/// 回归测试:**外部(Web 后台 / 别的设备)改过笔记后,App 必须能自己发现**。
|
|
///
|
|
/// 背景(真实踩过的 bug,和 AI 写账单那次同源):
|
|
/// `noteListProvider` 是 keepAlive 的 —— 为了从详情页返回时保住搜索词和
|
|
/// 标签筛选。代价是**再次进入页面不会重新请求**,于是你在 Web 后台导入的
|
|
/// 笔记,手机上永远看不到(表现是「还是那 2 条」)。
|
|
///
|
|
/// 这类「绕过 App 的写操作」没有任何通知可听,只能在恰当的时机主动对账。
|
|
/// 这三个入口就是那三个时机,任何一个是坏的都会让用户以为「导入失败了」。
|
|
void main() {
|
|
(ProviderContainer, FakeNoteApi) boot(List<NotePage> pages) {
|
|
final api = FakeNoteApi(pages);
|
|
final container = ProviderContainer(
|
|
overrides: [noteApiProvider.overrideWithValue(api)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
return (container, api);
|
|
}
|
|
|
|
test('首次进入页面会加载', () async {
|
|
final (container, api) = boot([pageOf([1, 2])]);
|
|
|
|
container.listen(noteListProvider, (_, _) {});
|
|
// build() 里是 Future.microtask,等它跑完
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(api.listCalls, 1);
|
|
expect(container.read(noteListProvider).items.length, 2);
|
|
});
|
|
|
|
test('refreshOnEnter:重新进入页面能拿到外部新增的笔记', () async {
|
|
// 第 1 次进页面 2 条;在 Web 上导入了 1 篇之后,第 2 次应该是 3 条
|
|
final (container, api) = boot([
|
|
pageOf([1, 2]),
|
|
pageOf([3, 1, 2]),
|
|
]);
|
|
|
|
container.listen(noteListProvider, (_, _) {});
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(container.read(noteListProvider).items.length, 2);
|
|
|
|
// 模拟「退出页面 → 在 Web 导入 → 再次进入页面」
|
|
await container.read(noteListProvider.notifier).refreshOnEnter();
|
|
|
|
expect(api.listCalls, 2, reason: '进页面必须重新请求,不能吃缓存');
|
|
expect(container.read(noteListProvider).items.length, 3,
|
|
reason: 'Web 导入的那篇必须出现');
|
|
});
|
|
|
|
test('refreshOnEnter 保留筛选条件(不能把搜索词和标签弄丢)', () async {
|
|
final (container, api) = boot([pageOf([1, 2]), pageOf([1, 2])]);
|
|
|
|
container.listen(noteListProvider, (_, _) {});
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
// 用户搜了「MySQL」、选了「运维」标签、切到按标题排序
|
|
final n = container.read(noteListProvider.notifier);
|
|
n.setKeyword('MySQL');
|
|
n.setTag('运维');
|
|
n.setSort('title');
|
|
await Future<void>.delayed(const Duration(milliseconds: 400)); // 等搜索防抖
|
|
|
|
final before = container.read(noteListProvider);
|
|
|
|
await n.refreshOnEnter();
|
|
final after = container.read(noteListProvider);
|
|
|
|
expect(after.keyword, before.keyword, reason: '搜索词要还在');
|
|
expect(after.tag, before.tag, reason: '标签筛选要还在');
|
|
expect(after.sort, before.sort, reason: '排序方式要还在');
|
|
});
|
|
|
|
test('refreshIfStale:刚加载过就不重复请求(避免频繁进出页面白刷)', () async {
|
|
final (container, api) = boot([pageOf([1, 2])]);
|
|
|
|
container.listen(noteListProvider, (_, _) {});
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(api.listCalls, 1);
|
|
|
|
// 刚加载完,此时切回前台不该再打一次
|
|
await container.read(noteListProvider.notifier).refreshIfStale();
|
|
|
|
expect(api.listCalls, 1, reason: '数据还新鲜,不该白打请求');
|
|
});
|
|
|
|
test('刷新是静默的:不置 loading,界面不会闪加载圈', () async {
|
|
final (container, api) = boot([pageOf([1, 2]), pageOf([3, 1, 2])]);
|
|
|
|
container.listen(noteListProvider, (_, _) {});
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
// 记录刷新过程中的 loading 变化
|
|
final seen = <bool>[];
|
|
final sub = container.listen(noteListProvider, (_, next) {
|
|
seen.add(next.loading);
|
|
});
|
|
addTearDown(sub.close);
|
|
|
|
await container.read(noteListProvider.notifier).refreshOnEnter();
|
|
|
|
expect(seen.contains(true), isFalse,
|
|
reason: '静默刷新不该出现 loading=true,否则列表会闪一下');
|
|
expect(container.read(noteListProvider).items.length, 3);
|
|
});
|
|
}
|