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];