diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 463860b4e8..d0bb1c587f 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1,69 +1,189 @@ +import 'dart:convert'; + import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/content.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import '../api/fake_api.dart'; import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; -import '../model/binding.dart'; -import '../model/test_store.dart'; - -const int userId = 1; - -Future messageListViewWithMessages(List messages, ZulipStream stream, Narrow narrow) async { - addTearDown(TestZulipBinding.instance.reset); +import '../stdlib_checks.dart'; +import 'content_checks.dart'; - await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot()); +void main() async { + // These variables are the common state operated on by each test. + // Each test case calls [prepare] to initialize them. + late PerAccountStore store; + late FakeApiConnection connection; + late MessageListView model; + late int notifiedCount; + + void checkNotNotified() { + check(notifiedCount).equals(0); + } + + void checkNotifiedOnce() { + check(notifiedCount).equals(1); + notifiedCount = 0; + } + + /// Initialize [model] and the rest of the test state. + void prepare({Narrow narrow = const AllMessagesNarrow()}) { + store = eg.store(); + connection = store.connection as FakeApiConnection; + notifiedCount = 0; + model = MessageListView.init(store: store, narrow: narrow) + ..addListener(() { + checkInvariants(model); + notifiedCount++; + }); + check(model).fetched.isFalse(); + checkInvariants(model); + checkNotNotified(); + } + + /// Perform the initial message fetch for [model]. + /// + /// The test case must have already called [prepare] to initialize the state. + Future prepareMessages({ + required bool foundOldest, + required List messages, + }) async { + connection.prepare(json: + newestResult(foundOldest: foundOldest, messages: messages).toJson()); + await model.fetch(); + checkNotifiedOnce(); + } + + void checkLastRequest({ + required ApiNarrow narrow, + required String anchor, + bool? includeAnchor, + required int numBefore, + required int numAfter, + }) { + check(connection.lastRequest).isA() + ..method.equals('GET') + ..url.path.equals('/api/v1/messages') + ..url.queryParameters.deepEquals({ + 'narrow': jsonEncode(narrow), + 'anchor': anchor, + if (includeAnchor != null) 'include_anchor': includeAnchor.toString(), + 'num_before': numBefore.toString(), + 'num_after': numAfter.toString(), + }); + } + + test('fetch', () async { + const narrow = AllMessagesNarrow(); + prepare(narrow: narrow); + connection.prepare(json: newestResult( + foundOldest: false, + messages: List.generate(100, (i) => eg.streamMessage(id: 1000 + i)), + ).toJson()); + final fetchFuture = model.fetch(); + check(model).fetched.isFalse(); + checkInvariants(model); + + checkNotNotified(); + await fetchFuture; + checkNotifiedOnce(); + check(model).messages.length.equals(100); + checkLastRequest( + narrow: narrow.apiEncode(), + anchor: 'newest', + numBefore: 100, + numAfter: 10, + ); + }); - final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id); - store.addUser(eg.user(userId: userId)); - store.addStream(stream); + test('fetch, short history', () async { + prepare(); + connection.prepare(json: newestResult( + foundOldest: true, + messages: List.generate(30, (i) => eg.streamMessage(id: 1000 + i)), + ).toJson()); + await model.fetch(); + checkNotifiedOnce(); + check(model).messages.length.equals(30); + }); - final messageList = MessageListView.init(store: store, narrow: narrow); + test('fetch, no messages found', () async { + prepare(); + connection.prepare(json: newestResult( + foundOldest: true, + messages: [], + ).toJson()); + await model.fetch(); + checkNotifiedOnce(); + check(model) + ..fetched.isTrue() + ..messages.isEmpty(); + }); - final connection = store.connection as FakeApiConnection; - connection.prepare(json: GetMessagesResult( - anchor: messages.first.id, - foundNewest: true, - foundOldest: true, - foundAnchor: true, - historyLimited: false, - messages: messages, - ).toJson()); - await messageList.fetch(); + test('maybeAddMessage', () async { + final stream = eg.stream(); + prepare(narrow: StreamNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(id: 1000 + i, stream: stream))); - return messageList; -} + check(model).messages.length.equals(30); + model.maybeAddMessage(eg.streamMessage(id: 1100, stream: stream)); + checkNotifiedOnce(); + check(model).messages.length.equals(31); + }); -void main() async { - TestZulipBinding.ensureInitialized(); + test('maybeAddMessage, not in narrow', () async { + final stream = eg.stream(streamId: 123); + prepare(narrow: StreamNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(id: 1000 + i, stream: stream))); + + check(model).messages.length.equals(30); + final otherStream = eg.stream(streamId: 234); + model.maybeAddMessage(eg.streamMessage(id: 1100, stream: otherStream)); + checkNotNotified(); + check(model).messages.length.equals(30); + }); - final stream = eg.stream(); - final narrow = StreamNarrow(stream.streamId); + test('maybeAddMessage, before fetch', () async { + final stream = eg.stream(); + prepare(narrow: StreamNarrow(stream.streamId)); + model.maybeAddMessage(eg.streamMessage(id: 1100, stream: stream)); + checkNotNotified(); + check(model).fetched.isFalse(); + checkInvariants(model); + }); test('findMessageWithId', () async { - final m1 = eg.streamMessage(id: 2, stream: stream); - final m2 = eg.streamMessage(id: 4, stream: stream); - final m3 = eg.streamMessage(id: 6, stream: stream); - final messageList = await messageListViewWithMessages([m1, m2, m3], stream, narrow); + prepare(); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(id: 2), + eg.streamMessage(id: 4), + eg.streamMessage(id: 6), + ]); // Exercise the binary search before, at, and after each element of the list. - check(messageList.findMessageWithId(1)).equals(-1); - check(messageList.findMessageWithId(2)).equals(0); - check(messageList.findMessageWithId(3)).equals(-1); - check(messageList.findMessageWithId(4)).equals(1); - check(messageList.findMessageWithId(5)).equals(-1); - check(messageList.findMessageWithId(6)).equals(2); - check(messageList.findMessageWithId(7)).equals(-1); + check(model.findMessageWithId(1)).equals(-1); + check(model.findMessageWithId(2)).equals(0); + check(model.findMessageWithId(3)).equals(-1); + check(model.findMessageWithId(4)).equals(1); + check(model.findMessageWithId(5)).equals(-1); + check(model.findMessageWithId(6)).equals(2); + check(model.findMessageWithId(7)).equals(-1); }); group('maybeUpdateMessage', () { test('update a message', () async { - final originalMessage = eg.streamMessage(id: 243, stream: stream, + final originalMessage = eg.streamMessage(id: 243, content: "

Hello, world

"); final updateEvent = UpdateMessageEvent( id: 1, @@ -73,24 +193,22 @@ void main() async { renderedContent: "

Hello, edited

", editTimestamp: 99999, isMeMessage: true, - userId: userId, + userId: 1, renderingOnly: false, ); + prepare(); + await prepareMessages(foundOldest: true, messages: [originalMessage]); - final messageList = await messageListViewWithMessages([originalMessage], stream, narrow); - bool listenersNotified = false; - messageList.addListener(() { listenersNotified = true; }); - - final message = messageList.messages.single; + final message = model.messages.single; check(message) ..content.not(it()..equals(updateEvent.renderedContent!)) ..lastEditTimestamp.isNull() ..flags.not(it()..deepEquals(updateEvent.flags)) ..isMeMessage.not(it()..equals(updateEvent.isMeMessage!)); - messageList.maybeUpdateMessage(updateEvent); - check(listenersNotified).isTrue(); - check(messageList.messages.single) + model.maybeUpdateMessage(updateEvent); + checkNotifiedOnce(); + check(model).messages.single ..identicalTo(message) ..content.equals(updateEvent.renderedContent!) ..lastEditTimestamp.equals(updateEvent.editTimestamp) @@ -99,7 +217,7 @@ void main() async { }); test('ignore when message not present', () async { - final originalMessage = eg.streamMessage(id: 243, stream: stream, + final originalMessage = eg.streamMessage(id: 243, content: "

Hello, world

"); final updateEvent = UpdateMessageEvent( id: 1, @@ -108,24 +226,22 @@ void main() async { flags: originalMessage.flags, renderedContent: "

Hello, edited

", editTimestamp: 99999, - userId: userId, + userId: 1, renderingOnly: false, ); + prepare(); + await prepareMessages(foundOldest: true, messages: [originalMessage]); - final messageList = await messageListViewWithMessages([originalMessage], stream, narrow); - bool listenersNotified = false; - messageList.addListener(() { listenersNotified = true; }); - - messageList.maybeUpdateMessage(updateEvent); - check(listenersNotified).isFalse(); - check(messageList.messages.single) + model.maybeUpdateMessage(updateEvent); + checkNotNotified(); + check(model).messages.single ..content.equals(originalMessage.content) ..content.not(it()..equals(updateEvent.renderedContent!)); }); // TODO(server-5): Cut legacy case for rendering-only message update Future checkRenderingOnly({required bool legacy}) async { - final originalMessage = eg.streamMessage(id: 972, stream: stream, + final originalMessage = eg.streamMessage(id: 972, lastEditTimestamp: 78492, content: "

Hello, world

"); final updateEvent = UpdateMessageEvent( @@ -138,15 +254,13 @@ void main() async { renderingOnly: legacy ? null : true, userId: null, ); + prepare(); + await prepareMessages(foundOldest: true, messages: [originalMessage]); + final message = model.messages.single; - final messageList = await messageListViewWithMessages([originalMessage], stream, narrow); - bool listenersNotified = false; - messageList.addListener(() { listenersNotified = true; }); - - final message = messageList.messages.single; - messageList.maybeUpdateMessage(updateEvent); - check(listenersNotified).isTrue(); - check(messageList.messages.single) + model.maybeUpdateMessage(updateEvent); + checkNotifiedOnce(); + check(model).messages.single ..identicalTo(message) // Content is updated... ..content.equals(updateEvent.renderedContent!) @@ -177,35 +291,27 @@ void main() async { } test('add reaction', () async { - final originalMessage = eg.streamMessage(stream: stream, reactions: []); - final messageList = await messageListViewWithMessages([originalMessage], stream, narrow); - - final message = messageList.messages.single; + final originalMessage = eg.streamMessage(reactions: []); + prepare(); + await prepareMessages(foundOldest: true, messages: [originalMessage]); + final message = model.messages.single; - bool listenersNotified = false; - messageList.addListener(() { listenersNotified = true; }); - - messageList.maybeUpdateMessageReactions( + model.maybeUpdateMessageReactions( mkEvent(eg.unicodeEmojiReaction, ReactionOp.add, originalMessage.id)); - - check(listenersNotified).isTrue(); - check(messageList.messages.single) + checkNotifiedOnce(); + check(model).messages.single ..identicalTo(message) ..reactions.jsonEquals([eg.unicodeEmojiReaction]); }); test('add reaction; message is not in list', () async { final someMessage = eg.streamMessage(id: 1, reactions: []); - final messageList = await messageListViewWithMessages([someMessage], stream, narrow); - - bool listenersNotified = false; - messageList.addListener(() { listenersNotified = true; }); - - messageList.maybeUpdateMessageReactions( + prepare(); + await prepareMessages(foundOldest: true, messages: [someMessage]); + model.maybeUpdateMessageReactions( mkEvent(eg.unicodeEmojiReaction, ReactionOp.add, 1000)); - - check(listenersNotified).isFalse(); - check(messageList.messages.single).reactions.jsonEquals([]); + checkNotNotified(); + check(model).messages.single.reactions.jsonEquals([]); }); test('remove reaction', () async { @@ -228,37 +334,95 @@ void main() async { final reaction4 = Reaction.fromJson(eventReaction.toJson() ..['emoji_name'] = 'hello'); - final originalMessage = eg.streamMessage(stream: stream, + final originalMessage = eg.streamMessage( reactions: [reaction2, reaction3, reaction4]); - final messageList = await messageListViewWithMessages([originalMessage], stream, narrow); + prepare(); + await prepareMessages(foundOldest: true, messages: [originalMessage]); + final message = model.messages.single; - final message = messageList.messages.single; - - bool listenersNotified = false; - messageList.addListener(() { listenersNotified = true; }); - - messageList.maybeUpdateMessageReactions( + model.maybeUpdateMessageReactions( mkEvent(eventReaction, ReactionOp.remove, originalMessage.id)); - - check(listenersNotified).isTrue(); - check(messageList.messages.single) + checkNotifiedOnce(); + check(model).messages.single ..identicalTo(message) ..reactions.jsonEquals([reaction2, reaction3]); }); test('remove reaction; message is not in list', () async { final someMessage = eg.streamMessage(id: 1, reactions: [eg.unicodeEmojiReaction]); - final messageList = await messageListViewWithMessages([someMessage], stream, narrow); - - bool listenersNotified = false; - messageList.addListener(() { listenersNotified = true; }); - - messageList.maybeUpdateMessageReactions( + prepare(); + await prepareMessages(foundOldest: true, messages: [someMessage]); + model.maybeUpdateMessageReactions( mkEvent(eg.unicodeEmojiReaction, ReactionOp.remove, 1000)); - - check(listenersNotified).isFalse(); - check(messageList.messages.single).reactions.jsonEquals([eg.unicodeEmojiReaction]); + checkNotNotified(); + check(model).messages.single.reactions.jsonEquals([eg.unicodeEmojiReaction]); }); }); }); + + test('reassemble', () async { + final stream = eg.stream(); + prepare(narrow: StreamNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: + List.generate(30, (i) => eg.streamMessage(id: 1000 + i, stream: stream))); + model.maybeAddMessage(eg.streamMessage(id: 1100, stream: stream)); + checkNotifiedOnce(); + check(model).messages.length.equals(31); + + // Mess with model.contents, to simulate it having come from + // a previous version of the code. + final correctContent = parseContent(model.messages[0].content); + model.contents[0] = const ZulipContent(nodes: [ + ParagraphNode(links: null, nodes: [TextNode('something outdated')]) + ]); + check(model.contents[0]).not(it()..equalsNode(correctContent)); + + model.reassemble(); + checkNotifiedOnce(); + check(model).messages.length.equals(31); + check(model.contents[0]).equalsNode(correctContent); + }); +} + +void checkInvariants(MessageListView model) { + if (!model.fetched) { + check(model).messages.isEmpty(); + } + + for (int i = 0; i < model.messages.length - 1; i++) { + check(model.messages[i].id).isLessThan(model.messages[i+1].id); + } + + check(model).contents.length.equals(model.messages.length); + for (int i = 0; i < model.contents.length; i++) { + check(model.contents[i]) + .equalsNode(parseContent(model.messages[i].content)); + } +} + +extension MessageListViewChecks on Subject { + Subject get store => has((x) => x.store, 'store'); + Subject get narrow => has((x) => x.narrow, 'narrow'); + Subject> get messages => has((x) => x.messages, 'messages'); + Subject> get contents => has((x) => x.contents, 'contents'); + Subject get fetched => has((x) => x.fetched, 'fetched'); +} + +/// A GetMessagesResult the server might return on an `anchor=newest` request. +GetMessagesResult newestResult({ + required bool foundOldest, + bool historyLimited = false, + required List messages, +}) { + return GetMessagesResult( + // These anchor, foundAnchor, and foundNewest values are what the server + // appears to always return when the request had `anchor=newest`. + anchor: 10000000000000000, // that's 16 zeros + foundAnchor: false, + foundNewest: true, + + foundOldest: foundOldest, + historyLimited: historyLimited, + messages: messages, + ); }