diff --git a/packages/hyper_render_core/CHANGELOG.md b/packages/hyper_render_core/CHANGELOG.md index 55f9345..65b6f44 100644 --- a/packages/hyper_render_core/CHANGELOG.md +++ b/packages/hyper_render_core/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — hyper_render_core +## Unreleased + +- **Block-tier plugins on custom tags now render.** ``, `` and any other tag without a UA display style are built by the HTML adapters as inline nodes, so a registered **block** plugin on them never took the block path: its widget was built, linked to no fragment, laid out at 0×0 and never painted, with "Layout Warning: More child widgets than fragments" in debug. Only tags that are already block (`figure`, `div`) worked. A registered block tag is now treated as a block whatever its node type. Existing tests passed because they hand-built `BlockNode`s or only asserted `findsOneWidget`, which a 0×0 widget satisfies. + ## 1.10.0 ### 🆕 New 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 d01f115..6f2e25f 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 @@ -106,6 +106,16 @@ extension _RenderHyperBoxLayout on RenderHyperBox { } void _handleInlineNode(UDTNode node) { + // A custom tag (``, ``) has no UA display style, so the + // adapters build it as an InlineNode even when its plugin is block-tier. + // Routing it through the inline path never emits a fragment for the plugin + // widget: the widget was built but never linked, laid out at 0x0 and never + // painted. A registered block tag is a block whatever its node type. + if (_blockPluginTags.isNotEmpty && + _blockPluginTags.contains(node.tagName?.toLowerCase())) { + _tokenizeBlockPlugin(node); + return; + } if (_inlinePluginTags.isNotEmpty && _inlinePluginTags.contains(node.tagName?.toLowerCase())) { _fragments.add(Fragment.atomic( diff --git a/test/block_plugin_custom_tag_test.dart b/test/block_plugin_custom_tag_test.dart new file mode 100644 index 0000000..0db3173 --- /dev/null +++ b/test/block_plugin_custom_tag_test.dart @@ -0,0 +1,207 @@ +// A block-tier plugin on a CUSTOM tag (``, ``), reached the +// documented way — HyperViewer(html:, pluginRegistry:). The adapters build a +// custom tag as an InlineNode (no UA display style), so the block-plugin path +// was never taken: the widget was built, linked to no fragment, laid out at +// 0x0 and never painted ("Layout Warning: More child widgets than fragments"). +// +// The older plugin tests constructed `BlockNode(tagName: 'figure')` by hand or +// used `findsOneWidget`, which a 0x0 widget satisfies — so none could see it. +// These assert size, position and pixels. +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +const _amber = Color(0xFFFFC107); + +class _Block implements HyperNodePlugin { + const _Block(this.tag, {this.height = 40}); + final String tag; + final double height; + @override + List get tagNames => [tag]; + @override + bool get isInline => false; + @override + Widget? buildWidget(UDTNode node, HyperPluginBuildContext ctx) => SizedBox( + key: ValueKey(tag), + height: height, + child: const ColoredBox(color: _amber), + ); +} + +Future<({List warnings, GlobalKey capture})> _pump( + WidgetTester t, + String html, + List plugins, { + HyperRenderMode mode = HyperRenderMode.sync, +}) async { + final warnings = []; + final reg = HyperPluginRegistry(); + for (final p in plugins) { + reg.register(p); + } + final key = GlobalKey(); + // flutter_test fails a test that leaves `debugPrint` replaced, and tearDown + // callbacks run after that check — so restore it here, not in addTearDown. + final old = debugPrint; + debugPrint = (m, {wrapWidth}) { + if (m != null && m.contains('More child widgets than fragments')) { + warnings.add(m); + } + }; + try { + await t.pumpWidget(MaterialApp( + home: Scaffold( + backgroundColor: Colors.white, + body: RepaintBoundary( + key: key, + child: SizedBox( + width: 400, + height: 400, + child: HyperViewer( + html: html, + pluginRegistry: reg, + mode: mode, + renderConfig: const HyperRenderConfig(useMicrotaskParsing: true), + ), + ), + ), + ), + )); + await t.pumpAndSettle(); + } finally { + debugPrint = old; + } + return (warnings: warnings, capture: key); +} + +/// Top offset of each text fragment, keyed by its text. +Map _textTops(WidgetTester t) { + RenderHyperBox? box; + void walk(RenderObject o) { + if (o is RenderHyperBox) box ??= o; + o.visitChildren(walk); + } + + walk(t.renderObject(find.byType(HyperRenderWidget).first)); + return { + for (final f in box!.debugFragments()) + if (f['text'] != null && f['offsetY'] != null) + (f['text'] as String).trim(): (f['offsetY'] as num).toDouble(), + }; +} + +Future _amberPixels(WidgetTester t, GlobalKey key) async => + (await t.runAsync(() async { + final b = key.currentContext!.findRenderObject() as RenderRepaintBoundary; + final d = (await (await b.toImage()).toByteData())!; + var n = 0; + for (var i = 0; i < d.lengthInBytes; i += 4) { + if (d.getUint8(i) == 255 && + d.getUint8(i + 1) == 193 && + d.getUint8(i + 2) == 7) { + n++; + } + } + return n; + }))!; + +void main() { + for (final html in [ + '

Before

After

', + '

Before

content the plugin owns

After

', + ]) { + testWidgets( + 'block plugin on a custom tag is sized, placed and painted: ' + '$html', (t) async { + final r = await _pump(t, html, [const _Block('x-a')]); + final f = find.byKey(const ValueKey('x-a')); + expect(f, findsOneWidget); + + // Full available width, its own height. + expect(t.getSize(f), const Size(400, 40)); + + // Between the two paragraphs, not stacked on top of either. Text is + // painted on the canvas, so read its position from the render object. + final y = _textTops(t); + final top = t.getTopLeft(f).dy; + expect(top, greaterThan(y['Before']!)); + expect(y['After']!, greaterThanOrEqualTo(top + 40 - 0.5)); + + expect(await _amberPixels(t, r.capture), 400 * 40, + reason: 'the plugin widget must actually paint'); + expect(r.warnings, isEmpty); + }); + } + + testWidgets('the plugin owns its children: their text is not also rendered', + (t) async { + await _pump( + t, '

Before

SECRET-CHILD', [const _Block('x-a')]); + expect( + find.textContaining('SECRET-CHILD', findRichText: true), findsNothing); + }); + + testWidgets('three adjacent block plugins stack, none overlapping', + (t) async { + await _pump(t, '', [ + const _Block('x-a'), + const _Block('x-b', height: 30), + const _Block('x-c', height: 20) + ]); + final ys = [ + for (final k in ['x-a', 'x-b', 'x-c']) + t.getTopLeft(find.byKey(ValueKey(k))).dy, + ]; + expect(ys[1], greaterThanOrEqualTo(ys[0] + 40 - 0.5)); + expect(ys[2], greaterThanOrEqualTo(ys[1] + 30 - 0.5)); + }); + + testWidgets('works in virtualized mode too', (t) async { + final r = await _pump( + t, '

Before

After

', [const _Block('x-a')], + mode: HyperRenderMode.virtualized); + expect(t.getSize(find.byKey(const ValueKey('x-a'))), const Size(400, 40)); + expect(r.warnings, isEmpty); + }); + + testWidgets( + 'an unregistered custom tag is unaffected (no widget, no warning)', + (t) async { + final r = await _pump(t, '

Before

inline text', []); + expect(find.byKey(const ValueKey('x-zzz')), findsNothing); + expect(r.warnings, isEmpty); + }); + + testWidgets('an INLINE plugin on a custom tag still flows with text', + (t) async { + final reg = HyperPluginRegistry()..register(const _InlineChip()); + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + child: HyperViewer( + html: '

Status: done

', + pluginRegistry: reg, + mode: HyperRenderMode.sync, + ), + ), + ), + )); + await t.pumpAndSettle(); + expect(find.byKey(const ValueKey('chip')), findsOneWidget); + expect(t.getSize(find.byKey(const ValueKey('chip'))).width, greaterThan(0)); + }); +} + +class _InlineChip implements HyperNodePlugin { + const _InlineChip(); + @override + List get tagNames => const ['x-chip']; + @override + bool get isInline => true; + @override + Widget? buildWidget(UDTNode node, HyperPluginBuildContext ctx) => + const SizedBox(key: ValueKey('chip'), width: 40, height: 16); +}