diff --git a/ROADMAP.md b/ROADMAP.md index 03e78e7..b88b10b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ This document outlines the architectural roadmap for **HyperRender** to become t | **v1.10.0** | Shipped 2026-10-02 | **DevTools v2 & Stylesheet CSS Correctness** | Timeline / Selection / live CSS-variable / snapshot DevTools tabs; stylesheet `var()`, `url()`, `calc()` and `:root` fixed. | | **v1.11.0** | Shipped 2026-10-04 | **Dark Mode & Root Colors** | Dark-`Theme` text default, `HyperViewer(textColor:)` / `EpubReader(textColor:)`, `body`/`html` `color`, `:root` fix, contrast guard (#20). | | **v1.12.0** | Shipped 2026-10-04 | **Dark-Surface Element Colors** | Links, `
`, `` / `
`, `` keep >= 4.5:1 on dark surfaces (#23). |
-| **v1.13.0** | Shipped 2026-10-08 | **Character-Range Highlight Geometry** | `getBoxesForCharRange` / `debugLineFragments` for app-drawn highlights; ellipsis character-count fix (#26). |
+| **v1.13.0** | Release candidate | **Highlight Geometry, Line-Breaking Fixes & CJK Layout Speed** | `getBoxesForCharRange` / `debugLineFragments` for app-drawn highlights; wrapped text stays inside the box, RTL wraps, `word-break` / `overflow-wrap` inherit; a 14,000-character unspaced CJK paragraph lays out in well under a second instead of ~13 s (#26, #28, #29). |
 | **Next** | Unscheduled | **Native Vector Diagramming & Headless Export** | Pure Canvas/Vector Mermaid.js & GraphViz (Zero-WebView), Headless Image & PDF byte stream generator. |
 | **v2.0.0** | Q1 2027 | **Interactive Editorial & Magazine Typography** | Medium-style Text Annotation/Highlighting layer, Multi-column layout (`column-count`), Z-Index Stacking Context, Vertical Text (`writing-mode: vertical-rl`). |
 
diff --git a/doc/CSS_PROPERTIES_MATRIX.md b/doc/CSS_PROPERTIES_MATRIX.md
index a82d480..944f926 100644
--- a/doc/CSS_PROPERTIES_MATRIX.md
+++ b/doc/CSS_PROPERTIES_MATRIX.md
@@ -126,7 +126,8 @@ This document lists CSS property support in HyperRender.
 | `text-indent` | ✅ | px, em, rem, pt, % | First line of a block (LTR); inherits. `%` resolves against the containing block width |
 | `text-overflow` | ✅ | clip, ellipsis | |
 | `white-space` | ✅ | normal, nowrap, pre, pre-wrap | |
-| `word-break` | ✅ | normal, break-all, keep-all | |
+| `word-break` | ✅ | normal, break-all | `break-all` fills each line by character. Inherited (1.13.0; before, a value set on a block never reached its text). `keep-all` is parsed but behaves as `normal` |
+| `overflow-wrap` | ✅ | normal, break-word, anywhere | A word wider than the whole line breaks at character level instead of overflowing. Inherited (1.13.0) |
 | `vertical-align` | ⚠️ | baseline, sub, super | Limited support |
 | `text-shadow` | ✅ | x y blur color | Multiple shadows supported in v1.2.0 |
 
@@ -250,4 +251,4 @@ This document lists CSS property support in HyperRender.
 
 ---
 
-*Last updated: September 6, 2026 — HyperRender v1.8.0*
+*Last updated: October 10, 2026 — HyperRender v1.13.0*
diff --git a/doc/LIMITATIONS.md b/doc/LIMITATIONS.md
index 87c10ac..a0e75cf 100644
--- a/doc/LIMITATIONS.md
+++ b/doc/LIMITATIONS.md
@@ -238,4 +238,4 @@ the full suite plus goldens as the safety net.
 
 ---
 
-*Last updated: September 6, 2026 — HyperRender v1.8.0*
+*Last updated: October 10, 2026 — HyperRender v1.13.0*
diff --git a/doc/README.md b/doc/README.md
index d28f217..e49f842 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -1,6 +1,6 @@
 # HyperRender Documentation
 
-Welcome to the documentation for **HyperRender v1.3.3** — the high-performance HTML/Markdown renderer for Flutter.
+Welcome to the documentation for **HyperRender v1.13.0** — the high-performance HTML/Markdown renderer for Flutter.
 
 ## Documentation Index
 
@@ -29,6 +29,7 @@ Welcome to the documentation for **HyperRender v1.3.3** — the high-performance
 | **[CSS Properties Matrix](CSS_PROPERTIES_MATRIX.md)** | Full CSS property support status |
 | **[Roadmap](ROADMAP.md)** | Planned features and versioning strategy |
 | **[Contributing Guide](CONTRIBUTING.md)** | How to contribute code, docs, and issues |
+| **[Testing](TESTING.md)** | Test map by kind (unit, integration, system, performance, stress, security), commands and conventions |
 | **[Code of Conduct](CODE_OF_CONDUCT.md)** | Community standards |
 
 ### Architecture Decision Records
diff --git a/doc/ROADMAP.md b/doc/ROADMAP.md
index 54db51c..c809f63 100644
--- a/doc/ROADMAP.md
+++ b/doc/ROADMAP.md
@@ -1,7 +1,7 @@
 # HyperRender — Product Roadmap
 
 **Last Updated**: 2026-10-08
-**Current Stable**: v1.13.0
+**Current Stable**: v1.12.0 (v1.13.0 is the release candidate)
 **Repository**: [github.com/brewkits/hyper_render](https://github.com/brewkits/hyper_render)
 
 This document tracks the long-term direction of the HyperRender ecosystem.
@@ -116,7 +116,7 @@ Properties deferred from Phase 3 in [`CSS_PROPERTIES_MATRIX.md`](CSS_PROPERTIES_
 - [x] `text-shadow` — parsed + applied to `TextStyle.shadows` in `ComputedStyle`
 - [x] `text-overflow: ellipsis` — parsed + executed in `render_hyper_box_fragments.dart`
 - [x] `box-shadow` — parsed + applied in `render_hyper_box_paint.dart`
-- [x] `word-break`, `overflow-wrap` — parsed + executed in `render_hyper_box_layout.dart` (L1339–1375)
+- [x] `word-break: break-all`, `overflow-wrap: break-word | anywhere` — parsed, inherited and executed by the line breaker (`word-break: keep-all` is parsed only)
 - [x] `list-style-type`, `list-style-position` — fully parsed, resolved, and painted (v1.3.1)
 - [x] `background-repeat` — parsed + mapped to `ImageRepeat` in `paintImage()`
 - [x] `background-position` — parsed + mapped to `Alignment` in `paintImage()` (keyword values: top/center/bottom/left/right and combinations)
diff --git a/doc/TESTING.md b/doc/TESTING.md
new file mode 100644
index 0000000..ac91b4e
--- /dev/null
+++ b/doc/TESTING.md
@@ -0,0 +1,70 @@
+# Testing
+
+How HyperRender is tested, where each kind of test lives, and how to run it.
+
+## Commands
+
+```bash
+# Everything that gates a release (root + core, goldens excluded)
+flutter test test/ packages/hyper_render_core/test/ --exclude-tags golden
+
+# One file / one test
+flutter test test/line_breaking_regression_test.dart
+flutter test test/html_adapter_test.dart --plain-name 'parses links'
+
+# Sub-packages the root does not depend on run from inside the package
+(cd packages/hyper_render_html && flutter test)      # also _markdown, _highlight, _math, _epub, _clipboard, _devtools
+(cd packages/hyper_render_devtools/devtools_ui && flutter test --platform chrome)
+
+# The line-breaking suites also run on the web (native ICU line breaking)
+flutter test --platform chrome test/line_breaking_regression_test.dart \
+  test/line_breaking_perf_and_parity_test.dart test/line_breaking_stress_test.dart \
+  test/fuzz/layout_fuzz_test.dart
+
+# Demo app: 44 widget tests, then every demo screen on a real device
+(cd example && flutter test)
+(cd example && flutter test integration_test/all_demos_test.dart -d macos)
+
+# Coverage across root, core and the other in-repo packages
+flutter test --coverage --coverage-package='hyper_render_core|hyper_render' \
+  test/ packages/hyper_render_core/test/ --exclude-tags golden
+```
+
+## What lives where
+
+| Kind | What it proves | Where |
+|---|---|---|
+| **Unit** | One class or function in isolation: CSS parsing and resolution, adapters, fragments, selection maths | `packages/hyper_render_core/test/`, `test/*_test.dart` (parsers, `html_adapter_test`, `css_*`, `text_breaking_test`, …) |
+| **Integration** | Several layers together on realistic content | `test/integration/` (real-world HTML, selection, lifecycle, error recovery, dark mode), `test/integration_test*.dart` |
+| **System** | The whole app and pixels | `test/golden/` (pixel tests, CI-only), `example/integration_test/all_demos_test.dart` (opens every demo screen, fails on any `FlutterError`) |
+| **Performance** | Cost does not grow faster than it should | `test/line_breaking_perf_and_parity_test.dart` (counts `TextPainter` layouts, checks N→2N scaling; no clock), `test/integration/performance_*`, `test/streaming_engine_benchmark_test.dart`, `packages/hyper_render_core/test/*performance*`, `benchmark/` |
+| **Stress** | Very large or degenerate input terminates and stays correct | `test/line_breaking_stress_test.dart` (200 000-character paragraphs, zero-width and 1 px boxes, padding or floats wider than the container, narrow glyphs), `test/hyper_render_stress_test.dart`, `test/integration/large_document_test.dart`, `cjk_stress_test.dart`, `animation_v150_stress_test.dart` |
+| **Security** | Hostile input cannot execute, escape, or hang the parser | `test/html_sanitizer_test.dart` (incl. ReDoS timing), `test/hyper_render_security_test.dart`, `test/security_edge_cases_test.dart`, `test/integration/*security*`, `url_safety` tests, `stylesheet_var_security_test.dart` |
+| **Fuzz** | Mutated input never throws or hangs | `test/fuzz/parser_fuzz_test.dart` (parse only), `test/fuzz/layout_fuzz_test.dart` (lays mutants out at widths 0, 1, 37 and 300) |
+| **Docs sync** | The CSS matrix never over-claims | `test/docs_matrix_sync_test.dart` |
+
+## Conventions that have caught real bugs
+
+- **Test the behavior, not that nothing threw.** A 0×0 widget satisfies
+  `findsOneWidget`; assert sizes, line positions or pixels.
+- **Mutation-check new regression tests.** Revert the fix and confirm the test
+  fails. Several tests in this repo were written to a passing state that did not
+  actually exercise the code.
+- **Flutter's test font (Ahem) makes every glyph 1 em wide.** That hides any bug
+  that depends on narrow glyphs; `TextScaler.linear(0.5)` is the way to reach
+  them (see `line_breaking_stress_test.dart`). Real-font checks need a device.
+- **Layout assertions use `debugLineFragments()`**, which reports the position
+  and character range each fragment actually has on its line.
+- **Prefer counts and ratios to wall-clock thresholds.** Performance tests that
+  compare milliseconds flake on shared CI runners.
+- **A synchronous hang cannot be interrupted by the test timeout.** A breaker
+  regression that turns linear work quadratic blocks the isolate until the CI job
+  timeout rather than failing cleanly.
+- **Goldens only match on the CI runner** (pinned Ubuntu image, Flutter version
+  and fonts). Do not regenerate them locally: run the `Visual Regression`
+  workflow with `workflow_dispatch`, then review every changed image next to
+  its previous version before accepting it.
+- **Repo tests cannot catch a missing export** from the root barrel
+  (`lib/hyper_render.dart`), because they resolve through `dependency_overrides`.
+  After publishing, install the real packages into a throwaway project and
+  analyze code that names every new public type.
diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart
index e4b6151..9877d79 100644
--- a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart
+++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart
@@ -1329,7 +1329,6 @@ extension _RenderHyperBoxLayout on RenderHyperBox {
 
       // Collapsible spaces at the start of a line are removed (CSS Text 3
       // §4.1.2), e.g. the indentation newline after `

` or after `
`. - // Without this every such line started one space in. if (currentLineFragments.isEmpty && fragment.type == FragmentType.text && fragment.text != null) { @@ -1775,29 +1774,33 @@ extension _RenderHyperBoxLayout on RenderHyperBox { return (space < 0 ? text.length : space) <= fit; } - /// Checks if the leading run of [text] starting at [start] contains CJK characters. - bool _isLeadingCjk(String text, [int start = 0]) { - final limit = math.min(text.length, start + 32); - for (int i = start; i < limit; i++) { - if (KinsokuProcessor.isCjkCodeUnit(text.codeUnitAt(i))) return true; - } - return false; - } - - /// Calculates the maximum number of characters that could possibly fit - /// within [maxWidth] given [style]. Bounded prefixes prevent O(N^2) - /// text shaping overhead on massive paragraphs (e.g. Gutenberg EPUBs). - int _safeCandidateCharLimit(double maxWidth, ComputedStyle style, - {bool isCjk = false}) { + /// The number of characters of [text], from [start], that could possibly fit + /// on one line of [maxWidth] (plus a safety margin). Candidate prefixes are + /// bounded by this so a long paragraph is not re-shaped from scratch for + /// every line (O(N^2)). + /// + /// Walks the text adding the narrowest width each character can have: a full + /// width CJK character is 1em, anything else is assumed to be at least + /// 0.25em, both scaled by the active text scaler. Measuring the actual text + /// matters for mixed content: a Latin run after a single CJK character must + /// not inherit the CJK bound, or a line is cut short. + int _safeCandidateCharLimit( + String text, int start, double maxWidth, ComputedStyle style) { + const margin = 64; final effectiveWidth = maxWidth > 0 ? maxWidth : 1.0; - final fontSize = style.fontSize > 0 ? style.fontSize : 16.0; - if (isCjk) { - // In CJK, fullwidth characters are 1.0 * fontSize wide. Bounding to - // (effectiveWidth / (fontSize * 0.75)).ceil() + 16 provides ample margin - // (~80-85 chars at 800px) while avoiding over-shaping 116+ chars per line. - return (effectiveWidth / (fontSize * 0.75)).ceil() + 16; - } - return (effectiveWidth / (fontSize * 0.25)).ceil() + 64; + final fontSize = + _textScaler.scale(style.fontSize > 0 ? style.fontSize : 16.0); + final cjkMin = fontSize; + final otherMin = fontSize * 0.25; + var width = 0.0; + var i = start; + while (i < text.length && width < effectiveWidth) { + width += KinsokuProcessor.isCjkCodeUnit(text.codeUnitAt(i)) + ? cjkMin + : otherMin; + i++; + } + return (i - start) + margin; } /// Reusable TextPainter for layout candidate prefixes, avoiding allocating @@ -1839,9 +1842,8 @@ extension _RenderHyperBoxLayout on RenderHyperBox { final text = fragment.text!; if (text.isEmpty) return null; - final isCjk = _isLeadingCjk(text); final safeLimit = - _safeCandidateCharLimit(maxWidth, fragment.style, isCjk: isCjk); + _safeCandidateCharLimit(text, 0, maxWidth, fragment.style); final isCandidate = text.length > safeLimit; final candidateText = isCandidate ? text.substring(0, safeLimit) : text; @@ -1979,7 +1981,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox { // Container width _maxWidth is used rather than line maxWidth to avoid // erroneously assuming the tail fits on the next full-width line. final safeContainerLimit = - _safeCandidateCharLimit(_maxWidth, fragment.style, isCjk: isCjk); + _safeCandidateCharLimit(text, breakIndex, _maxWidth, fragment.style); if (secondPart.length > safeContainerLimit) { secondFragment.measuredSize = Size(double.infinity, firstFragment.height); secondFragment.baseline = firstFragment.baseline; @@ -2012,8 +2014,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox { final bool overflowWrap = style.overflowWrap == 'break-word' || style.overflowWrap == 'anywhere'; - final isCjk = _isLeadingCjk(text); - final safeLimit = _safeCandidateCharLimit(maxWidth, style, isCjk: isCjk); + final safeLimit = _safeCandidateCharLimit(text, 0, maxWidth, style); final isCandidate = text.length > safeLimit; final candidateText = isCandidate ? text.substring(0, safeLimit) : text; @@ -2052,8 +2053,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox { } // Fallback: a single word wider than the whole line. Break it after as - // many characters as fit (as Flutter's Text does) rather than after the - // first character, which used to leave a column of one-letter lines. + // many characters as fit, as Flutter's Text does. if (breakIndex <= 0) breakIndex = math.max(1, fitIndex); if (breakIndex >= text.length) return null; @@ -2120,7 +2120,7 @@ extension _RenderHyperBoxLayout on RenderHyperBox { )..globalOffset = fragment.globalOffset + breakIndex; final safeContainerLimit = - _safeCandidateCharLimit(_maxWidth, fragment.style, isCjk: isCjk); + _safeCandidateCharLimit(text, breakIndex, _maxWidth, fragment.style); if (secondPart.length > safeContainerLimit) { secondFragment.measuredSize = Size(double.infinity, firstFragment.height); secondFragment.baseline = firstFragment.baseline; diff --git a/packages/hyper_render_core/lib/src/style/resolver.dart b/packages/hyper_render_core/lib/src/style/resolver.dart index 1078678..9437b19 100644 --- a/packages/hyper_render_core/lib/src/style/resolver.dart +++ b/packages/hyper_render_core/lib/src/style/resolver.dart @@ -3488,8 +3488,8 @@ class StyleResolver { // White space - inherit if not explicitly set style.whiteSpace ??= parentStyle.whiteSpace; - // word-break / overflow-wrap inherit in CSS. Without this a value set on - // a

never reached its text nodes, which is where line breaking reads it. + // word-break / overflow-wrap inherit in CSS. Line breaking reads them from + // the text node's own style, so they must be copied down from the block. style.wordBreak ??= parentStyle.wordBreak; style.overflowWrap ??= parentStyle.overflowWrap; diff --git a/test/char_range_and_hooks_test.dart b/test/char_range_and_hooks_test.dart new file mode 100644 index 0000000..72576ca --- /dev/null +++ b/test/char_range_and_hooks_test.dart @@ -0,0 +1,144 @@ +// Edge cases for the 1.13.0 additions that the main suites reach only +// indirectly: character-range boxes over ruby text, the ellipsis-only line, +// and the TextPainter-layout debug hooks (used for a deterministic, +// clock-free check that line breaking does a bounded amount of shaping). + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; +import 'package:hyper_render_core/hyper_render_core.dart' + show HyperRenderDebugHooks; + +Future _pump( + WidgetTester tester, + String html, { + double width = 300, +}) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SingleChildScrollView( + child: SizedBox( + width: width, + child: HyperViewer(html: html, mode: HyperRenderMode.sync), + ), + ), + ), + ), + )); + await tester.pump(const Duration(milliseconds: 50)); + return tester.renderObject( + find.byType(HyperRenderWidget).first, + ); +} + +void main() { + group('getBoxesForCharRange', () { + testWidgets('a ruby fragment yields one box spanning its line', + (tester) async { + final box = await _pump( + tester, + '

前日本語にほんご後

', + ); + final ruby = box + .debugLineFragments() + .firstWhere((f) => f['type'] == 'ruby' || f['rubyText'] != null); + final start = ruby['charStart'] as int; + final end = ruby['charEnd'] as int; + final boxes = box.getBoxesForCharRange(start, end); + expect(boxes, hasLength(1)); + expect(boxes.single.left, closeTo(ruby['offsetX'] as double, 0.5)); + expect(boxes.single.width, closeTo(ruby['width'] as double, 0.5)); + // The box spans the whole line, not just the base text. + expect( + boxes.single.height, greaterThanOrEqualTo(ruby['height'] as double)); + }); + + testWidgets('an out-of-range or empty range yields nothing', + (tester) async { + final box = await _pump(tester, '

Some plain text.

'); + expect(box.getBoxesForCharRange(500, 600), isEmpty); + expect(box.getBoxesForCharRange(3, 3), isEmpty); + expect(box.getBoxesForCharRange(0, 4), isNotEmpty); + }); + }); + + group('text-overflow: ellipsis', () { + testWidgets('a box too narrow for any character shows only the ellipsis', + (tester) async { + final box = await _pump( + tester, + '
Truncated heading
', + width: 10, + ); + final lines = + box.debugLineFragments().where((f) => f['type'] == 'text').toList(); + expect(lines, hasLength(1)); + expect(lines.single['text'], '…'); + expect(lines.single['ellipsisVisibleLength'], 0); + // The ellipsis glyph is not a source character: no box for char 0. + expect(box.getBoxesForCharRange(0, 16), isEmpty); + }); + }); + + group('TextPainter layout hooks', () { + late int layouts; + late int lineLayouts; + + setUp(() { + layouts = 0; + lineLayouts = 0; + HyperRenderDebugHooks.onTextPainterLayout = () => layouts++; + HyperRenderDebugHooks.onLineLayoutTextPainter = () => lineLayouts++; + }); + + tearDown(() { + HyperRenderDebugHooks.onTextPainterLayout = null; + HyperRenderDebugHooks.onLineLayoutTextPainter = null; + }); + + testWidgets('the bounded breaker shapes a bounded amount per line', + (tester) async { + // break-all keeps this off the single-pass native path, so it measures + // candidate prefixes line by line. + final text = 'あいうえおかきくけこ' * 500; // 5,000 characters + final box = await _pump( + tester, + '

注$text

', + ); + final lines = box + .debugLineFragments() + .map((f) => f['lineIndex'] as int) + .toSet() + .length; + expect(lines, greaterThan(200)); + expect(lineLayouts, greaterThan(0), reason: 'hook should fire'); + // A constant number of candidate layouts per line, not a number that + // grows with the remaining text. + expect(lineLayouts, lessThanOrEqualTo(lines * 4 + 20)); + expect(layouts, greaterThanOrEqualTo(lineLayouts)); + }); + + testWidgets('hooks are silent when unset', (tester) async { + HyperRenderDebugHooks.onTextPainterLayout = null; + HyperRenderDebugHooks.onLineLayoutTextPainter = null; + final before = layouts + lineLayouts; + await _pump(tester, '

${'word ' * 200}

'); + expect(layouts + lineLayouts, before); + }); + + testWidgets('intrinsic measurement of a long paragraph is bounded', + (tester) async { + final box = await _pump( + tester, + '
x ${'あいうえお' * 400}
', + ); + layouts = 0; + final max = box.getMaxIntrinsicWidth(double.infinity); + expect(max.isFinite, isTrue); + expect(layouts, lessThan(2000)); + }); + }); +} diff --git a/test/fuzz/layout_fuzz_test.dart b/test/fuzz/layout_fuzz_test.dart new file mode 100644 index 0000000..f984b58 --- /dev/null +++ b/test/fuzz/layout_fuzz_test.dart @@ -0,0 +1,100 @@ +// Layout fuzz: the parser fuzz suite only parses, so a hang or crash in the +// line breaker would never surface there. This lays mutated documents out at +// awkward widths (zero, one pixel, a prime, and a normal one) and asserts the +// render object finishes with a finite size and throws nothing. +// +// Same fixed-seed approach as parser_fuzz_test.dart: a failing mutant is +// printed and should be pinned as its own regression test. +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +const _longWord = 'Supercalifragilisticexpialidocious'; +const _cjk = '日本語のテキストは行末で適切に折り返される必要があります。'; + +const _seeds = [ + '

Hello world, $_longWord and a link that wraps.

', + '

$_cjk$_cjk$_cjk

', + '

注意:$_cjk$_cjk Latin words after CJK text follow.

', + '

هذا نص عربي طويل يجب أن يلتف بشكل صحيح

', + '
' + '

$_longWord $_longWord text beside a float $_cjk

', + '

$_longWord $_longWord

', + '

centered inline text that wraps over lines

', + '
  • $_longWord item
  • $_cjk
', + '
$_cjkx $_longWord
', + '
$_longWord $_cjk
', +]; + +typedef _Mutator = String Function(String input, Random rng); + +String _delete(String s, Random rng) { + if (s.length < 2) return s; + final i = rng.nextInt(s.length); + return s.replaceRange(i, i + 1, ''); +} + +String _insert(String s, Random rng) { + const chars = '<>"\'&/{}  ​注ا'; + final i = rng.nextInt(s.length + 1); + return s.replaceRange(i, i, chars[rng.nextInt(chars.length)]); +} + +String _duplicate(String s, Random rng) { + if (s.length < 4) return s; + final start = rng.nextInt(s.length - 2); + final end = start + 1 + rng.nextInt(min(60, s.length - start - 1)); + return s.replaceRange(start, start, s.substring(start, end)); +} + +String _truncate(String s, Random rng) => + s.isEmpty ? s : s.substring(0, rng.nextInt(s.length)); + +const List<_Mutator> _mutators = [_delete, _insert, _duplicate, _truncate]; + +String _mutate(String seed, Random rng) { + var out = seed; + for (var i = 0; i < 3; i++) { + out = _mutators[rng.nextInt(_mutators.length)](out, rng); + } + return out; +} + +void main() { + final rng = Random(20261008); + final mutants = [ + ..._seeds, + for (final seed in _seeds) + for (var i = 0; i < 4; i++) _mutate(seed, rng), + ]; + + for (final width in const [0.0, 1.0, 37.0, 300.0]) { + testWidgets('layout at ${width}px never throws and stays finite', + (tester) async { + for (final html in mutants) { + final doc = HtmlAdapter().parse(html); + StyleResolver().resolveStyles(doc); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + child: SingleChildScrollView( + child: HyperRenderWidget(document: doc), + ), + ), + ), + )); + expect(tester.takeException(), isNull, reason: html); + final box = tester.renderObject( + find.byType(HyperRenderWidget).first, + ); + expect(box.size.width.isFinite, isTrue, reason: html); + expect(box.size.height.isFinite, isTrue, reason: html); + expect(box.size.height, greaterThanOrEqualTo(0), reason: html); + } + }); + } +} diff --git a/test/integration/README.md b/test/integration/README.md index 7c86678..8c34514 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -1,63 +1,30 @@ -# Integration Tests +# Integration tests -This directory contains integration tests for HyperRender that test real-world scenarios, performance, and cross-cutting concerns. +Tests that exercise several layers together on realistic content. The full +map of test kinds, commands and conventions is in +[`doc/TESTING.md`](../../doc/TESTING.md). -## Test Categories +## Files -### 1. Real-World Content Tests -- `real_world_html_test.dart` - News articles, blog posts, documentation -- `markdown_integration_test.dart` - Complete Markdown documents -- `delta_integration_test.dart` - Quill Delta documents +| Area | Files | +|---|---| +| Real-world content | `real_world_html_test.dart` (news, blog, docs samples from `../fixtures/integration/`) | +| Large documents | `large_document_test.dart`, `cjk_stress_test.dart` | +| Selection | `selection_integration_test.dart` | +| Lifecycle and resources | `lifecycle_stability_test.dart`, `resource_management_test.dart`, `error_recovery_test.dart` | +| Security and accessibility | `security_integration_test.dart`, `advanced_security_test.dart`, `accessibility_test.dart`, `security_accessibility_integration_test.dart` | +| Rendering modes | `dark_mode_hardening_test.dart` | +| Animation | `animation_v150_integration_test.dart`, `animation_v150_performance_test.dart`, `animation_v150_stress_test.dart` | +| Performance | `performance_benchmarks_test.dart`, `performance_regression_test.dart` | -### 2. Performance Tests -- `performance_regression_test.dart` - Ensure performance doesn't degrade -- `large_document_test.dart` - 100K+ character documents -- `memory_test.dart` - Memory leak detection +Fixtures: `../fixtures/integration/` (`news_article.html`, `blog_post.html`, +`documentation.html`, `large_document.html`, `complex_table.html`, +`float_layout.html`, `cjk_content.html`). -### 3. Error Recovery Tests -- `error_recovery_test.dart` - Malformed HTML, missing resources, edge cases -- `security_test.dart` - XSS attack vectors, sanitization - -### 4. Feature Integration Tests -- `selection_integration_test.dart` - Complex selection scenarios -- `float_integration_test.dart` - Float layout with real content -- `table_integration_test.dart` - Complex table rendering - -## Running Tests +## Running ```bash -# Run all integration tests flutter test test/integration/ - -# Run specific test file flutter test test/integration/real_world_html_test.dart - -# Run with coverage flutter test --coverage test/integration/ - -# Run in profile mode (for performance tests) -flutter test --profile test/integration/performance_regression_test.dart ``` - -## Test Data - -Real-world HTML samples are stored in `test/fixtures/integration/`: -- `news_article.html` - News article with images -- `blog_post.html` - Blog post with code blocks -- `documentation.html` - Technical documentation -- `large_document.html` - 100KB+ document - -## CI/CD Integration - -These tests can be run: -- On every PR (fast tests only) -- On merge to main (all tests) -- Nightly (performance + memory tests) - -## Performance Testing - -These tests help track performance over time: -- Parse time for various document sizes -- Memory usage during rendering -- Scroll performance for virtualized mode -- Selection latency diff --git a/test/line_breaking_stress_test.dart b/test/line_breaking_stress_test.dart new file mode 100644 index 0000000..9994674 --- /dev/null +++ b/test/line_breaking_stress_test.dart @@ -0,0 +1,290 @@ +// Stress, boundary and termination tests for the line breaker. +// +// The line breaker bounds how much text it shapes per line (for performance on +// very long paragraphs), which is exactly the kind of optimisation that breaks +// on inputs the happy-path tests never produce: zero-width boxes, padding wider +// than the container, floats wider than the container, glyphs narrower than the +// bound assumes, and shrink-to-fit parents asking for intrinsic sizes. +// +// Every test here must terminate (the default 30 s test timeout is the hang +// detector), throw nothing, and keep every character exactly once. +// +// Flutter's test font (Ahem) makes every glyph 1em wide, which hides any bug +// that depends on narrow glyphs. `textScaler` below 1 makes glyphs narrower +// without changing the em the layout bounds are computed from, so it is how +// those cases are reached. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +String _words(int count) => List.generate( + count, + (i) => const [ + 'lorem', + 'ipsum', + 'dolor', + 'sit', + 'amet', + 'consectetur' + ][i % 6], + ).join(' '); + +const _cjk = '日本語のテキストは行末で適切に折り返される必要があります。'; +const _arabic = 'هذا نص عربي طويل يجب أن يلتف بشكل صحيح عبر عدة أسطر '; + +Future _pump( + WidgetTester tester, + String html, { + double width = 300, + double textScale = 1.0, +}) async { + await tester.pumpWidget(MaterialApp( + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScale)), + child: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SingleChildScrollView( + child: SizedBox( + width: width, + child: HyperViewer(html: html, mode: HyperRenderMode.sync), + ), + ), + ), + ), + ), + )); + await tester.pump(const Duration(milliseconds: 50)); + return tester.renderObject( + find.byType(HyperRenderWidget).first, + ); +} + +List> _textFragments(RenderHyperBox box) => + box.debugLineFragments().where((f) => f['type'] == 'text').toList(); + +/// All text on the lines, in order, with whitespace removed. +String _flatText(RenderHyperBox box) => _textFragments(box) + .map((f) => f['text'] as String) + .join() + .replaceAll(RegExp(r'\s+'), ''); + +String _squash(String s) => s.replaceAll(RegExp(r'\s+'), ''); + +void main() { + group('Narrow glyphs do not under-fill lines', () { + // 800px at 8px per glyph is 100 characters per line. A bound derived from + // the first 32 characters containing CJK (0.75em per glyph) used to cut + // the first line at about 83. + testWidgets('Latin text after a single CJK character', (tester) async { + final text = _words(120); + final box = await _pump( + tester, + '

注日本語 $text

', + width: 800, + textScale: 0.5, + ); + final lines = {}; + for (final f in _textFragments(box)) { + final line = f['lineIndex'] as int; + final right = (f['offsetX'] as double) + (f['width'] as double); + if (right > (lines[line] ?? 0)) lines[line] = right; + } + final ordered = lines.keys.toList()..sort(); + expect(ordered.length, greaterThan(2)); + // Every line but the last reaches within one long word (11 glyphs at + // 8px) of the right edge. + for (final line in ordered.take(ordered.length - 1)) { + expect(800 - lines[line]!, lessThan(11 * 8 + 1), + reason: 'line $line stops ${800 - lines[line]!}px short'); + } + }); + + testWidgets('Latin text beside a float', (tester) async { + final box = await _pump( + tester, + '
' + '

${_words(150)}

', + width: 800, + textScale: 0.5, + ); + expect(_flatText(box), _squash(_words(150))); + }); + }); + + group('No characters are lost or duplicated', () { + final samples = { + 'unspaced latin': 'x' * 2000, + 'unspaced CJK': _cjk * 40, + 'spaced latin': _words(400), + 'arabic': _arabic * 10, + 'mixed scripts': '${_words(20)} ${_cjk * 3} ${_words(20)}', + }; + for (final entry in samples.entries) { + for (final width in const [1.0, 37.0, 300.0, 5000.0]) { + testWidgets('${entry.key} at ${width.toInt()}px', (tester) async { + final box = + await _pump(tester, '

${entry.value}

', width: width); + expect(tester.takeException(), isNull); + expect(_flatText(box), _squash(entry.value)); + }); + } + } + + testWidgets('after an inline element', (tester) async { + final box = await _pump( + tester, + '

Note: ${_words(300)}

', + ); + expect(_flatText(box), _squash('Note: ${_words(300)}')); + }); + + testWidgets('CJK after an inline element', (tester) async { + final box = await _pump(tester, '

注意:${_cjk * 30}

'); + expect(_flatText(box), _squash('注意:${_cjk * 30}')); + }); + }); + + group('Terminates on degenerate geometry', () { + for (final width in const [0.0, 0.5, 1.0]) { + testWidgets('width $width', (tester) async { + for (final html in [ + '

${'x' * 500}

', + '

${_cjk * 20}

', + '

${_arabic * 5}

', + '

a ${_words(50)}

', + '

${_words(30)}

', + ]) { + await _pump(tester, html, width: width); + expect(tester.takeException(), isNull, reason: html); + } + }); + } + + testWidgets('padding wider than the container', (tester) async { + final box = await _pump( + tester, + '

${_cjk * 20}

', + ); + expect(tester.takeException(), isNull); + expect(_flatText(box), _squash(_cjk * 20)); + }); + + testWidgets('nested padding wider than the container', (tester) async { + final box = await _pump( + tester, + '
' + '

${_words(60)}

', + ); + expect(tester.takeException(), isNull); + expect(_flatText(box), _squash(_words(60))); + }); + + testWidgets('a float wider than the container, then unspaced CJK', + (tester) async { + final box = await _pump( + tester, + '
' + '

${_cjk * 20}

', + ); + expect(tester.takeException(), isNull); + expect(_flatText(box), _squash(_cjk * 20)); + }); + + testWidgets('floats on both sides leaving no room', (tester) async { + final box = await _pump( + tester, + '
' + '' + '

${_words(40)}

', + ); + expect(tester.takeException(), isNull); + expect(_flatText(box), _squash(_words(40))); + }); + }); + + group('Intrinsic sizes stay finite', () { + // A shrink-to-fit parent asks for intrinsic widths. A long tail after an + // inline element used to carry an estimated infinite width. + for (final entry in { + 'table cell': '
注 ${_cjk * 15}
', + 'table cell, Latin': '' + '
x ${_words(120)}
', + 'inline-block': '
注 ' + '${_cjk * 15}
', + 'plain paragraph': '

x ${_words(200)}

', + }.entries) { + testWidgets(entry.key, (tester) async { + await _pump(tester, entry.value); + expect(tester.takeException(), isNull); + final boxes = tester + .renderObjectList(find.byType(HyperRenderWidget)) + .toList(); + expect(boxes, isNotEmpty); + for (final box in boxes) { + for (final w in [0.0, 1.0, 100.0, 300.0, double.infinity]) { + final max = box.getMaxIntrinsicWidth(w); + final min = box.getMinIntrinsicWidth(w); + expect(max.isFinite, isTrue, reason: 'max intrinsic at $w'); + expect(min.isFinite, isTrue, reason: 'min intrinsic at $w'); + expect(min, lessThanOrEqualTo(max + 0.01)); + } + expect(box.size.width.isFinite, isTrue); + expect(box.size.height.isFinite, isTrue); + } + }); + } + }); + + group('Stress', () { + testWidgets('one 200,000-character unspaced CJK paragraph', (tester) async { + final text = (_cjk * 8000).substring(0, 200000); + final stopwatch = Stopwatch()..start(); + final box = await _pump(tester, '

$text

'); + stopwatch.stop(); + expect(tester.takeException(), isNull); + final lines = _textFragments(box).map((f) => f['lineIndex']).toSet(); + // 18 glyphs fit a 300px line at the test font size. + expect(lines.length, inInclusiveRange(200000 ~/ 19, 200000 ~/ 17 + 50)); + // Generous ceiling: a quadratic breaker needs minutes here, a linear one + // a couple of seconds. This is a hang guard, not a benchmark. + expect(stopwatch.elapsed, lessThan(const Duration(seconds: 60))); + }); + + // `word-break: break-all` keeps the text off the single-pass native path + // (which would make this fast whatever the candidate bound is), so every + // line goes through the bounded fallback breaker. Without the bound on the + // candidate prefix each line re-shapes the whole remaining paragraph: + // quadratic. That blocks the test isolate, so a regression shows up as the + // run hanging until the job timeout, not as a clean failure. With the + // bound it takes a couple of seconds. + testWidgets('100,000 unspaced characters on the bounded fallback path', + (tester) async { + final text = (_cjk * 4000).substring(0, 100000); + final box = await _pump( + tester, + '

注意:$text

', + ); + expect(tester.takeException(), isNull); + final lines = _textFragments(box).map((f) => f['lineIndex']).toSet(); + expect(lines.length, inInclusiveRange(100000 ~/ 19, 100000 ~/ 17 + 50)); + }); + + testWidgets('50,000 spaced Latin characters after an inline element', + (tester) async { + final text = _words(8000); + final box = await _pump(tester, '

Note: $text

'); + expect(tester.takeException(), isNull); + expect(_flatText(box), _squash('Note: $text')); + }); + + testWidgets('thousands of short inline runs in one paragraph', + (tester) async { + final html = '

${List.generate(1500, (i) => 'w$i ').join()}

'; + final box = await _pump(tester, html); + expect(tester.takeException(), isNull); + expect(_textFragments(box), isNotEmpty); + }); + }); +}