|
1 | 1 | import 'dart:convert';
|
2 | 2 |
|
3 | 3 | import 'package:checks/checks.dart';
|
| 4 | +import 'package:collection/collection.dart'; |
4 | 5 | import 'package:flutter/foundation.dart';
|
5 | 6 | import 'package:http/http.dart' as http;
|
6 | 7 | import 'package:test/scaffolding.dart';
|
@@ -1649,6 +1650,214 @@ void main() {
|
1649 | 1650 | });
|
1650 | 1651 | });
|
1651 | 1652 |
|
| 1653 | + group('middleMessage maintained', () { |
| 1654 | + // In [checkInvariants] we verify that messages don't move from the |
| 1655 | + // top to the bottom slice or vice versa. |
| 1656 | + // Most of these test cases rely on that for all the checks they need. |
| 1657 | + |
| 1658 | + test('on fetchInitial empty', () async { |
| 1659 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1660 | + await prepareMessages(foundOldest: true, messages: []); |
| 1661 | + check(model)..messages.isEmpty() |
| 1662 | + ..middleMessage.equals(0); |
| 1663 | + }); |
| 1664 | + |
| 1665 | + test('on fetchInitial empty due to muting', () async { |
| 1666 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1667 | + final stream = eg.stream(); |
| 1668 | + await store.addStream(stream); |
| 1669 | + await store.addSubscription(eg.subscription(stream, isMuted: true)); |
| 1670 | + await prepareMessages(foundOldest: true, messages: [ |
| 1671 | + eg.streamMessage(stream: stream), |
| 1672 | + ]); |
| 1673 | + check(model)..messages.isEmpty() |
| 1674 | + ..middleMessage.equals(0); |
| 1675 | + }); |
| 1676 | + |
| 1677 | + test('on fetchInitial not empty', () async { |
| 1678 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1679 | + final stream1 = eg.stream(); |
| 1680 | + final stream2 = eg.stream(); |
| 1681 | + await store.addStreams([stream1, stream2]); |
| 1682 | + await store.addSubscription(eg.subscription(stream1)); |
| 1683 | + await store.addSubscription(eg.subscription(stream2, isMuted: true)); |
| 1684 | + final messages = [ |
| 1685 | + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), |
| 1686 | + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), |
| 1687 | + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), |
| 1688 | + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), |
| 1689 | + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), |
| 1690 | + ]; |
| 1691 | + await prepareMessages(foundOldest: true, messages: messages); |
| 1692 | + // The anchor message is the last visible message… |
| 1693 | + check(model) |
| 1694 | + ..messages.length.equals(5) |
| 1695 | + ..middleMessage.equals(model.messages.length - 1) |
| 1696 | + // … even though that's not the last message that was in the response. |
| 1697 | + ..messages[model.middleMessage].id |
| 1698 | + .equals(messages[messages.length - 2].id); |
| 1699 | + }); |
| 1700 | + |
| 1701 | + /// Like [prepareMessages], but arrange for the given top and bottom slices. |
| 1702 | + Future<void> prepareMessageSplit(List<Message> top, List<Message> bottom, { |
| 1703 | + bool foundOldest = true, |
| 1704 | + }) async { |
| 1705 | + assert(bottom.isNotEmpty); // could handle this too if necessary |
| 1706 | + await prepareMessages(foundOldest: foundOldest, messages: [ |
| 1707 | + ...top, |
| 1708 | + bottom.first, |
| 1709 | + ]); |
| 1710 | + if (bottom.length > 1) { |
| 1711 | + await store.addMessages(bottom.skip(1)); |
| 1712 | + checkNotifiedOnce(); |
| 1713 | + } |
| 1714 | + check(model) |
| 1715 | + ..messages.length.equals(top.length + bottom.length) |
| 1716 | + ..middleMessage.equals(top.length); |
| 1717 | + } |
| 1718 | + |
| 1719 | + test('on fetchOlder', () async { |
| 1720 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1721 | + final stream = eg.stream(); |
| 1722 | + await store.addStream(stream); |
| 1723 | + await store.addSubscription(eg.subscription(stream)); |
| 1724 | + await prepareMessageSplit(foundOldest: false, |
| 1725 | + [eg.streamMessage(id: 100, stream: stream)], |
| 1726 | + [eg.streamMessage(id: 101, stream: stream)]); |
| 1727 | + |
| 1728 | + connection.prepare(json: olderResult(anchor: 100, foundOldest: true, |
| 1729 | + messages: List.generate(5, (i) => |
| 1730 | + eg.streamMessage(id: 95 + i, stream: stream))).toJson()); |
| 1731 | + await model.fetchOlder(); |
| 1732 | + checkNotified(count: 2); |
| 1733 | + }); |
| 1734 | + |
| 1735 | + test('on fetchOlder, from top empty', () async { |
| 1736 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1737 | + final stream = eg.stream(); |
| 1738 | + await store.addStream(stream); |
| 1739 | + await store.addSubscription(eg.subscription(stream)); |
| 1740 | + await prepareMessageSplit(foundOldest: false, |
| 1741 | + [], [eg.streamMessage(id: 100, stream: stream)]); |
| 1742 | + |
| 1743 | + connection.prepare(json: olderResult(anchor: 100, foundOldest: true, |
| 1744 | + messages: List.generate(5, (i) => |
| 1745 | + eg.streamMessage(id: 95 + i, stream: stream))).toJson()); |
| 1746 | + await model.fetchOlder(); |
| 1747 | + checkNotified(count: 2); |
| 1748 | + // The messages from fetchOlder should go in the top sliver, always. |
| 1749 | + check(model).middleMessage.equals(5); |
| 1750 | + }); |
| 1751 | + |
| 1752 | + test('on MessageEvent', () async { |
| 1753 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1754 | + final stream = eg.stream(); |
| 1755 | + await store.addStream(stream); |
| 1756 | + await store.addSubscription(eg.subscription(stream)); |
| 1757 | + await prepareMessageSplit(foundOldest: false, |
| 1758 | + [eg.streamMessage(stream: stream)], |
| 1759 | + [eg.streamMessage(stream: stream)]); |
| 1760 | + |
| 1761 | + await store.addMessage(eg.streamMessage(stream: stream)); |
| 1762 | + checkNotifiedOnce(); |
| 1763 | + }); |
| 1764 | + |
| 1765 | + test('on messages muted, including anchor', () async { |
| 1766 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1767 | + final stream = eg.stream(); |
| 1768 | + await store.addStream(stream); |
| 1769 | + await store.addSubscription(eg.subscription(stream)); |
| 1770 | + await prepareMessageSplit([ |
| 1771 | + eg.streamMessage(stream: stream, topic: 'foo'), |
| 1772 | + eg.streamMessage(stream: stream, topic: 'bar'), |
| 1773 | + ], [ |
| 1774 | + eg.streamMessage(stream: stream, topic: 'bar'), |
| 1775 | + eg.streamMessage(stream: stream, topic: 'foo'), |
| 1776 | + ]); |
| 1777 | + |
| 1778 | + await store.handleEvent(eg.userTopicEvent( |
| 1779 | + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); |
| 1780 | + checkNotifiedOnce(); |
| 1781 | + }); |
| 1782 | + |
| 1783 | + test('on messages muted, not including anchor', () async { |
| 1784 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1785 | + final stream = eg.stream(); |
| 1786 | + await store.addStream(stream); |
| 1787 | + await store.addSubscription(eg.subscription(stream)); |
| 1788 | + await prepareMessageSplit([ |
| 1789 | + eg.streamMessage(stream: stream, topic: 'foo'), |
| 1790 | + eg.streamMessage(stream: stream, topic: 'bar'), |
| 1791 | + ], [ |
| 1792 | + eg.streamMessage(stream: stream, topic: 'foo'), |
| 1793 | + ]); |
| 1794 | + |
| 1795 | + await store.handleEvent(eg.userTopicEvent( |
| 1796 | + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); |
| 1797 | + checkNotifiedOnce(); |
| 1798 | + }); |
| 1799 | + |
| 1800 | + test('on messages muted, bottom empty', () async { |
| 1801 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1802 | + final stream = eg.stream(); |
| 1803 | + await store.addStream(stream); |
| 1804 | + await store.addSubscription(eg.subscription(stream)); |
| 1805 | + await prepareMessageSplit([ |
| 1806 | + eg.streamMessage(stream: stream, topic: 'foo'), |
| 1807 | + eg.streamMessage(stream: stream, topic: 'bar'), |
| 1808 | + ], [ |
| 1809 | + eg.streamMessage(stream: stream, topic: 'third'), |
| 1810 | + ]); |
| 1811 | + |
| 1812 | + await store.handleEvent(eg.deleteMessageEvent([ |
| 1813 | + model.messages.last as StreamMessage])); |
| 1814 | + checkNotifiedOnce(); |
| 1815 | + check(model).middleMessage.equals(model.messages.length); |
| 1816 | + |
| 1817 | + await store.handleEvent(eg.userTopicEvent( |
| 1818 | + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); |
| 1819 | + checkNotifiedOnce(); |
| 1820 | + }); |
| 1821 | + |
| 1822 | + test('on messages deleted', () async { |
| 1823 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1824 | + final stream = eg.stream(); |
| 1825 | + await store.addStream(stream); |
| 1826 | + await store.addSubscription(eg.subscription(stream)); |
| 1827 | + final messages = [ |
| 1828 | + eg.streamMessage(id: 1, stream: stream), |
| 1829 | + eg.streamMessage(id: 2, stream: stream), |
| 1830 | + eg.streamMessage(id: 3, stream: stream), |
| 1831 | + eg.streamMessage(id: 4, stream: stream), |
| 1832 | + ]; |
| 1833 | + await prepareMessageSplit(messages.sublist(0, 2), messages.sublist(2)); |
| 1834 | + |
| 1835 | + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 3))); |
| 1836 | + checkNotifiedOnce(); |
| 1837 | + }); |
| 1838 | + |
| 1839 | + test('on messages deleted, bottom empty', () async { |
| 1840 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1841 | + final stream = eg.stream(); |
| 1842 | + await store.addStream(stream); |
| 1843 | + await store.addSubscription(eg.subscription(stream)); |
| 1844 | + final messages = [ |
| 1845 | + eg.streamMessage(id: 1, stream: stream), |
| 1846 | + eg.streamMessage(id: 2, stream: stream), |
| 1847 | + eg.streamMessage(id: 3, stream: stream), |
| 1848 | + eg.streamMessage(id: 4, stream: stream), |
| 1849 | + ]; |
| 1850 | + await prepareMessageSplit(messages.sublist(0, 3), messages.sublist(3)); |
| 1851 | + |
| 1852 | + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(3))); |
| 1853 | + checkNotifiedOnce(); |
| 1854 | + check(model).middleMessage.equals(model.messages.length); |
| 1855 | + |
| 1856 | + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 2))); |
| 1857 | + checkNotifiedOnce(); |
| 1858 | + }); |
| 1859 | + }); |
| 1860 | + |
1652 | 1861 | group('handle content parsing into subclasses of ZulipMessageContent', () {
|
1653 | 1862 | test('ZulipContent', () async {
|
1654 | 1863 | final stream = eg.stream();
|
@@ -1922,6 +2131,10 @@ void main() {
|
1922 | 2131 | });
|
1923 | 2132 | }
|
1924 | 2133 |
|
| 2134 | +MessageListView? _lastModel; |
| 2135 | +List<Message>? _lastMessages; |
| 2136 | +int? _lastMiddleMessage; |
| 2137 | + |
1925 | 2138 | void checkInvariants(MessageListView model) {
|
1926 | 2139 | if (!model.fetched) {
|
1927 | 2140 | check(model)
|
@@ -1964,6 +2177,21 @@ void checkInvariants(MessageListView model) {
|
1964 | 2177 | ..isGreaterOrEqual(0)
|
1965 | 2178 | ..isLessOrEqual(model.messages.length);
|
1966 | 2179 |
|
| 2180 | + if (identical(model, _lastModel) |
| 2181 | + && model.generation == _lastModel!.generation) { |
| 2182 | + // All messages that were present, and still are, should be on the same side |
| 2183 | + // of `middleMessage` (still top or bottom slice respectively) as they were. |
| 2184 | + _checkNoIntersection(ListSlice(model.messages, 0, model.middleMessage), |
| 2185 | + ListSlice(_lastMessages!, _lastMiddleMessage!, _lastMessages!.length), |
| 2186 | + because: 'messages moved from bottom slice to top slice'); |
| 2187 | + _checkNoIntersection(ListSlice(_lastMessages!, 0, _lastMiddleMessage!), |
| 2188 | + ListSlice(model.messages, model.middleMessage, model.messages.length), |
| 2189 | + because: 'messages moved from top slice to bottom slice'); |
| 2190 | + } |
| 2191 | + _lastModel = model; |
| 2192 | + _lastMessages = model.messages.toList(); |
| 2193 | + _lastMiddleMessage = model.middleMessage; |
| 2194 | + |
1967 | 2195 | check(model).contents.length.equals(model.messages.length);
|
1968 | 2196 | for (int i = 0; i < model.contents.length; i++) {
|
1969 | 2197 | final poll = model.messages[i].poll;
|
@@ -2013,6 +2241,17 @@ void checkInvariants(MessageListView model) {
|
2013 | 2241 | }
|
2014 | 2242 | }
|
2015 | 2243 |
|
| 2244 | +void _checkNoIntersection(List<Message> xs, List<Message> ys, {String? because}) { |
| 2245 | + // Both lists are sorted by ID. As an optimization, bet on all or nearly all |
| 2246 | + // of the first list having smaller IDs than all or nearly all of the other. |
| 2247 | + if (xs.isEmpty || ys.isEmpty) return; |
| 2248 | + if (xs.last.id < ys.first.id) return; |
| 2249 | + final yCandidates = Set.of(ys.takeWhile((m) => m.id <= xs.last.id)); |
| 2250 | + final intersection = xs.reversed.takeWhile((m) => ys.first.id <= m.id) |
| 2251 | + .where(yCandidates.contains); |
| 2252 | + check(intersection, because: because).isEmpty(); |
| 2253 | +} |
| 2254 | + |
2016 | 2255 | extension MessageListRecipientHeaderItemChecks on Subject<MessageListRecipientHeaderItem> {
|
2017 | 2256 | Subject<MessageBase> get message => has((x) => x.message, 'message');
|
2018 | 2257 | }
|
|
0 commit comments