-
Notifications
You must be signed in to change notification settings - Fork 318
lightbox: Prevent hero animation between message lists #1348
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
1fad781
to
bf1d607
Compare
Thanks! Chat thread here: |
bf1d607
to
decb997
Compare
decb997
to
78e44cd
Compare
Hi @PIG208, PR is ready for a review now, PTAL, Thanks! |
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 fixing this! The approach looks fine to me. Left a comment on the implementation and some more on the tests.
lib/widgets/lightbox.dart
Outdated
@@ -21,20 +21,22 @@ import 'store.dart'; | |||
// fly to an image preview with a different URL, following a message edit | |||
// while the lightbox was open. | |||
class _LightboxHeroTag { | |||
_LightboxHeroTag({required this.messageId, required this.src}); | |||
_LightboxHeroTag({required this.messageId, required this.src, required this.pageContext}); |
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: Wrap this because the parameter list gets a bit too wide.
lib/widgets/lightbox.dart
Outdated
|
||
@override | ||
Widget build(BuildContext context) { | ||
return Hero( | ||
tag: _LightboxHeroTag(messageId: message.id, src: src), | ||
tag: _LightboxHeroTag(messageId: message.id, src: src, pageContext: pageContext), |
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.
Do things go wrong if we get the page context from PageRoot.contextOf(context)
here? If that works we don't need to pass it around.
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.
Things would go wrong since using PageRoot.contextOf(context) here would break the hero animation because the widget exists in two different page contexts simultaneously in the MessageListPage
and the lightbox
. By explicitly passing the source page's context (pageContext), we ensure both the source and destination heroes use the same context value in their tags, allowing Flutter to properly match them for the animation.
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.
Indeed. Thanks for the explanation.
Narrow narrow = const CombinedFeedNarrow(), | ||
List<Message>? messages, | ||
List<ZulipStream>? streams, | ||
|
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: extra empty line
test/widgets/lightbox_test.dart
Outdated
// ZulipApp instead of TestZulipApp because we need: | ||
// 1. The navigator to push the lightbox route. The lightbox page works | ||
// together with the route; it takes the route's entrance animation. | ||
// 2. The PageRoot widget to provide context for Hero animations between | ||
// the message list and lightbox. | ||
await tester.pumpWidget(PageRoot( | ||
child: const ZulipApp() | ||
)); | ||
await tester.pump(); | ||
final navigator = await ZulipApp.navigator; | ||
unawaited(navigator.push(getImageLightboxRoute( | ||
accountId: eg.selfAccount.id, | ||
pageContext: PageRoot.contextOf(navigator.context), |
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 should be able to skip these changes if not having pageContext
as a parameter works.
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 we can't skip passing pageContext
because during the hero animation, the transitioning widget lives in Flutter's overlay layer outside our PageRoot hierarchy. The hero animation needs the same tag value at both ends (source and destination) to know which widgets to animate between, and since we can't get the PageRoot context during the transition, we must pass it explicitly.
test/widgets/lightbox_test.dart
Outdated
|
||
group('LightboxHero', () { | ||
testWidgets('no hero animation occurs between different message list pages for same image', (tester) async { | ||
final channel = eg.stream(streamId: eg.defaultStreamMessageStreamId); |
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.
Since the value of streamId
is not used later, we can skip specifying. Using the default value should help keep the setup boring and bring focus to what's more interesting.
test/widgets/lightbox_test.dart
Outdated
await tester.pumpAndSettle(); | ||
}); | ||
}); | ||
} |
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.
Need a newline at the end of the file.
test/widgets/lightbox_test.dart
Outdated
await tester.pump(const Duration(milliseconds: 150)); | ||
|
||
final imageInTransition = tester.getRect(imageFinder); | ||
check(imageInTransition.top).equals(initialImageRect.top); | ||
check(imageInTransition.left).equals(initialImageRect.left); | ||
await tester.pumpAndSettle(); |
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 supposed to be mid transition time? While this works, I feel that it can miss bugs if the duration somehow changes.
We can have a while-loop that checks tester.hasRunningAnimations
, which pumps repeatedly with a fixed duration, and repeats the check:
await tester.pump(const Duration(milliseconds: 150)); | |
final imageInTransition = tester.getRect(imageFinder); | |
check(imageInTransition.top).equals(initialImageRect.top); | |
check(imageInTransition.left).equals(initialImageRect.left); | |
await tester.pumpAndSettle(); | |
int timeElapsed = 0; | |
const interval = 50; | |
while (timeElapsed < interval || tester.hasRunningAnimations) { | |
final imageInTransition = tester.getRect(imageFinder); | |
check(imageInTransition.top).equals(initialImageRect.top); | |
check(imageInTransition.left).equals(initialImageRect.left); | |
await tester.pump(const Duration(milliseconds: interval)); | |
timeElapsed += interval; | |
} | |
check(timeElapsed).isGreaterOrEqual(interval); |
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.
Just saw the CZO comment:
In order to have that benefit, it's important to keep the two test cases similar to each other as far as possible. It should be easy for the reader to convince themself that the two tests are checking the same thing, and just expecting opposite outcomes.
I agree that having these two tests similar to each other does mitigate the concern of one of them not working properly. So it's probably also fine to leave them as-is. Either way, we should comment that the duration is specifically chosen such that we are in the middle of a hero animation (if there is one).
test/widgets/lightbox_test.dart
Outdated
late PerAccountStore store; | ||
late FakeApiConnection connection; | ||
|
||
Future<void> setupMessageListPage(WidgetTester tester, { |
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 keep this helper close to where it is used in the 'LightBoxHero' group.
test/widgets/lightbox_test.dart
Outdated
List<Message>? messages, | ||
List<ZulipStream>? streams, |
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.
Because none of the two callers actually rely on having different messages/channels, it looks like we can remove these parameters. An advantage of keeping the helper close to where it's used is that we can more easily spot what properties the tests rely on.
For things that are constant to the tests (like the topic name, finders), we can have a shared local variable within the group.
test/widgets/lightbox_test.dart
Outdated
connection.prepare(json: | ||
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); |
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: move this to the line just before tester.tap
, because it prepares an API response for that
78e44cd
to
992c875
Compare
test/widgets/lightbox_test.dart
Outdated
await tester.pump(const Duration(milliseconds: 150)); | ||
|
||
final imageInTransition = tester.getRect(imageFinder); | ||
check(imageInTransition.top).not((it) => it.equals(initialImageRect.top)); |
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 missed this in the previous review — we can further change this to
check(imageInTransition).top.not((it) => it.equals(initialImageRect.top));
992c875
to
df965ae
Compare
Pushed the revision, PTAL @PIG208. Thanks! |
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 update! I found a place where we can potentially shake off the dependency on PageRoot. Let me know what you think!
test/widgets/lightbox_test.dart
Outdated
final message = eg.streamMessage(stream: channel, | ||
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic'); |
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: indentation
final message = eg.streamMessage(stream: channel, | |
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic'); | |
final message = eg.streamMessage(stream: channel, | |
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic'); |
test/widgets/lightbox_test.dart
Outdated
|
||
await store.addUser(eg.selfUser); |
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:
await store.addUser(eg.selfUser); | |
await store.addUser(eg.selfUser); |
because store.addUser
is a part of the setup code stanza
lib/widgets/content.dart
Outdated
@@ -663,12 +665,14 @@ class MessageImage extends StatelessWidget { | |||
src: resolvedSrcUrl, | |||
thumbnailUrl: resolvedThumbnailUrl, | |||
originalWidth: node.originalWidth, | |||
originalHeight: node.originalHeight)); | |||
originalHeight: node.originalHeight, | |||
pageContext: pageContext)); |
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.
While testing this, I tried passing context
to pageContext
(and below), and it appears to work. Let's try this so that we don't need to rely on having a PageRoot.
Pushed the revision @PIG208, please have a look. Thanks! |
Thanks! I think because we are not using the context of |
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 @lakshya1goel for taking this on, and @PIG208 for the previous reviews! Comments below.
lib/widgets/lightbox.dart
Outdated
required Uri? thumbnailUrl, | ||
required double? originalWidth, | ||
required double? originalHeight, | ||
required BuildContext pageContext, |
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.
When a function has a bunch of parameters like this (or a class has a bunch of fields, etc.), it's important to keep them organized logically — that makes a real difference both for people trying to understand and make changes to the implementation of the function (or class etc.) itself, and for people trying to use it.
So that means new parameters should go in the position that makes the structure of the list make sense, not necessarily at the end of the list.
What are existing parameters on this function that this new one does a similar job to?
lib/widgets/lightbox.dart
Outdated
|
||
final int messageId; | ||
final Uri src; | ||
final BuildContext pageContext; |
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 addition to Zixuan's point above at #1348 (comment) about the name of this field, let's also give it a bit of dartdoc explaining what it's expected to be. I think that will help with thinking through the behavior.
lib/widgets/lightbox.dart
Outdated
// fly to an image preview with a different URL, following a message edit | ||
// while the lightbox was open. |
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 comment points out one of the scenarios that can make it complicated to get these tags right — a message can be edited while the user has one of its images open in the lightbox. How does this version behave if that happens?
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 current implementation handles this scenario well. When a message is edited while its image is open in the lightbox, the behavior depends on whether the image URL changes:
- If the edit changes the image URL:
The Hero animation won't find a matching tag (because src is part of the tag and has changed)
It will gracefully fall back to a fade transition instead of attempting to animate to the wrong image - If the edit doesn't change the image URL:
All three tag components (messageId, src, and messageImageContext) still match
The Hero animation will work normally
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 — it seems like this PR resolves this TODO comment. That means the TODO comment should be removed.
It looks like this also fixes the issue the TODO points at, namely #44. So the commit and PR should be marked as fixing that issue.
Because the PR is otherwise ready, I'll go ahead and merge, after making those edits.
A good follow-up for you to do, while this area is fresh in your mind, would be to write a test confirming that #44 is fixed. That test would then serve as a regression test to ensure we don't accidentally reintroduce the issue in the future. In writing such a test, keep in mind the points we've discussed on the tests in this PR, including:
- make the test as clear and easy to follow as possible;
- in particular, keep the test focused on the details that are essential for the test;
- make sure the test thoroughly checks that the behavior is the way it's expected to be, and rules out all kinds of alternative scenarios for buggy ways the code could behave instead.
test/widgets/lightbox_test.dart
Outdated
@@ -558,4 +566,76 @@ void main() { | |||
check(platform.position).equals(position); | |||
}); | |||
}); | |||
|
|||
group('LightboxHero', () { |
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: keep tests in an order matching the order of the code they're testing
This is really another example of the same point as the comment above about function parameters — when adding something new, take a moment to think about the most logical place for it to appear, rather than just putting it at the end.
test/widgets/lightbox_test.dart
Outdated
final imageInTransition = tester.getRect(imageFinder); | ||
check(imageInTransition).top.equals(initialImageRect.top); | ||
check(imageInTransition).left.equals(initialImageRect.left); |
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.
Given imageFinder = find.byType(RealmContentNetworkImage).first
, this doesn't make an entirely convincing check that there isn't a hero animation going on. This effectively says that the original image is still the first RealmContentNetworkImage in the tree — but if there were a hero animation going on, there's no reason that necessarily has to be the first image in the tree. Maybe it comes later in the tree than the original image.
test/widgets/lightbox_test.dart
Outdated
final imageInTransition = tester.getRect(imageFinder); | ||
check(imageInTransition).top.not((it) => it.equals(initialImageRect.top)); | ||
check(imageInTransition).left.not((it) => it.equals(initialImageRect.left)); |
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.
Similarly, this says that the first RealmContentNetworkImage in the tree isn't at the place the original image was. But
- there's the new route, the lightbox, which has a version of the image — maybe that's the first one in the tree now
- there's the old route, the message list, which may have a version of the image — and although right now that won't have moved, maybe in the future we'll use a different navigation transition here so that the old route moves away while the new one is moving in. Again maybe that's the first one in the tree now.
So this should get more specific about our expectations in order to be convincing that it wouldn't end up passing even in some future where we've broken the hero animation entirely.
Pushed the revision, PTAL @gnprice, thanks! |
test/widgets/lightbox_test.dart
Outdated
imagePositionsOverTime.putIfAbsent(element, () => []); | ||
imagePositionsOverTime[element]!.add(position); | ||
} | ||
} | ||
|
||
for (final element in imagePositionsOverTime.keys) { | ||
final positions = imagePositionsOverTime[element]!; | ||
check(positions).isNotEmpty(); | ||
|
||
final initialPos = positions[0]; | ||
for (final position in positions) { | ||
check(position).equals(initialPos); |
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 still isn't very specific, because it doesn't check that the set of elements at one step has any relationship to the set of elements at another step.
For example, one plausible buggy behavior would be that at each step, there's a new image element and the old image element is gone. From the user's perspective this could produce an animation, because the new element could be at a different position along an animation path. But that would pass this test, because each list would have just one entry in it.
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.
Instead, try this:
- Before navigating to the second page, find the one image element and remember its location.
- While on the second page, find the one image element that's now visible and remember its location.
- Once the animation starts, at each step, check that the set of image elements is exactly those two image elements.
- Also at each step, check that each image element is in the same place as it was at the start.
Does that pass? If so, great — that's very specific and makes clear what the behavior is.
If not, then loosen the conditions a little in order to make it pass. But experiment to find just how and why the specific version fails, so that you can loosen it in only the minimal way that makes it pass, keeping it as specific as possible.
b9788fc
to
6c64509
Compare
Hi, @gnprice just pushed the revision following the comments above. The test is passing without loosening the constraints. |
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! The logic for the tests looks good now — I think these tests will do their job effectively.
There's a variety of things that can be done now to make the tests simpler and easier to understand. Here's a review focused on those.
We're closing in now on getting this PR to something we can merge — I didn't want to get into this sort of style and clarity feedback until we knew there weren't bigger changes we were going to make.
test/widgets/lightbox_test.dart
Outdated
check(currentImages) | ||
..length.equals(2) | ||
..contains(firstElement) | ||
..contains(secondElement); |
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.
Fun shorthand for this:
check(currentImages) | |
..length.equals(2) | |
..contains(firstElement) | |
..contains(secondElement); | |
check(currentImages).unorderedEquals([firstElement, secondElement]); |
test/widgets/lightbox_test.dart
Outdated
for (final position in firstImagePositions) { | ||
check(position).equals(firstImagePosition); | ||
} | ||
for (final position in secondImagePositions) { | ||
check(position).equals(secondImagePosition); |
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 checks aren't really benefitting from getting saved until after the loop — they'll still print exactly the information of the first one of these that fails. So they can be simplified by getting moved inside the loop, and skipping the firstImagePositions
etc. lists.
test/widgets/lightbox_test.dart
Outdated
final currentImages = find.byWidgetPredicate((widget) => | ||
widget is RealmContentNetworkImage && widget.src == imageSrcUrl).evaluate(); |
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 is the same finder as secondImageFinder
, right? It's really "find images with this URL". So it'd be good to combine them, under a name that reflects that shared meaning.
They can also replace firstImageFinder
, if we just identify the URL in advance.
And we can do that, because this test controls what the URL is going to be — it set up the message list and supplied the message HTML.
Probably cleanest is to just duplicate the URL from that ContentExample value. It's unlikely to change; if it does change, the tests will fail because they won't find elements they expect. So like:
final message = eg.streamMessage(stream: channel,
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');
// From ContentExample.imageSingle.
final imageSrcUrlStr = '/user_uploads/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg';
final imageSrcUrl = …;
final imageFinder = …;
test/widgets/lightbox_test.dart
Outdated
final backButton = find.byType(BackButton); | ||
await tester.tap(backButton); |
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: simpler if inlined
test/widgets/lightbox_test.dart
Outdated
connection.prepare(json: | ||
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); | ||
|
||
await tester.tap(find.descendant( | ||
of: find.byType(StreamMessageRecipientHeader), | ||
matching: find.text('test topic'))); | ||
await tester.pumpAndSettle(); |
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: these are logically one operation, so join as one stanza:
connection.prepare(json: | |
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); | |
await tester.tap(find.descendant( | |
of: find.byType(StreamMessageRecipientHeader), | |
matching: find.text('test topic'))); | |
await tester.pumpAndSettle(); | |
connection.prepare(json: | |
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); | |
await tester.tap(find.descendant( | |
of: find.byType(StreamMessageRecipientHeader), | |
matching: find.text('test topic'))); | |
await tester.pumpAndSettle(); |
test/widgets/lightbox_test.dart
Outdated
const heroAnimationDuration = Duration(milliseconds: 300); | ||
const steps = 150; | ||
final stepDuration = heroAnimationDuration ~/ steps; | ||
List<Rect> animatedPositions = []; |
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: final
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.
const
(which is stronger than final
) was fine for the locals where it applied — this comment was meant for animatedPositions
🙂
(In GitHub's review interface, if you make a comment targeting just a single line — like this one was — it shows the 4 lines up through that one as the context. That does makes it look a bit ambiguous, though in cases where the difference matters it is possible to find if you look closely: some comments say "Comment on lines MMM to NNN" above them.)
The principle here is that when a value is marked as immutable, that makes the code easier for the reader to reason about. So whenever we don't actually need to mutate it (like we do for loop variables like previousPosition
), it's best to declare it final
or const
.
test/widgets/lightbox_test.dart
Outdated
final message = eg.streamMessage(stream: channel, | ||
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic'); |
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: order elements logically: so stream, topic, then content, because the stream and topic give the more general context where the content appears
Hi, @gnprice I have pushed the revision. PTAL, Thanks! |
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! This is getting closer; here's comments on this version.
(This review is a bit delayed because I was out sick part of last week.)
test/widgets/lightbox_test.dart
Outdated
late FakeApiConnection connection; | ||
|
||
final channel = eg.stream(); | ||
final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp'; |
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: this should have a comment saying where it comes from, as it otherwise looks quite random — cf #1348 (comment)
test/widgets/lightbox_test.dart
Outdated
const heroAnimationDuration = Duration(milliseconds: 300); | ||
const steps = 150; | ||
final stepDuration = heroAnimationDuration ~/ steps; | ||
List<Rect> animatedPositions = []; |
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.
const
(which is stronger than final
) was fine for the locals where it applied — this comment was meant for animatedPositions
🙂
(In GitHub's review interface, if you make a comment targeting just a single line — like this one was — it shows the 4 lines up through that one as the context. That does makes it look a bit ambiguous, though in cases where the difference matters it is possible to find if you look closely: some comments say "Comment on lines MMM to NNN" above them.)
The principle here is that when a value is marked as immutable, that makes the code easier for the reader to reason about. So whenever we don't actually need to mutate it (like we do for loop variables like previousPosition
), it's best to declare it final
or const
.
test/widgets/lightbox_test.dart
Outdated
final messageContentFinder = find.byWidgetPredicate((widget) => | ||
widget is MessageContent && widget.message.id == message.id | ||
); | ||
final messageListImageFinder = find.descendant( | ||
of: messageContentFinder, | ||
matching: find.byType(RealmContentNetworkImage) | ||
); | ||
final initialImagePosition = tester.getRect(messageListImageFinder); |
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 can be simplified to work the same way as the corresponding part of the other test case. (And then imageFinder
can be shared between the test cases.)
test/widgets/lightbox_test.dart
Outdated
check(tester.getRect( | ||
find.byElementPredicate((e) => e == firstElement))).equals(firstImagePosition); |
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: line too long; instead:
check(tester.getRect( | |
find.byElementPredicate((e) => e == firstElement))).equals(firstImagePosition); | |
check(tester.getRect(find.byElementPredicate((e) => e == firstElement))) | |
.equals(firstImagePosition); |
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.
Better yet, make a little helper function within this test case, and then:
check(tester.getRect( | |
find.byElementPredicate((e) => e == firstElement))).equals(firstImagePosition); | |
check(getElementRect(firstElement)).equals(firstImagePosition); |
Then the same getElementRect
can be used for defining firstImagePosition
in terms of firstElement
in the first place. That way this check works the same way as firstImagePosition
got computed originally — so we're doing the same thing the same way both times, instead of two somewhat different ways, and that means one fewer thing for the reader to understand and think through.
test/widgets/lightbox_test.dart
Outdated
final currentImages = imageFinder.evaluate(); | ||
check(currentImages).unorderedEquals([firstElement, secondElement]); |
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: simpler if inlined
test/widgets/lightbox_test.dart
Outdated
widget is RealmContentNetworkImage && widget.src == imageSrcUrl | ||
); | ||
final firstImagePosition = tester.getRect(imageFinder); | ||
final firstElement = imageFinder.evaluate().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.
Bit cleaner (as evaluate
is meant really as internal API for implementing these finders):
final firstElement = imageFinder.evaluate().single; | |
final firstElement = tester.element(imageFinder); |
test/widgets/lightbox_test.dart
Outdated
final stepDistance = sqrt( | ||
pow(position.top - previousPosition.top, 2) + | ||
pow(position.left - previousPosition.left, 2)); |
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 logic here can become a bit easier to see by making a small helper function inside the test case:
final stepDistance = sqrt( | |
pow(position.top - previousPosition.top, 2) + | |
pow(position.left - previousPosition.left, 2)); | |
final stepDistance = dist(previousPosition, position); |
Particularly for comparison with totalDistance
above.
test/widgets/lightbox_test.dart
Outdated
|
||
connection.prepare(json: | ||
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); | ||
|
||
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, | ||
child: MessageListPage(initNarrow: const CombinedFeedNarrow()))); | ||
|
||
await tester.pumpAndSettle(); |
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: these are basically one operation; join them as one stanza:
connection.prepare(json: | |
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); | |
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, | |
child: MessageListPage(initNarrow: const CombinedFeedNarrow()))); | |
await tester.pumpAndSettle(); | |
connection.prepare(json: | |
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); | |
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, | |
child: MessageListPage(initNarrow: const CombinedFeedNarrow()))); | |
await tester.pumpAndSettle(); |
Hi, I have pushed the revision. PTAL @gnprice. Thanks! |
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! Down now to a couple of small comments, both of them echoes of comments from the last round.
test/widgets/lightbox_test.dart
Outdated
final stepDuration = heroAnimationDuration ~/ steps; | ||
for (int i = 0; i < steps; i++) { | ||
await tester.pump(stepDuration); | ||
check(imageFinder.evaluate()).unorderedEquals([firstElement, secondElement]); |
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: similar comment as: #1348 (comment)
test/widgets/lightbox_test.dart
Outdated
final channel = eg.stream(); | ||
// From ContentExample.imageSingle. | ||
final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp'; | ||
final imageSrcUrl = Uri.parse(imageSrcUrlStr); | ||
final message = eg.streamMessage(stream: channel, | ||
topic: 'test topic', contentMarkdown: ContentExample.imageSingle.html); | ||
|
||
final imageFinder = find.byWidgetPredicate( | ||
(widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl); |
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: this is clearer if rearranged (like at #1348 (comment)):
final channel = eg.stream(); | |
// From ContentExample.imageSingle. | |
final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp'; | |
final imageSrcUrl = Uri.parse(imageSrcUrlStr); | |
final message = eg.streamMessage(stream: channel, | |
topic: 'test topic', contentMarkdown: ContentExample.imageSingle.html); | |
final imageFinder = find.byWidgetPredicate( | |
(widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl); | |
final channel = eg.stream(); | |
final message = eg.streamMessage(stream: channel, | |
topic: 'test topic', contentMarkdown: ContentExample.imageSingle.html); | |
// From ContentExample.imageSingle. | |
final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp'; | |
final imageSrcUrl = Uri.parse(imageSrcUrlStr); | |
final imageFinder = find.byWidgetPredicate( | |
(widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl); |
There's basically two narratives (or mini-narratives) here: setting up message
, and setting up imageFinder
. This way the reader gets through one, then goes through the other, rather than interleaving them.
This way also makes the comment on imageSrcUrlStr
make more sense — the reason ContentExample.imageSingle
is relevant is that it's what's used in message
. When this comes shortly after message
rather than before it, the reader has that context.
test/widgets/lightbox_test.dart
Outdated
check(tester.element(imageFinder.first)).anyOf([ | ||
(e) => e.equals(firstElement), (e) => e.equals(secondElement)]); | ||
check(tester.element(imageFinder.last)).anyOf([ | ||
(e) => e.equals(firstElement), (e) => e.equals(secondElement)]); |
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 no longer checks that the situation is the way we expect: it allows there to be three or more elements matching imageFinder
.
It's also a lot more code to read than an unorderedEquals
check. (Cf. my previous comment #1348 (comment) .)
Using unorderedEquals
is the right way to do this. You'll want something different from tester.element
, though.
Take a look around the API surrounding tester.element
to see what variations of it you can find that would be useful here. I recommend jumping in your IDE to the definition of tester.element
— that will let you browse around the neighboring code.
pubspec.lock
Outdated
name: leak_tracker | ||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" | ||
sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" |
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 diff doesn't seem related to what this commit is doing. To keep the commits clear and coherent, this change should be kept to a separate commit.
It should also go with a corresponding change in pubspec.yaml
. See our README about upgrading Flutter.
Hi @gnprice, I have pushed the correction please have a look. Thanks! |
784d83a
to
9a58e05
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 @lakshya1goel for all your work on this!
The tests all look good now, modulo one nit.
See also one more-substantive comment below. But I think it doesn't call for any code changes, only comments and metadata.
So I'll make those edits and merge. I'll also add a commit that revises the docs from this PR.
From reading the TODO comment that I remarked on in an earlier review, I see that this in fact fixes not only #930 but also another issue #44. A good followup for you would be to do next would be to write a regression test to ensure that #44 stays fixed; see details below.
test/widgets/lightbox_test.dart
Outdated
check(tester.elementList(imageFinder)).unorderedEquals([ | ||
firstElement, secondElement]); |
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: follow the pattern of other check
calls in the codebase:
check(tester.elementList(imageFinder)).unorderedEquals([ | |
firstElement, secondElement]); | |
check(tester.elementList(imageFinder)) | |
.unorderedEquals([firstElement, secondElement]); |
lib/widgets/lightbox.dart
Outdated
// fly to an image preview with a different URL, following a message edit | ||
// while the lightbox was open. |
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 — it seems like this PR resolves this TODO comment. That means the TODO comment should be removed.
It looks like this also fixes the issue the TODO points at, namely #44. So the commit and PR should be marked as fixing that issue.
Because the PR is otherwise ready, I'll go ahead and merge, after making those edits.
A good follow-up for you to do, while this area is fresh in your mind, would be to write a test confirming that #44 is fixed. That test would then serve as a regression test to ensure we don't accidentally reintroduce the issue in the future. In writing such a test, keep in mind the points we've discussed on the tests in this PR, including:
- make the test as clear and easy to follow as possible;
- in particular, keep the test focused on the details that are essential for the test;
- make sure the test thoroughly checks that the behavior is the way it's expected to be, and rules out all kinds of alternative scenarios for buggy ways the code could behave instead.
Fixes #930.
Fixes #44.
Videos
Before
WhatsApp.Video.2025-02-12.at.5.18.39.PM.mp4
After
WhatsApp.Video.2025-02-12.at.5.18.35.PM.mp4