|
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';
|
@@ -1645,6 +1646,209 @@ void main() {
|
1645 | 1646 | });
|
1646 | 1647 | });
|
1647 | 1648 |
|
| 1649 | + group('middleMessage maintained', () { |
| 1650 | + // In [checkInvariants] we verify that messages don't move from the |
| 1651 | + // top to the bottom slice or vice versa. |
| 1652 | + // Most of these test cases rely on that for all the checks they need. |
| 1653 | + |
| 1654 | + test('on fetchInitial empty', () async { |
| 1655 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1656 | + await prepareMessages(foundOldest: true, messages: []); |
| 1657 | + check(model)..messages.isEmpty() |
| 1658 | + ..middleMessage.equals(0); |
| 1659 | + }); |
| 1660 | + |
| 1661 | + test('on fetchInitial empty due to muting', () async { |
| 1662 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1663 | + final stream = eg.stream(); |
| 1664 | + await store.addStream(stream); |
| 1665 | + await store.addSubscription(eg.subscription(stream, isMuted: true)); |
| 1666 | + await prepareMessages(foundOldest: true, messages: [ |
| 1667 | + eg.streamMessage(stream: stream), |
| 1668 | + ]); |
| 1669 | + check(model)..messages.isEmpty() |
| 1670 | + ..middleMessage.equals(0); |
| 1671 | + }); |
| 1672 | + |
| 1673 | + test('on fetchInitial not empty', () async { |
| 1674 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1675 | + final stream1 = eg.stream(); |
| 1676 | + final stream2 = eg.stream(); |
| 1677 | + await store.addStreams([stream1, stream2]); |
| 1678 | + await store.addSubscription(eg.subscription(stream1, isMuted: true)); |
| 1679 | + await store.addSubscription(eg.subscription(stream2)); |
| 1680 | + await prepareMessages(foundOldest: true, messages: [ |
| 1681 | + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), |
| 1682 | + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), |
| 1683 | + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), |
| 1684 | + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), |
| 1685 | + eg.streamMessage(stream: stream1), eg.streamMessage(stream: stream2), |
| 1686 | + ]); |
| 1687 | + check(model) |
| 1688 | + ..messages.length.equals(5) |
| 1689 | + ..middleMessage.equals(4); |
| 1690 | + }); |
| 1691 | + |
| 1692 | + /// Like [prepareMessages], but arrange for the given top and bottom slices. |
| 1693 | + Future<void> prepareMessageSplit(List<Message> top, List<Message> bottom, { |
| 1694 | + bool foundOldest = true, |
| 1695 | + }) async { |
| 1696 | + assert(bottom.isNotEmpty); // could handle this too if necessary |
| 1697 | + await prepareMessages(foundOldest: foundOldest, messages: [ |
| 1698 | + ...top, |
| 1699 | + bottom.first, |
| 1700 | + ]); |
| 1701 | + if (bottom.length > 1) { |
| 1702 | + await store.addMessages(bottom.skip(1)); |
| 1703 | + checkNotifiedOnce(); |
| 1704 | + } |
| 1705 | + check(model) |
| 1706 | + ..messages.length.equals(top.length + bottom.length) |
| 1707 | + ..middleMessage.equals(top.length); |
| 1708 | + } |
| 1709 | + |
| 1710 | + test('on fetchOlder', () async { |
| 1711 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1712 | + final stream = eg.stream(); |
| 1713 | + await store.addStream(stream); |
| 1714 | + await store.addSubscription(eg.subscription(stream)); |
| 1715 | + await prepareMessageSplit(foundOldest: false, |
| 1716 | + [eg.streamMessage(id: 100, stream: stream)], |
| 1717 | + [eg.streamMessage(id: 101, stream: stream)]); |
| 1718 | + |
| 1719 | + connection.prepare(json: olderResult(anchor: 100, foundOldest: true, |
| 1720 | + messages: List.generate(5, (i) => |
| 1721 | + eg.streamMessage(id: 95 + i, stream: stream))).toJson()); |
| 1722 | + await model.fetchOlder(); |
| 1723 | + checkNotified(count: 2); |
| 1724 | + }); |
| 1725 | + |
| 1726 | + test('on fetchOlder, from top empty', () async { |
| 1727 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1728 | + final stream = eg.stream(); |
| 1729 | + await store.addStream(stream); |
| 1730 | + await store.addSubscription(eg.subscription(stream)); |
| 1731 | + await prepareMessageSplit(foundOldest: false, |
| 1732 | + [], [eg.streamMessage(id: 100, stream: stream)]); |
| 1733 | + |
| 1734 | + connection.prepare(json: olderResult(anchor: 100, foundOldest: true, |
| 1735 | + messages: List.generate(5, (i) => |
| 1736 | + eg.streamMessage(id: 95 + i, stream: stream))).toJson()); |
| 1737 | + await model.fetchOlder(); |
| 1738 | + checkNotified(count: 2); |
| 1739 | + // The messages from fetchOlder should go in the top sliver, always. |
| 1740 | + check(model).middleMessage.equals(5); |
| 1741 | + }); |
| 1742 | + |
| 1743 | + test('on MessageEvent', () async { |
| 1744 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1745 | + final stream = eg.stream(); |
| 1746 | + await store.addStream(stream); |
| 1747 | + await store.addSubscription(eg.subscription(stream)); |
| 1748 | + await prepareMessageSplit(foundOldest: false, |
| 1749 | + [eg.streamMessage(stream: stream)], |
| 1750 | + [eg.streamMessage(stream: stream)]); |
| 1751 | + |
| 1752 | + await store.addMessage(eg.streamMessage(stream: stream)); |
| 1753 | + checkNotifiedOnce(); |
| 1754 | + }); |
| 1755 | + |
| 1756 | + test('on messages muted, including anchor', () async { |
| 1757 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1758 | + final stream = eg.stream(); |
| 1759 | + await store.addStream(stream); |
| 1760 | + await store.addSubscription(eg.subscription(stream)); |
| 1761 | + await prepareMessageSplit([ |
| 1762 | + eg.streamMessage(stream: stream, topic: 'foo'), |
| 1763 | + eg.streamMessage(stream: stream, topic: 'bar'), |
| 1764 | + ], [ |
| 1765 | + eg.streamMessage(stream: stream, topic: 'bar'), |
| 1766 | + eg.streamMessage(stream: stream, topic: 'foo'), |
| 1767 | + ]); |
| 1768 | + |
| 1769 | + await store.handleEvent(eg.userTopicEvent( |
| 1770 | + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); |
| 1771 | + checkNotifiedOnce(); |
| 1772 | + }); |
| 1773 | + |
| 1774 | + test('on messages muted, not including anchor', () async { |
| 1775 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1776 | + final stream = eg.stream(); |
| 1777 | + await store.addStream(stream); |
| 1778 | + await store.addSubscription(eg.subscription(stream)); |
| 1779 | + await prepareMessageSplit([ |
| 1780 | + eg.streamMessage(stream: stream, topic: 'foo'), |
| 1781 | + eg.streamMessage(stream: stream, topic: 'bar'), |
| 1782 | + ], [ |
| 1783 | + eg.streamMessage(stream: stream, topic: 'foo'), |
| 1784 | + ]); |
| 1785 | + |
| 1786 | + await store.handleEvent(eg.userTopicEvent( |
| 1787 | + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); |
| 1788 | + checkNotifiedOnce(); |
| 1789 | + }); |
| 1790 | + |
| 1791 | + test('on messages muted, bottom empty', () async { |
| 1792 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1793 | + final stream = eg.stream(); |
| 1794 | + await store.addStream(stream); |
| 1795 | + await store.addSubscription(eg.subscription(stream)); |
| 1796 | + await prepareMessageSplit([ |
| 1797 | + eg.streamMessage(stream: stream, topic: 'foo'), |
| 1798 | + eg.streamMessage(stream: stream, topic: 'bar'), |
| 1799 | + ], [ |
| 1800 | + eg.streamMessage(stream: stream, topic: 'third'), |
| 1801 | + ]); |
| 1802 | + |
| 1803 | + await store.handleEvent(eg.deleteMessageEvent([ |
| 1804 | + model.messages.last as StreamMessage])); |
| 1805 | + checkNotifiedOnce(); |
| 1806 | + check(model).middleMessage.equals(model.messages.length); |
| 1807 | + |
| 1808 | + await store.handleEvent(eg.userTopicEvent( |
| 1809 | + stream.streamId, 'bar', UserTopicVisibilityPolicy.muted)); |
| 1810 | + checkNotifiedOnce(); |
| 1811 | + }); |
| 1812 | + |
| 1813 | + test('on messages deleted', () async { |
| 1814 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1815 | + final stream = eg.stream(); |
| 1816 | + await store.addStream(stream); |
| 1817 | + await store.addSubscription(eg.subscription(stream)); |
| 1818 | + final messages = [ |
| 1819 | + eg.streamMessage(id: 1, stream: stream), |
| 1820 | + eg.streamMessage(id: 2, stream: stream), |
| 1821 | + eg.streamMessage(id: 3, stream: stream), |
| 1822 | + eg.streamMessage(id: 4, stream: stream), |
| 1823 | + ]; |
| 1824 | + await prepareMessageSplit(messages.sublist(0, 2), messages.sublist(2)); |
| 1825 | + |
| 1826 | + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 3))); |
| 1827 | + checkNotifiedOnce(); |
| 1828 | + }); |
| 1829 | + |
| 1830 | + test('on messages deleted, bottom empty', () async { |
| 1831 | + await prepare(narrow: const CombinedFeedNarrow()); |
| 1832 | + final stream = eg.stream(); |
| 1833 | + await store.addStream(stream); |
| 1834 | + await store.addSubscription(eg.subscription(stream)); |
| 1835 | + final messages = [ |
| 1836 | + eg.streamMessage(id: 1, stream: stream), |
| 1837 | + eg.streamMessage(id: 2, stream: stream), |
| 1838 | + eg.streamMessage(id: 3, stream: stream), |
| 1839 | + eg.streamMessage(id: 4, stream: stream), |
| 1840 | + ]; |
| 1841 | + await prepareMessageSplit(messages.sublist(0, 3), messages.sublist(3)); |
| 1842 | + |
| 1843 | + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(3))); |
| 1844 | + checkNotifiedOnce(); |
| 1845 | + check(model).middleMessage.equals(model.messages.length); |
| 1846 | + |
| 1847 | + await store.handleEvent(eg.deleteMessageEvent(messages.sublist(1, 2))); |
| 1848 | + checkNotifiedOnce(); |
| 1849 | + }); |
| 1850 | + }); |
| 1851 | + |
1648 | 1852 | group('handle content parsing into subclasses of ZulipMessageContent', () {
|
1649 | 1853 | test('ZulipContent', () async {
|
1650 | 1854 | final stream = eg.stream();
|
@@ -1919,6 +2123,10 @@ void main() {
|
1919 | 2123 | });
|
1920 | 2124 | }
|
1921 | 2125 |
|
| 2126 | +MessageListView? _lastModel; |
| 2127 | +List<Message>? _lastMessages; |
| 2128 | +int? _lastMiddleMessage; |
| 2129 | + |
1922 | 2130 | void checkInvariants(MessageListView model) {
|
1923 | 2131 | if (!model.fetched) {
|
1924 | 2132 | check(model)
|
@@ -1961,6 +2169,21 @@ void checkInvariants(MessageListView model) {
|
1961 | 2169 | ..isGreaterOrEqual(0)
|
1962 | 2170 | ..isLessOrEqual(model.messages.length);
|
1963 | 2171 |
|
| 2172 | + if (identical(model, _lastModel) |
| 2173 | + && model.generation == _lastModel!.generation) { |
| 2174 | + // All messages that were present, and still are, should be on the same side |
| 2175 | + // of `middleMessage` (still top or bottom slice respectively) as they were. |
| 2176 | + _checkNoIntersection(ListSlice(model.messages, 0, model.middleMessage), |
| 2177 | + ListSlice(_lastMessages!, _lastMiddleMessage!, _lastMessages!.length), |
| 2178 | + because: 'messages moved from bottom slice to top slice'); |
| 2179 | + _checkNoIntersection(ListSlice(_lastMessages!, 0, _lastMiddleMessage!), |
| 2180 | + ListSlice(model.messages, model.middleMessage, model.messages.length), |
| 2181 | + because: 'messages moved from top slice to bottom slice'); |
| 2182 | + } |
| 2183 | + _lastModel = model; |
| 2184 | + _lastMessages = model.messages.toList(); |
| 2185 | + _lastMiddleMessage = model.middleMessage; |
| 2186 | + |
1964 | 2187 | check(model).contents.length.equals(model.messages.length);
|
1965 | 2188 | for (int i = 0; i < model.contents.length; i++) {
|
1966 | 2189 | final poll = model.messages[i].poll;
|
@@ -2018,6 +2241,17 @@ void checkInvariants(MessageListView model) {
|
2018 | 2241 | }
|
2019 | 2242 | }
|
2020 | 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 | + |
2021 | 2255 | extension MessageListRecipientHeaderItemChecks on Subject<MessageListRecipientHeaderItem> {
|
2022 | 2256 | Subject<MessageBase> get message => has((x) => x.message, 'message');
|
2023 | 2257 | }
|
|
0 commit comments