From 91907311124d8b6e1dd4f353b67d03a79a16a62c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Sat, 10 Oct 2026 14:25:40 +0700 Subject: [PATCH] fix(core): size line-break candidates from the actual text; pre-release review for 1.13.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line breaking - _safeCandidateCharLimit guessed the bound from whether the first 32 characters contained CJK and ignored the text scaler, so a Latin run after a single CJK character (e.g. `注日本語 english …`) was cut at ~83 characters and the line ended up to 18 glyphs short. It now walks the text from the break position, adding each character's narrowest width (CJK 1em, other 0.25em, scaled by the text scaler). Removes the unused _isLeadingCjk. Tests - line_breaking_stress_test: narrow glyphs, no character lost or duplicated, zero/1px widths, padding and floats wider than the container, intrinsic sizes in table cells and inline-blocks, 200,000-character paragraphs and a 100,000-character paragraph on the bounded fallback path. - fuzz/layout_fuzz_test: lays mutated documents out at 0, 1, 37 and 300 px. - char_range_and_hooks_test: ruby ranges, the ellipsis-only line, and the TextPainter layout hooks as a clock-free per-line bound. Docs - CSS matrix: word-break keep-all is parsed but not applied (was listed as supported); add overflow-wrap; drop stale line-number references. - ROADMAP: 1.13.0 is a release candidate until published. - New doc/TESTING.md; rewrite test/integration/README.md, which listed six files that do not exist; refresh stale version stamps. --- ROADMAP.md | 2 +- doc/CSS_PROPERTIES_MATRIX.md | 5 +- doc/LIMITATIONS.md | 2 +- doc/README.md | 3 +- doc/ROADMAP.md | 4 +- doc/TESTING.md | 70 +++++ .../lib/src/core/render_hyper_box_layout.dart | 62 ++-- .../lib/src/style/resolver.dart | 4 +- test/char_range_and_hooks_test.dart | 144 +++++++++ test/fuzz/layout_fuzz_test.dart | 100 ++++++ test/integration/README.md | 71 ++--- test/line_breaking_stress_test.dart | 290 ++++++++++++++++++ 12 files changed, 665 insertions(+), 92 deletions(-) create mode 100644 doc/TESTING.md create mode 100644 test/char_range_and_hooks_test.dart create mode 100644 test/fuzz/layout_fuzz_test.dart create mode 100644 test/line_breaking_stress_test.dart 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); + }); + }); +}