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}', // "👍" + '

:thumbs_up:

', + const UnicodeEmojiNode(emojiUnicode: '\u{1f44d}')); + + static final emojiUnicodeMultiCodepoint = ContentExample.inline( + 'Unicode emoji, encoded in span element, multiple codepoints', + ":transgender_flag:", + expectedText: '\u{1f3f3}\u{fe0f}\u{200d}\u{26a7}\u{fe0f}', // "🏳️‍⚧️" + '

:transgender_flag:

', + const UnicodeEmojiNode(emojiUnicode: '\u{1f3f3}\u{fe0f}\u{200d}\u{26a7}\u{fe0f}')); + + static final emojiUnicodeLiteral = ContentExample.inline( + 'Unicode emoji, not encoded in span element', + "\u{1fabf}", + expectedText: '\u{1fabf}', // "🪿" + '

\u{1fabf}

', + const TextNode('\u{1fabf}')); + + static final emojiCustom = ContentExample.inline( + 'custom emoji', + ":flutter:", + '

:flutter:

', + const ImageEmojiNode( + src: '/user_avatars/2/emoji/images/204.png', alt: ':flutter:')); + + static final emojiZulipExtra = ContentExample.inline( + 'Zulip extra emoji', + ":zulip:", + '

:zulip:

', + const ImageEmojiNode( + src: '/static/generated/emoji/images/emoji/unicode/zulip.png', alt: ':zulip:')); + + static const quotation = ContentExample( + 'quotation', + "```quote\nwords\n```", + expectedText: 'words', + '
\n

words

\n
', [ + QuotationNode([ParagraphNode(links: null, nodes: [TextNode('words')])]) + ]); + + static const codeBlockPlain = ContentExample( + 'code block without syntax highlighting', + "```\nverb\natim\n```", + expectedText: 'verb\natim', + '
verb\natim\n
', [ + CodeBlockNode([ + CodeBlockSpanNode(text: 'verb\natim', type: CodeBlockSpanType.text), + ]), + ]); + + static const codeBlockHighlightedShort = ContentExample( + 'code block with syntax highlighting', + "```dart\nclass A {}\n```", + expectedText: 'class A {}', + '
'
+        'class '
+        'A {}'
+        '\n
', [ + CodeBlockNode([ + CodeBlockSpanNode(text: 'class', type: CodeBlockSpanType.keywordDeclaration), + CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), + CodeBlockSpanNode(text: 'A', type: CodeBlockSpanType.nameClass), + CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), + CodeBlockSpanNode(text: '{}', type: CodeBlockSpanType.punctuation), + ]), + ]); + + static const codeBlockHighlightedMultiline = ContentExample( + 'code block, multiline, with syntax highlighting', + '```rust\nfn main() {\n print!("Hello ");\n\n print!("world!\\n");\n}\n```', + expectedText: 'fn main() {\n print!("Hello ");\n\n print!("world!\\n");\n}', + '
'
+        'fn main'
+        '() {\n'
+        '    print!('
+        '"Hello ");\n\n'
+        '    print!('
+        '"world!\\n"'
+        ');\n}\n'
+        '
', [ + CodeBlockNode([ + CodeBlockSpanNode(text: 'fn', type: CodeBlockSpanType.keyword), + CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.text), + CodeBlockSpanNode(text: 'main', type: CodeBlockSpanType.nameFunction), + CodeBlockSpanNode(text: '()', type: CodeBlockSpanType.punctuation), + CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), + CodeBlockSpanNode(text: '{', type: CodeBlockSpanType.punctuation), + CodeBlockSpanNode(text: '\n', type: CodeBlockSpanType.text), + CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), + CodeBlockSpanNode(text: 'print!', type: CodeBlockSpanType.nameFunctionMagic), + CodeBlockSpanNode(text: '(', type: CodeBlockSpanType.punctuation), + CodeBlockSpanNode(text: '"Hello "', type: CodeBlockSpanType.string), + CodeBlockSpanNode(text: ');', type: CodeBlockSpanType.punctuation), + CodeBlockSpanNode(text: '\n\n', type: CodeBlockSpanType.text), + CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), + CodeBlockSpanNode(text: 'print!', type: CodeBlockSpanType.nameFunctionMagic), + CodeBlockSpanNode(text: '(', type: CodeBlockSpanType.punctuation), + CodeBlockSpanNode(text: '"world!', type: CodeBlockSpanType.string), + CodeBlockSpanNode(text: '\\n', type: CodeBlockSpanType.stringEscape), + CodeBlockSpanNode(text: '"', type: CodeBlockSpanType.string), + CodeBlockSpanNode(text: ');', type: CodeBlockSpanType.punctuation), + CodeBlockSpanNode(text: '\n', type: CodeBlockSpanType.text), + CodeBlockSpanNode(text: '}', type: CodeBlockSpanType.punctuation), + ]), + ]); + + static final codeBlockWithHighlightedLines = ContentExample( + 'code block, with syntax highlighting and highlighted lines', + '```\n::markdown hl_lines="2 4"\n# he\n## llo\n### world\n```', + '
'
+        '::markdown hl_lines="2 4"\n'
+        '# he\n'
+        '## llo\n'
+        '### world\n'
+        '
', [ + // TODO: Fix this, see comment under `CodeBlockSpanType.highlightedLines` case in lib/model/content.dart. + blockUnimplemented('
'
+        '::markdown hl_lines="2 4"\n'
+        '# he\n'
+        '## llo\n'
+        '### world\n'
+        '
'), + ]); + + static final codeBlockWithUnknownSpanType = ContentExample( + 'code block, with an unknown span type', + null, // this test is for future Pygments versions adding new token types + '
'
+        'class'
+        '\n
', [ + blockUnimplemented('
'
+        'class'
+        '\n
'), + ]); + + static final mathInline = ContentExample.inline( + 'inline math', + r"$$ \lambda $$", + expectedText: r'\lambda', + '

' + 'λ' + ' \\lambda ' + '

', + const MathInlineNode(texSource: r'\lambda')); + + static const mathBlock = ContentExample( + 'math block', + "```math\n\\lambda\n```", + expectedText: r'\lambda', + '

' + 'λ' + '\\lambda' + '

', + [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

' + '' + 'λ' + '\\lambda' + '' + '
\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) - '

:thumbs_up:

', - const UnicodeEmojiNode(emojiUnicode: '\u{1f44d}')); // "👍" - - testParseInline('parse Unicode emoji, encoded in span element, multiple codepoints', - // ":transgender_flag:" - '

:transgender_flag:

', - const UnicodeEmojiNode(emojiUnicode: '\u{1f3f3}\u{fe0f}\u{200d}\u{26a7}\u{fe0f}')); // "🏳️‍⚧️" + testParseExample(ContentExample.emojiUnicode); + testParseExample(ContentExample.emojiUnicodeClassesFlipped); + testParseExample(ContentExample.emojiUnicodeMultiCodepoint); + testParseExample(ContentExample.emojiUnicodeLiteral); + testParseExample(ContentExample.emojiCustom); + testParseExample(ContentExample.emojiZulipExtra); - testParseInline('parse Unicode emoji, not encoded in span element', - // "\u{1fabf}" - '

\u{1fabf}

', - const TextNode('\u{1fabf}')); // "🪿" - - testParseInline('parse custom emoji', - // ":flutter:" - '

:flutter:

', - const ImageEmojiNode( - src: '/user_avatars/2/emoji/images/204.png', alt: ':flutter:')); - - testParseInline('parse Zulip extra emoji', - // ":zulip:" - '

:zulip:

', - const ImageEmojiNode( - src: '/static/generated/emoji/images/emoji/unicode/zulip.png', alt: ':zulip:')); - - testParseInline('parse inline math', - // "$$ \\lambda $$" - '

' - 'λ' - ' \\lambda ' - '

', - const MathInlineNode(texSource: r'\lambda')); + testParseExample(ContentExample.mathInline); group('global times', () { testParseInline('smoke', @@ -356,121 +565,16 @@ void main() { ])]); }); - testParse('parse quotations', - // "```quote\nwords\n```" - '
\n

words

\n
', const [ - QuotationNode([ParagraphNode(links: null, nodes: [TextNode('words')])]), - ]); - - testParse('parse code blocks, without syntax highlighting', - // "```\nverb\natim\n```" - '
verb\natim\n
', const [ - CodeBlockNode([ - CodeBlockSpanNode(text: 'verb\natim', type: CodeBlockSpanType.text), - ]), - ]); - - testParse('parse code blocks, with syntax highlighting', - // "```dart\nclass A {}\n```" - '
'
-        'class '
-        'A {}'
-        '\n
', const [ - CodeBlockNode([ - CodeBlockSpanNode(text: 'class', type: CodeBlockSpanType.keywordDeclaration), - CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), - CodeBlockSpanNode(text: 'A', type: CodeBlockSpanType.nameClass), - CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), - CodeBlockSpanNode(text: '{}', type: CodeBlockSpanType.punctuation), - ]), - ]); - - testParse('parse code blocks, multiline, with syntax highlighting', - // '```rust\nfn main() {\n print!("Hello ");\n\n print!("world!\\n");\n}\n```' - '
'
-        'fn main'
-        '() {\n'
-        '    print!('
-        '"Hello ");\n\n'
-        '    print!('
-        '"world!\\n"'
-        ');\n}\n'
-        '
', const [ - CodeBlockNode([ - CodeBlockSpanNode(text: 'fn', type: CodeBlockSpanType.keyword), - CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.text), - CodeBlockSpanNode(text: 'main', type: CodeBlockSpanType.nameFunction), - CodeBlockSpanNode(text: '()', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), - CodeBlockSpanNode(text: '{', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: '\n', type: CodeBlockSpanType.text), - CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), - CodeBlockSpanNode(text: 'print!', type: CodeBlockSpanType.nameFunctionMagic), - CodeBlockSpanNode(text: '(', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: '"Hello "', type: CodeBlockSpanType.string), - CodeBlockSpanNode(text: ');', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: '\n\n', type: CodeBlockSpanType.text), - CodeBlockSpanNode(text: ' ', type: CodeBlockSpanType.whitespace), - CodeBlockSpanNode(text: 'print!', type: CodeBlockSpanType.nameFunctionMagic), - CodeBlockSpanNode(text: '(', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: '"world!', type: CodeBlockSpanType.string), - CodeBlockSpanNode(text: '\\n', type: CodeBlockSpanType.stringEscape), - CodeBlockSpanNode(text: '"', type: CodeBlockSpanType.string), - CodeBlockSpanNode(text: ');', type: CodeBlockSpanType.punctuation), - CodeBlockSpanNode(text: '\n', type: CodeBlockSpanType.text), - CodeBlockSpanNode(text: '}', type: CodeBlockSpanType.punctuation), - ]), - ]); - - testParse('parse code blocks, with syntax highlighting and highlighted lines', - // '```\n::markdown hl_lines="2 4"\n# he\n## llo\n### world\n```' - '
'
-        '::markdown hl_lines="2 4"\n'
-        '# he\n'
-        '## llo\n'
-        '### world\n'
-        '
', [ - // TODO: Fix this, see comment under `CodeBlockSpanType.highlightedLines` case in lib/model/content.dart. - blockUnimplemented('
'
-        '::markdown hl_lines="2 4"\n'
-        '# he\n'
-        '## llo\n'
-        '### world\n'
-        '
'), - ]); + testParseExample(ContentExample.quotation); - testParse('parse code blocks, unknown span type', - // (no markdown; this test is for future Pygments versions adding new token types) - '
'
-        'class'
-        '\n
', [ - blockUnimplemented('
'
-        'class'
-        '\n
'), - ]); - - testParse('parse math block', - // "```math\n\\lambda\n```" - '

' - 'λ' - '\\lambda' - '

', - [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

' - '' - 'λ' - '\\lambda' - '' - '
\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" '
six
'); tester.widget(find.text('six')); }); testWidgets('smoke test for h1, h2, h3, h4, h5', (tester) async { - await prepareContent(tester, + await prepareContentBare(tester, // "# one\n## two\n### three\n#### four\n##### five" '

one

\n

two

\n

three

\n

four

\n
five
'); check(find.byType(Heading).evaluate()).length.equals(5); }); }); - group("CodeBlock", () { - Future prepareContent(WidgetTester tester, String html) async { - await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(html).nodes))); - } - - testWidgets('without syntax highlighting', (WidgetTester tester) async { - // "```\nverb\natim\n```" - await prepareContent(tester, - '
verb\natim\n
'); - tester.widget(find.text('verb\natim')); - }); - - testWidgets('with syntax highlighting', (WidgetTester tester) async { - // "```dart\nclass A {}\n```" - await prepareContent(tester, - '
'
-          'class '
-          'A {}'
-          '\n
'); - tester.widget(find.text('class A {}')); - }); + testContentSmoke(ContentExample.quotation); - testWidgets('multiline, with syntax highlighting', (WidgetTester tester) async { - // '```rust\nfn main() {\n print!("Hello ");\n\n print!("world!\\n");\n}\n```' - await prepareContent(tester, - '
'
-            'fn main'
-            '() {\n'
-            '    print!('
-            '"Hello ");\n\n'
-            '    print!('
-            '"world!\\n"'
-            ');\n}\n'
-            '
'); - tester.widget(find.text('fn main() {\n print!("Hello ");\n\n print!("world!\\n");\n}')); - }); + group("CodeBlock", () { + testContentSmoke(ContentExample.codeBlockPlain); + testContentSmoke(ContentExample.codeBlockHighlightedShort); + testContentSmoke(ContentExample.codeBlockHighlightedMultiline); }); - testWidgets('MathBlock', (tester) async { - // "```math\n\\lambda\n```" - await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent( - '

' - 'λ' - '\\lambda' - '

', - ).nodes))); - tester.widget(find.text(r'\lambda')); - }); + testContentSmoke(ContentExample.mathBlock); Future tapText(WidgetTester tester, Finder textFinder) async { final height = tester.getSize(textFinder).height; @@ -245,42 +233,12 @@ void main() { }); group('UnicodeEmoji', () { - Future prepareContent(WidgetTester tester, String html) async { - await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(html).nodes))); - } - - testWidgets('encoded emoji span', (tester) async { - await prepareContent(tester, - // ":thumbs_up:" - '

:thumbs_up:

'); - tester.widget(find.text('\u{1f44d}')); // "👍" - }); - - testWidgets('encoded emoji span, with multiple codepoints', (tester) async { - await prepareContent(tester, - // ":transgender_flag:" - '

:transgender_flag:

'); - tester.widget(find.text('\u{1f3f3}\u{fe0f}\u{200d}\u{26a7}\u{fe0f}')); // "🏳️‍⚧️" - }); - - testWidgets('non encoded emoji', (tester) async { - await prepareContent(tester, - // "\u{1fabf}" - '

\u{1fabf}

'); - tester.widget(find.text('\u{1fabf}')); // "🪿" - }); + testContentSmoke(ContentExample.emojiUnicode); + testContentSmoke(ContentExample.emojiUnicodeMultiCodepoint); + testContentSmoke(ContentExample.emojiUnicodeLiteral); }); - testWidgets('MathInlineNode', (tester) async { - // "$$ \\lambda $$" - await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent( - '

' - 'λ' - ' \\lambda ' - '

', - ).nodes))); - tester.widget(find.text(r'\lambda')); - }); + testContentSmoke(ContentExample.mathInline); testWidgets('GlobalTime smoke', (tester) async { // "" diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index d671f4fb51..ab54f666f2 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -22,6 +22,7 @@ import 'package:zulip/widgets/store.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; +import '../model/content_test.dart'; import '../model/message_list_test.dart'; import '../model/test_store.dart'; import '../flutter_checks.dart'; @@ -136,7 +137,7 @@ void main() { // Regression test for: https://github.com/zulip/zulip-flutter/issues/507 await setupMessageListPage(tester, foundOldest: false, messages: [ ...List.generate(300, (i) => eg.streamMessage(id: 1000 + i)), - eg.streamMessage(id: 1301, content: '
verb\natim\n
'), + eg.streamMessage(id: 1301, content: ContentExample.codeBlockPlain.html), ...List.generate(100, (i) => eg.streamMessage(id: 1302 + i)), ]); final lastRequest = connection.lastRequest; diff --git a/web/favicon.png b/web/favicon.png deleted file mode 100644 index 8aaa46ac1a..0000000000 Binary files a/web/favicon.png and /dev/null differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png deleted file mode 100644 index b749bfef07..0000000000 Binary files a/web/icons/Icon-192.png and /dev/null differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48dff..0000000000 Binary files a/web/icons/Icon-512.png and /dev/null differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d76e5..0000000000 Binary files a/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c56691f..0000000000 Binary files a/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/web/index.html b/web/index.html deleted file mode 100644 index bbe795ed78..0000000000 --- a/web/index.html +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Zulip - - - - - - - - - - diff --git a/web/manifest.json b/web/manifest.json deleted file mode 100644 index 1b103c963e..0000000000 --- a/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "Zulip", - "short_name": "zulip", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A Zulip client for Android and iOS", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -}