Files
lixi 9892b65a19
CI / flutter-gates (push) Successful in 3m9s
新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目
  (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay,
  删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready)
- 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿
  已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、
  网络失败同键重放不重复建帖;40902 自动取新 version 重提一次
- 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新
  一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点)
- 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典
  v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq;
  pageName 枚举补 post_form
- PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 +
  进度覆盖层 + 失败整格重试;页级上传汇总条
- create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts /
  publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态
  CTA 改为 push 真实发布页
- 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态
  九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控)
- flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过
  (选图上传 → 发布 → Feed 置顶 → 第二客户端可见)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:42:13 +08:00

194 lines
7.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:patbond_flutter/app/app.dart';
import 'package:patbond_flutter/core/widgets/post_card.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/community/media_compression.dart';
import 'package:patbond_flutter/features/community/media_picking.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。
class _InMemoryTokenStore implements TokenStore {
final Map<String, String> _values = {};
@override
Future<String?> read(String key) async => _values[key];
@override
Future<void> write(String key, String value) async => _values[key] = value;
@override
Future<void> delete(String key) async => _values.remove(key);
}
/// 1x1 真 PNG(70B):桌面实测的「选中的照片」。
final Uint8List _pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAF'
'AAH/q842iQAAAABJRU5ErkJggg==',
);
/// 桌面选图替身:Linux 桌面无 image_picker 平台实现,实测注入字节。
class _DesktopPicker implements MediaImagePicker {
@override
Future<List<PickedMediaImage>> pickImages({required int limit}) async => [
PickedMediaImage(bytes: _pngBytes, name: 'live.png'),
];
}
/// 桌面压缩替身:Linux 桌面无 flutter_image_compress 原生实现,原样透传
/// mime 落 image/png,在服务端白名单内)。其余环节全为生产实现。
class _PassthroughCompressor implements MediaImageCompressor {
@override
Future<CompressedMediaImage> compress(
PickedMediaImage source, {
required int quality,
}) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png');
}
/// T3-17 发布链路桌面真链路实测(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根:docker compose up -d --build),再:
/// PATBOND_PUBLISH_LIVE=1 flutter test integration_test/publish_live_test.dart -d linux
/// ```
///
/// 驱动**真实 App**Linux 桌面渲染 + 真实 HTTP + MinIO 预签名直传):
/// 注册两个一次性账号 → A 登录 → 创作 Tab「发布动态」→ 输入正文 → 选图
/// 上传(真 createUpload / 真预签名 PUT / 真 confirm)→ 发布(建草稿 →
/// PATCH 迁移)→ 回首页 Feed 新帖置顶可见 → **另起一个全新 App 实例
/// (新会话/新控制器/新 HTTP 客户端)以 B 账号登录,B 的 Feed 同样看到
/// 该帖**(M3 验收「发布后可在另一客户端看到」取证)。
///
/// 桌面替身仅两处:选图与压缩(Linux 无这两个插件的平台实现)。
/// 注意:桌面 `platform` 值为 `linux`,不在契约枚举内 → 埋点批次被服务端
/// 400 整批拒绝(既有预期行为,见 analytics_service.dart 注释);埋点落库
/// 取证走 curl26 号报告 §5c)。
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
final enabled = Platform.environment['PATBOND_PUBLISH_LIVE'] == '1';
const authBase = 'http://127.0.0.1:8081';
Future<void> pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 30),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 250));
if (finder.evaluate().isNotEmpty) return;
}
fail('等待超时:$finder');
}
Future<void> register(String username, String password, int seed) async {
final client = HttpClient();
final request = await client.postUrl(
Uri.parse('$authBase/api/v1/auth/register'),
);
request.headers.contentType = ContentType.json;
request.add(
utf8.encode(
jsonEncode({
'username': username,
'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}',
'password': password,
}),
),
);
final response = await request.close();
expect(response.statusCode, 200, reason: '注册测试账号失败:$username');
client.close();
}
Future<void> login(
WidgetTester tester,
String username,
String password,
) async {
await pumpUntil(tester, find.text('登录'));
await tester.enterText(find.byType(TextField).at(0), username);
await tester.enterText(find.byType(TextField).at(1), password);
await tester.tap(find.text('登录'));
}
testWidgets('发布桌面真链路:选图上传 → 发布 → Feed 置顶 → 第二账号可见', (tester) async {
final seed = DateTime.now().millisecondsSinceEpoch;
final authorName = 'publive$seed';
final readerName = 'pubread$seed';
const password = 'Live1234!publish';
final marker = '真链路发布 $seed';
await register(authorName, password, seed);
await register(readerName, password, seed + 1);
await tester.pumpWidget(
App(
sessionManager: SessionManager(store: _InMemoryTokenStore()),
mediaUploaderFactory: (repository, analytics) => MediaUploader(
repository: repository,
picker: _DesktopPicker(),
compressor: _PassthroughCompressor(),
analytics: analytics,
),
),
);
// ---- A 登录并发布(正文 + 一张真上传的图)----
await login(tester, authorName, password);
await pumpUntil(tester, find.text('创作'));
await tester.tap(find.text('创作'));
await pumpUntil(tester, find.text('发布动态'));
await tester.tap(find.text('发布动态'));
await pumpUntil(tester, find.widgetWithText(TextButton, '存草稿'));
await tester.enterText(find.byType(TextField).first, marker);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined));
// 上传全程(createUpload → 预签名 PUT → confirm)走真实链路。
await pumpUntil(tester, find.byType(Image));
final publish = find.widgetWithText(FilledButton, '发布');
for (var i = 0; i < 120; i++) {
await tester.pump(const Duration(milliseconds: 250));
if (tester.widget<FilledButton>(publish).onPressed != null) break;
}
expect(
tester.widget<FilledButton>(publish).onPressed,
isNotNull,
reason: '图片未在 30s 内 ready,发布钮仍禁用',
);
await tester.tap(publish);
// ---- 回首页 Feed:新帖置顶可见 ----
await pumpUntil(tester, find.byType(PostCard));
await pumpUntil(tester, find.textContaining(marker));
final cards = tester.widgetList<PostCard>(find.byType(PostCard)).toList();
expect(cards.first.card.contentPreview, contains(marker));
expect(cards.first.card.mediaCount, 1);
// ---- 第二客户端(全新 App 实例 + 全新会话):B 登录后同样看到该帖 ----
// 换 key 强制重建整棵树:新的 SessionManager / Controller / HTTP 客户端,
// 等价于另一台客户端首次登录(不是同一实例内的账号切换)。
await tester.pumpWidget(
App(
key: const ValueKey('client-b'),
sessionManager: SessionManager(store: _InMemoryTokenStore()),
),
);
await login(tester, readerName, password);
await pumpUntil(tester, find.byType(PostCard));
await pumpUntil(tester, find.textContaining(marker));
final readerCards = tester
.widgetList<PostCard>(find.byType(PostCard))
.toList();
expect(readerCards.first.card.contentPreview, contains(marker));
}, skip: !enabled);
}