-
Notifications
You must be signed in to change notification settings - Fork 309
local echo (5/n): Create outbox messages on send #1472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
eeb6ef2
to
ac35860
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Comments below.
lib/model/store.dart
Outdated
/// Always equal to `connection.zulipFeatureLevel` | ||
/// and `account.zulipFeatureLevel`. | ||
int get zulipFeatureLevel => connection.zulipFeatureLevel!; | ||
|
||
String get zulipVersion => account.zulipVersion; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about also moving zulipVersion
along with zulipFeatureLevel
, so they stay together?
lib/model/message_list.dart
Outdated
@@ -626,6 +627,17 @@ class MessageListView with ChangeNotifier, _MessageSequence { | |||
} | |||
} | |||
|
|||
void handleOutboxMessage(OutboxMessage outboxMessage) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about a name like _addOutboxMessage
? There are multiple tasks that could accurately be described as a message list "handling" an outbox message.
lib/model/message_list.dart
Outdated
/// Remove the [outboxMessage] from the view. | ||
/// | ||
/// This is a no-op if the message is not found. | ||
void removeOutboxMessageIfExists(OutboxMessage outboxMessage) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about just removeOutboxMessage
? I think we can leave the "if exists" part as implied.
lib/model/message.dart
Outdated
/// ``` | ||
/// | ||
/// During its lifecycle, it is guaranteed that the outbox message is deleted | ||
/// as soon an message event with a matching [MessageEvent.localMessageId] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "a message event"
lib/model/message.dart
Outdated
/// ``` | ||
/// ┌─────────────────────────────────────┐ | ||
/// │ Event received, │ | ||
/// Send │ or we abandoned │ | ||
/// immediately. │ 200. the queue. ▼ | ||
/// (create) ──────────────► sending ────────► sent ──────────────► (delete) | ||
/// │4xx, │ ▲ | ||
/// │other error, │Reached User │ | ||
/// │or reached │time limit. cancels.│ | ||
/// │time limit. ▼ │ | ||
/// └───────────► failed ─────────────────┘ | ||
/// ``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we leave out the "reached time limit" parts in this first version? The "failed" state doesn't feel accurate for when that happens (especially when coming there from "sent"), and it's not part of the spec for #133, the more complicated outbox design, so we'd have to remove it when implementing that. It's also not necessary for the main idea of #1441:
The point is to better handle the case where you type out a message and hit send, and it fails because you don't have working network at that moment.
It looks like "reached time limit" comes from a parenthetical in the #1441 spec, with "perhaps":
Then if a message fails to send, we show on the local-echo placeholder an option that lets you recover it. (Similarly perhaps if it's been a while, like 10s, since trying to send and the request hasn't completed one way or another.)
If we want to keep "reached time limit" in this PR, how about adding a new node for it in the diagram, separate from "failed"? Then I think it'll be easier to reason clearly about some things later: what should the UI say for this state (not "failed" because we haven't been told that it failed); what should happen if the send-message event arrives when in this state.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like the "User cancels" label comes from #133. In that context, it means the user decided not to press a "Retry" button to retry the message-send request, and they want us to just forget about the message-send attempts and their failures.
That label doesn't feel accurate in this context, where a retry button isn't part of the picture. I think the word from the #1441 spec is "recover"; how about we say "User recovers the draft" or similar:
Then if a message fails to send, we show on the local-echo placeholder an option that lets you recover it. […]
You might want to retry sending, or just copy the text to save elsewhere. To cover both options, it can take the text and just put it back in the compose box. […] The placeholder in the message list then disappears.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the "sent" state needed? What would happen if we just ignored a 200 response and removed "sent" from the diagram, and didn't consider the message to be sent until we got its event?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the "Send immediately" label is implied and can be removed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Combining all those points, what do you think of this as an updated diagram:
/// We abandoned the queue.
/// ┌──────────────────────────────────────┐
/// │ │
/// │ Event received. ▼
/// (create) ─► sending ──────────────────────────────► (delete)
/// │ ▲
/// │ 4xx or other User restores │
/// │ error. the draft. │
/// └──────────────► failed ───────────────┘
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regarding time limit, started a discussion here: #mobile > handle failed send @ 💬
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the diagram update, started a discussion here: #mobile > #F1441 Handle retry state machine @ 💬
test/model/message_test.dart
Outdated
check(connection.lastRequest).isA<http.Request>() | ||
..bodyFields['queue_id'].equals(store.queueId) | ||
..bodyFields['local_id'].equals('${outboxMessage.localMessageId}'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this connection.lastRequest
needed, and if so, should the other similar tests have a check like it too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could move these checks to the helper, but I moved them from there to this test in a previous revision since just checking it once seems fine to me; this de-duplicates some helper code and most tests focus on other things.
test/model/message_test.dart
Outdated
..hidden.isTrue(); | ||
})); | ||
|
||
test('while message is being sent, message event arrives, then the send fails', () => awaitFakeAsync((async) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps worth a comment (if there isn't one in the implementation) that this can actually happen: the message-send can succeed, but then the message-send request has a network issue that doesn't affect the event-poll request.
test/model/message_test.dart
Outdated
|
||
// Handle the event after the message is sent but before the debounce | ||
// timeout. The outbox message should remain hidden since the send | ||
// request was sucessful. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "successful"
test/model/message_test.dart
Outdated
check(store.outboxMessages).isEmpty(); | ||
check(outboxMessage) | ||
..state.equals(OutboxMessageLifecycle.sent) | ||
..hidden.isTrue(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A lot of these tests have checks on outboxMessage
after checking that store.outboxMessages
is empty. Do they all need checks like that? Anyway, this test and later ones do it without a comment explaining why:
// […] The outbox message should no
// longer get updated because it is not in the store any more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The second sentence of the comment before store.handleEvent
is meant to address this check:
// Handle the event after the message is sent but before the debounce
// timeout. The outbox message should remain hidden since the send
// request was successful.
Perhaps it will be clearer to move this right before the check.
test/model/message_test.dart
Outdated
..hidden.isFalse(); | ||
}); | ||
|
||
test('send request pending until after kSendMessageTimeLimit, completes successfully, then message event arrives', () => awaitFakeAsync((async) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I skipped reading the tests with kSendMessageTimeLimit
in their names, pending an earlier comment that might lead to removing them)
Updated the PR! Thanks for the review. There are some pending questions mainly with regards to the state diagram and the send time limit. I introduced the new |
Will be working on a new revision to reorganize some of the implementation code with further state machine changes. |
The PR has been updated to implement the state diagram discussed in chat: #mobile > #F1441 Handle retry state machine @ 💬 |
b96c5e9
to
13edb83
Compare
401f7b7
to
eddca30
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @PIG208 for building this, and @chrisbobbe for the previous reviews! Exciting to see this approaching ready.
Generally this logic all looks good; various comments below. I haven't yet read the tests.
lib/model/message.dart
Outdated
} | ||
|
||
/// Manages the outbox messages portion of [MessageStore]. | ||
mixin _OutboxMessageStore on PerAccountStoreBase { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's put all of this new code after MessageStoreImpl, instead of before.
That way in particular MessageStore remains right at the start of the file. It's basically the interface for the whole file from the rest of the code's perspective, so it's helpful to have it emphasized up front that way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bump — after MessageStoreImpl, not only MessageStore, and including all the new code so the MessageStore interface remains at the top 🙂
(That way when the reader is thinking about aspects other than the outbox, for example the maintenance of the messages
map itself, they can quickly go straight to those without skimming past the outbox logic; and conversely when they want to study how the outbox works, that's still all together in one place, namely the end of the file.)
lib/model/message.dart
Outdated
const kLocalEchoDebounceDuration = Duration(milliseconds: 500); // TODO(#1441) find the right value for this | ||
const kSendMessageOfferRestoreWaitPeriod = Duration(seconds: 10); // TODO(#1441) find the right value for this |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These should each get a bit of dartdoc saying what they mean; it's not evident from the names (particularly "debounce").
The answers are basically explained in the OutboxMessageState docs already, so a reference there may suffice.
lib/model/message.dart
Outdated
/// The [sendMessage] request has started but hasn't finished, and the | ||
/// outbox message is hidden to the user. | ||
/// | ||
/// This is the initial state when an [OutboxMessage] is created. | ||
hidden, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this state, the actual request may or may not have finished, right? If I'm reading the code correctly, the state doesn't change when the request finishes successfully.
Rather than "request hasn't finished", I believe what this wants to say is that the resulting [MessageEvent] hasn't arrived, and nor has the request failed.
lib/model/message.dart
Outdated
/// The message was assumed not delivered after some time it was sent. | ||
/// | ||
/// This state can be reached when the message event hasn't arrived in | ||
/// [kSendMessageOfferRestoreWaitPeriod] since the outbox message's creation. | ||
waitPeriodExpired, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. What happens if
- the message gets successfully sent — and we know it succeeded because we got a 200 response from the sendMessage request
- but we don't get the event for a while?
For example this would happen if the event queue is gone; in that case the store will eventually get replaced after a fresh registerQueue request, but that can take some time.
This can also happen if the network is just intermittent, e.g. if the user is moving around.
It looks like in that case, this version would cause the outbox-message to appear as failed in the UI, and invite the user to retry sending it.
That seems like it's going to lead to double-sends. I think it'd be better for that case to be shown with a loading indicator, the same as we show before this timeout if we don't yet have a response from the sendMessage request. So in other words the logic would be:
- if we got an error on the request, we may not know whether the request really went through, but we assume it didn't, and invite the user to retry
- (if it was a 4xx error, then we actually know it didn't go through; but we don't make a distinction based on that)
- if we got success on the request, then we know it went through, so we carry on showing the loading indicator indefinitely until we get the MessageEvent to replace it
- otherwise, i.e. if the request hasn't yet completed either way, then after a timeout we effectively assume it failed, and so invite the user to retry.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this proposed behavior sounds better to me.
I think an easy implementation will just require us to cancel the timer that leads to waitPeriodExpired
when the request is successful. At first I thought we might want to re-introduce that sending
state from a previous draft, but I believe that shouldn't be necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good!
lib/model/message.dart
Outdated
/// The message could not be delivered. | ||
/// | ||
/// This state can be reached when we got a 4xx or other error in the HTTP | ||
/// response. | ||
failed, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned in my previous comment just above, I believe this state covers a broader set of situations than this description says: it could be that the message couldn't be delivered (a 4xx response, or a bit more specifically a ZulipApiException), or it could be that we don't know whether it was delivered (a 5xx response, or a bit more generally a Server5xxException; or a network error NetworkException). In the latter case, some of the time it will turn out that the message really was delivered.
Rather than "4xx or other error", I think a reference to [ApiRequestException] would help clarify the space of possibilities.
lib/model/message.dart
Outdated
|
||
/// A fresh ID to use for [OutboxMessage.localMessageId], | ||
/// unique within this instance. | ||
int _nextLocalMessageId = 0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's start at 1 rather than 0. That matches the practice in all the server's autoincrementing database indexes.
lib/model/message.dart
Outdated
} | ||
return _outboxSendMessage( | ||
destination: destination, content: content, | ||
realmEmptyTopicDisplayName: realmEmptyTopicDisplayName); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like this doesn't conform to the constraint written on the realmEmptyTopicDisplayName
getter, and enforced with an assert there. This will eagerly call that getter, before knowing whether the ZFL means its value will be needed.
I think the refactor that would be needed to make that assertion fully hold for this path is probably too much to be worth doing here directly. Instead, let's just bypass it (e.g., look at _realmEmptyTopicDisplayName
directly) and leave a TODO comment.
I think probably the right solution is ultimately to have TopicName.processLikeServer
(which is the method that consults this value and causes the need to pass it down here) to instead be a method on the per-account store, and more specifically on the RealmSettingStore (or similar) that we plan to set up in the future. Then it can consult that setting, as well as zulipFeatureLevel
, directly off of that substore, and call the realmEmptyTopicDisplayName
for itself only after it determines it's needed. But that substore is a post-launch refactor.
lib/model/message.dart
Outdated
topic.processLikeServer( | ||
// Processing this just once on creating the outbox message | ||
// allows an uncommon bug, because either of these values can change. | ||
// During the outbox message's life, a topic processed from |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's move this topic.processLikeServer
call out to a little private helper method. That will keep the flow of the rest of this method's logic a bit easier to read.
lib/model/message.dart
Outdated
realmEmptyTopicDisplayName: realmEmptyTopicDisplayName), | ||
displayRecipient: null), | ||
content: content), | ||
DmDestination(:final userIds) => DmOutboxMessage( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about giving OutboxMessage
a factory constructor that takes an arbitrary Conversation
, and dispatches between these two subclass constructors? That would allow this switch
to get pushed down into computing just conversation
, and the rest of these two constructor calls to get deduplicated. I think that'd make the structure of what this is doing a bit clearer.
The existing OutboxMessage
constructor could get renamed to OutboxMessage._
to make room for the nice factory constructor to have the plain OutboxMessage
name.
lib/model/message.dart
Outdated
_outboxMessageDebounceTimers[localMessageId] = Timer(kLocalEchoDebounceDuration, () { | ||
assert(!_disposed); | ||
assert(outboxMessages.containsKey(localMessageId), | ||
'The timer should have been canceled when the outbox message was removed.'); | ||
_outboxMessageDebounceTimers.remove(localMessageId); | ||
_updateOutboxMessage(localMessageId, newState: OutboxMessageState.waiting); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's pull this callback out to its own method:
_outboxMessageDebounceTimers[localMessageId] = Timer(kLocalEchoDebounceDuration, () { | |
assert(!_disposed); | |
assert(outboxMessages.containsKey(localMessageId), | |
'The timer should have been canceled when the outbox message was removed.'); | |
_outboxMessageDebounceTimers.remove(localMessageId); | |
_updateOutboxMessage(localMessageId, newState: OutboxMessageState.waiting); | |
}); | |
_outboxMessageDebounceTimers[localMessageId] = Timer( | |
kLocalEchoDebounceDuration, () => _handleOutboxDebounce(localMessageId)); |
That will help shorten this method and make its overall structure easier to see; and it'll also make clear there's a narrow interface between this overall method and the callback, with just the one bit of data localMessageId
passing between them (vs. all the other parameters and locals that this method otherwise would offer to the callback's closure).
Thanks for the review! Updated the PR. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Comments below. I'll also go take a quick initial look at the tests.
lib/model/message.dart
Outdated
} | ||
|
||
/// Manages the outbox messages portion of [MessageStore]. | ||
mixin _OutboxMessageStore on PerAccountStoreBase { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bump — after MessageStoreImpl, not only MessageStore, and including all the new code so the MessageStore interface remains at the top 🙂
(That way when the reader is thinking about aspects other than the outbox, for example the maintenance of the messages
map itself, they can quickly go straight to those without skimming past the outbox logic; and conversely when they want to study how the outbox works, that's still all together in one place, namely the end of the file.)
lib/model/message.dart
Outdated
// TODO move [TopicName.processLikeServer] to a substore, eliminating this | ||
realmEmptyTopicDisplayName: _realmEmptyTopicDisplayName); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: let's add a link to #1472 (comment) to expand on the details of this thought:
// TODO move [TopicName.processLikeServer] to a substore, eliminating this | |
realmEmptyTopicDisplayName: _realmEmptyTopicDisplayName); | |
// TODO move [TopicName.processLikeServer] to a substore, eliminating this; | |
// see https://github.com/zulip/zulip-flutter/pull/1472#discussion_r2099069276 | |
realmEmptyTopicDisplayName: _realmEmptyTopicDisplayName); |
lib/model/message.dart
Outdated
// TODO(dart): This has to be a plain static method, because factories/constructors | ||
// do not support type parameters: https://github.com/dart-lang/language/issues/647 | ||
static OutboxMessage fromConversation(Conversation conversation, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah right, indeed — so it can't quite have the short OutboxMessage(…)
name like I suggested at #1472 (comment) above.
lib/model/message.dart
Outdated
readBySender: true, | ||
queueId: queueId, | ||
localId: localMessageId.toString()); | ||
_outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, I guess this line is the implementation of #1472 (comment) .
lib/model/message.dart
Outdated
readBySender: true, | ||
queueId: queueId, | ||
localId: localMessageId.toString()); | ||
_outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels a bit mysterious on its own, so let's add a comment explaining why it's here:
_outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); | |
// The send request succeeded, so the message was definitely sent. | |
// Cancel the timer that would have had us start presuming that the | |
// send might have failed. | |
_outboxMessageWaitPeriodTimers.remove(localMessageId)?.cancel(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
and nit: move this outside of the try/catch, to make clear the try/catch is focused on exceptions from _apiSendMessage
(where exceptions are expected) and not potential exceptions from this line (which shouldn't be possible). Discussion here:
https://github.com/zulip/zulip-mobile/blob/main/docs/style.md#catch-specific
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Writing out that comment also caused me to have the thought: what happens if the request succeeds and the state has already moved to waitPeriodExpired
? The expiry means we've already started presuming that it probably failed, but suddenly we know for sure it actually succeeded.
If there isn't a quick change to make that would handle that, it's fine to leave a TODO comment for it instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
and nit: move this outside of the try/catch, to make clear the try/catch is focused on exceptions from
_apiSendMessage
(where exceptions are expected) and not potential exceptions from this line (which shouldn't be possible). Discussion here: https://github.com/zulip/zulip-mobile/blob/main/docs/style.md#catch-specific
Ah. This reminds me of adding a if (_disposed)
check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Writing out that comment also caused me to have the thought: what happens if the request succeeds and the state has already moved to
waitPeriodExpired
? The expiry means we've already started presuming that it probably failed, but suddenly we know for sure it actually succeeded.If there isn't a quick change to make that would handle that, it's fine to leave a TODO comment for it instead.
I think this would be a matter of implementing a new state transition:
Got an [ApiRequestException].
┌──────┬────────────────────────────┬──────────► failed
│ │ │ │
│ │ [sendMessage] │ │
(create) │ │ request succeeds. │ │
└► hidden waiting ◄─────────────── waitPeriodExpired ──┴─────► (delete)
│ ▲ │ ▲ User restores
└──────┘ └─────────────────────┘ the draft.
Debounce [sendMessage] request
timed out. not finished when
wait period timed out.
Event received.
(any state) ─────────────────► (delete)
I guess we can't really convey that waitPeriodExpired
cannot be reached again without having a variant of waiting
, but we can imply that from the dart doc of waitPeriodExpired
:
/// The [sendMessage] HTTP request did not finish in time and the user is
/// invited to retry it.
///
/// This state can be reached when the request has not finished
/// [kSendMessageOfferRestoreWaitPeriod] since the outbox message's creation.
waitPeriodExpired,
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a new commit message: Avoid double-sends after send-message request succeeds
addressing this. I found it to be a good way seeing how we can incrementally add features to this state machine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, and generally the tests look good.
I've read the miscellaneous test changes outside the main block in message_test.dart
, and skimmed over the latter; just a couple of comments below. I'll leave for another day reading through that main block of tests in full.
test/model/message_test.dart
Outdated
test('smoke DM: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { | ||
await prepareOutboxMessage(destination: DmDestination( | ||
userIds: [eg.selfUser.userId, eg.otherUser.userId])); | ||
checkState(expectedState: OutboxMessageState.hidden); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can put these in more canonical checks
style:
checkState(expectedState: OutboxMessageState.hidden); | |
checkState().equals(OutboxMessageState.hidden); |
test/model/message_test.dart
Outdated
test('smoke stream message: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { | ||
await prepareOutboxMessage(); | ||
checkState(expectedState: OutboxMessageState.hidden); | ||
|
||
async.elapse(kLocalEchoDebounceDuration); | ||
checkState(expectedState: OutboxMessageState.waiting); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this structure where the helpers center on a single outbox-message. It helps keep the tests focused, since the bulk of them are all about a single outbox-message (because there's very little between-message interaction to be tested).
This implements the waitPeriodExpired -> waiting state transition. GitHub discussion: zulip#1472 (comment)
This implements the waitPeriodExpired -> waiting state transition. GitHub discussion: zulip#1472 (comment)
This implements the waitPeriodExpired -> waiting state transition. GitHub discussion: zulip#1472 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, and here's a full review of the remainder: the tests, and the new commit at the end. Generally all looks good.
test/model/message_test.dart
Outdated
test('smoke stream message: hidden -> waiting -> (delete)', () => awaitFakeAsync((async) async { | ||
await prepareOutboxMessage(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: since the test description specifically calls for a stream message, the test should explicitly make it a stream message
(the bulk of the other tests below can cheerfully go on using the default that prepareOutboxMessage
supplies, since they don't care about the specifics of the message)
test/model/message_test.dart
Outdated
await store.handleEvent(eg.messageEvent( | ||
eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]), | ||
localMessageId: store.outboxMessages.keys.single)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: could tighten these tests further (going in the same direction noted at #1472 (comment)) with a helper for this step:
await store.handleEvent(eg.messageEvent( | |
eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]), | |
localMessageId: store.outboxMessages.keys.single)); | |
await receiveMessage(eg.dmMessage(from: eg.selfUser, to: [eg.otherUser])); |
Could even have that helper default to the shared message
local, since that's what most of these tests use.
test/model/message_test.dart
Outdated
checkState().equals(OutboxMessageState.waiting); | ||
})); | ||
|
||
test('waitPeriodExpired -> waiting and never return to waitPeriodExpired', () => awaitFakeAsync((async) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
test('waitPeriodExpired -> waiting and never return to waitPeriodExpired', () => awaitFakeAsync((async) async { | |
test('waiting -> waitPeriodExpired -> waiting and never return to waitPeriodExpired', () => awaitFakeAsync((async) async { |
to express that this is the test case that covers that first arrow too (right? I don't see another that's more specific)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are two cases of "waiting -> waitPeriodExpired -> (delete) …" that cover this. But I think including the first edge will still be helpful because it shows that there is a loop.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool. Yeah, I see those as focused on the later arrow that goes to "(delete)"; partly because they're tucked inside the group "… -> (delete)". When looking for the test that covers this arrow of the state diagram, this seems like the more natural spot to find it.
(Ideally in fact I think there'd be a case here that was explicitly focused on just that arrow, without a subsequent arrow.)
test/model/message_test.dart
Outdated
await check(outboxMessageFailFuture).throws(); | ||
async.elapse(Duration(seconds: 1)); | ||
checkState().equals(OutboxMessageState.failed); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After the future completes, that should mean the timer is done, right? Does this test need the additional 1s async.elapse
?
In any case, it'd be good to put the check before the elapse — that strengthens the test a bit, and when the failure has happened the state should promptly be getting set to failed.
// Complete the send request. There should be no error despite | ||
// the send request failure, because the outbox message is not | ||
// in the store any more. | ||
await check(outboxMessageFailFuture).completes(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, this is an important subtle case to test.
(Important because it could be fairly common if the user is on a flaky connection — if the rate of requests randomly failing is anywhere between like 20% and 80%, then a good fraction of the time, the send will make it through, the response won't and so the request will eventually throw when the connection times out, and in the meantime the event will make it through. Important because also it'd be pretty annoying to get a useless error dialog when nothing actually went wrong.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, exactly. This is for covering the change from Chris here: #1472 (comment)
test/model/message_test.dart
Outdated
message = eg.streamMessage(stream: stream); | ||
await prepare(stream: stream); | ||
await prepareMessages([eg.streamMessage(stream: stream)]); | ||
connection.prepare(apiException: eg.apiBadRequest(), delay: delay); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: for better realism, make it an error that's consistent with the request having actually succeeded on the server side:
connection.prepare(apiException: eg.apiBadRequest(), delay: delay); | |
connection.prepare(httpException: SocketException('failed'), delay: delay); |
That way it's consistent with the test cases where a corresponding MessageEvent arrives even though the request failed. That also may help a future reader in understanding why that scenario can even happen (which is important to understand since we have some logic carefully written to specifically handle it).
test/model/message_test.dart
Outdated
destination: StreamDestination(stream.streamId, TopicName('(no topic)')), | ||
zulipFeatureLevel: 370); | ||
async.elapse(kLocalEchoDebounceDuration); | ||
check(store.outboxMessages.values.single) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: use checks-getters
check(store.outboxMessages.values.single) | |
check(store.outboxMessages).values.single |
if (_outboxMessages[localMessageId]!.state | ||
== OutboxMessageState.waitPeriodExpired) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Neat, glad that change turned out to be clean to make! (Following up on #1472 (comment).)
The diffstat is:
lib/model/message.dart | 28 ++++++++++++++++++++--------
test/model/message_test.dart | 28 ++++++++++++++++++++++++++++
2 files changed, 48 insertions(+), 8 deletions(-)
and outside the new test, most of that is comments; I count 6+/1- other than tests and comments.
This implements the waitPeriodExpired -> waiting state transition. GitHub discussion: zulip#1472 (comment)
This implements the waitPeriodExpired -> waiting state transition. GitHub discussion: zulip#1472 (comment)
This implements the waitPeriodExpired -> waiting state transition. GitHub discussion: zulip#1472 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Just one nit remaining.
test/model/message_test.dart
Outdated
|
||
final localMessageIds = store.outboxMessages.keys.toList(); | ||
store.takeOutboxMessage(localMessageIds.removeAt(5)); | ||
check(store.outboxMessages.keys).deepEquals(localMessageIds); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: use checks-getter (like #1472 (comment) )
check(store.outboxMessages.keys).deepEquals(localMessageIds); | |
check(store.outboxMessages).keys.deepEquals(localMessageIds); |
test/model/message_test.dart
Outdated
checkState().equals(OutboxMessageState.waiting); | ||
})); | ||
|
||
test('waitPeriodExpired -> waiting and never return to waitPeriodExpired', () => awaitFakeAsync((async) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool. Yeah, I see those as focused on the later arrow that goes to "(delete)"; partly because they're tucked inside the group "… -> (delete)". When looking for the test that covers this arrow of the state diagram, this seems like the more natural spot to find it.
(Ideally in fact I think there'd be a case here that was explicitly focused on just that arrow, without a subsequent arrow.)
This change should have no user-facing effect. The one spot where we have an `if (_disposed)` check in editMessage prevents a state update and a rebuild from happening. This only applies if the store is disposed before the edit request fails, but the MessageListView with the edited message should get rebuilt anyway (through onNewStore) when that happens.
While we do create outbox messages, there are in no way user-visible changes since the outbox messages don't end up in message list views. We create skeletons for helpers needed from message list view, but don't implement them yet, to make the diff smaller. For testing, similar to TypingNotifier.debugEnable, we add MessageStoreImpl.debugOutboxEnable for tests that do not intend to cover outbox messages.
In addition to the nit, added a test that targets "waiting -> waitPeriodExpired" specifically, for #1472 (comment), dropping the emphasis on "waiting -> waitPeriodExpired" from those two "waiting -> waitingPeriodExpired -> (delete)" tests. |
This implements the waitPeriodExpired -> waiting state transition. GitHub discussion: zulip#1472 (comment)
Thanks! Those changes all look good; merging. |
This is stacked atop #1463, toward #1441.
No UI/user-facing change in this PR.