[ermine] Refactor AppModel for unittest

This change adds a unittest for AppModel, the main
respository of application level state in Ermine. The AppModel class
is refactored to be able to take mock depedencies injected into its
constructor.
- minor renamings and tweaks.
- tighten logic to confirm to expected behavior, with test coverage.

Bug: 49908

Testing: Adds a unittest for almost 100% coverage on AppModel class.

Change-Id: I953c7418b42872f225663cb23d250b2f3f66e4f0
Reviewed-on: https://fuchsia-review.googlesource.com/c/experiences/+/379582
Commit-Queue: Sanjay Chouksey <sanjayc@google.com>
Reviewed-by: Chase Latta <chaselatta@google.com>
Reviewed-by: Yeonhee Lee <yhlee@google.com>
diff --git a/session_shells/ermine/keyboard_shortcuts/BUILD.gn b/session_shells/ermine/keyboard_shortcuts/BUILD.gn
index 0a06f79..534688f 100644
--- a/session_shells/ermine/keyboard_shortcuts/BUILD.gn
+++ b/session_shells/ermine/keyboard_shortcuts/BUILD.gn
@@ -18,6 +18,7 @@
     "//sdk/fidl/fuchsia.ui.input2",
     "//sdk/fidl/fuchsia.ui.shortcut",
     "//sdk/fidl/fuchsia.ui.views",
+    "//topaz/public/dart/fuchsia_services",
     "//third_party/dart-pkg/git/flutter/packages/flutter",
   ]
 }
diff --git a/session_shells/ermine/keyboard_shortcuts/lib/src/keyboard_shortcuts.dart b/session_shells/ermine/keyboard_shortcuts/lib/src/keyboard_shortcuts.dart
index b1535b9..b00e231 100644
--- a/session_shells/ermine/keyboard_shortcuts/lib/src/keyboard_shortcuts.dart
+++ b/session_shells/ermine/keyboard_shortcuts/lib/src/keyboard_shortcuts.dart
@@ -9,8 +9,9 @@
 
 import 'package:fidl_fuchsia_ui_input2/fidl_async.dart';
 import 'package:fidl_fuchsia_ui_shortcut/fidl_async.dart' as ui_shortcut
-    show Registry, Shortcut, Trigger, Listener, ListenerBinding;
+    show Registry, RegistryProxy, Shortcut, Trigger, Listener, ListenerBinding;
 import 'package:fidl_fuchsia_ui_views/fidl_async.dart' show ViewRef;
+import 'package:fuchsia_services/services.dart' show StartupContext;
 import 'package:zircon/zircon.dart' show EventPairPair;
 
 /// Listens for keyboard shortcuts and triggers callbacks when they occur.
@@ -36,7 +37,25 @@
     shortcuts.forEach(registry.registerShortcut);
   }
 
+  factory KeyboardShortcuts.fromStartupContext(
+    StartupContext startupContext, {
+    Map<String, VoidCallback> actions,
+    String bindings,
+  }) {
+    final shortcutRegistry = ui_shortcut.RegistryProxy();
+    startupContext.incoming.connectToService(shortcutRegistry);
+    return KeyboardShortcuts(
+      registry: shortcutRegistry,
+      actions: actions,
+      bindings: bindings,
+    );
+  }
+
   void dispose() {
+    if (registry is ui_shortcut.RegistryProxy) {
+      ui_shortcut.RegistryProxy proxy = registry;
+      proxy.ctrl.close();
+    }
     shortcuts.clear();
     _listenerBinding.close();
   }
diff --git a/session_shells/ermine/shell/BUILD.gn b/session_shells/ermine/shell/BUILD.gn
index dbf2951..d8e51f6 100644
--- a/session_shells/ermine/shell/BUILD.gn
+++ b/session_shells/ermine/shell/BUILD.gn
@@ -169,6 +169,7 @@
 
 flutter_test("ermine_unittests") {
   sources = [
+    "app_model_test.dart",
     "app_widget_test.dart",
     "ask_model_test.dart",
     "ask_widget_test.dart",
diff --git a/session_shells/ermine/shell/lib/src/models/app_model.dart b/session_shells/ermine/shell/lib/src/models/app_model.dart
index 662a172..a321fd6 100644
--- a/session_shells/ermine/shell/lib/src/models/app_model.dart
+++ b/session_shells/ermine/shell/lib/src/models/app_model.dart
@@ -6,14 +6,10 @@
 import 'dart:io';
 
 import 'package:fidl_fuchsia_intl/fidl_async.dart';
-import 'package:fidl_fuchsia_ui_input/fidl_async.dart' as input;
-import 'package:fidl_fuchsia_ui_shortcut/fidl_async.dart' as ui_shortcut
-    show RegistryProxy;
 import 'package:fidl_fuchsia_ui_policy/fidl_async.dart';
 import 'package:flutter/material.dart';
 import 'package:fuchsia_internationalization_flutter/internationalization.dart';
 import 'package:fuchsia_inspect/inspect.dart' as inspect;
-
 import 'package:fuchsia_services/services.dart' show StartupContext;
 import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'
     show KeyboardShortcuts;
@@ -27,13 +23,16 @@
 import 'topbar_model.dart';
 
 /// Model that manages all the application state of this session shell.
+///
+/// Its primary responsibility is to manage visibility of top level UI widgets
+/// like Overview, Recents, Ask and Status.
 class AppModel {
-  final _presentation = PresentationProxy();
-  final _pointerEventsListener = PointerEventsListener();
-  final _shortcutRegistry = ui_shortcut.RegistryProxy();
+  KeyboardShortcuts _keyboardShortcuts;
+  PointerEventsListener _pointerEventsListener;
+  SuggestionService _suggestionService;
+
   final _intl = PropertyProviderProxy();
 
-  SuggestionService _suggestionService;
   PresenterService _presenterService;
 
   /// The [GlobalKey] associated with [Ask] widget.
@@ -52,30 +51,60 @@
   ValueNotifier<bool> peekNotifier = ValueNotifier(false);
   ValueNotifier<bool> recentsVisibility = ValueNotifier(false);
   Stream<Locale> _localeStream;
-  KeyboardShortcuts _keyboardShortcuts;
+
   ClustersModel clustersModel;
-  StatusModel status;
+  StatusModel statusModel;
   TopbarModel topbarModel;
-  String keyboardShortcuts = 'Help Me!';
+  String keyboardShortcutsHelpText = 'Help Me!';
 
-  AppModel() {
-    _startupContext.incoming.connectToService(_shortcutRegistry);
-    _startupContext.incoming.connectToService(_intl);
-    _startupContext.incoming.connectToService(_presentation);
+  AppModel({
+    KeyboardShortcuts keyboardShortcuts,
+    PointerEventsListener pointerEventsListener,
+    LocaleSource localeSource,
+    SuggestionService suggestionService,
+    this.statusModel,
+    this.clustersModel,
+  })  : _keyboardShortcuts = keyboardShortcuts,
+        _pointerEventsListener = pointerEventsListener,
+        _suggestionService = suggestionService {
+    // Setup child models.
+    topbarModel = TopbarModel(appModel: this);
 
-    _localeStream = LocaleSource(_intl).stream().asBroadcastStream();
+    statusModel ??= StatusModel.fromStartupContext(_startupContext, onLogout);
 
-    clustersModel = ClustersModel();
+    clustersModel ??= ClustersModel();
 
-    _suggestionService = SuggestionService.fromStartupContext(
+    // Setup keyboard shortcuts.
+    _keyboardShortcuts ??= KeyboardShortcuts.fromStartupContext(
+      _startupContext,
+      actions: actions,
+      bindings: keyboardBindings,
+    );
+    keyboardShortcutsHelpText = _keyboardShortcuts.helpText();
+
+    // Setup pointer events listener.
+    _pointerEventsListener ??=
+        _PointerEventsListener.fromStartupContext(_startupContext);
+
+    // Setup locale stream.
+    if (localeSource == null) {
+      _startupContext.incoming.connectToService(_intl);
+      localeSource = LocaleSource(_intl);
+    }
+    _localeStream = localeSource.stream().asBroadcastStream();
+
+    // Suggestion service.
+    _suggestionService ??= SuggestionService.fromStartupContext(
       startupContext: _startupContext,
       onSuggestion: clustersModel.storySuggested,
     );
 
-    topbarModel = TopbarModel(appModel: this);
+    // Expose PresenterService to the environment.
+    advertise();
+  }
 
-    status = StatusModel.fromStartupContext(_startupContext, onLogout);
-
+  @visibleForTesting
+  void advertise() {
     // Expose the presenter service to the environment.
     _presenterService = PresenterService(clustersModel.presentStory);
     _startupContext.outgoing
@@ -93,33 +122,9 @@
   /// Called after runApp which initializes flutter's gesture system.
   Future<void> onStarted() async {
     // Capture pointer events directly from Scenic.
-    _pointerEventsListener.listen(_presentation);
-
-    // Capture key pressess for key bindings in keyboard_shortcuts.json.
-    File file = File('/pkg/data/keyboard_shortcuts.json');
-    if (file.existsSync()) {
-      final bindings = await file.readAsString();
-      _keyboardShortcuts = KeyboardShortcuts(
-        registry: _shortcutRegistry,
-        actions: {
-          'shortcuts': onKeyboard,
-          'ask': onMeta,
-          'overview': onOverview,
-          'recents': onRecents,
-          'fullscreen': onFullscreen,
-          'cancel': onCancel,
-          'close': onClose,
-          'status': onStatus,
-          'nextCluster': clustersModel.nextCluster,
-          'previousCluster': clustersModel.previousCluster,
-          'logout': onLogout,
-        },
-        bindings: bindings,
-      );
-      keyboardShortcuts = _keyboardShortcuts.helpText();
-    } else {
-      throw ArgumentError.value(
-          'keyboard_shortcuts.json', 'fileName', 'File does not exist');
+    if (_pointerEventsListener is _PointerEventsListener) {
+      _PointerEventsListener listener = _pointerEventsListener;
+      listener.listen(listener.presentation);
     }
 
     // Update the current time every second.
@@ -141,6 +146,28 @@
     inspect.Inspect.onDemand('ermine', _onInspect);
   }
 
+  // Map key shortcuts to corresponding actions.
+  Map<String, VoidCallback> get actions => {
+        'shortcuts': onKeyboard,
+        'ask': onAsk,
+        'overview': onOverview,
+        'recents': onRecents,
+        'fullscreen': onFullscreen,
+        'cancel': onCancel,
+        'close': onClose,
+        'status': onStatus,
+        'nextCluster': clustersModel.nextCluster,
+        'previousCluster': clustersModel.previousCluster,
+        'logout': onLogout,
+      };
+
+  // Returns key bindings in keyboard_shortcuts.json. Throws a fatal exception
+  // if not found.
+  String get keyboardBindings {
+    File file = File('/pkg/data/keyboard_shortcuts.json');
+    return file.readAsStringSync();
+  }
+
   void onFullscreen() {
     if (clustersModel.fullscreenStory != null) {
       clustersModel.fullscreenStory.restore();
@@ -151,9 +178,9 @@
     }
   }
 
-  /// Toggles the Ask bar.
-  void onMeta() {
-    if (!hasStories) {
+  /// Toggles the Ask bar when Overview is not visible.
+  void onAsk() {
+    if (!hasStories || overviewVisibility.value == true) {
       return;
     }
     if (askVisibility.value == false) {
@@ -176,9 +203,9 @@
     overviewVisibility.value = !overviewVisibility.value;
   }
 
-  /// Toggles recents.
+  /// Toggles recents when Overview is not visible.
   void onRecents() {
-    if (!hasStories) {
+    if (!hasStories || overviewVisibility.value == true) {
       return;
     }
     if (recentsVisibility.value == false) {
@@ -189,9 +216,9 @@
     recentsVisibility.value = !recentsVisibility.value;
   }
 
-  /// Toggles the Status menu on/off.
+  /// Toggles the Status menu on/off when Overview is not visible.
   void onStatus() {
-    if (!hasStories) {
+    if (!hasStories || overviewVisibility.value == true) {
       return;
     }
     if (statusVisibility.value == false) {
@@ -204,22 +231,25 @@
   /// Called when tapped behind Ask bar, quick settings, notifications or the
   /// Escape key was pressed.
   void onCancel() {
-    status.reset();
+    statusModel.reset();
     askVisibility.value = false;
     statusVisibility.value = false;
     helpVisibility.value = false;
     recentsVisibility.value = false;
-    overviewVisibility.value = !hasStories;
+    overviewVisibility.value = overviewVisibility.value || !hasStories;
   }
 
   /// Called when the user wants to delete the story.
   void onClose() {
-    clustersModel.focusedStory?.delete();
+    // Close is allowed when not in Overview.
+    if (overviewVisibility.value == false) {
+      clustersModel.focusedStory?.delete();
+    }
   }
 
   /// Called when the keyboard help button is tapped.
   void onKeyboard() {
-    if (!hasStories) {
+    if (overviewVisibility.value == true) {
       return;
     }
     if (helpVisibility.value == false) {
@@ -232,51 +262,14 @@
   /// Called when the user initiates logout (using keyboard or UI).
   void onLogout() {
     onCancel();
+    _keyboardShortcuts.dispose();
     _pointerEventsListener.stop();
 
-    _intl.ctrl.close();
+    _intl?.ctrl?.close();
     _suggestionService.dispose();
-    status.dispose();
-    _keyboardShortcuts.dispose();
-    _shortcutRegistry.ctrl.close();
-    _presentation.ctrl.close();
+    statusModel.dispose();
   }
 
-  void injectTap(Offset offset) {
-    _presentation
-      ..injectPointerEventHack(_createPointerEvent(
-        phase: input.PointerEventPhase.add,
-        offset: offset,
-      ))
-      ..injectPointerEventHack(_createPointerEvent(
-        phase: input.PointerEventPhase.down,
-        offset: offset,
-      ))
-      ..injectPointerEventHack(_createPointerEvent(
-        phase: input.PointerEventPhase.up,
-        offset: offset,
-      ))
-      ..injectPointerEventHack(_createPointerEvent(
-        phase: input.PointerEventPhase.remove,
-        offset: offset,
-      ));
-  }
-
-  input.PointerEvent _createPointerEvent({
-    input.PointerEventPhase phase,
-    Offset offset,
-  }) =>
-      input.PointerEvent(
-        eventTime: 0,
-        deviceId: 0,
-        pointerId: 0,
-        type: input.PointerEventType.touch,
-        phase: phase,
-        x: offset.dx,
-        y: offset.dy,
-        buttons: 0,
-      );
-
   void _onInspect(inspect.Node node) {
     // Session.
     node.stringProperty('session').setValue('started');
@@ -285,9 +278,27 @@
     askKey.currentState?.onInspect(node.child('ask'));
 
     // Status.
-    status.onInspect(node.child('status'));
+    statusModel.onInspect(node.child('status'));
 
     // Topbar.
     topbarModel.onInspect(node.child('topbar'));
   }
 }
+
+class _PointerEventsListener extends PointerEventsListener {
+  final PresentationProxy presentation;
+
+  _PointerEventsListener(this.presentation) : super();
+
+  factory _PointerEventsListener.fromStartupContext(StartupContext context) {
+    final presentation = PresentationProxy();
+    context.incoming.connectToService(presentation);
+    return _PointerEventsListener(presentation);
+  }
+
+  @override
+  void stop() {
+    super.stop();
+    presentation.ctrl.close();
+  }
+}
diff --git a/session_shells/ermine/shell/lib/src/models/cluster_model.dart b/session_shells/ermine/shell/lib/src/models/cluster_model.dart
index fa33de6..e9d9bd7 100644
--- a/session_shells/ermine/shell/lib/src/models/cluster_model.dart
+++ b/session_shells/ermine/shell/lib/src/models/cluster_model.dart
@@ -157,8 +157,12 @@
     }
 
     // If the story was also focused, set focus on next story in same cluster.
-    if (focusedStory == story && currentCluster.value.stories.isNotEmpty) {
-      currentCluster.value.stories.last.focus();
+    if (focusedStory == story) {
+      if (currentCluster.value.stories.isNotEmpty) {
+        currentCluster.value.stories.last.focus();
+      } else {
+        focusedStoryNotifier.value = null;
+      }
     }
   }
 
diff --git a/session_shells/ermine/shell/lib/src/models/topbar_model.dart b/session_shells/ermine/shell/lib/src/models/topbar_model.dart
index 96bd789..439d40e 100644
--- a/session_shells/ermine/shell/lib/src/models/topbar_model.dart
+++ b/session_shells/ermine/shell/lib/src/models/topbar_model.dart
@@ -43,7 +43,7 @@
   void showRecents() => appModel.onRecents();
 
   /// Display the Ask bar. Called by Ask Button.
-  void showAsk() => appModel.onMeta();
+  void showAsk() => appModel.onAsk();
 
   /// Display the keyboard help panel.
   void showKeyboardHelp() => appModel.onKeyboard();
diff --git a/session_shells/ermine/shell/lib/src/widgets/status/status_container.dart b/session_shells/ermine/shell/lib/src/widgets/status/status_container.dart
index be83201..6f41c19 100644
--- a/session_shells/ermine/shell/lib/src/widgets/status/status_container.dart
+++ b/session_shells/ermine/shell/lib/src/widgets/status/status_container.dart
@@ -26,7 +26,7 @@
             ErmineStyle.kTopBarHeight -
             ErmineStyle.kStoryTitleHeight;
     final status = Material(
-      key: model.status.key,
+      key: model.statusModel.key,
       color: ErmineStyle.kBackgroundColor,
       elevation: Elevations.systemOverlayElevation,
       child: Container(
@@ -36,7 +36,7 @@
         decoration: BoxDecoration(
           border: Border.all(color: ErmineStyle.kOverlayBorderColor),
         ),
-        child: Status(model: model.status),
+        child: Status(model: model.statusModel),
       ),
     );
     return RepaintBoundary(
diff --git a/session_shells/ermine/shell/lib/src/widgets/support/keyboard_help.dart b/session_shells/ermine/shell/lib/src/widgets/support/keyboard_help.dart
index 0b61c8c..3ab3807 100644
--- a/session_shells/ermine/shell/lib/src/widgets/support/keyboard_help.dart
+++ b/session_shells/ermine/shell/lib/src/widgets/support/keyboard_help.dart
@@ -51,7 +51,7 @@
                           height: 500,
                           child: SingleChildScrollView(
                             child: Text(
-                              model.keyboardShortcuts,
+                              model.keyboardShortcutsHelpText,
                               style: TextStyle(
                                 fontFamily: 'RobotoMono',
                                 fontSize: 14.0,
diff --git a/session_shells/ermine/shell/lib/src/widgets/support/overview.dart b/session_shells/ermine/shell/lib/src/widgets/support/overview.dart
index 792be7d..f415555 100644
--- a/session_shells/ermine/shell/lib/src/widgets/support/overview.dart
+++ b/session_shells/ermine/shell/lib/src/widgets/support/overview.dart
@@ -74,7 +74,7 @@
                 Expanded(
                   child: Container(
                     padding: ErmineStyle.kOverviewElementPadding,
-                    child: Status(model: model.status),
+                    child: Status(model: model.statusModel),
                   ),
                 ),
               ],
diff --git a/session_shells/ermine/shell/test/app_model_test.dart b/session_shells/ermine/shell/test/app_model_test.dart
new file mode 100644
index 0000000..4177393
--- /dev/null
+++ b/session_shells/ermine/shell/test/app_model_test.dart
@@ -0,0 +1,213 @@
+// 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:ui';
+
+import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'
+    show KeyboardShortcuts;
+import 'package:lib.widgets/utils.dart' show PointerEventsListener;
+import 'package:fuchsia_internationalization_flutter/internationalization.dart';
+import 'package:test/test.dart';
+import 'package:mockito/mockito.dart';
+
+// ignore_for_file: implementation_imports
+import 'package:ermine/src/models/cluster_model.dart';
+import 'package:ermine/src/models/ermine_story.dart';
+import 'package:ermine/src/models/status_model.dart';
+import 'package:ermine/src/utils/suggestions.dart';
+import 'package:ermine/src/models/app_model.dart';
+
+void main() {
+  AppModel appModel;
+  KeyboardShortcuts keyboardShortcuts;
+  PointerEventsListener pointerEventsListener;
+  LocaleSource localeSource;
+  SuggestionService suggestionService;
+  StatusModel statusModel;
+  ClustersModel clustersModel;
+
+  setUp(() async {
+    keyboardShortcuts = MockKeyboardShortcuts();
+    pointerEventsListener = MockPointerEventsListener();
+    localeSource = MockLocaleSource();
+    suggestionService = MockSuggestionService();
+    statusModel = MockStatusModel();
+    clustersModel = MockClustersModel();
+
+    when(localeSource.stream()).thenAnswer((_) => Stream<Locale>.empty());
+
+    appModel = _TestAppModel(
+      keyboardShortcuts: keyboardShortcuts,
+      pointerEventsListener: pointerEventsListener,
+      localeSource: localeSource,
+      suggestionService: suggestionService,
+      statusModel: statusModel,
+      clustersModel: clustersModel,
+    );
+    await appModel.onStarted();
+  });
+
+  tearDown(() {
+    when(clustersModel.hasStories).thenReturn(false);
+
+    appModel.onLogout();
+
+    verify(keyboardShortcuts.dispose()).called(1);
+    verify(pointerEventsListener.stop()).called(1);
+    verify(suggestionService.dispose()).called(1);
+    verify(statusModel.dispose()).called(1);
+  });
+
+  test('Should start in Overview state', () async {
+    expect(appModel.overviewVisibility.value, true);
+    expect(appModel.askVisibility.value, false);
+  });
+
+  test('Toggle Overview state with or without stories', () async {
+    when(clustersModel.hasStories).thenReturn(false);
+    appModel.onOverview();
+    expect(appModel.overviewVisibility.value, true);
+
+    when(clustersModel.hasStories).thenReturn(true);
+    appModel.onOverview();
+    expect(appModel.overviewVisibility.value, false);
+  });
+
+  test('Should not toggle from Overview on Ask', () async {
+    when(clustersModel.hasStories).thenReturn(true);
+
+    appModel.overviewVisibility.value = true;
+    appModel.onAsk();
+    expect(appModel.askVisibility.value, false);
+    expect(appModel.overviewVisibility.value, true);
+  });
+
+  test('Allow toggling Ask when NOT in Overview', () async {
+    when(clustersModel.hasStories).thenReturn(true);
+    appModel.overviewVisibility.value = false;
+
+    appModel.onAsk();
+    expect(appModel.askVisibility.value, true);
+    appModel.onAsk();
+    expect(appModel.askVisibility.value, false);
+  });
+
+  test('Allow toggling Recents when NOT in Overview', () async {
+    when(clustersModel.hasStories).thenReturn(true);
+    appModel.overviewVisibility.value = false;
+
+    appModel.onRecents();
+    expect(appModel.recentsVisibility.value, true);
+    appModel.onRecents();
+    expect(appModel.recentsVisibility.value, false);
+  });
+
+  test('Allow toggling Status when NOT in Overview', () async {
+    when(clustersModel.hasStories).thenReturn(true);
+    appModel.overviewVisibility.value = false;
+
+    appModel.onStatus();
+    expect(appModel.statusVisibility.value, true);
+    appModel.onStatus();
+    expect(appModel.statusVisibility.value, false);
+  });
+
+  test('Escape key should dismiss top level widgets.', () async {
+    // When no stories are present, onCancel should display Overview.
+    when(clustersModel.hasStories).thenReturn(false);
+    appModel.onCancel();
+    expect(appModel.overviewVisibility.value, true);
+    expect(appModel.askVisibility.value, false);
+    expect(appModel.statusVisibility.value, false);
+    expect(appModel.helpVisibility.value, false);
+    expect(appModel.recentsVisibility.value, false);
+
+    // When stories are present, onCancel should not toggle Overview.
+    when(clustersModel.hasStories).thenReturn(true);
+    appModel.overviewVisibility.value = false;
+
+    appModel.onCancel();
+    expect(appModel.overviewVisibility.value, false);
+    expect(appModel.askVisibility.value, false);
+    expect(appModel.statusVisibility.value, false);
+    expect(appModel.helpVisibility.value, false);
+    expect(appModel.recentsVisibility.value, false);
+  });
+
+  test('Close should remove focused story.', () async {
+    final story = MockErmineStory();
+    when(clustersModel.focusedStory).thenReturn(story);
+
+    // Close is not allowed in Overview.
+    appModel.overviewVisibility.value = true;
+    appModel.onClose();
+    verifyNever(story.delete());
+
+    // But allowed from cluster view.
+    appModel.overviewVisibility.value = false;
+    appModel.onClose();
+    verify(story.delete()).called(1);
+  });
+
+  test('Keyboard help can be visible when not in Overview.', () async {
+    when(clustersModel.hasStories).thenReturn(true);
+
+    appModel.overviewVisibility.value = false;
+    appModel.onKeyboard();
+    expect(appModel.helpVisibility.value, true);
+  });
+
+  test('Should toggle fullscreen for focused story.', () async {
+    final story = MockErmineStory();
+    when(clustersModel.focusedStory).thenReturn(story);
+
+    appModel.onFullscreen();
+    verify(clustersModel.maximize(any)).called(1);
+
+    when(clustersModel.fullscreenStory).thenReturn(story);
+    expect(appModel.isFullscreen, true);
+    appModel.onFullscreen();
+    verify(story.restore()).called(1);
+  });
+
+  test('Should not go fullscreen if no story is in focus.', () async {
+    appModel.onFullscreen();
+    expect(appModel.isFullscreen, false);
+  });
+}
+
+class _TestAppModel extends AppModel {
+  _TestAppModel({
+    KeyboardShortcuts keyboardShortcuts,
+    PointerEventsListener pointerEventsListener,
+    LocaleSource localeSource,
+    SuggestionService suggestionService,
+    StatusModel statusModel,
+    ClustersModel clustersModel,
+  }) : super(
+          keyboardShortcuts: keyboardShortcuts,
+          pointerEventsListener: pointerEventsListener,
+          localeSource: localeSource,
+          suggestionService: suggestionService,
+          statusModel: statusModel,
+          clustersModel: clustersModel,
+        );
+
+  @override
+  void advertise() {}
+}
+
+class MockKeyboardShortcuts extends Mock implements KeyboardShortcuts {}
+
+class MockPointerEventsListener extends Mock implements PointerEventsListener {}
+
+class MockLocaleSource extends Mock implements LocaleSource {}
+
+class MockSuggestionService extends Mock implements SuggestionService {}
+
+class MockStatusModel extends Mock implements StatusModel {}
+
+class MockClustersModel extends Mock implements ClustersModel {}
+
+class MockErmineStory extends Mock implements ErmineStory {}