diff --git a/.gitignore b/.gitignore
index a630c807e2..ca55974330 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,10 @@ migrate_working_dir/
*.iml
*.ipr
*.iws
-.idea/
+/.idea/*
+/android/.idea/*
+!/.idea/inspectionProfiles/
+!/android/.idea/inspectionProfiles/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000000..696d717c79
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.metadata b/.metadata
index 4e38c9c9c3..623a8e999f 100644
--- a/.metadata
+++ b/.metadata
@@ -27,9 +27,6 @@ migration:
- platform: macos
create_revision: c9dd4584702dc5ab67a6dc9c6ebaa33739bac811
base_revision: c9dd4584702dc5ab67a6dc9c6ebaa33739bac811
- - platform: web
- create_revision: c9dd4584702dc5ab67a6dc9c6ebaa33739bac811
- base_revision: c9dd4584702dc5ab67a6dc9c6ebaa33739bac811
- platform: windows
create_revision: c9dd4584702dc5ab67a6dc9c6ebaa33739bac811
base_revision: c9dd4584702dc5ab67a6dc9c6ebaa33739bac811
diff --git a/android/.idea/inspectionProfiles/Project_Default.xml b/android/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000000..146ab09b7c
--- /dev/null
+++ b/android/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pubspec.lock b/pubspec.lock
index 8ec3264fd5..e5d4d01a3b 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -241,14 +241,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
- cupertino_icons:
- dependency: "direct main"
- description:
- name: cupertino_icons
- sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d
- url: "https://pub.dev"
- source: hosted
- version: "1.0.6"
dart_style:
dependency: transitive
description:
@@ -1026,7 +1018,7 @@ packages:
source: hosted
version: "0.34.0"
stack_trace:
- dependency: transitive
+ dependency: "direct dev"
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
diff --git a/pubspec.yaml b/pubspec.yaml
index 53937c7c90..3693e36522 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -36,38 +36,34 @@ environment:
dependencies:
flutter:
sdk: flutter
+ flutter_localizations:
+ sdk: flutter
- # The following adds the Cupertino Icons font to your application.
- # Use with the CupertinoIcons class for iOS style icons.
- cupertino_icons: ^1.0.2
-
- json_annotation: ^4.8.1
- http: ^1.0.0
- html: ^0.15.1
- intl: ^0.18.0
- share_plus_platform_interface: ^3.3.1
- share_plus: ^7.0.0
+ app_settings: ^5.0.0
+ collection: ^1.17.2
+ convert: ^3.1.1
+ crypto: ^3.0.3
device_info_plus: ^9.0.0
- file_picker: ^6.0.0
drift: ^2.5.0
- path_provider: ^2.0.13
- path: ^1.8.3
- sqlite3_flutter_libs: ^0.5.13
- app_settings: ^5.0.0
+ file_picker: ^6.0.0
+ firebase_core: ^2.14.0
+ firebase_messaging: ^14.6.3
+ flutter_color_models: ^1.3.3+2
+ flutter_local_notifications: ^16.1.0
+ flutter_local_notifications_platform_interface: ^7.0.0+1
+ html: ^0.15.1
+ http: ^1.0.0
image_picker: ^1.0.0
+ intl: ^0.18.0
+ json_annotation: ^4.8.1
package_info_plus: ^5.0.1
- collection: ^1.17.2
+ path: ^1.8.3
+ path_provider: ^2.0.13
+ share_plus: ^7.0.0
+ share_plus_platform_interface: ^3.3.1
+ sqlite3_flutter_libs: ^0.5.13
url_launcher: ^6.1.11
- convert: ^3.1.1
url_launcher_android: ">=6.1.0"
- flutter_localizations:
- sdk: flutter
- firebase_messaging: ^14.6.3
- firebase_core: ^2.14.0
- flutter_local_notifications_platform_interface: ^7.0.0+1
- flutter_local_notifications: ^16.1.0
- crypto: ^3.0.3
- flutter_color_models: ^1.3.3+2
dev_dependencies:
flutter_driver:
@@ -77,19 +73,14 @@ dev_dependencies:
integration_test:
sdk: flutter
- # The "flutter_lints" package below contains a set of recommended lints to
- # encourage good coding practices. The lint set provided by the package is
- # activated in the `analysis_options.yaml` file located at the root of your
- # package. See that file for information about deactivating specific lint
- # rules and activating additional ones.
- flutter_lints: ^3.0.0
-
- json_serializable: ^6.5.4
build_runner: ^2.3.3
- test: ^1.23.1
checks: ^0.3.0
drift_dev: ^2.5.2
fake_async: ^1.3.1
+ flutter_lints: ^3.0.0
+ json_serializable: ^6.5.4
+ stack_trace: ^1.11.1
+ test: ^1.23.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
diff --git a/test/model/content_test.dart b/test/model/content_test.dart
index bdfe8dbfda..9d8c4f22d0 100644
--- a/test/model/content_test.dart
+++ b/test/model/content_test.dart
@@ -1,16 +1,261 @@
+import 'dart:io';
+
import 'package:checks/checks.dart';
import 'package:html/parser.dart';
+import 'package:stack_trace/stack_trace.dart';
import 'package:test/scaffolding.dart';
import 'package:zulip/model/code_block.dart';
import 'package:zulip/model/content.dart';
import 'content_checks.dart';
-void testParse(String name, String html, List nodes) {
- test(name, () {
- check(parseContent(html))
- .equalsNode(ZulipContent(nodes: nodes));
- });
+/// An example of Zulip content for test cases.
+//
+// When writing examples:
+//
+// * Try to use actual HTML emitted by a Zulip server for [html].
+// Record the corresponding Markdown source in [markdown].
+//
+// * Here's a handy `curl` command for getting the server's HTML.
+// First, as one-time setup, create a file with a test account's
+// Zulip credentials in "netrc" format, meaning one line that looks like:
+// machine HOSTNAME login EMAIL password API_KEY
+//
+// * Then send some test messages, and fetch with a command like this.
+// (Change "sender" operand to your user ID, and "topic" etc. as desired.)
+/* $ curl -sS --netrc-file ../.netrc -G https://chat.zulip.org/api/v1/messages \
+ --data-urlencode 'narrow=[{"operator":"sender", "operand":2187},
+ {"operator":"stream", "operand":"test here"},
+ {"operator":"topic", "operand":"content"}]' \
+ --data-urlencode anchor=newest --data-urlencode num_before=10 --data-urlencode num_after=0 \
+ --data-urlencode apply_markdown=true \
+ | jq '.messages[] | .content'
+ */
+//
+// * To get the corresponding Markdown source, use the same command
+// with `apply_markdown` changed to `false`.
+class ContentExample {
+ const ContentExample(this.description, this.markdown, this.html,
+ this.expectedNodes, {this.expectedText});
+
+ ContentExample.inline(this.description, this.markdown, this.html,
+ InlineContentNode parsed, {this.expectedText})
+ : expectedNodes = [ParagraphNode(links: null, nodes: [parsed])];
+
+ /// A description string, for use in names of tests.
+ final String description;
+
+ /// The Zulip Markdown source, if any, that the server renders as [html].
+ ///
+ /// This is useful for reproducing the example content for live use in the
+ /// app, and as a starting point for variations on it.
+ ///
+ /// Currently the test suite does not verify the relationship between
+ /// [markdown] and [html].
+ ///
+ /// If there is no known Markdown that a Zulip server can render as [html],
+ /// then this should be null and a comment should explain why the test uses
+ /// such an example.
+ final String? markdown;
+
+ /// A fragment of Zulip HTML, to be parsed as a [ZulipContent].
+ ///
+ /// Generally this should be actual HTML emitted by a Zulip server.
+ /// See the example `curl` command in comments on this class for help in
+ /// conveniently getting such HTML.
+ final String html;
+
+ /// The [ZulipContent.nodes] expected from parsing [html].
+ final List expectedNodes;
+
+ /// The text, if applicable, of a text widget expected from
+ /// rendering [expectedNodes].
+ ///
+ /// Strictly this belongs to the widget tests, not the model tests, as it
+ /// encodes choices about how the content widgets work. But it's convenient
+ /// to have it defined for each test case right next to [html] and [expectedNodes].
+ final String? expectedText;
+
+ static final emojiUnicode = ContentExample.inline(
+ 'Unicode emoji, encoded in span element',
+ ":thumbs_up:",
+ expectedText: '\u{1f44d}', // "👍"
+ '
:thumbs_up:
',
+ const UnicodeEmojiNode(emojiUnicode: '\u{1f44d}'));
+
+ static final emojiUnicodeClassesFlipped = ContentExample.inline(
+ 'Unicode emoji, encoded in span element, class order reversed',
+ null, // ":thumbs_up:" (hypothetical server variation)
+ expectedText: '\u{1f44d}', // "👍"
+ '
'),
+ ]);
+
+ static final codeBlockWithUnknownSpanType = ContentExample(
+ 'code block, with an unknown span type',
+ null, // this test is for future Pygments versions adding new token types
+ '
',
+ [MathBlockNode(texSource: r'\lambda')]);
+
+ static const mathBlockInQuote = ContentExample(
+ 'math block in quote',
+ // There's sometimes a quirky extra ` \n` at the end of the `
` that
+ // encloses the math block. In particular this happens when the math block
+ // is the last thing in the quote; though not in a doubly-nested quote;
+ // and there might be further wrinkles yet to be found. Some experiments:
+ // https://chat.zulip.org/#narrow/stream/7-test-here/topic/content/near/1715732
+ "````quote\n```math\n\\lambda\n```\n````",
+ '
\n
'
+ ''
+ ''
+ 'λ'
+ ' \n
\n
',
+ [QuotationNode([MathBlockNode(texSource: r'\lambda')])]);
}
UnimplementedBlockContentNode blockUnimplemented(String html) {
@@ -23,30 +268,25 @@ UnimplementedInlineContentNode inlineUnimplemented(String html) {
return UnimplementedInlineContentNode(htmlNode: fragment.nodes.single);
}
+void testParse(String name, String html, List nodes) {
+ test(name, () {
+ check(parseContent(html))
+ .equalsNode(ZulipContent(nodes: nodes));
+ });
+}
+
+void testParseExample(ContentExample example) {
+ testParse('parse ${example.description}', example.html, example.expectedNodes);
+}
+
void main() {
// When writing test cases in this file:
//
- // * Try to use actual HTML emitted by a Zulip server.
- // Record the corresponding Markdown source in a comment.
+ // * Prefer to add a [ContentExample] static and use [testParseExample].
+ // Then add one line of code to `test/widgets/content_test.dart`,
+ // calling `testContentSmoke`, for a widgets test on the same example.
//
- // * Here's a handy `curl` command for getting the server's HTML.
- // First, as one-time setup, create a file with a test account's
- // Zulip credentials in "netrc" format, meaning one line that looks like:
- // machine HOSTNAME login EMAIL password API_KEY
- //
- // * Then send some test messages, and fetch with a command like this.
- // (Change "sender" operand to your user ID, and "topic" etc. as desired.)
- /* $ curl -sS --netrc-file ../.netrc -G https://chat.zulip.org/api/v1/messages \
- --data-urlencode 'narrow=[{"operator":"sender", "operand":2187},
- {"operator":"stream", "operand":"test here"},
- {"operator":"topic", "operand":"content"}]' \
- --data-urlencode anchor=newest --data-urlencode num_before=10 --data-urlencode num_after=0 \
- --data-urlencode apply_markdown=true \
- | jq '.messages[] | .content'
- */
- //
- // * To get the corresponding Markdown source, use the same command
- // with `apply_markdown` changed to `false`.
+ // * To write the example, see comment at top of [ContentExample].
//
// Inline content.
@@ -161,45 +401,14 @@ void main() {
// TODO test wildcard mentions
});
- testParseInline('parse Unicode emoji, encoded in span element',
- // ":thumbs_up:"
- '
:thumbs_up:
',
- const UnicodeEmojiNode(emojiUnicode: '\u{1f44d}')); // "👍"
-
- testParseInline('parse Unicode emoji, encoded in span element, class order reversed',
- // ":thumbs_up:" (hypothetical server variation)
- '
'),
- ]);
+ testParseExample(ContentExample.quotation);
- testParse('parse code blocks, unknown span type',
- // (no markdown; this test is for future Pygments versions adding new token types)
- '
',
- [const MathBlockNode(texSource: r'\lambda')]);
+ testParseExample(ContentExample.codeBlockPlain);
+ testParseExample(ContentExample.codeBlockHighlightedShort);
+ testParseExample(ContentExample.codeBlockHighlightedMultiline);
+ testParseExample(ContentExample.codeBlockWithHighlightedLines);
+ testParseExample(ContentExample.codeBlockWithUnknownSpanType);
- testParse('parse math block in quote',
- // There's sometimes a quirky extra ` \n` at the end of the `
` that
- // encloses the math block. In particular this happens when the math block
- // is the last thing in the quote; though not in a doubly-nested quote;
- // and there might be further wrinkles yet to be found. Some experiments:
- // https://chat.zulip.org/#narrow/stream/7-test-here/topic/content/near/1715732
- // "````quote\n```math\n\\lambda\n```\n````"
- '
\n
'
- ''
- ''
- 'λ'
- ' \n
\n
',
- [const QuotationNode([MathBlockNode(texSource: r'\lambda')])]);
+ testParseExample(ContentExample.mathBlock);
+ testParseExample(ContentExample.mathBlockInQuote);
testParse('parse image',
// "https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3"
@@ -499,4 +603,24 @@ void main() {
ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('\n\n')]), // TODO avoid this; it renders wrong
]]),
]);
+
+ test('all content examples are tested', () {
+ // Check that every ContentExample defined above has a corresponding
+ // actual test case that runs on it. If you've added a new example
+ // and this test breaks, remember to add a `testParseExample` call for it.
+
+ // This implementation is a bit of a hack; it'd be cleaner to get the
+ // actual Dart parse tree using package:analyzer. Unfortunately that
+ // approach takes several seconds just to load the parser library, enough
+ // to add noticeably to the runtime of our whole test suite.
+ final thisFilename = Trace.current().frames[0].uri.path;
+ final source = File(thisFilename).readAsStringSync();
+ final declaredExamples = RegExp(multiLine: true,
+ r'^\s*static\s+(?:const|final)\s+(\w+)\s*=\s*ContentExample\s*(?:\.\s*inline\s*)?\(',
+ ).allMatches(source).map((m) => m.group(1));
+ final testedExamples = RegExp(multiLine: true,
+ r'^\s*testParseExample\s*\(\s*ContentExample\s*\.\s*(\w+)\);',
+ ).allMatches(source).map((m) => m.group(1));
+ check(testedExamples).unorderedEquals(declaredExamples);
+ });
}
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index b60e4789c8..9e8b9789b6 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -15,6 +15,7 @@ import 'package:zulip/widgets/store.dart';
import '../example_data.dart' as eg;
import '../model/binding.dart';
+import '../model/content_test.dart';
import '../test_images.dart';
import '../test_navigation.dart';
import 'dialog_checks.dart';
@@ -22,76 +23,63 @@ import 'message_list_checks.dart';
import 'page_checks.dart';
void main() {
+ // For testing a new content feature:
+ //
+ // * Start by writing parsing tests using [ContentExample].
+ // Then use [testContentSmoke] here to smoke-test the widgets.
+ //
+ // * If the widgets have any interactive behavior, test that here too.
+ // Those tests might not use [ContentExample], because they're often
+ // clearest if the HTML text is visible directly in the test source code
+ // to compare with the other details of the test.
+ // For examples, see the "LinkNode interactions" group below.
+
TestZulipBinding.ensureInitialized();
- group('Heading', () {
- Future prepareContent(WidgetTester tester, String html) async {
- await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(html).nodes)));
- }
+ Future prepareContentBare(WidgetTester tester, String html) async {
+ await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(html).nodes)));
+ }
+ /// Test that the given content example renders without throwing an exception.
+ ///
+ /// This requires [ContentExample.expectedText] to be non-null in order to
+ /// check that the content has actually rendered. For examples where there's
+ /// no suitable value for [ContentExample.expectedText], use [prepareContentBare]
+ /// and write an appropriate content-has-rendered check directly.
+ void testContentSmoke(ContentExample example) {
+ testWidgets('smoke: ${example.description}', (tester) async {
+ await prepareContentBare(tester, example.html);
+ assert(example.expectedText != null,
+ 'testContentExample requires expectedText');
+ tester.widget(find.text(example.expectedText!));
+ });
+ }
+
+ group('Heading', () {
testWidgets('plain h6', (tester) async {
- await prepareContent(tester,
+ await prepareContentBare(tester,
// "###### six"
'