新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
CI / flutter-gates (push) Successful in 3m9s

- 新增 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>
This commit is contained in:
2026-09-10 14:42:13 +08:00
parent f873acf9a3
commit 9892b65a19
23 changed files with 2760 additions and 99 deletions
+193
View File
@@ -0,0 +1,193 @@
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);
}