mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
Follow-up to [#8294](https://github.com/block/buzz/pull/8294). Malformed trailing thread tags could make a notification open the right thread while Flutter indexed its reply elsewhere; uppercase references could similarly miss lowercase event IDs. Share one validated thread-reference parser between the mobile timeline, Inbox, and feed helper. It ignores malformed markers, keeps the last valid root/reply, and normalizes lookup IDs to lowercase. Native push parsing now uses the same casing. Existing thread fixtures use valid event IDs so they continue exercising real thread relationships. Add RunnerTests coverage for the production native-to-Flutter argument conversion, including buffered cold-start navigation and omitted optional thread IDs, and run it in the native CI lane. ### Validation - New parser and Inbox regressions cover malformed markers and uppercase references. Widget regressions open and highlight the exact reply for valid, uppercase, and trailing-malformed tags. - After integrating [#8106](https://github.com/block/buzz/pull/8106), Flutter analysis and the complete mobile suite pass: 3,337 tests, four existing skips. Integration preserves per-event read predicates and migrates incoming thread fixtures to valid IDs. An unchanged keyboard-history test also failed with main's original parser; recording reads before the scroll transition fixes its timing assumption while retaining visibility assertions. - Native package tests passed (127 tests), and both Runner bridge tests passed on an iOS simulator with Xcode 26.5. Removing the production `threadRootId` assignment made the bridge regression fail; restoring it passed. Native code is unchanged by the main integration. - [Current-head CI passed](https://github.com/block/buzz/actions/runs/38659307247), including Mobile and Mobile Swift. [Codex review](https://github.com/block/buzz/pull/8332#issuecomment-6181458528) and [security review](https://github.com/block/buzz/pull/8332#issuecomment-6181449235) found no issues on the integrated commit. [Owner approval](https://github.com/block/buzz/pull/8332#pullrequestreview-5526812957) covers the exact integrated commit. - Standards and spec reviews found no blockers, including the merge resolutions and test adjustments. Validation is scoped to mobile/native per VISION_MOBILE; unrelated repository-wide checks were stopped, not counted as passing. The bridge test covers serialization and buffering, not end-to-end APNs delivery or app launch. No new physical-device acceptance is claimed; routine post-merge dogfood remains human-owned. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
317 lines
8.3 KiB
Dart
317 lines
8.3 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:buzz/features/activity/feed_item.dart';
|
|
|
|
import '../../helpers/thread_ids.dart';
|
|
|
|
void main() {
|
|
group('FeedItem.fromJson', () {
|
|
test('parses all fields', () {
|
|
final item = FeedItem.fromJson({
|
|
'id': 'evt1',
|
|
'kind': 9,
|
|
'pubkey': 'abc123',
|
|
'content': 'Hello world',
|
|
'created_at': 1700000000,
|
|
'channel_id': 'ch1',
|
|
'channel_name': 'general',
|
|
'tags': [
|
|
['p', 'abc123'],
|
|
],
|
|
'category': 'mention',
|
|
});
|
|
|
|
expect(item.id, 'evt1');
|
|
expect(item.kind, 9);
|
|
expect(item.pubkey, 'abc123');
|
|
expect(item.content, 'Hello world');
|
|
expect(item.createdAt, 1700000000);
|
|
expect(item.channelId, 'ch1');
|
|
expect(item.channelName, 'general');
|
|
expect(item.tags, [
|
|
['p', 'abc123'],
|
|
]);
|
|
expect(item.category, 'mention');
|
|
});
|
|
|
|
test('handles null channel_id', () {
|
|
final item = FeedItem.fromJson({
|
|
'id': 'evt2',
|
|
'kind': 9,
|
|
'pubkey': 'abc',
|
|
'content': '',
|
|
'created_at': 0,
|
|
'channel_id': null,
|
|
'channel_name': null,
|
|
'tags': null,
|
|
'category': 'activity',
|
|
});
|
|
|
|
expect(item.channelId, isNull);
|
|
expect(item.channelName, '');
|
|
expect(item.tags, isEmpty);
|
|
});
|
|
});
|
|
|
|
group('FeedItem.threadHeadId', () {
|
|
FeedItem makeItem(List<List<String>> tags) => FeedItem(
|
|
id: 'reply',
|
|
kind: 9,
|
|
pubkey: 'pk',
|
|
content: '',
|
|
createdAt: 0,
|
|
channelId: 'channel',
|
|
channelName: '',
|
|
tags: tags,
|
|
category: 'mention',
|
|
);
|
|
|
|
test('uses the reply marker for a direct thread reply', () {
|
|
expect(
|
|
makeItem(const [
|
|
['e', threadIdRoot, '', 'reply'],
|
|
]).threadHeadId,
|
|
threadIdRoot,
|
|
);
|
|
});
|
|
|
|
test('uses the direct parent marker for a nested thread reply', () {
|
|
expect(
|
|
makeItem(const [
|
|
['e', threadIdRoot, '', 'root'],
|
|
['e', threadIdParent, '', 'reply'],
|
|
]).threadHeadId,
|
|
threadIdParent,
|
|
);
|
|
});
|
|
|
|
test('uses the last valid parent and normalizes its case', () {
|
|
expect(
|
|
makeItem([
|
|
['e', threadIdRoot, '', 'reply'],
|
|
['e', threadIdParent.toUpperCase(), '', 'reply'],
|
|
['e', 'bad', '', 'reply'],
|
|
]).threadHeadId,
|
|
threadIdParent,
|
|
);
|
|
});
|
|
|
|
test('does not treat unrelated event references as threads', () {
|
|
expect(
|
|
makeItem(const [
|
|
['e', 'linked-event'],
|
|
]).threadHeadId,
|
|
isNull,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('FeedItem.headline', () {
|
|
FeedItem makeItem({required int kind, String category = 'activity'}) =>
|
|
FeedItem(
|
|
id: 'x',
|
|
kind: kind,
|
|
pubkey: 'pk',
|
|
content: '',
|
|
createdAt: 0,
|
|
channelId: null,
|
|
channelName: '',
|
|
tags: const [],
|
|
category: category,
|
|
);
|
|
|
|
test('returns known kind labels', () {
|
|
expect(makeItem(kind: 45001).headline, 'Forum post');
|
|
expect(makeItem(kind: 45003).headline, 'Forum reply');
|
|
expect(makeItem(kind: 46010).headline, 'Approval requested');
|
|
expect(makeItem(kind: 43001).headline, 'Job requested');
|
|
expect(makeItem(kind: 43002).headline, 'Job accepted');
|
|
expect(makeItem(kind: 43003).headline, 'Progress update');
|
|
expect(makeItem(kind: 43004).headline, 'Job result');
|
|
expect(makeItem(kind: 43005).headline, 'Job cancelled');
|
|
expect(makeItem(kind: 43006).headline, 'Job failed');
|
|
});
|
|
|
|
test('falls back to category for unknown kinds', () {
|
|
expect(makeItem(kind: 9, category: 'mention').headline, 'Mention');
|
|
expect(
|
|
makeItem(kind: 9, category: 'agent_activity').headline,
|
|
'Agent update',
|
|
);
|
|
expect(
|
|
makeItem(kind: 9, category: 'activity').headline,
|
|
'Channel update',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('FeedItem.displayContent', () {
|
|
FeedItem makeItem({required String content, int kind = 9}) => FeedItem(
|
|
id: 'x',
|
|
kind: kind,
|
|
pubkey: 'pk',
|
|
content: content,
|
|
createdAt: 0,
|
|
channelId: null,
|
|
channelName: '',
|
|
tags: const [],
|
|
category: 'activity',
|
|
);
|
|
|
|
test('returns trimmed content when non-empty', () {
|
|
expect(makeItem(content: ' Hello ').displayContent, 'Hello');
|
|
});
|
|
|
|
test('returns approval fallback for empty approval events', () {
|
|
expect(
|
|
makeItem(content: '', kind: 46010).displayContent,
|
|
'A workflow is waiting for approval.',
|
|
);
|
|
});
|
|
|
|
test('returns generic fallback for other empty events', () {
|
|
expect(makeItem(content: '').displayContent, 'No additional details.');
|
|
expect(makeItem(content: ' ').displayContent, 'No additional details.');
|
|
});
|
|
});
|
|
|
|
group('HomeFeedResponse', () {
|
|
test('parses from JSON with all four categories', () {
|
|
final response = HomeFeedResponse.fromJson({
|
|
'feed': {
|
|
'mentions': [
|
|
{
|
|
'id': 'm1',
|
|
'kind': 9,
|
|
'pubkey': 'pk',
|
|
'content': 'hi',
|
|
'created_at': 100,
|
|
'channel_id': 'c1',
|
|
'channel_name': 'general',
|
|
'tags': [],
|
|
'category': 'mention',
|
|
},
|
|
],
|
|
'needs_action': [],
|
|
'activity': [
|
|
{
|
|
'id': 'a1',
|
|
'kind': 9,
|
|
'pubkey': 'pk',
|
|
'content': 'update',
|
|
'created_at': 200,
|
|
'channel_id': 'c2',
|
|
'channel_name': 'dev',
|
|
'tags': [],
|
|
'category': 'activity',
|
|
},
|
|
],
|
|
'agent_activity': [],
|
|
},
|
|
'meta': {'since': 0, 'total': 2, 'generated_at': 300},
|
|
});
|
|
|
|
expect(response.mentions.length, 1);
|
|
expect(response.needsAction, isEmpty);
|
|
expect(response.activity.length, 1);
|
|
expect(response.agentActivity, isEmpty);
|
|
});
|
|
|
|
test('handles null category lists gracefully', () {
|
|
final response = HomeFeedResponse.fromJson({
|
|
'feed': {
|
|
'mentions': null,
|
|
'needs_action': null,
|
|
'activity': null,
|
|
'agent_activity': null,
|
|
},
|
|
});
|
|
|
|
expect(response.mentions, isEmpty);
|
|
expect(response.isEmpty, isTrue);
|
|
});
|
|
|
|
test('all merges and sorts newest-first', () {
|
|
final response = HomeFeedResponse(
|
|
mentions: [
|
|
FeedItem(
|
|
id: 'old',
|
|
kind: 9,
|
|
pubkey: 'pk',
|
|
content: '',
|
|
createdAt: 100,
|
|
channelId: null,
|
|
channelName: '',
|
|
tags: const [],
|
|
category: 'mention',
|
|
),
|
|
],
|
|
needsAction: const [],
|
|
activity: [
|
|
FeedItem(
|
|
id: 'new',
|
|
kind: 9,
|
|
pubkey: 'pk',
|
|
content: '',
|
|
createdAt: 300,
|
|
channelId: null,
|
|
channelName: '',
|
|
tags: const [],
|
|
category: 'activity',
|
|
),
|
|
],
|
|
agentActivity: [
|
|
FeedItem(
|
|
id: 'mid',
|
|
kind: 9,
|
|
pubkey: 'pk',
|
|
content: '',
|
|
createdAt: 200,
|
|
channelId: null,
|
|
channelName: '',
|
|
tags: const [],
|
|
category: 'agent_activity',
|
|
),
|
|
],
|
|
);
|
|
|
|
final all = response.all;
|
|
expect(all.length, 3);
|
|
expect(all[0].id, 'new');
|
|
expect(all[1].id, 'mid');
|
|
expect(all[2].id, 'old');
|
|
});
|
|
|
|
test('isEmpty returns true when all lists empty', () {
|
|
final response = HomeFeedResponse(
|
|
mentions: [],
|
|
needsAction: [],
|
|
activity: [],
|
|
agentActivity: [],
|
|
);
|
|
expect(response.isEmpty, isTrue);
|
|
});
|
|
|
|
test('isEmpty returns false when any list has items', () {
|
|
final response = HomeFeedResponse(
|
|
mentions: [
|
|
FeedItem(
|
|
id: 'x',
|
|
kind: 9,
|
|
pubkey: 'pk',
|
|
content: '',
|
|
createdAt: 0,
|
|
channelId: null,
|
|
channelName: '',
|
|
tags: const [],
|
|
category: 'mention',
|
|
),
|
|
],
|
|
needsAction: const [],
|
|
activity: const [],
|
|
agentActivity: const [],
|
|
);
|
|
expect(response.isEmpty, isFalse);
|
|
});
|
|
});
|
|
}
|