From 55dbd6e23c1b70283044c94e8ed36452a5408c81 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Tue, 20 Oct 2026 19:15:18 +0000 Subject: [PATCH] perf(dart/core): check composition against what an update changes Under a validation config that defers the graph checks, a batch whose graph check started from what the check before found is checked against only the composition edges it changes, as the Python and TypeScript cores do with their holding index: its own components against all their children, and each component outside the batch against the components of the batch it holds. The shortcut applies while only the default catalog declares constraints, and a batch it rejects is checked again against the whole surface, so the errors raised are unchanged. Adds tests comparing the incremental and whole-surface checks, and a performance-tagged timing test for a container whose catalog declares allowedChildren, receiving one child per message. Signed-off-by: Nan Yu --- dart/a2ui_core/CHANGELOG.md | 6 + .../lib/src/core/component_graph_bound.dart | 4 + .../lib/src/core/component_model.dart | 15 +- .../lib/src/processing/processor.dart | 42 ++- .../lib/src/validation/component_graph.dart | 206 ++++++++++--- .../test/composition_constraints_test.dart | 289 ++++++++++++++++++ .../test/composition_scaling_test.dart | 96 ++++++ ...ge_processor_scaling_conformance_test.dart | 55 +--- dart/a2ui_core/test/support/timing.dart | 69 +++++ 9 files changed, 672 insertions(+), 110 deletions(-) create mode 100644 dart/a2ui_core/test/composition_scaling_test.dart create mode 100644 dart/a2ui_core/test/support/timing.dart diff --git a/dart/a2ui_core/CHANGELOG.md b/dart/a2ui_core/CHANGELOG.md index 2905584f10..b078010501 100644 --- a/dart/a2ui_core/CHANGELOG.md +++ b/dart/a2ui_core/CHANGELOG.md @@ -5,6 +5,12 @@ - Extracted `ComponentGraphBound` for incremental `SurfaceComponentsModel` component graph checks and optimized its initial height traversal to linear time. +- Under a validation config that defers the graph checks, `MessageProcessor` + checks the `allowedParents` and `allowedChildren` constraints of an + `updateComponents` against only the edges it changes, so a container whose + catalog declares `allowedChildren`, receiving one child per message, is no + longer checked against all of its children each time. The errors raised are + unchanged. - Exported `checkPathsAndRecursion`, the check of data-model paths and nesting depth that `MessageProcessor` runs on each message under a validation config. Its new `grownFrom` argument takes an earlier form of diff --git a/dart/a2ui_core/lib/src/core/component_graph_bound.dart b/dart/a2ui_core/lib/src/core/component_graph_bound.dart index 447af87798..de5eeff15a 100644 --- a/dart/a2ui_core/lib/src/core/component_graph_bound.dart +++ b/dart/a2ui_core/lib/src/core/component_graph_bound.dart @@ -99,6 +99,10 @@ class ComponentGraphBound { /// The reference fields each type's references were read through. final Map _fields = {}; + /// The components that reference [id], held or not, in the graph this + /// bound describes. + Iterable parentsOf(String id) => _parents[id] ?? const {}; + void _setChildren( String id, String type, diff --git a/dart/a2ui_core/lib/src/core/component_model.dart b/dart/a2ui_core/lib/src/core/component_model.dart index 64a69f6735..4d9d13feb3 100644 --- a/dart/a2ui_core/lib/src/core/component_model.dart +++ b/dart/a2ui_core/lib/src/core/component_model.dart @@ -276,8 +276,9 @@ class SurfaceComponentsModel { void validateComponentsUpdate( List> components, [ ValidationConfig config = ValidationConfig.strict, - ]) => - checkComponentsUpdate(components, config, refFields); + ]) { + checkComponentsUpdate(components, config, refFields); + } /// [validateComponentsUpdate] with [refFields] in place of this model's, /// for a batch that draws on catalogs the model has not seen, and @@ -290,8 +291,13 @@ class SurfaceComponentsModel { /// shows the graph passes. Otherwise, or when it cannot show that, the /// whole graph is walked, so a batch the graph rejects fails as it would /// have without the shortcut. + /// + /// Returns what the check found about the graph with the batch applied + /// when it started from what the check before found, which means nothing + /// changed the model since the batch before was checked and applied, or + /// null when it walked the whole graph. @internal - void checkComponentsUpdate( + ComponentGraphBound? checkComponentsUpdate( List> components, ValidationConfig config, Map refFields, { @@ -342,7 +348,7 @@ class SurfaceComponentsModel { )) { _bound = bound; _boundBatch = components; - return; + return bound; } // Each component as its type and the map its references are read from. @@ -360,6 +366,7 @@ class SurfaceComponentsModel { _bound = ComponentGraphBound.of(candidate, refFields); _boundBatch = components; } + return null; } /// Reports that [components], the batch the last [checkComponentsUpdate] diff --git a/dart/a2ui_core/lib/src/processing/processor.dart b/dart/a2ui_core/lib/src/processing/processor.dart index c7ff8c86bb..210d896e84 100644 --- a/dart/a2ui_core/lib/src/processing/processor.dart +++ b/dart/a2ui_core/lib/src/processing/processor.dart @@ -18,6 +18,7 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../core/catalog.dart'; import '../core/common.dart'; +import '../core/component_graph_bound.dart'; import '../core/component_model.dart'; import '../core/messages.dart'; import '../core/renderer_capabilities.dart'; @@ -854,10 +855,11 @@ class MessageProcessor { catalogIds, ); final ValidationConfig? config = validationConfig; + ComponentGraphBound? bound; if (config == null) { checkDuplicateComponentIds(resolved); } else { - model.checkComponentsUpdate( + bound = model.checkComponentsUpdate( resolved, config, refFields, @@ -869,12 +871,42 @@ class MessageProcessor { // arrived yet is skipped, so this holds without a config too. None of the // catalogs the surface draws on may declare any, which is the common case // and leaves nothing to check. - if (!{ - if (surface.defaultCatalog case final catalog?) catalog.id, + final Catalog? defaultCatalog = + surface.defaultCatalog; + final Set ruled = { + if (defaultCatalog != null) defaultCatalog.id, for (final String? id in catalogIds) if (id != null) id, - }.any((String id) => _rulesOf(surface.resolveCatalog(id)).isNotEmpty)) { - return (resolved: resolved, refFields: refFields); + } + .where((String id) => _rulesOf(surface.resolveCatalog(id)).isNotEmpty) + .toSet(); + if (ruled.isEmpty) return (resolved: resolved, refFields: refFields); + // When the graph check started from what the check before found, the + // surface passed the check of the batch before, so only the edges this + // batch changes are checked. That needs constraints the surface passed: + // while only the default catalog declares any, they are its own, and + // every check before took its constraints first. A batch that fails is + // checked again below, for the error the whole surface raises. + if (bound != null && + defaultCatalog != null && + ruled.length == 1 && + ruled.single == defaultCatalog.id) { + final types = { + for (final Map c in resolved) + if ((c['id'], c['component']) + case (final String id, final String type)) + id: type, + }; + if (compositionHoldsAfter( + resolved, + refFields, + _rulesOf(defaultCatalog), + typeOf: (String id) => types[id] ?? model.get(id)?.type, + parentsOf: bound.parentsOf, + rootId: surface.rootId, + )) { + return (resolved: resolved, refFields: refFields); + } } final List> existing = [ for (final ComponentModel c in model.all) diff --git a/dart/a2ui_core/lib/src/validation/component_graph.dart b/dart/a2ui_core/lib/src/validation/component_graph.dart index 268904bab1..029b8794a2 100644 --- a/dart/a2ui_core/lib/src/validation/component_graph.dart +++ b/dart/a2ui_core/lib/src/validation/component_graph.dart @@ -109,59 +109,21 @@ void checkCompositionConstraints( case (final String id, final String type)) id: type, }; - String list(List names) => - '[${names.map((name) => "'$name'").join(', ')}]'; // [pointer] locates [component] in the message, or is null when the // component was already stored. void check(Map component, String? pointer) { - final Object? id = component['id']; - final Object? type = component['component']; - if (id is! String || type is! String) return; - - final List? rootParents = rules[type]?.allowedParents; - if (id == rootId && - rootParents != null && - !rootParents.contains(_surfaceParent)) { - throw A2uiValidationError( - "Component '$id' ($type) cannot be placed under parent " - "'$_surfaceParent' ($_surfaceParent). Allowed parents: " - '${list(rootParents)}.', - code: 'UNALLOWED_PARENT', - path: pointer, - surfaceId: surfaceId, - ); - } - - final List? allowedChildren = rules[type]?.allowedChildren; - for (final ComponentReference reference in _referencesOf( + if (_componentViolation( component, refFields, - )) { - final String? childType = types[reference.id]; - if (childType == null) continue; - final String? path = pointer == null - ? null - : '$pointer${referencePointer(reference.field)}'; - if (allowedChildren != null && !allowedChildren.contains(childType)) { - throw A2uiValidationError( - "Container '$id' ($type) cannot contain child '${reference.id}' " - '($childType). Allowed children: ${list(allowedChildren)}.', - code: 'UNALLOWED_CHILD', - path: path, - surfaceId: surfaceId, - ); - } - final List? allowedParents = rules[childType]?.allowedParents; - if (allowedParents != null && !allowedParents.contains(type)) { - throw A2uiValidationError( - "Component '${reference.id}' ($childType) cannot be placed under " - "parent '$id' ($type). Allowed parents: ${list(allowedParents)}.", - code: 'UNALLOWED_PARENT', - path: path, - surfaceId: surfaceId, - ); - } + rules, + (String id) => types[id], + rootId: rootId, + pointer: pointer, + surfaceId: surfaceId, + ) + case final A2uiValidationError error) { + throw error; } } @@ -173,6 +135,156 @@ void checkCompositionConstraints( } } +/// Whether [checkCompositionConstraints] would pass on the surface +/// [components] leave behind, checking only the edges they change. +/// +/// This holds only when [checkCompositionConstraints] passed on the surface +/// before [components], under the same [rules]. [typeOf] gives the type of +/// each component the surface holds with [components] applied, and +/// [parentsOf] the components that reference an id there. +/// +/// A component of [components] is checked against the surface and all of +/// its children, as [checkCompositionConstraints] checks it. A component +/// outside [components] that references one of them, which holds it, keeps +/// its other children, so its edge to each component of [components] it +/// holds is checked and the rest are not. Every other edge is unchanged and +/// passed before. A container holding many children, receiving one per +/// message, is then checked against one child per message rather than all +/// of them. +bool compositionHoldsAfter( + List> components, + Map refFields, + Map rules, { + required String? Function(String id) typeOf, + required Iterable Function(String id) parentsOf, + String rootId = _rootComponentId, +}) { + if (rules.isEmpty) return true; + final ids = { + for (final component in components) + if (component['id'] case final String id) id, + }; + for (final component in components) { + final A2uiValidationError? error = _componentViolation( + component, + refFields, + rules, + typeOf, + rootId: rootId, + ); + if (error != null) return false; + } + for (final id in ids) { + final String? type = typeOf(id); + if (type == null) continue; + for (final String holder in parentsOf(id)) { + if (ids.contains(holder)) continue; + final String? holderType = typeOf(holder); + if (holderType == null) continue; + if (_edgeViolation(holder, holderType, id, type, rules) != null) { + return false; + } + } + } + return true; +} + +String _typeList(List names) => + '[${names.map((name) => "'$name'").join(', ')}]'; + +/// The first composition error of [component]: the surface as the parent of +/// the root, then each of its references whose child [typeOf] knows, in +/// order. Null when there is none. +/// +/// [pointer] locates [component] in the message, or is null when the +/// component was already stored. +A2uiValidationError? _componentViolation( + Map component, + Map refFields, + Map rules, + String? Function(String id) typeOf, { + required String rootId, + String? pointer, + String? surfaceId, +}) { + final Object? id = component['id']; + final Object? type = component['component']; + if (id is! String || type is! String) return null; + + final List? rootParents = rules[type]?.allowedParents; + if (id == rootId && + rootParents != null && + !rootParents.contains(_surfaceParent)) { + return A2uiValidationError( + "Component '$id' ($type) cannot be placed under parent " + "'$_surfaceParent' ($_surfaceParent). Allowed parents: " + '${_typeList(rootParents)}.', + code: 'UNALLOWED_PARENT', + path: pointer, + surfaceId: surfaceId, + ); + } + + for (final ComponentReference reference in _referencesOf( + component, + refFields, + )) { + final String? childType = typeOf(reference.id); + if (childType == null) continue; + if (_edgeViolation( + id, + type, + reference.id, + childType, + rules, + path: pointer == null + ? null + : '$pointer${referencePointer(reference.field)}', + surfaceId: surfaceId, + ) + case final A2uiValidationError error) { + return error; + } + } + return null; +} + +/// The composition error of the edge from [parent] to [child], checking the +/// children [parentType] allows and then the parents [childType] allows. +/// Null when there is none. +A2uiValidationError? _edgeViolation( + String parent, + String parentType, + String child, + String childType, + Map rules, { + String? path, + String? surfaceId, +}) { + final List? allowedChildren = rules[parentType]?.allowedChildren; + if (allowedChildren != null && !allowedChildren.contains(childType)) { + return A2uiValidationError( + "Container '$parent' ($parentType) cannot contain child '$child' " + '($childType). Allowed children: ${_typeList(allowedChildren)}.', + code: 'UNALLOWED_CHILD', + path: path, + surfaceId: surfaceId, + ); + } + final List? allowedParents = rules[childType]?.allowedParents; + if (allowedParents != null && !allowedParents.contains(parentType)) { + return A2uiValidationError( + "Component '$child' ($childType) cannot be placed under " + "parent '$parent' ($parentType). Allowed parents: " + '${_typeList(allowedParents)}.', + code: 'UNALLOWED_PARENT', + path: path, + surfaceId: surfaceId, + ); + } + return null; +} + /// Matches a JSON Pointer as A2UI writes data-model paths, allowing the /// leading slash to be omitted. final RegExp _pathPattern = RegExp( diff --git a/dart/a2ui_core/test/composition_constraints_test.dart b/dart/a2ui_core/test/composition_constraints_test.dart index 4a2b7cc11c..bf976120e7 100644 --- a/dart/a2ui_core/test/composition_constraints_test.dart +++ b/dart/a2ui_core/test/composition_constraints_test.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'dart:math'; + import 'package:a2ui_core/a2ui_core.dart'; import 'package:a2ui_core/src/validation/component_graph.dart'; import 'package:a2ui_core/src/validation/component_refs.dart'; @@ -424,4 +426,291 @@ void main() { ); }); }); + + group('MessageProcessor composition constraints under a deferred config', () { + // Each batch after the first is checked against only the edges it + // changes. A second processor checks each batch against the whole + // surface, and the two must agree on every message. + final configs = { + 'missing root allowed': const ValidationConfig( + allowDanglingReferences: true, + allowOrphanComponents: true, + allowMissingRoot: true, + ), + 'orphans allowed': const ValidationConfig( + allowDanglingReferences: true, + allowOrphanComponents: true, + ), + }; + + Map component( + String id, + String type, [ + List? children, + ]) => + { + 'id': id, + 'component': type, + if (children != null) 'children': children, + }; + + for (final MapEntry(key: String name, value: ValidationConfig config) + in configs.entries) { + group(name, () { + test('rejects a child that arrives after its container', () { + final p = _DeferredPair(config); + p.expectSame([ + component('root', 'Strict', ['a']), + ]); + expect( + p.expectSame([component('a', 'Card')]), + _violation('UNALLOWED_CHILD', path: null), + ); + }); + + test('rejects a container that arrives after its child', () { + final p = _DeferredPair(config); + p.expectSame([component('root', 'Box')]); + p.expectSame([component('a', 'Card')]); + expect( + p.expectSame([ + component('b', 'Strict', ['a']), + ]), + _violation('UNALLOWED_CHILD', path: '/components/0/children/0'), + ); + }); + + test('rejects a child added to a container in a later message', () { + final p = _DeferredPair(config); + p.expectSame([ + component('root', 'Strict', ['a']), + ]); + p.expectSame([component('a', 'Item')]); + p.expectSame([component('b', 'Card')]); + expect( + p.expectSame([ + component('root', 'Strict', ['a', 'b']), + ]), + _violation('UNALLOWED_CHILD', path: '/components/0/children/1'), + ); + }); + + test('accepts a container rewritten to drop a child it disallows', () { + final p = _DeferredPair(config); + p.expectSame([ + component('root', 'Box', ['a', 'h']), + ]); + p.expectSame([component('a', 'Item'), component('h', 'Header')]); + expect( + p.expectSame([ + component('root', 'Strict', ['a', 'h']), + ]), + _violation('UNALLOWED_CHILD', path: '/components/0/children/1'), + ); + expect( + p.expectSame([ + component('root', 'Strict', ['a']), + ]), + isNull, + ); + // `h` no longer has a parent, and may be held by a `Box` again. + expect( + p.expectSame([ + component('b', 'Box', ['h']), + ]), + isNull, + ); + }); + + test('rejects a held child whose type changes', () { + final p = _DeferredPair(config); + p.expectSame([ + component('root', 'Strict', ['a']), + component('a', 'Item'), + ]); + expect( + p.expectSame([component('a', 'Header')]), + _violation('UNALLOWED_CHILD', path: null), + ); + }); + + test('rejects a child under a holder its allowedParents omits', () { + final p = _DeferredPair(config); + p.expectSame([ + component('root', 'Box', ['c']), + {'id': 'c', 'component': 'Card', 'child': 'x'}, + ]); + expect( + p.expectSame([component('x', 'Header')]), + _violation('UNALLOWED_PARENT', path: null), + ); + }); + + test('rejects a root its allowedParents does not let the surface hold', + () { + final p = _DeferredPair(config); + p.expectSame([ + component('a', 'Box', ['root']), + ]); + expect( + p.expectSame([component('root', 'Header')]), + _violation('UNALLOWED_PARENT', path: '/components/0'), + ); + }); + + test('agrees with the whole-surface check on random batches', () { + const ids = ['root', 'a', 'b', 'c', 'd', 'e']; + const types = ['Box', 'Strict', 'Card', 'Item', 'Header']; + for (var seed = 0; seed < 200; seed++) { + final random = Random(seed); + final p = _DeferredPair(config); + for (var step = 0; step < 20; step++) { + final batch = >{}; + for (int k = 1 + random.nextInt(3); k > 0; k--) { + final String id = ids[random.nextInt(ids.length)]; + final String type = types[random.nextInt(types.length)]; + batch[id] = { + 'id': id, + 'component': type, + if (type == 'Box' || type == 'Strict') + 'children': (ids.toList()..shuffle(random)) + .take(random.nextInt(4)) + .toList(), + if (type == 'Card') 'child': ids[random.nextInt(ids.length)], + }; + } + p.expectSame( + batch.values.toList(), + reason: 'seed $seed, step $step', + ); + } + } + }); + }); + } + }); +} + +/// A catalog for [_DeferredPair], declaring both kinds of composition +/// constraint. +final Catalog _deferredCatalog = + Catalog( + id: 'custom', + protocolVersion: A2uiProtocolVersion.v1_0, + components: Catalog.fromJson({ + 'catalogId': 'custom', + 'protocolVersion': 'v1.0', + 'components': { + 'Box': { + 'type': 'object', + 'properties': { + 'children': {r'$ref': _childListRef}, + }, + }, + 'Strict': { + 'type': 'object', + 'allowedChildren': ['Item', 'Box'], + 'properties': { + 'children': {r'$ref': _childListRef}, + }, + }, + 'Card': { + 'type': 'object', + 'properties': { + 'child': { + r'$ref': 'https://a2ui.org/specification/v1_0/common_types.json' + '#/\$defs/ComponentId', + }, + }, + }, + 'Item': { + 'type': 'object', + 'allowedParents': ['Strict', 'Box', 'Surface'], + }, + 'Header': { + 'type': 'object', + 'allowedParents': ['Box'], + }, + }, + }).components.values.toList(), +); + +/// Two processors under the same deferred [ValidationConfig], each holding +/// surface `s1`. +/// +/// The first checks each batch against what the check of the batch before +/// found. The second has a component of its surface touched after each +/// batch, so it never starts from what the check before found, and checks +/// each batch against the whole surface. +class _DeferredPair { + _DeferredPair(ValidationConfig config) + : incremental = _processor(config), + whole = _processor(config); + + final MessageProcessor incremental; + final MessageProcessor whole; + + static MessageProcessor _processor(ValidationConfig config) => + MessageProcessor( + catalogs: [_deferredCatalog], + defaultVersion: A2uiProtocolVersion.v1_0, + validationConfig: config, + )..processMessages([ + { + 'version': 'v1.0', + 'createSurface': {'surfaceId': 's1', 'catalogId': 'custom'}, + }, + ]); + + /// Sends [components] to both processors, expects the same outcome and the + /// same components from both, and returns the error both raised, or null. + A2uiError? expectSame( + List> components, { + String? reason, + }) { + final A2uiError? expected = _send(whole, components); + final A2uiError? actual = _send(incremental, components); + final context = '${reason ?? ''} batch $components'; + expect(_describe(actual), _describe(expected), reason: context); + expect(_snapshot(incremental), _snapshot(whole), reason: context); + if (expected == null) { + final ComponentModel first = + whole.model.getSurface('s1')!.componentsModel.all.first; + first.properties = first.properties; + } + return actual; + } + + static A2uiError? _send( + MessageProcessor processor, + List> components, + ) { + try { + processor.processMessages([ + { + 'version': 'v1.0', + 'updateComponents': {'surfaceId': 's1', 'components': components}, + }, + ]); + return null; + } on A2uiError catch (error) { + return error; + } + } + + static String? _describe(A2uiError? error) => switch (error) { + null => null, + final A2uiValidationError e => + '${e.runtimeType} ${e.code} ${e.path}: ${e.message}', + final e => '${e.runtimeType}: ${e.message}', + }; + + static Map _snapshot( + MessageProcessor processor, + ) => + { + for (final ComponentModel c + in processor.model.getSurface('s1')!.componentsModel.all) + c.id: c.toJson(), + }; } diff --git a/dart/a2ui_core/test/composition_scaling_test.dart b/dart/a2ui_core/test/composition_scaling_test.dart new file mode 100644 index 0000000000..247735af77 --- /dev/null +++ b/dart/a2ui_core/test/composition_scaling_test.dart @@ -0,0 +1,96 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Times the composition checks of [MessageProcessor.processMessages] under +/// a validation config that defers the graph checks, as an agent's renderer +/// state configures it. +/// +/// The tests are tagged `performance`, which `dart_test.yaml` skips unless +/// the `performance` preset is selected, as in `dart test -P performance`. +@Tags(['performance']) +library; + +import 'dart:convert'; + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'support/renderer_catalog.dart'; +import 'support/timing.dart'; + +/// A catalog whose `Column` holds only `Text` children. +final Catalog _catalog = rendererCatalog( + { + 'catalogId': 'allowed_children', + 'components': { + 'Column': { + 'type': 'object', + 'allowedChildren': ['Text'], + 'properties': { + 'children': {r'$ref': '#/\$defs/ChildList'}, + }, + }, + 'Text': { + 'type': 'object', + 'properties': { + 'text': {'type': 'string'}, + }, + }, + }, + }, + protocolVersion: 'v1.0', +); + +void main() { + test('checks each child once when children arrive one per message', () { + // `root` lists every child up front, and the children then arrive one + // per message. Each child is checked against `root` without reading the + // other children, so the time grows with the number of children rather + // than its square. + const Map testCase = { + 'payload': [ + '[{"version": "v1.0", "createSurface": {"surfaceId": "s1", ' + '"catalogId": "allowed_children"}}, ' + '{"version": "v1.0", "updateComponents": {"surfaceId": "s1", ' + '"components": [{"id": "root", "component": "Column", ' + '"children": [', + {'repeat': '"item_{i}"', 'separator': ', '}, + ']}]}}, ', + { + 'repeat': '{"version": "v1.0", "updateComponents": ' + '{"surfaceId": "s1", "components": [{"id": "item_{i}", ' + '"component": "Text", "text": "Item number {i}"}]}}', + 'separator': ', ', + }, + ']', + ], + }; + + /// Processes the payload at [size] on a new processor. + void Function() process(int size) { + final Object? messages = jsonDecode(writePayload(testCase, size)); + return () => MessageProcessor( + catalogs: [_catalog], + validationConfig: ValidationConfig.relaxed, + ).processMessages(messages); + } + + expectTimeRatio( + process(2000), + process(500), + 8, + what: 'processMessages at 2000 children against 500', + ); + }); +} diff --git a/dart/a2ui_core/test/conformance/message_processor_scaling_conformance_test.dart b/dart/a2ui_core/test/conformance/message_processor_scaling_conformance_test.dart index dde1212331..99747fe719 100644 --- a/dart/a2ui_core/test/conformance/message_processor_scaling_conformance_test.dart +++ b/dart/a2ui_core/test/conformance/message_processor_scaling_conformance_test.dart @@ -27,6 +27,7 @@ import 'package:a2ui_core/a2ui_core.dart'; import 'package:test/test.dart'; import '../support/renderer_catalog.dart'; +import '../support/timing.dart'; import 'conformance_harness.dart'; void main() { @@ -77,57 +78,3 @@ void _runCase(ConformanceTestCase testCase) { what: 'processMessages at $larger against $smaller', ); } - -/// The payload of a timing case written at [size]; see `PayloadTemplate` in -/// `conformance_schema.json`. -String writePayload(ConformanceTestCase testCase, int size) => [ - for (final Object? segment in testCase['payload']! as List) - switch (segment) { - final String text => text, - {'repeat': final String text} => [ - for (var i = 0; i < size; i++) text.replaceAll('{i}', '$i'), - ].join((segment as Map)['separator'] as String? ?? ''), - _ => throw ArgumentError.value(segment, 'segment'), - }, - ].join(); - -/// How many times each call is timed. -const int _runs = 5; - -/// Expects [measured] to take at most [maxRatio] times as long as -/// [baseline], comparing the median of [_runs] timed runs of each, made in -/// turn after one untimed run of each. -/// -/// [what] names the two calls in the failure message. -void expectTimeRatio( - void Function() measured, - void Function() baseline, - num maxRatio, { - required String what, -}) { - baseline(); - measured(); - final baselineTimes = []; - final measuredTimes = []; - for (var run = 0; run < _runs; run++) { - baselineTimes.add(_time(baseline)); - measuredTimes.add(_time(measured)); - } - final int measuredMedian = _median(measuredTimes); - final int baselineMedian = _median(baselineTimes); - expect( - measuredMedian / baselineMedian, - lessThanOrEqualTo(maxRatio), - reason: 'Median time of $what: $measuredMedian µs against ' - '$baselineMedian µs.', - ); -} - -/// The time [body] takes, in microseconds. -int _time(void Function() body) { - final watch = Stopwatch()..start(); - body(); - return watch.elapsedMicroseconds; -} - -int _median(List times) => (times.toList()..sort())[times.length ~/ 2]; diff --git a/dart/a2ui_core/test/support/timing.dart b/dart/a2ui_core/test/support/timing.dart new file mode 100644 index 0000000000..b3296ef022 --- /dev/null +++ b/dart/a2ui_core/test/support/timing.dart @@ -0,0 +1,69 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:test/test.dart'; + +/// The payload of a timing case written at [size]; see `PayloadTemplate` in +/// `conformance_schema.json`. +String writePayload(Map testCase, int size) => [ + for (final Object? segment in testCase['payload']! as List) + switch (segment) { + final String text => text, + {'repeat': final String text} => [ + for (var i = 0; i < size; i++) text.replaceAll('{i}', '$i'), + ].join((segment as Map)['separator'] as String? ?? ''), + _ => throw ArgumentError.value(segment, 'segment'), + }, + ].join(); + +/// How many times each call is timed. +const int _runs = 5; + +/// Expects [measured] to take at most [maxRatio] times as long as +/// [baseline], comparing the median of [_runs] timed runs of each, made in +/// turn after one untimed run of each. +/// +/// [what] names the two calls in the failure message. +void expectTimeRatio( + void Function() measured, + void Function() baseline, + num maxRatio, { + required String what, +}) { + baseline(); + measured(); + final baselineTimes = []; + final measuredTimes = []; + for (var run = 0; run < _runs; run++) { + baselineTimes.add(_time(baseline)); + measuredTimes.add(_time(measured)); + } + final int measuredMedian = _median(measuredTimes); + final int baselineMedian = _median(baselineTimes); + expect( + measuredMedian / baselineMedian, + lessThanOrEqualTo(maxRatio), + reason: 'Median time of $what: $measuredMedian µs against ' + '$baselineMedian µs.', + ); +} + +/// The time [body] takes, in microseconds. +int _time(void Function() body) { + final watch = Stopwatch()..start(); + body(); + return watch.elapsedMicroseconds; +} + +int _median(List times) => (times.toList()..sort())[times.length ~/ 2];