[fuchsia_inspect] Create matchers for testing.
Change-Id: I6fd0cde441f2666c60749492e8192c017f56bf5a
diff --git a/public/dart/fuchsia_inspect/BUILD.gn b/public/dart/fuchsia_inspect/BUILD.gn
index c932edc..230d722 100644
--- a/public/dart/fuchsia_inspect/BUILD.gn
+++ b/public/dart/fuchsia_inspect/BUILD.gn
@@ -18,6 +18,8 @@
"src/inspect/internal/_inspect_impl.dart",
"src/inspect/node.dart",
"src/inspect/property.dart",
+ "src/testing/matcher.dart",
+ "src/testing/util.dart",
"src/vmo/bitfield64.dart",
"src/vmo/block.dart",
"src/vmo/heap.dart",
@@ -26,9 +28,12 @@
"src/vmo/vmo_fields.dart",
"src/vmo/vmo_holder.dart",
"src/vmo/vmo_writer.dart",
+ "testing.dart",
]
deps = [
+ "//third_party/dart-pkg/pub/collection",
+ "//third_party/dart-pkg/pub/matcher",
"//third_party/dart-pkg/pub/meta",
"//topaz/public/dart/fuchsia_services",
"//topaz/public/dart/fuchsia_vfs",
diff --git a/public/dart/fuchsia_inspect/examples/inspect_mod/test/inspect_mod_test.dart b/public/dart/fuchsia_inspect/examples/inspect_mod/test/inspect_mod_test.dart
index d941c91..20d8cda 100644
--- a/public/dart/fuchsia_inspect/examples/inspect_mod/test/inspect_mod_test.dart
+++ b/public/dart/fuchsia_inspect/examples/inspect_mod/test/inspect_mod_test.dart
@@ -14,11 +14,10 @@
import 'package:fuchsia_services/services.dart';
import 'package:glob/glob.dart';
import 'package:test/test.dart';
-import 'package:test_vmo_reader/vmo_reader.dart' show VmoReader;
+import 'package:fuchsia_inspect/testing.dart' show VmoMatcher, hasNoErrors;
// ignore: implementation_imports
import 'util.dart';
-
const Pattern _testAppName = 'inspect_mod.cmx';
const _testAppUrl = 'fuchsia-pkg://fuchsia.com/inspect_mod#meta/$_testAppName';
const _modularTestHarnessURL =
@@ -107,7 +106,7 @@
await storyPuppetMasterProxy.execute();
}
-Future<String> _readInspect() async {
+Future<FakeVmoReader> _readInspect() async {
// WARNING: 0) These paths are extremely fragile.
var globs = [
// TODO(vickiecheng): remove this one once stories reuse session envs.
@@ -128,7 +127,7 @@
vmoData.setUint8(i, vmoBytes[i]);
}
var vmo = FakeVmoReader.usingData(vmoData);
- return VmoReader(vmo).toString();
+ return vmo;
}
}
}
@@ -166,7 +165,7 @@
testHarnessController.ctrl.close();
});
- Future<String> tapAndWait(String buttonName, String nextState) async {
+ Future<FakeVmoReader> tapAndWait(String buttonName, String nextState) async {
await driver.tap(find.text(buttonName));
await driver.waitFor(find.byValueKey(nextState));
return await _readInspect();
@@ -175,61 +174,91 @@
test('Put the program through its paces', () async {
// Wait for initial StateBloc value to appear
await driver.waitFor(find.byValueKey('Program has started'));
- var inspect = await _readInspect();
- expect('<> >> IntProperty "interesting": 118', isIn(inspect));
- expect('<> >> DoubleProperty "double down": 3.23', isIn(inspect));
- expect('<> >> ByteDataProperty "bytes": 01 02 03 04', isIn(inspect));
- expect('<> >> StringProperty "greeting": "Hello World"', isIn(inspect));
- expect('<> >> Node: "home-page"', isIn(inspect));
- expect('<> >> >> IntProperty "counter": 0', isIn(inspect));
- expect('<> >> >> StringProperty "background-color": "Color(0xffffffff)"',
- isIn(inspect));
- expect('<> >> >> StringProperty "title": "Hello Inspect!"', isIn(inspect));
+ var matcher = VmoMatcher(await _readInspect());
- // Tap the "Increment counter" button
- inspect = await tapAndWait('Increment counter', 'Counter was incremented');
- expect('IntProperty "counter": 0', isNot(isIn(inspect)));
- expect('IntProperty "counter": 1', isIn(inspect));
+ expect(
+ matcher.node()
+ ..propertyEquals('interesting', 118)
+ ..propertyEquals('double down', 3.23)
+ ..propertyEquals('bytes', Uint8List.fromList([1, 2, 3, 4]))
+ ..propertyEquals('greeting', 'Hello World'),
+ hasNoErrors);
- // Tap the "Decrement counter" button
- inspect = await tapAndWait('Decrement counter', 'Counter was decremented');
- expect('IntProperty "counter": 1', isNot(isIn(inspect)));
- expect('IntProperty "counter": 0', isIn(inspect));
+ expect(
+ matcher.node().at(['home-page'])
+ ..propertyEquals('counter', 0)
+ ..propertyEquals('background-color', 'Color(0xffffffff)')
+ ..propertyEquals('title', 'Hello Inspect!'),
+ hasNoErrors);
+
+ matcher = VmoMatcher(
+ await tapAndWait('Increment counter', 'Counter was incremented'));
+ expect(matcher.node().at(['home-page'])..propertyEquals('counter', 1),
+ hasNoErrors);
+
+ matcher = VmoMatcher(
+ await tapAndWait('Decrement counter', 'Counter was decremented'));
+ expect(matcher.node().at(['home-page'])..propertyEquals('counter', 0),
+ hasNoErrors);
// The node name below is truncated due to limitations of the maximum node
// name length.
- var preTreeInspect = inspect;
- inspect = await tapAndWait('Make tree', 'Tree was made');
+ matcher = VmoMatcher(await tapAndWait('Make tree', 'Tree was made'));
expect(
- '<> >> >> Node: "I think that I shall never see01234567890123456789012345"\n'
- '<> >> >> >> IntProperty "int0": 0',
- isIn(inspect));
+ matcher.node().at([
+ 'home-page',
+ 'I think that I shall never see01234567890123456789012345'
+ ])
+ ..propertyEquals('int0', 0),
+ hasNoErrors);
- inspect = await tapAndWait('Grow tree', 'Tree was grown');
- expect('<> >> >> >> IntProperty "int0": 0', isIn(inspect));
- expect('<> >> >> >> IntProperty "int1": 1', isIn(inspect));
-
- inspect = await tapAndWait('Delete tree', 'Tree was deleted');
- expect(inspect, preTreeInspect);
-
- inspect = await tapAndWait('Grow tree', 'Tree was grown');
- expect(inspect, preTreeInspect);
-
- inspect = await tapAndWait('Make tree', 'Tree was made');
+ matcher = VmoMatcher(await tapAndWait('Grow tree', 'Tree was grown'));
expect(
- '<> >> >> Node: "I think that I shall never see01234567890123456789012345"\n'
- '<> >> >> >> IntProperty "int3": 3',
- isIn(inspect));
+ matcher.node().at([
+ 'home-page',
+ 'I think that I shall never see01234567890123456789012345'
+ ])
+ ..propertyEquals('int0', 0)
+ ..propertyEquals('int1', 1),
+ hasNoErrors);
- inspect = await tapAndWait('Get answer', 'Waiting for answer');
- expect('>> StringProperty "waiting": "for a hint"', isIn(inspect));
+ matcher = VmoMatcher(await tapAndWait('Delete tree', 'Tree was deleted'));
+ expect(
+ matcher.node().at(['home-page'])
+ ..missingChild(
+ 'I think that I shall never see01234567890123456789012345'),
+ hasNoErrors);
- inspect = await tapAndWait('Give hint', 'Displayed answer');
- expect('>> StringProperty "waiting": "for a hint"', isNot(isIn(inspect)));
+ matcher = VmoMatcher(await tapAndWait('Grow tree', 'Tree was grown'));
+ expect(
+ matcher.node().at(['home-page'])
+ ..missingChild(
+ 'I think that I shall never see01234567890123456789012345'),
+ hasNoErrors);
- inspect = await tapAndWait('Change color', 'Color was changed');
- expect('<> >> >> StringProperty "background-color": "Color(0xffffffff)"',
- isNot(isIn(inspect)));
- expect('<> >> >> StringProperty "background-color": "', isIn(inspect));
+ matcher = VmoMatcher(await tapAndWait('Make tree', 'Tree was made'));
+ expect(
+ matcher.node().at([
+ 'home-page',
+ 'I think that I shall never see01234567890123456789012345'
+ ])
+ ..propertyEquals('int3', 3),
+ hasNoErrors);
+
+ matcher = VmoMatcher(await tapAndWait('Get answer', 'Waiting for answer'));
+ expect(
+ matcher.node().at(['home-page'])
+ ..propertyEquals('waiting', 'for a hint'),
+ hasNoErrors);
+
+ matcher = VmoMatcher(await tapAndWait('Give hint', 'Displayed answer'));
+ expect(
+ matcher.node().at(['home-page'])..missingChild('waiting'), hasNoErrors);
+
+ matcher = VmoMatcher(await tapAndWait('Change color', 'Color was changed'));
+ expect(
+ matcher.node().at(['home-page']).propertyNotEquals(
+ 'background-color', 'Color(0xffffffff)'),
+ hasNoErrors);
});
}
diff --git a/public/dart/fuchsia_inspect/lib/src/testing/matcher.dart b/public/dart/fuchsia_inspect/lib/src/testing/matcher.dart
new file mode 100644
index 0000000..00853dc
--- /dev/null
+++ b/public/dart/fuchsia_inspect/lib/src/testing/matcher.dart
@@ -0,0 +1,369 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:convert' show utf8;
+import 'dart:math' show min;
+import 'dart:typed_data';
+import 'package:collection/collection.dart';
+import 'package:matcher/matcher.dart';
+
+import 'package:fuchsia_inspect/src/vmo/block.dart';
+import 'package:fuchsia_inspect/src/vmo/vmo_fields.dart';
+import 'package:fuchsia_inspect/src/vmo/vmo_holder.dart';
+
+abstract class _HasErrors {
+ List<String> get errors;
+ void resetErrors();
+}
+
+/// A matcher for testing the structure of Inspect data written to a VMO.
+///
+/// This matcher aggregates errors from attempting to access or match
+/// values stored in the VMO. The set of aggregated errors may be taken
+/// and reset using the |errors| getter for the purposes of unit testing an
+/// Inspect hierarchy.
+///
+/// Note that this class is not optimized for efficiency, and should only
+/// be used in tests.
+class VmoMatcher implements _HasErrors {
+ final VmoHolder _holder;
+ List<String> _errors = [];
+
+ /// Create a new matcher that matches against the given VmoHolder.
+ VmoMatcher(this._holder);
+
+ /// Gets the list of errors.
+ @override
+ List<String> get errors => _errors;
+
+ /// Resets the recorded errors from this VmoMatcher.
+ @override
+ void resetErrors() {
+ _errors = [];
+ }
+
+ /// Retrieve the root node matcher, which can be used to match against
+ /// nested properties and children.
+ NodeMatcher node() {
+ if (Block.read(_holder, 1).type == BlockType.nodeValue) {
+ return NodeMatcher._valid(this, 1);
+ } else {
+ _addError('No root node found at index 1');
+ return NodeMatcher._invalid(this);
+ }
+ }
+
+ // Internal method to check if the given index is valid and can be
+ // safely read as a block.
+ bool _specifiesValidBlock(int index) {
+ if (index * bytesPerIndex + 16 > _holder.size) {
+ // Entire block header doesn't fit into the VMO.
+ return false;
+ }
+ var blockSize = Block.read(_holder, index).size;
+
+ // Ensure the entire block as specified fits into the VMO.
+ return index * bytesPerIndex + blockSize <= _holder.size;
+ }
+
+ // Internal method to aggregate errors.
+ void _addError(String s) {
+ _errors.add(s);
+ }
+
+ // Internal method to find the named child of a given parent index.
+ int _findNamedValue(String name, int parentIndex) {
+ for (int readPosBytes = 0; readPosBytes < _holder.size;) {
+ var index = readPosBytes ~/ bytesPerIndex;
+ var block = Block.read(_holder, index);
+ switch (block.type) {
+ case BlockType.nodeValue:
+ case BlockType.propertyValue:
+ case BlockType.intValue:
+ case BlockType.doubleValue:
+ if (block.parentIndex == parentIndex &&
+ _nameForBlock(block) == name) {
+ return index;
+ }
+ break;
+ default:
+ break;
+ }
+ readPosBytes += block.size;
+ }
+
+ return 0;
+ }
+
+ // Internal method to find and parse the name for a block.
+ String _nameForBlock(Block block) {
+ if (!_specifiesValidBlock(block.nameIndex)) {
+ return null;
+ }
+
+ var nameBlock = Block.read(_holder, block.nameIndex);
+ return _utf8ToString(nameBlock.nameUtf8);
+ }
+
+ ByteData _extentsToByteData(Block property) {
+ var payloadLength = property.propertyTotalLength;
+ var bytes = ByteData(payloadLength);
+ int amountCopied = 0;
+ for (Block extentBlock = Block.read(_holder, property.propertyExtentIndex);;
+ extentBlock = Block.read(property.vmo, extentBlock.nextExtent)) {
+ int copyEnd =
+ min(payloadLength, amountCopied + extentBlock.payloadSpaceBytes);
+ bytes.buffer.asUint8List().setRange(
+ amountCopied, copyEnd, extentBlock.payloadBytes.buffer.asUint8List());
+ if (extentBlock.nextExtent == 0) {
+ break;
+ }
+ }
+ return bytes;
+ }
+
+ String _extentsToString(Block property) {
+ return _utf8ToString(_extentsToByteData(property));
+ }
+}
+
+/// Matcher for a particular Node in the VMO.
+///
+/// NodeMatchers may be valid or invalid. A valid NodeMatcher refers to
+/// an actual node stored in the VMO, while an invalid NodeMatcher represents
+/// a node that could not be found. The creation of an invalid NodeMatcher
+/// records an error in the top-level matcher, and operations on it have
+/// no effect.
+class NodeMatcher implements _HasErrors {
+ final VmoMatcher _parent;
+ final int _index;
+ final bool _valid;
+
+ /// Creates a valid NodeMatcher with the given index.
+ NodeMatcher._valid(this._parent, this._index) : _valid = true;
+
+ /// Creates an invalid NodeMatcher.
+ NodeMatcher._invalid(this._parent)
+ : _valid = false,
+ _index = 0;
+
+ /// Get a NodeMatcher for the node at the given path below this one.
+ ///
+ /// If any step of the path could not be found or is not a node,
+ /// an error is recorded and an invalid NodeMatcher is returned.
+ NodeMatcher at(List<String> path) {
+ if (!_valid) {
+ // No error, it was already recorded by the creation of this.
+ return NodeMatcher._invalid(_parent);
+ }
+
+ int curIndex = _index;
+ for (var p in path) {
+ int next = _parent._findNamedValue(p, curIndex);
+ if (next == 0) {
+ _parent._addError('Cannot find node $p in $path');
+ return NodeMatcher._invalid(_parent);
+ } else if (Block.read(_parent._holder, next).type !=
+ BlockType.nodeValue) {
+ _parent._addError('Value $p in $path found, but it is not a node');
+ return NodeMatcher._invalid(_parent);
+ }
+
+ curIndex = next;
+ }
+ return NodeMatcher._valid(_parent, curIndex);
+ }
+
+ /// Asserts that this Node does not have the named child.
+ void missingChild(String name) {
+ if (!_valid) {
+ return;
+ }
+
+ int next = _parent._findNamedValue(name, _index);
+ if (next != 0) {
+ _parent._addError('Found $name which was expected to be missing');
+ }
+ }
+
+ /// Get a PropertyMatcher for a property on this node.
+ ///
+ /// If the property cannot be found or is not a value type, an invalid
+ /// PropertyMatcher is returned.
+ PropertyMatcher property(String name) {
+ if (!_valid) {
+ // No error, it would have been added creating the node itself.
+ return PropertyMatcher._invalid(_parent);
+ }
+
+ int valueIndex = _parent._findNamedValue(name, _index);
+ if (valueIndex == 0) {
+ _parent._addError('Cannot find property $name');
+ return PropertyMatcher._invalid(_parent);
+ } else if ([
+ BlockType.nodeValue,
+ BlockType.tombstone,
+ BlockType.nameUtf8,
+ BlockType.extent
+ ].contains(Block.read(_parent._holder, valueIndex).type)) {
+ _parent._addError('Value $name found, but it is not a property type');
+ return PropertyMatcher._invalid(_parent);
+ }
+
+ return PropertyMatcher._valid(_parent, valueIndex);
+ }
+
+ /// Checks that a property of this node equals the given value.
+ PropertyMatcher propertyEquals(String name, dynamic val) {
+ return property(name)..equals(val);
+ }
+
+ /// Checks that a property of this node exists but does not equal the given value.
+ PropertyMatcher propertyNotEquals(String name, dynamic val) {
+ return property(name)..notEquals(val);
+ }
+
+ /// Gets the list of errors.
+ @override
+ List<String> get errors => _parent._errors;
+
+ /// Resets the recorded errors from the parent matcher.
+ @override
+ void resetErrors() {
+ _parent.resetErrors();
+ }
+}
+
+/// Matcher for a particular Property in the VMO.
+///
+/// PropertyMatchers may be valid or invalid, with the semantics of
+/// NodeMatcher.
+class PropertyMatcher implements _HasErrors {
+ final VmoMatcher _parent;
+ final int _index;
+
+ // Create a valid PropertyMatcher for the given index.
+ PropertyMatcher._valid(this._parent, this._index);
+
+ // Create an invalid PropertyMatcher.
+ PropertyMatcher._invalid(this._parent) : _index = 0;
+
+ // Internal getter to check if this matcher is valid.
+ bool get _valid => _index != 0; // Properties cannot ever be at index 0.
+
+ void _internalEquals(dynamic val, bool invert) {
+ if (!_valid) {
+ return;
+ }
+
+ var negation = invert ? 'not ' : '';
+ // ignore: avoid_positional_boolean_parameters
+ bool maybeNegate(bool val) => (invert ? !val : val);
+
+ var block = Block.read(_parent._holder, _index);
+ if (val is int) {
+ if (block.type != BlockType.intValue) {
+ _parent
+ ._addError('Expected int ($val), found ${block.type.toString()}');
+ } else if (maybeNegate(block.intValue != val)) {
+ _parent._addError(
+ 'Expected ${negation}value $val, found ${block.intValue}');
+ }
+ } else if (val is double) {
+ if (block.type != BlockType.doubleValue) {
+ _parent._addError(
+ 'Expected double ($val), found ${block.type.toString()}');
+ } else if (maybeNegate(block.doubleValue != val)) {
+ _parent._addError(
+ 'Expected ${negation}value $val, found ${block.doubleValue}');
+ }
+ } else if (val is String) {
+ if (block.type != BlockType.propertyValue) {
+ _parent._addError(
+ 'Expected string ($val), found ${block.type.toString()}');
+ } else if (block.propertyFlags != propertyUtf8Flag) {
+ _parent._addError('Expected UTF-8 string, found binary');
+ } else {
+ var storedValue = _parent._extentsToString(block);
+ if (maybeNegate(storedValue != val)) {
+ _parent
+ ._addError('Expected ${negation}value $val, found $storedValue');
+ }
+ }
+ } else if (val is Uint8List) {
+ if (block.type != BlockType.propertyValue) {
+ _parent._addError(
+ 'Expected bytes (${val.join(", ")}), found ${block.type.toString()}');
+ } else if (block.propertyFlags == propertyUtf8Flag) {
+ _parent._addError('Expected bytes string, found UTF-8');
+ } else {
+ var storedValue =
+ _parent._extentsToByteData(block).buffer.asUint8List();
+ if (maybeNegate(!ListEquality().equals(storedValue, val))) {
+ _parent._addError(
+ 'Expected ${negation}value [${val.join(", ")}], found [${storedValue.join(", ")}]');
+ }
+ }
+ } else {
+ _parent._addError(
+ 'Unknown type ${val.runtimeType} passed to matcher. Expected int, double, String, or Uint8List.');
+ }
+ }
+
+ /// Match that this property equals a value.
+ void equals(dynamic val) => _internalEquals(val, false);
+
+ /// Matches that this property exists but does not equal a value.
+ void notEquals(dynamic val) => _internalEquals(val, true);
+
+ /// Gets the list of errors.
+ @override
+ List<String> get errors => _parent._errors;
+
+ /// Resets the recorded errors from the parent matcher.
+ @override
+ void resetErrors() {
+ _parent.resetErrors();
+ }
+}
+
+class _HasNoErrorsMatcher extends Matcher {
+ const _HasNoErrorsMatcher();
+
+ @override
+ bool matches(dynamic item, Map<dynamic, dynamic> matchState) {
+ if (item is _HasErrors) {
+ return item.errors.isEmpty;
+ } else {
+ return false;
+ }
+ }
+
+ @override
+ Description describe(Description description) {
+ return description..add('The Inspect matcher has no recorded errors');
+ }
+
+ @override
+ Description describeMismatch(dynamic item, Description mismatchDescription,
+ Map<dynamic, dynamic> matchState, bool verbose) {
+ if (item is _HasErrors) {
+ var output = item.errors.join('\n- ');
+ mismatchDescription.add('Has errors:\n- $output');
+ } else {
+ mismatchDescription.add(
+ 'The item being matched is not a VmoMatcher, NodeMatcher, or PropertyMatcher');
+ }
+ return mismatchDescription;
+ }
+}
+
+/// Matcher that asserts a VmoMatcher, NodeMatcher, or PropertyMatcher
+/// has no recorded errors.
+const Matcher hasNoErrors = _HasNoErrorsMatcher();
+
+String _utf8ToString(ByteData bytes) {
+ return utf8.decode(bytes.buffer
+ .asUint8ClampedList(bytes.offsetInBytes, bytes.lengthInBytes));
+}
diff --git a/public/dart/fuchsia_inspect/lib/src/testing/util.dart b/public/dart/fuchsia_inspect/lib/src/testing/util.dart
new file mode 100644
index 0000000..e03a660
--- /dev/null
+++ b/public/dart/fuchsia_inspect/lib/src/testing/util.dart
@@ -0,0 +1,58 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// ignore_for_file: implementation_imports
+
+import 'dart:typed_data';
+
+import 'package:zircon/zircon.dart';
+import 'package:fuchsia_inspect/src/vmo/vmo_holder.dart';
+
+/// A VmoHolder that simply wraps some ByteData.
+class FakeVmoHolder implements VmoHolder {
+ /// The memory contents of this "VMO".
+ final ByteData bytes;
+
+ @override
+ Vmo get vmo => null;
+
+ /// Size of the "VMO".
+ @override
+ final int size;
+
+ /// Wraps a [FakeVmoHolder] around the given data.
+ FakeVmoHolder.usingData(this.bytes) : size = bytes.lengthInBytes;
+
+ @override
+ void beginWork() {}
+
+ @override
+ void commit() {}
+
+ /// Writes to the "VMO".
+ @override
+ void write(int offset, ByteData data) {}
+
+ /// Reads from the "VMO".
+ @override
+ ByteData read(int offset, int size) {
+ var reading = ByteData(size);
+ reading.buffer
+ .asUint8List()
+ .setAll(0, bytes.buffer.asUint8List(offset, size));
+ return reading;
+ }
+
+ /// Writes int64 to VMO.
+ @override
+ void writeInt64(int offset, int value) {}
+
+ /// Writes int64 directly to VMO for immediate visibility.
+ @override
+ void writeInt64Direct(int offset, int value) {}
+
+ /// Reads int64 from VMO.
+ @override
+ int readInt64(int offset) => bytes.getInt64(offset, Endian.little);
+}
diff --git a/public/dart/fuchsia_inspect/lib/testing.dart b/public/dart/fuchsia_inspect/lib/testing.dart
new file mode 100644
index 0000000..f17acca
--- /dev/null
+++ b/public/dart/fuchsia_inspect/lib/testing.dart
@@ -0,0 +1,9 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Testing matchers for Inspect Dart.
+export 'src/testing/matcher.dart';
+
+// Utility classes, like FakeVmoHolder.
+export 'src/testing/util.dart';