Saturday, May 18, 2013

BDD a Dart Menu

‹prev | My Chain | next›

The sharing feature in the ICE Code Editor is not quite done, but I am going to risk starting a new feature tonight. It is a bit of a concern having multiple features under development at the same time, but it could be a good thing for my #pairwithme sessions.

The most interesting next feature is the project menu. What makes it interesting is that it combines three different classes in the project: the core editor, the localStorage, and the full-screen IDE. I start with four empty tests that still start me on the way:
  group("project menu", (){
    skip_test("clicking the project menu item opens the project dialog", (){});
    skip_test("the escape key closes the project dialog", (){});
    skip_test("the menu button closes the projects dialog", (){});
    skip_test("contains a default project on first load", (){});
  });
I will probably leave that last one, which begins to exercise the localStorage component, until tonight's #pairwithme session, but hopefully I can get through the rest.

I start by adding the usual setup and teardown:
  group("project menu", (){
    setUp(()=> new Full(enable_javascript_mode: false));
    tearDown(()=> document.query('#ice').remove());

    skip_test("clicking the project menu item opens the project dialog", (){});
    // ...
  });
The enable_javascript_mode option is a test-only feature. It disables JavaScript syntax highlighting because the underlying ACE code editor uses web workers, which do not work for file:// URLs. Since the tests are run from a URL like file:///home/chris/repos/ice-code-editor/test/index.html, the web workers are guaranteed to fail, adding messy warnings to the test output.

I convert the first test from a skip_test() to the real thing and set my expectations:
    test("clicking the project menu item opens the project dialog", (){
      queryAll('button').
        firstWhere((e)=> e.text=='☰').
        click();

      queryAll('li').
        firstWhere((e)=> e.text=='Projects').
        click();

      expect(
        queryAll('div').map((e)=> e.text).toList(),
        contains(matches('Saved Projects'))
      );
    });
This test says that, after clicking the menu button and the “Projects” menu item, I expect that the project sub-menu will be open. I like using the firstWhere() method when looking for elements to click in tests—it throws exceptions if there is no matching element. That is what happens in this case:
FAIL: project menu clicking the project menu item opens the project dialog
  Caught Bad state: No matching element
  Object&ListMixin.firstWhere                                    dart:collection 561:5
  full_tests.<anonymous closure>.<anonymous closure>             file:///home/chris/repos/ice-code-editor/test/full_test.dart 90:19
This is because I have named the menu item “Open.” I rename it to “Projects” and now I have a failure that the projects dialog is not open:
FAIL: project menu clicking the project menu item opens the project dialog
  Expected: contains match 'Saved Projects'
       but: was <[☰XNewProjectsSaveMake a CopyShareDownloadHelp, ☰, X, , , , , , , , , , , , , X, , , ]>.
  
  full_tests.<anonymous closure>.<anonymous closure>             file:///home/chris/repos/ice-code-editor/test/full_test.dart 93:13
I make that pass by calling a new private method to add the Projects menu item:
    menu.children
      ..add(new Element.html('<li>New</li>'))
      ..add(_projectsMenuItem())
      // ...
The new private method creates the menu item and attaches a click listener:
  _projectsMenuItem() {
    return new Element.html('<li>Projects</li>')
      ..onClick.listen((e) => _openProjectsMenu());
  }
Finally, the menu that opens the project menu and makes the test pass:
  _openProjectsMenu() {
    var menu = new Element.html(
        '''
        <div class=ice-menu>
        <h1>Saved Projects
        </div>
        '''
    );

    el.children.add(menu);

    menu.style
      ..maxHeight = '560px'
      ..overflowY = 'auto'
      ..position = 'absolute'
      ..right = '17px'
      ..top = '60px'
      ..zIndex = '1000';
  }
Both the CSS and the code seem ripe to extraction. I have the feeling that this is not the last menu that I need. Still, I will worry about generalizing the behavior later.

Update: The close-menu-with-escape proves to be extremely difficult. But, thanks to Damon Douglas, tonight's #pairwithme partner, I have a solution.

As best I can tell, there is no way to properly simulate keyboard events in Dart. It is possible to create an instance of KeyboardEvent, but it is not possible to set the charCode of that event—either in the constructor or via a setter. Craziness!

We found that it is possible to specify a key identifier in the constructor. If the character code for the escape key is 27, then I can create an escape keyboard event with:
    test("the escape key closes the project dialog", (){
      // open the project menu

      document.body.dispatchEvent(
        new KeyboardEvent(
          'keyup',
          keyIdentifier: new String.fromCharCode(27)
        )
      );

      expect(
        queryAll('div').map((e)=> e.text).toList(),
        isNot(contains(matches('Saved Projects')))
      );
    });
Unfortunately, this still does not set a charCode on the resulting event. Nevertheless, it does set information that can be passed from the test into the application code. To get this test to pass, we have to check both the charCode and the keyIdentifier values in the application code:
    document.onKeyUp.listen((e) {
      if (e.keyCode == 27) _hideMenu();
      if (e.$dom_keyIdentifier.codeUnits.first == 27) _hideMenu();
    });
It is never a good thing to call one of the dollar-sign methods in Dart. It is also not a good thing to write code just to make a test pass. There is no way that second conditional will get triggered in live code and it does contain the same spirit as the real event conditional on the previous line.

Still, I will be much happier once there is a real way to test keyboard events in Dart. This is a useful first approximation, but nothing beats the real thing.


Day #755

2 comments:

  1. Hi Chris, neat Dart post. Is all the work on the children's book / code editor a breadwinning job or is it all on the side? Think it's very possible to say that this much effort, testing, and total manhours have never been put into a children's book before in well... ever.

    ReplyDelete
    Replies
    1. Thanks! I make money on the kids book and the Dart book (as well as the others that I've written). Not enough to support the family on that income alone, but I do make some money.

      I've got to think that just about any kids programming book requires this level of effort. If the goal is to encourage rather than discourage, the author, editor and everyone else responsible has to work their collective tail off to eliminate as much dead-end narrative as possible. It's not an undertaking for the faint of heart, but I have no doubt that the folks behind books like Super Scratch (from No Starch) worked just as hard, if not harder.

      Thanks for the kind words!

      Delete