9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
1293 lines
37 KiB
Dart
1293 lines
37 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:buzz/features/channels/channel_window.dart';
|
|
import 'package:buzz/features/channels/timeline_message.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
|
|
NostrEvent _textMsg({
|
|
required String id,
|
|
String pubkey = 'alice',
|
|
String content = 'hello',
|
|
int createdAt = 1000,
|
|
List<List<String>>? extraTags,
|
|
}) => NostrEvent(
|
|
id: id,
|
|
pubkey: pubkey,
|
|
createdAt: createdAt,
|
|
kind: EventKind.streamMessage,
|
|
tags: [
|
|
['h', 'ch1'],
|
|
...?extraTags,
|
|
],
|
|
content: content,
|
|
sig: '',
|
|
);
|
|
|
|
/// A reply message with proper root/reply e-tag markers.
|
|
NostrEvent _replyMsg({
|
|
required String id,
|
|
required String parentId,
|
|
String? rootId,
|
|
String pubkey = 'alice',
|
|
String content = 'reply',
|
|
int createdAt = 2000,
|
|
List<List<String>> extraTags = const [],
|
|
}) {
|
|
final root = rootId ?? parentId;
|
|
final eTags = parentId == root
|
|
? [
|
|
['e', root, '', 'reply'],
|
|
]
|
|
: [
|
|
['e', root, '', 'root'],
|
|
['e', parentId, '', 'reply'],
|
|
];
|
|
return _textMsg(
|
|
id: id,
|
|
pubkey: pubkey,
|
|
content: content,
|
|
createdAt: createdAt,
|
|
extraTags: [...eTags, ...extraTags],
|
|
);
|
|
}
|
|
|
|
NostrEvent _systemMsg({
|
|
required String id,
|
|
required Map<String, dynamic> payload,
|
|
int createdAt = 1000,
|
|
}) => NostrEvent(
|
|
id: id,
|
|
pubkey: 'relay',
|
|
createdAt: createdAt,
|
|
kind: EventKind.systemMessage,
|
|
tags: [
|
|
['h', 'ch1'],
|
|
],
|
|
content: jsonEncode(payload),
|
|
sig: '',
|
|
);
|
|
|
|
NostrEvent _deletion({
|
|
required String id,
|
|
required List<String> targets,
|
|
int createdAt = 2000,
|
|
}) => NostrEvent(
|
|
id: id,
|
|
pubkey: 'alice',
|
|
createdAt: createdAt,
|
|
kind: EventKind.deletion,
|
|
tags: [
|
|
['h', 'ch1'],
|
|
for (final t in targets) ['e', t],
|
|
],
|
|
content: '',
|
|
sig: '',
|
|
);
|
|
|
|
NostrEvent _nip29Deletion({
|
|
required String id,
|
|
required List<String> targets,
|
|
int createdAt = 2000,
|
|
}) => NostrEvent(
|
|
id: id,
|
|
pubkey: 'alice',
|
|
createdAt: createdAt,
|
|
kind: EventKind.nip29DeleteEvent,
|
|
tags: [
|
|
['h', 'ch1'],
|
|
for (final t in targets) ['e', t],
|
|
],
|
|
content: '',
|
|
sig: '',
|
|
);
|
|
|
|
NostrEvent _edit({
|
|
required String id,
|
|
required String targetId,
|
|
required String content,
|
|
int createdAt = 2000,
|
|
List<List<String>> extraTags = const [],
|
|
}) => NostrEvent(
|
|
id: id,
|
|
pubkey: 'alice',
|
|
createdAt: createdAt,
|
|
kind: EventKind.streamMessageEdit,
|
|
tags: [
|
|
['h', 'ch1'],
|
|
['e', targetId],
|
|
...extraTags,
|
|
],
|
|
content: content,
|
|
sig: '',
|
|
);
|
|
|
|
NostrEvent _reaction({
|
|
required String id,
|
|
required String targetId,
|
|
int createdAt = 2000,
|
|
String content = '👍',
|
|
List<List<String>> extraTags = const [],
|
|
}) => NostrEvent(
|
|
id: id,
|
|
pubkey: 'bob',
|
|
createdAt: createdAt,
|
|
kind: EventKind.reaction,
|
|
tags: [
|
|
['h', 'ch1'],
|
|
['e', targetId],
|
|
...extraTags,
|
|
],
|
|
content: content,
|
|
sig: '',
|
|
);
|
|
|
|
NostrEvent _huddleEvent({
|
|
required String id,
|
|
required int kind,
|
|
String pubkey = 'pk1',
|
|
int createdAt = 1000,
|
|
}) => NostrEvent(
|
|
id: id,
|
|
pubkey: pubkey,
|
|
createdAt: createdAt,
|
|
kind: kind,
|
|
tags: [
|
|
['h', 'ch1'],
|
|
],
|
|
content: jsonEncode({
|
|
'ephemeral_channel_id': '8d764100-fd8f-44cf-9c98-6d8fbd739b8c',
|
|
}),
|
|
sig: '',
|
|
);
|
|
|
|
void main() {
|
|
group('SystemEvent.fromContent', () {
|
|
test('parses all known event types', () {
|
|
final types = {
|
|
'member_joined': SystemEventType.memberJoined,
|
|
'member_left': SystemEventType.memberLeft,
|
|
'member_removed': SystemEventType.memberRemoved,
|
|
'topic_changed': SystemEventType.topicChanged,
|
|
'purpose_changed': SystemEventType.purposeChanged,
|
|
'channel_created': SystemEventType.channelCreated,
|
|
'channel_archived': SystemEventType.channelArchived,
|
|
'channel_unarchived': SystemEventType.channelUnarchived,
|
|
};
|
|
|
|
for (final entry in types.entries) {
|
|
final event = SystemEvent.fromContent(
|
|
jsonEncode({'type': entry.key, 'actor': 'pk1'}),
|
|
);
|
|
expect(event, isNotNull, reason: 'Failed for ${entry.key}');
|
|
expect(event!.type, entry.value);
|
|
expect(event.actorPubkey, 'pk1');
|
|
}
|
|
});
|
|
|
|
test('returns null for unknown type', () {
|
|
final event = SystemEvent.fromContent(
|
|
jsonEncode({'type': 'unknown_type'}),
|
|
);
|
|
expect(event, isNull);
|
|
});
|
|
|
|
test('returns null for invalid JSON', () {
|
|
expect(SystemEvent.fromContent('not json'), isNull);
|
|
});
|
|
|
|
test('returns null for wrong JSON field types', () {
|
|
expect(
|
|
SystemEvent.fromContent(
|
|
jsonEncode({
|
|
'type': ['member_joined'],
|
|
'actor': 123,
|
|
}),
|
|
),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test('parses target, topic, and purpose fields', () {
|
|
final event = SystemEvent.fromContent(
|
|
jsonEncode({
|
|
'type': 'topic_changed',
|
|
'actor': 'pk1',
|
|
'target': 'pk2',
|
|
'topic': 'New topic',
|
|
'purpose': 'New purpose',
|
|
}),
|
|
);
|
|
|
|
expect(event, isNotNull);
|
|
expect(event!.targetPubkey, 'pk2');
|
|
expect(event.topic, 'New topic');
|
|
expect(event.purpose, 'New purpose');
|
|
});
|
|
});
|
|
|
|
group('SystemEvent.describe', () {
|
|
String resolve(String? pk) => pk == 'pk1' ? 'Alice' : 'Bob';
|
|
|
|
test('member_joined self', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.memberJoined,
|
|
actorPubkey: 'pk1',
|
|
targetPubkey: 'pk1',
|
|
);
|
|
expect(event.describe(resolve), 'Alice joined the channel');
|
|
});
|
|
|
|
test('member_joined by other', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.memberJoined,
|
|
actorPubkey: 'pk1',
|
|
targetPubkey: 'pk2',
|
|
);
|
|
expect(event.describe(resolve), 'Bob was added by Alice');
|
|
});
|
|
|
|
test('member_left', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.memberLeft,
|
|
actorPubkey: 'pk1',
|
|
);
|
|
expect(event.describe(resolve), 'Alice left the channel');
|
|
});
|
|
|
|
test('member_removed', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.memberRemoved,
|
|
actorPubkey: 'pk1',
|
|
targetPubkey: 'pk2',
|
|
);
|
|
expect(event.describe(resolve), 'Alice removed Bob from the channel');
|
|
});
|
|
|
|
test('topic_changed', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.topicChanged,
|
|
actorPubkey: 'pk1',
|
|
topic: 'Release v2',
|
|
);
|
|
expect(
|
|
event.describe(resolve),
|
|
'Alice changed the topic to "Release v2"',
|
|
);
|
|
});
|
|
|
|
test('purpose_changed', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.purposeChanged,
|
|
actorPubkey: 'pk1',
|
|
purpose: 'Daily standups',
|
|
);
|
|
expect(
|
|
event.describe(resolve),
|
|
'Alice changed the purpose to "Daily standups"',
|
|
);
|
|
});
|
|
|
|
// The relay reports a clear as a change carrying an empty string, so
|
|
// without the cleared branch this reads: changed the topic to "".
|
|
test('a blank topic or purpose reads as cleared', () {
|
|
for (final blank in [null, '', ' \n\t ']) {
|
|
expect(
|
|
SystemEvent(
|
|
type: SystemEventType.topicChanged,
|
|
actorPubkey: 'pk1',
|
|
topic: blank,
|
|
).describe(resolve),
|
|
'Alice cleared the topic',
|
|
);
|
|
expect(
|
|
SystemEvent(
|
|
type: SystemEventType.purposeChanged,
|
|
actorPubkey: 'pk1',
|
|
purpose: blank,
|
|
).describe(resolve),
|
|
'Alice cleared the purpose',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('no caption announces empty quotes', () {
|
|
for (final value in [null, '', ' ', 'Real topic']) {
|
|
expect(
|
|
SystemEvent(
|
|
type: SystemEventType.topicChanged,
|
|
actorPubkey: 'pk1',
|
|
topic: value,
|
|
).describe(resolve),
|
|
isNot(contains('""')),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('surrounding whitespace is trimmed out of the quotes', () {
|
|
expect(
|
|
SystemEvent(
|
|
type: SystemEventType.topicChanged,
|
|
actorPubkey: 'pk1',
|
|
topic: ' Release v2 ',
|
|
).describe(resolve),
|
|
'Alice changed the topic to "Release v2"',
|
|
);
|
|
});
|
|
|
|
test('channel_created', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.channelCreated,
|
|
actorPubkey: 'pk1',
|
|
);
|
|
expect(event.describe(resolve), 'Alice created this channel');
|
|
});
|
|
|
|
test('channel_archived', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.channelArchived,
|
|
actorPubkey: 'pk1',
|
|
);
|
|
expect(event.describe(resolve), 'Alice archived this channel');
|
|
});
|
|
|
|
test('channel_unarchived', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.channelUnarchived,
|
|
actorPubkey: 'pk1',
|
|
);
|
|
expect(event.describe(resolve), 'Alice unarchived this channel');
|
|
});
|
|
|
|
test('huddle_started', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.huddleStarted,
|
|
actorPubkey: 'pk1',
|
|
);
|
|
expect(event.describe(resolve), 'Alice started a huddle');
|
|
});
|
|
|
|
test('huddle_ended', () {
|
|
final event = SystemEvent(
|
|
type: SystemEventType.huddleEnded,
|
|
actorPubkey: 'pk1',
|
|
);
|
|
expect(event.describe(resolve), 'Alice ended the huddle');
|
|
});
|
|
});
|
|
|
|
group('formatTimeline', () {
|
|
test('channel filters include desktop huddle lifecycle event kinds', () {
|
|
expect(
|
|
EventKind.channelEventKinds,
|
|
containsAll([
|
|
EventKind.huddleStarted,
|
|
EventKind.huddleParticipantJoined,
|
|
EventKind.huddleParticipantLeft,
|
|
EventKind.huddleEnded,
|
|
]),
|
|
);
|
|
});
|
|
|
|
test('passes through text messages', () {
|
|
final events = [
|
|
_textMsg(id: 'a', content: 'hello'),
|
|
_textMsg(id: 'b', content: 'world', createdAt: 1100),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, hasLength(2));
|
|
expect(result[0].content, 'hello');
|
|
expect(result[1].content, 'world');
|
|
expect(result[0].isSystem, false);
|
|
expect(result[0].edited, false);
|
|
});
|
|
|
|
test('filters deleted messages', () {
|
|
final events = [
|
|
_textMsg(id: 'a', content: 'keep'),
|
|
_textMsg(id: 'b', content: 'delete', createdAt: 1100),
|
|
_deletion(id: 'd1', targets: ['b']),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, hasLength(1));
|
|
expect(result[0].content, 'keep');
|
|
});
|
|
|
|
test('filters messages deleted via kind:9005 (Buzz-native)', () {
|
|
// Agents emit kind:9005 deletes via the CLI. Mobile must mirror desktop
|
|
// and treat 9005 as a deletion marker, otherwise agent-deleted messages
|
|
// stay rendered until manual refresh.
|
|
final events = [
|
|
_textMsg(id: 'a', content: 'keep'),
|
|
_textMsg(id: 'b', content: 'delete', createdAt: 1100),
|
|
_nip29Deletion(id: 'd1', targets: ['b']),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, hasLength(1));
|
|
expect(result[0].content, 'keep');
|
|
});
|
|
|
|
test('applies edits', () {
|
|
final events = [
|
|
_textMsg(id: 'a', content: 'original'),
|
|
_edit(id: 'e1', targetId: 'a', content: 'edited'),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, hasLength(1));
|
|
expect(result[0].content, 'edited');
|
|
expect(result[0].edited, true);
|
|
});
|
|
|
|
test('applies edit tags for custom emoji rendering', () {
|
|
final events = [
|
|
_textMsg(
|
|
id: 'a',
|
|
content: 'original :old:',
|
|
extraTags: [
|
|
['emoji', 'old', 'https://relay.example/old.png'],
|
|
],
|
|
),
|
|
_edit(
|
|
id: 'e1',
|
|
targetId: 'a',
|
|
content: 'edited :new:',
|
|
extraTags: [
|
|
['emoji', 'new', 'https://relay.example/new.png'],
|
|
],
|
|
),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result.single.content, 'edited :new:');
|
|
expect(
|
|
result.single.tags.any(
|
|
(tag) =>
|
|
tag.length >= 3 &&
|
|
tag[0] == 'emoji' &&
|
|
tag[1] == 'new' &&
|
|
tag[2] == 'https://relay.example/new.png',
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
result.single.tags.any(
|
|
(tag) =>
|
|
tag.length >= 3 &&
|
|
tag[0] == 'emoji' &&
|
|
tag[1] == 'old' &&
|
|
tag[2] == 'https://relay.example/old.png',
|
|
),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('preserves custom emoji reaction image URL from emoji tag', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'm1'),
|
|
_reaction(
|
|
id: 'r1',
|
|
targetId: 'm1',
|
|
content: ':shipit:',
|
|
extraTags: [
|
|
['emoji', 'shipit', 'https://relay.example/shipit.png'],
|
|
],
|
|
),
|
|
], currentPubkey: 'bob');
|
|
|
|
final reaction = messages.single.reactions.single;
|
|
expect(reaction.emoji, ':shipit:');
|
|
expect(reaction.emojiUrl, 'https://relay.example/shipit.png');
|
|
expect(reaction.reactedByCurrentUser, isTrue);
|
|
});
|
|
|
|
test('latest edit wins', () {
|
|
final events = [
|
|
_textMsg(id: 'a', content: 'v1'),
|
|
_edit(id: 'e1', targetId: 'a', content: 'v2', createdAt: 2000),
|
|
_edit(id: 'e2', targetId: 'a', content: 'v3', createdAt: 3000),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result[0].content, 'v3');
|
|
});
|
|
|
|
test('deleted edit is ignored', () {
|
|
final events = [
|
|
_textMsg(id: 'a', content: 'original'),
|
|
_edit(id: 'e1', targetId: 'a', content: 'edited'),
|
|
_deletion(id: 'd1', targets: ['e1']),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result[0].content, 'original');
|
|
expect(result[0].edited, false);
|
|
});
|
|
|
|
test('edit of deleted message is ignored', () {
|
|
final events = [
|
|
_textMsg(id: 'a', content: 'original'),
|
|
_edit(id: 'e1', targetId: 'a', content: 'edited'),
|
|
_deletion(id: 'd1', targets: ['a']),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, isEmpty);
|
|
});
|
|
|
|
test('system messages are parsed', () {
|
|
final events = [
|
|
_systemMsg(
|
|
id: 's1',
|
|
payload: {'type': 'channel_created', 'actor': 'pk1'},
|
|
),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, hasLength(1));
|
|
expect(result[0].isSystem, true);
|
|
expect(result[0].systemEvent, isNotNull);
|
|
expect(result[0].systemEvent!.type, SystemEventType.channelCreated);
|
|
});
|
|
|
|
test('huddle start and end events render as system rows', () {
|
|
final result = formatTimeline([
|
|
_huddleEvent(id: 'h1', kind: EventKind.huddleStarted),
|
|
_huddleEvent(id: 'h2', kind: EventKind.huddleEnded, createdAt: 1100),
|
|
]);
|
|
|
|
expect(result, hasLength(2));
|
|
expect(result[0].isSystem, isTrue);
|
|
expect(result[0].systemEvent!.type, SystemEventType.huddleStarted);
|
|
expect(result[1].isSystem, isTrue);
|
|
expect(result[1].systemEvent!.type, SystemEventType.huddleEnded);
|
|
});
|
|
|
|
test('huddle participant events are lifecycle metadata only', () {
|
|
final result = formatTimeline([
|
|
_huddleEvent(id: 'h1', kind: EventKind.huddleParticipantJoined),
|
|
_huddleEvent(id: 'h2', kind: EventKind.huddleParticipantLeft),
|
|
]);
|
|
|
|
expect(result, isEmpty);
|
|
});
|
|
|
|
test('unknown system messages are dropped', () {
|
|
final events = [
|
|
_systemMsg(id: 's1', payload: {'type': 'unknown'}),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, isEmpty);
|
|
});
|
|
|
|
test('malformed system messages are skipped safely', () {
|
|
final events = [
|
|
_systemMsg(
|
|
id: 's1',
|
|
payload: {
|
|
'type': ['member_joined'],
|
|
'actor': 123,
|
|
},
|
|
),
|
|
_textMsg(id: 'a', content: 'hello', createdAt: 1100),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, hasLength(1));
|
|
expect(result[0].content, 'hello');
|
|
});
|
|
|
|
test('reactions and typing indicators are filtered out', () {
|
|
final events = [
|
|
_textMsg(id: 'a', content: 'hello'),
|
|
_reaction(id: 'r1', targetId: 'a'),
|
|
NostrEvent(
|
|
id: 'typing1',
|
|
pubkey: 'bob',
|
|
createdAt: 1000,
|
|
kind: EventKind.typingIndicator,
|
|
tags: [
|
|
['h', 'ch1'],
|
|
],
|
|
content: '',
|
|
sig: '',
|
|
),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, hasLength(1));
|
|
expect(result[0].content, 'hello');
|
|
});
|
|
|
|
test('preserves chronological order', () {
|
|
final events = [
|
|
_textMsg(id: 'a', content: 'first', createdAt: 1000),
|
|
_systemMsg(
|
|
id: 's1',
|
|
payload: {'type': 'member_joined', 'actor': 'pk1', 'target': 'pk1'},
|
|
createdAt: 1100,
|
|
),
|
|
_textMsg(id: 'b', content: 'second', createdAt: 1200),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, hasLength(3));
|
|
expect(result[0].content, 'first');
|
|
expect(result[1].isSystem, true);
|
|
expect(result[2].content, 'second');
|
|
});
|
|
|
|
test('streamMessageV2 events are included', () {
|
|
final events = [
|
|
NostrEvent(
|
|
id: 'v2msg',
|
|
pubkey: 'alice',
|
|
createdAt: 1000,
|
|
kind: EventKind.streamMessageV2,
|
|
tags: [
|
|
['h', 'ch1'],
|
|
],
|
|
content: 'legacy message',
|
|
sig: '',
|
|
),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, hasLength(1));
|
|
expect(result[0].content, 'legacy message');
|
|
});
|
|
|
|
test('streamMessageDiff events are included', () {
|
|
final events = [
|
|
NostrEvent(
|
|
id: 'diff1',
|
|
pubkey: 'alice',
|
|
createdAt: 1000,
|
|
kind: EventKind.streamMessageDiff,
|
|
tags: [
|
|
['h', 'ch1'],
|
|
],
|
|
content: '```diff\n-old\n+new\n```',
|
|
sig: '',
|
|
),
|
|
];
|
|
|
|
final result = formatTimeline(events);
|
|
expect(result, hasLength(1));
|
|
expect(result[0].content, contains('diff'));
|
|
});
|
|
});
|
|
|
|
group('NostrEvent.threadReference', () {
|
|
test('returns nulls for top-level message (no e-tags)', () {
|
|
final event = _textMsg(id: 'a');
|
|
final ref = event.threadReference;
|
|
expect(ref.parentId, isNull);
|
|
expect(ref.rootId, isNull);
|
|
});
|
|
|
|
test('returns nulls for e-tags without root/reply markers', () {
|
|
final event = _textMsg(
|
|
id: 'a',
|
|
extraTags: [
|
|
['e', 'target'],
|
|
],
|
|
);
|
|
final ref = event.threadReference;
|
|
expect(ref.parentId, isNull);
|
|
expect(ref.rootId, isNull);
|
|
});
|
|
|
|
test('parses direct reply to root (single reply marker)', () {
|
|
final event = _textMsg(
|
|
id: 'reply1',
|
|
extraTags: [
|
|
['e', 'root1', '', 'reply'],
|
|
],
|
|
);
|
|
final ref = event.threadReference;
|
|
expect(ref.parentId, 'root1');
|
|
expect(ref.rootId, 'root1');
|
|
});
|
|
|
|
test('parses nested reply (root + reply markers)', () {
|
|
final event = _textMsg(
|
|
id: 'reply2',
|
|
extraTags: [
|
|
['e', 'root1', '', 'root'],
|
|
['e', 'parent1', '', 'reply'],
|
|
],
|
|
);
|
|
final ref = event.threadReference;
|
|
expect(ref.parentId, 'parent1');
|
|
expect(ref.rootId, 'root1');
|
|
});
|
|
|
|
test('last reply tag wins when multiple exist', () {
|
|
final event = _textMsg(
|
|
id: 'reply3',
|
|
extraTags: [
|
|
['e', 'root1', '', 'root'],
|
|
['e', 'old_parent', '', 'reply'],
|
|
['e', 'new_parent', '', 'reply'],
|
|
],
|
|
);
|
|
final ref = event.threadReference;
|
|
expect(ref.parentId, 'new_parent');
|
|
expect(ref.rootId, 'root1');
|
|
});
|
|
});
|
|
|
|
group('formatTimeline threading', () {
|
|
test('root messages have null parentId and rootId', () {
|
|
final events = [_textMsg(id: 'a')];
|
|
final result = formatTimeline(events);
|
|
expect(result[0].parentId, isNull);
|
|
expect(result[0].rootId, isNull);
|
|
});
|
|
|
|
test('reply messages carry parentId and rootId', () {
|
|
final events = [
|
|
_textMsg(id: 'root1'),
|
|
_replyMsg(id: 'r1', parentId: 'root1', createdAt: 2000),
|
|
];
|
|
final result = formatTimeline(events);
|
|
final reply = result.firstWhere((m) => m.id == 'r1');
|
|
expect(reply.parentId, 'root1');
|
|
expect(reply.rootId, 'root1');
|
|
});
|
|
|
|
test('nested reply has distinct parentId and rootId', () {
|
|
final events = [
|
|
_textMsg(id: 'root1'),
|
|
_replyMsg(id: 'r1', parentId: 'root1', createdAt: 2000),
|
|
_replyMsg(id: 'r2', parentId: 'r1', rootId: 'root1', createdAt: 3000),
|
|
];
|
|
final result = formatTimeline(events);
|
|
final nested = result.firstWhere((m) => m.id == 'r2');
|
|
expect(nested.parentId, 'r1');
|
|
expect(nested.rootId, 'root1');
|
|
});
|
|
});
|
|
|
|
group('buildMainTimelineEntries', () {
|
|
test('returns only root messages', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', createdAt: 2000),
|
|
_textMsg(id: 'b', createdAt: 3000),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(messages);
|
|
expect(entries, hasLength(2));
|
|
expect(entries[0].message.id, 'a');
|
|
expect(entries[1].message.id, 'b');
|
|
});
|
|
|
|
test('includes broadcast replies in the main timeline', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'hidden', parentId: 'a', createdAt: 2000),
|
|
_replyMsg(
|
|
id: 'broadcast',
|
|
parentId: 'a',
|
|
createdAt: 3000,
|
|
extraTags: const [
|
|
['broadcast', '1'],
|
|
],
|
|
),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(messages);
|
|
expect(entries.map((entry) => entry.message.id), ['a', 'broadcast']);
|
|
});
|
|
|
|
test('root without replies has null summary', () {
|
|
final messages = formatTimeline([_textMsg(id: 'a')]);
|
|
final entries = buildMainTimelineEntries(messages);
|
|
expect(entries[0].summary, isNull);
|
|
});
|
|
|
|
test('root with replies has summary with correct count', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
|
_replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(messages);
|
|
expect(entries, hasLength(1));
|
|
expect(entries[0].summary, isNotNull);
|
|
expect(entries[0].summary!.replyCount, 2);
|
|
expect(entries[0].summary!.threadHeadId, 'a');
|
|
expect(entries[0].summary!.lastReplyAt, 3000);
|
|
});
|
|
|
|
test(
|
|
'summary counts every loaded descendant, not only direct children',
|
|
() {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', createdAt: 2000),
|
|
_replyMsg(id: 'r2', parentId: 'r1', rootId: 'a', createdAt: 3000),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(messages);
|
|
// The root badge stands for the whole thread, so the reply to r1 counts
|
|
// towards 'a' as well. This matches the relay's `descendant_count` and
|
|
// the desktop's `buildDescendantStatsByMessageId`.
|
|
expect(entries[0].summary!.replyCount, 2);
|
|
expect(entries[0].summary!.lastReplyAt, 3000);
|
|
},
|
|
);
|
|
|
|
test('summary has up to 3 unique participant pubkeys', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
|
_replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000),
|
|
_replyMsg(id: 'r3', parentId: 'a', pubkey: 'bob', createdAt: 4000),
|
|
_replyMsg(id: 'r4', parentId: 'a', pubkey: 'dave', createdAt: 5000),
|
|
_replyMsg(id: 'r5', parentId: 'a', pubkey: 'eve', createdAt: 6000),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(messages);
|
|
final participants = entries[0].summary!.participantPubkeys;
|
|
expect(participants, hasLength(3));
|
|
// Most recent unique: eve, dave, bob (r5, r4, r3 — carol skipped
|
|
// because bob at r3 comes before carol at r2 when walking backwards).
|
|
// Reversed to chronological order.
|
|
expect(participants, ['bob', 'dave', 'eve']);
|
|
});
|
|
|
|
test('system messages remain in entries (parentId is null)', () {
|
|
final events = [
|
|
_systemMsg(
|
|
id: 's1',
|
|
payload: {'type': 'channel_created', 'actor': 'pk1'},
|
|
createdAt: 500,
|
|
),
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
];
|
|
final messages = formatTimeline(events);
|
|
final entries = buildMainTimelineEntries(messages);
|
|
expect(entries, hasLength(2));
|
|
expect(entries[0].message.isSystem, true);
|
|
});
|
|
|
|
test('empty input returns empty', () {
|
|
expect(buildMainTimelineEntries([]), isEmpty);
|
|
});
|
|
});
|
|
|
|
group('buildMainTimelineEntries relay summary merge', () {
|
|
ChannelWindowThreadSummary relaySummary({
|
|
required int replyCount,
|
|
int? descendantCount,
|
|
int? lastReplyAt,
|
|
List<String> participantPubkeys = const ['zoe'],
|
|
}) => ChannelWindowThreadSummary(
|
|
replyCount: replyCount,
|
|
descendantCount: descendantCount ?? replyCount,
|
|
lastReplyAt: lastReplyAt,
|
|
participantPubkeys: participantPubkeys,
|
|
);
|
|
|
|
test('relay recount covers replies this client never loaded', () {
|
|
final messages = formatTimeline([_textMsg(id: 'a', createdAt: 1000)]);
|
|
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {'a': relaySummary(replyCount: 4, lastReplyAt: 9000)},
|
|
);
|
|
|
|
expect(entries.single.summary!.replyCount, 4);
|
|
expect(entries.single.summary!.lastReplyAt, 9000);
|
|
expect(entries.single.summary!.participantPubkeys, ['zoe']);
|
|
});
|
|
|
|
test('a reply newer than the recount is added to the badge', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
|
_replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000),
|
|
]);
|
|
|
|
// The relay counted only r1 before r2 landed.
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)},
|
|
);
|
|
|
|
expect(entries.single.summary!.replyCount, 2);
|
|
expect(entries.single.summary!.lastReplyAt, 3000);
|
|
});
|
|
|
|
test('a reply in the same second as the recount still counts', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
|
_replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 2000),
|
|
]);
|
|
|
|
// Relay timestamps have second precision, so an equal timestamp is no
|
|
// proof the recount already included the second reply.
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)},
|
|
);
|
|
|
|
expect(entries.single.summary!.replyCount, 2);
|
|
});
|
|
|
|
test('a lost recount still leaves a badge from the local reply', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(messages);
|
|
|
|
expect(entries.single.summary!.replyCount, 1);
|
|
expect(entries.single.summary!.participantPubkeys, ['bob']);
|
|
});
|
|
|
|
test('the relay recount wins when it is ahead of the loaded replies', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {'a': relaySummary(replyCount: 7, lastReplyAt: 8000)},
|
|
);
|
|
|
|
expect(entries.single.summary!.replyCount, 7);
|
|
expect(entries.single.summary!.lastReplyAt, 8000);
|
|
});
|
|
|
|
test('a zero recount does not erase a locally seen reply', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {'a': relaySummary(replyCount: 0, lastReplyAt: null)},
|
|
);
|
|
|
|
expect(entries.single.summary!.replyCount, 1);
|
|
expect(entries.single.summary!.lastReplyAt, 2000);
|
|
});
|
|
|
|
test('a locally seen nested reply raises its root badge', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
|
_replyMsg(
|
|
id: 'r2',
|
|
parentId: 'r1',
|
|
rootId: 'a',
|
|
pubkey: 'carol',
|
|
createdAt: 3000,
|
|
),
|
|
]);
|
|
|
|
// The recount for 'a' predates r2, so only the locally seen nested reply
|
|
// can bring the root badge, time, and facepile up to date.
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {
|
|
'a': relaySummary(
|
|
replyCount: 1,
|
|
lastReplyAt: 2000,
|
|
participantPubkeys: const ['bob'],
|
|
),
|
|
},
|
|
);
|
|
|
|
final root = entries.firstWhere((entry) => entry.message.id == 'a');
|
|
expect(root.summary!.replyCount, 2);
|
|
expect(root.summary!.lastReplyAt, 3000);
|
|
expect(root.summary!.participantPubkeys, ['bob', 'carol']);
|
|
});
|
|
|
|
test('the relay half counts descendants, not direct replies', () {
|
|
final messages = formatTimeline([_textMsg(id: 'a', createdAt: 1000)]);
|
|
|
|
// The relay reports both numbers. A thread of nested replies has a
|
|
// `reply_count` far below its `descendant_count`, and the badge stands
|
|
// for the whole thread.
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {
|
|
'a': relaySummary(
|
|
replyCount: 1,
|
|
descendantCount: 5,
|
|
lastReplyAt: 9000,
|
|
),
|
|
},
|
|
);
|
|
|
|
expect(entries.single.summary!.replyCount, 5);
|
|
});
|
|
|
|
test('a nested reply badges the reply it answers', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(
|
|
id: 'r1',
|
|
parentId: 'a',
|
|
pubkey: 'bob',
|
|
createdAt: 2000,
|
|
extraTags: const [
|
|
['broadcast', '1'],
|
|
],
|
|
),
|
|
_replyMsg(
|
|
id: 'r2',
|
|
parentId: 'r1',
|
|
rootId: 'a',
|
|
pubkey: 'carol',
|
|
createdAt: 3000,
|
|
),
|
|
]);
|
|
|
|
// The relay keys recounts by the outermost root, so r1 never gets one and
|
|
// its badge has to come from the locally seen nested reply.
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)},
|
|
);
|
|
|
|
final byId = {for (final entry in entries) entry.message.id: entry};
|
|
expect(byId['a']!.summary!.replyCount, 2);
|
|
expect(byId['r1']!.summary!.replyCount, 1);
|
|
expect(byId['r1']!.summary!.participantPubkeys, ['carol']);
|
|
expect(byId['r1']!.summary!.lastReplyAt, 3000);
|
|
});
|
|
|
|
test('merged participants keep relay identities first, capped at 3', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
|
_replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {
|
|
'a': relaySummary(
|
|
replyCount: 1,
|
|
lastReplyAt: 2000,
|
|
participantPubkeys: const ['zoe', 'yara'],
|
|
),
|
|
},
|
|
);
|
|
|
|
expect(entries.single.summary!.participantPubkeys, [
|
|
'zoe',
|
|
'yara',
|
|
'carol',
|
|
]);
|
|
});
|
|
|
|
test('a deleted reply does not hold the badge above the recount', () {
|
|
// The relay counts down on delete and re-emits the recount, so taking the
|
|
// larger count must not resurrect a deleted reply. `formatTimeline` drops
|
|
// it from the local half first, which is what keeps `max` honest here.
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000),
|
|
_replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000),
|
|
_deletion(id: 'd1', targets: ['r2']),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)},
|
|
);
|
|
|
|
expect(entries.single.summary!.replyCount, 1);
|
|
expect(entries.single.summary!.lastReplyAt, 2000);
|
|
});
|
|
|
|
test('a participant in both halves is not listed twice', () {
|
|
final messages = formatTimeline([
|
|
_textMsg(id: 'a', createdAt: 1000),
|
|
_replyMsg(id: 'r1', parentId: 'a', pubkey: 'BOB', createdAt: 2000),
|
|
_replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000),
|
|
]);
|
|
|
|
final entries = buildMainTimelineEntries(
|
|
messages,
|
|
relaySummaries: {
|
|
'a': relaySummary(
|
|
replyCount: 2,
|
|
lastReplyAt: 3000,
|
|
participantPubkeys: const ['carol', 'bob'],
|
|
),
|
|
},
|
|
);
|
|
|
|
expect(entries.single.summary!.participantPubkeys, ['carol', 'bob']);
|
|
});
|
|
});
|
|
|
|
group('groupMembershipTimelineEntries', () {
|
|
List<MainTimelineEntry> entries(List<NostrEvent> events) =>
|
|
buildMainTimelineEntries(formatTimeline(events));
|
|
|
|
test('groups consecutive additions by one actor within five minutes', () {
|
|
final grouped = groupMembershipTimelineEntries(
|
|
entries([
|
|
_systemMsg(
|
|
id: 'a',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'alice',
|
|
'target': 'bob',
|
|
},
|
|
createdAt: 1000,
|
|
),
|
|
_systemMsg(
|
|
id: 'b',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'alice',
|
|
'target': 'carol',
|
|
},
|
|
createdAt: 1060,
|
|
),
|
|
_systemMsg(
|
|
id: 'c',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'alice',
|
|
'target': 'dave',
|
|
},
|
|
createdAt: 1300,
|
|
),
|
|
]),
|
|
);
|
|
|
|
expect(grouped, hasLength(1));
|
|
expect(grouped.single.map((entry) => entry.message.id), ['a', 'b', 'c']);
|
|
});
|
|
|
|
test('groups self-joins from different people', () {
|
|
final grouped = groupMembershipTimelineEntries(
|
|
entries([
|
|
_systemMsg(
|
|
id: 'a',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'alice',
|
|
'target': 'alice',
|
|
},
|
|
createdAt: 1000,
|
|
),
|
|
_systemMsg(
|
|
id: 'b',
|
|
payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'},
|
|
createdAt: 1060,
|
|
),
|
|
]),
|
|
);
|
|
|
|
expect(grouped.single, hasLength(2));
|
|
});
|
|
|
|
test('uses a fixed window anchored on the newest addition', () {
|
|
final grouped = groupMembershipTimelineEntries(
|
|
entries([
|
|
_systemMsg(
|
|
id: 'a',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'alice',
|
|
'target': 'bob',
|
|
},
|
|
createdAt: 1000,
|
|
),
|
|
_systemMsg(
|
|
id: 'b',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'alice',
|
|
'target': 'carol',
|
|
},
|
|
createdAt: 1240,
|
|
),
|
|
_systemMsg(
|
|
id: 'c',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'alice',
|
|
'target': 'dave',
|
|
},
|
|
createdAt: 1301,
|
|
),
|
|
]),
|
|
);
|
|
|
|
expect(grouped.map((group) => group.length), [1, 2]);
|
|
});
|
|
|
|
test('actor changes, messages, and day boundaries break groups', () {
|
|
final dayOne =
|
|
DateTime(2026, 7, 14, 23, 59).millisecondsSinceEpoch ~/ 1000;
|
|
final dayTwo = DateTime(2026, 7, 15).millisecondsSinceEpoch ~/ 1000;
|
|
final grouped = groupMembershipTimelineEntries(
|
|
entries([
|
|
_systemMsg(
|
|
id: 'a',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'alice',
|
|
'target': 'bob',
|
|
},
|
|
createdAt: dayOne,
|
|
),
|
|
_systemMsg(
|
|
id: 'b',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'carol',
|
|
'target': 'dave',
|
|
},
|
|
createdAt: dayOne + 30,
|
|
),
|
|
_textMsg(id: 'message', createdAt: dayOne + 40),
|
|
_systemMsg(
|
|
id: 'c',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'carol',
|
|
'target': 'erin',
|
|
},
|
|
createdAt: dayOne + 50,
|
|
),
|
|
_systemMsg(
|
|
id: 'd',
|
|
payload: {
|
|
'type': 'member_joined',
|
|
'actor': 'carol',
|
|
'target': 'frank',
|
|
},
|
|
createdAt: dayTwo,
|
|
),
|
|
]),
|
|
);
|
|
|
|
expect(grouped.map((group) => group.length), [1, 1, 1, 1, 1]);
|
|
});
|
|
});
|
|
}
|