Sunday, October 13, 2013

Reusing Dart KeyEvent Streams for Great Testing


I am not quite satisfied with last night's custom keyboard testing solution for Dart's bleeding edge KeyEvent class. The primary problem that I am trying to work around is that KeyEvent instances have to be added to the same stream that is listening for events. There is no way to test for bubbling events. There is no way to dispatch an event to an element so that any number of streams that are attached can listen.

Last night's solution worked around this by creating a class stream on my key shortcut library's ShortCut class. I think that was probably a reasonable thing to do, but I am less happy with the class-level dispatchEvent():
class ShortCut {
  static var _stream = KeyboardEventStream.onKeyDown(document.body);
  static var _streamController = new StreamController.broadcast();
  static get stream {
    _stream.listen((e) {_streamController.add(new KeyEventX(e));});
    return _streamController.stream;
  }
  static void dispatchEvent(KeyEvent e)=> _stream.add(e);
  // ...
}
This means that listening to events looks like:
ShortCut.stream.listen((e) { /* ... */ });
And dispatching events looks like:
ShortCut.dispatchEvent(
  new KeyEvent('keydown', keyCode: some_kind_of_key_code)
);
This is probably OK for my keyboard shortcut library because none of this will be exposed by the library whose API will feature higher-level calls. What is unsatisfying in this approach is that I am making use to the new KeyEvent feature, but not supporting its interface. It may not matter much in this case, but I would like to have a handle on how to do it for the future.

I start by borrowing heavily from the CustomStream implementations in bleeding edge Dart. OK, I don't borrow. I copy directly, changing only the name to ShortCutStream:
class ShortCutStream<T extends Event> extends Stream<T>
    implements CustomStream<T> {
  StreamController<T> _streamController;
  String _type;

  ShortCutStream(String type) {
    _type = type;
    _streamController = new StreamController.broadcast(sync: true);
  }

  StreamSubscription<T> listen(void onData(T event))
    => _streamController.stream.listen(onData);

  Stream<T> asBroadcastStream()
      => _streamController.stream;

  bool get isBroadcast => true;

  void add(T event) {
    if (event.type == _type) _streamController.add(new KeyEventX(event));
  }
}
I also copy the optional attributes, but omit them above for a little brevity. This, I believe, is what is going to be necessary for any Stream that supports the new CustomStream interface, which mandates only that the class support the add() method. The class needs a stream controller, which is instantiated by the constructor. The regular stream methods are delegated to stream controller's stream property. The add() method can then add() events by adding them to the stream controller. As I said, this seems the only way to support a CustomStream interface, so we might see abstract classes along the lines of the above spring up in the future.

The only thing that makes the above a ShortCutStream instead of a generic custom stream is that the add() method adds KeyEventX instances to the stream instead of the normal KeyEvent objects. KeyEventX decorates KeyEvent with some helpful methods in a shortcut context (e.g. isCtrl, isShift).

To use ShortCutStream, I remove dispatchEvent() (I will add() dynamically created events directly to ShortCut.stream from now on) and define the static stream getter as:
class ShortCut {
  static CustomStream _stream;
  static get stream {
    if (_stream == null) _stream = new ShortCutStream('keydown');
    return _stream;
  }
  // ...
}
What is crazy is I do not need to do anything else to create and dispatch dynamic shortcut events. My test is again passing:
  test("can listen for key events", (){
    ShortCut.stream.listen(expectAsync1((e) {
      expect(e.isKey('A'), true);
    }));

    type('A');
  });
Where the type() helper now added to the same ShortCut stream:
type(String key) {
  ShortCut.stream.add(
    new KeyEvent('keydown', keyCode: keyCodeFor(key))
  );
}
In all honesty, that is somewhat disappointing. I believe that I have used CustomStream and KeyEvent properly here (in the spirit in which it is meant to be used). But all that I have managed to do is verify that, if I create a keyboard stream that could wrap actual keyboard events, then my code would work. Nothing in here actually captures real keyboard events. In other words, all I can do is test a wrapper for keyboard events, but not real keyboard events.

That said, I can workaround the buggy KeyEvent and KeyboardEventStream interfaces to obtains KeyEvent instances. Instead of using the stream providers from those classes (which currently generate Internal Dartium Exceptions in the face of real keyboard input), I can wrap the more standard KeyboardEvent instances in the ShortCutStream constructor:
class ShortCutStream<T extends Event> extends Stream<T>
    implements CustomStream<T> {
  // ...
  ShortCutStream(String type) {
    _type = type;
    _streamController = new StreamController.broadcast(sync: true);

    document.body.onKeyDown.listen((e) {
      KeyEvent wrapped_event = new KeyEvent.wrap(e);
      add(wrapped_event);
    });
  }
  // ...
}
With that, I am able to get all of the tests passing in my keyboard shortcut library (even one that had been mysteriously marked as “skip”). And, in all honesty, I think the code is improved for this change. Where before I had been creating a new stream for every keyboard shortcut, now I am re-using the existing stream. That has to cut down on the overhead of my code—especially when code is creating lots of keyboard shortcuts. It might even cut down on a race condition or two.

That said, I still do not know how this helps to test something along the lines of hitting Enter in a text field. It would seem that a library that caches streams per element is going to be required. As luck would have it, I am faced with just that problem over in the ICE Code Editor, so I will pick back up with that problem tomorrow.


Day #903

No comments:

Post a Comment