Repository: electron/fiddle
Pull request #640 (2021-04-06 01:01:29)

refactor: remove arrayToStringMap
Two small and related changes:

Our util function `arrayToStringMap()` can be replaced with es2019's `Object.fromEntries()`, meaning both the utils code and its spec can be removed. Also, there is some repetition in state.spec of assigning `this.versions = arrayToStringMap(foo)` and then calling `this.updateDownloadedVersionState()` to ensure the new version array's states are correct. This PR extracts that into a new private method `setVersions(versions: RunnableVersion[])` to avoid that repetition.

The seemingly-unrelated changes to the mocks in state-spec are to fix two state-spec bugs found while removing `arrayToStringMap()`.

1. Since `updateDownloadedVersionState()` did not get re-mocked in the `beforeEach()` call, and since our mocks reset after every test, in every test but the first, this mock was returning an empty value that broke `arrayToStringMap()`:

```
  console.warn
    State: Could not update Electron versions TypeError: Cannot read property 'forEach' of undefined
        at Object.arrayToStringMap (/home/charles/electron/fiddle/src/utils/array-to-stringmap.ts:16:9)
        at AppState.updateElectronVersions (/home/charles/electron/fiddle/src/renderer/state.ts:351:23)
        at processTicksAndRejections (node:internal/process/task_queues:94:5)

```

2. Likewise, since `getDownloadingVersions()` and `getDownloadedVersions()` were mocked but did not have a return value set, they returned a non-iterable value which broke `updateDownloadedVersionState()`:

```
  console.warn
    State: Could not update Electron versions TypeError: downloadingVersions is not iterable
        at AppState.updateDownloadedVersionState (/home/charles/electron/fiddle/src/renderer/state.ts:612:27)
        at executeAction (/home/charles/electron/fiddle/node_modules/mobx/lib/mobx.js:927:19)
        at AppState.updateDownloadedVersionState (/home/charles/electron/fiddle/node_modules/mobx/lib/mobx.js:914:16)
        at AppState.updateElectronVersions (/home/charles/electron/fiddle/src/renderer/state.ts:352:18)
        at processTicksAndRejections (node:internal/process/task_queues:94:5)
```

These thrown errors were not seen during test runs due to the mocking of console.warn() :slightly_smiling_face: 

*edit:* I don't think there are any untested LOC/branches here; I think coveralls is just upset about the removal of tested code :slightly_smiling_face: 

coveralls: 
[![Coverage Status](https://coveralls.io/builds/38547900/badge)](https://coveralls.io/builds/38547900)

Coverage decreased (-0.03%) to 93.605% when pulling **5a666e885e72bf8842347320b7f34a3f13896330 on refactor/remove-array-to-stringmap** into **248ac10a47d6380d01cb4fb84912675ed5d7bf0c on master**.


Patch:
diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx
index 5be8256a..6114e1b5 100644
--- a/src/renderer/app.tsx
+++ b/src/renderer/app.tsx
@@ -16,7 +16,6 @@ import { WEBCONTENTS_READY_FOR_IPC_SIGNAL } from '../ipc-events';
 import { updateEditorLayout } from '../utils/editor-layout';
 import { getEditorValue } from '../utils/editor-value';
 import { getPackageJson, PackageJsonOptions } from '../utils/get-package';
-import { getTitle } from '../utils/get-title';
 import { isEditorBackup } from '../utils/type-checks';
 import { EMPTY_EDITOR_CONTENT, SORTED_EDITORS } from './constants';
 import { FileManager } from './file-manager';
@@ -88,8 +87,6 @@ export class App {
     await this.setEditorValues(editorValues);
     this.state.isUnsaved = false;
 
-    document.title = getTitle(this.state);
-
     return true;
   }
 
@@ -188,6 +185,7 @@ export class App {
 
     this.setupResizeListener();
     this.setupThemeListeners();
+    this.setupTitleListeners();
 
     ipcRenderer.send(WEBCONTENTS_READY_FOR_IPC_SIGNAL);
 
@@ -269,6 +267,25 @@ export class App {
   public setupResizeListener(): void {
     window.addEventListener('resize', updateEditorLayout);
   }
+
+  /**
+   * Have document.title track state.title
+   */
+  public setupTitleListeners() {
+    // the observables used for the title usually change in a batch,
+    // so when setting document title, wait a tick to avoid flicker.
+    let titleIdle: any;
+    reaction(
+      () => this.state.title,
+      (title) => {
+        clearTimeout(titleIdle);
+        titleIdle = setTimeout(() => {
+          document.title = title;
+          titleIdle = null;
+        });
+      },
+    );
+  }
 }
 
 window.ElectronFiddle = window.ElectronFiddle || {};
diff --git a/src/renderer/components/chrome-mac.tsx b/src/renderer/components/chrome-mac.tsx
index 2b9d20cb..ff146a32 100644
--- a/src/renderer/components/chrome-mac.tsx
+++ b/src/renderer/components/chrome-mac.tsx
@@ -2,7 +2,6 @@ import { observer } from 'mobx-react';
 import * as React from 'react';
 import { IpcEvents } from '../../ipc-events';
 
-import { getTitle } from '../../utils/get-title';
 import { ipcRendererManager } from '../ipc';
 import { AppState } from '../state';
 
@@ -21,7 +20,7 @@ export class ChromeMac extends React.Component<ChromeMacProps> {
 
     return (
       <div className="chrome drag" onDoubleClick={this.handleDoubleClick}>
-        <small>{getTitle(this.props.appState)}</small>
+        <small>{this.props.appState.title}</small>
       </div>
     );
   }
diff --git a/src/renderer/components/editors.tsx b/src/renderer/components/editors.tsx
index 7558ff52..30a442b3 100644
--- a/src/renderer/components/editors.tsx
+++ b/src/renderer/components/editors.tsx
@@ -17,7 +17,12 @@ import {
   MosaicWindowProps,
 } from 'react-mosaic-component';
 
-import { EditorId, MosaicId, PanelId } from '../../interfaces';
+import {
+  EditorId,
+  MosaicId,
+  PanelId,
+  SetFiddleOptions,
+} from '../../interfaces';
 import { IpcEvents } from '../../ipc-events';
 import { updateEditorLayout } from '../../utils/editor-layout';
 import { getFocusedEditor } from '../../utils/focused-editor';
@@ -101,12 +106,14 @@ export class Editors extends React.Component<EditorsProps, EditorsState> {
     ipcRendererManager.on(IpcEvents.FS_NEW_FIDDLE, async (_event) => {
       const { version } = this.props.appState;
       const fiddle = await getTemplate(version);
-      await window.ElectronFiddle.app.replaceFiddle(fiddle, {});
+      const options: SetFiddleOptions = { templateName: version };
+      await window.ElectronFiddle.app.replaceFiddle(fiddle, options);
     });
 
     ipcRendererManager.on(IpcEvents.FS_NEW_TEST, async (_event) => {
       const fiddle = await getTestTemplate();
-      await window.ElectronFiddle.app.replaceFiddle(fiddle, {});
+      const options: SetFiddleOptions = { templateName: 'Test' };
+      await window.ElectronFiddle.app.replaceFiddle(fiddle, options);
     });
 
     ipcRendererManager.on(
diff --git a/src/renderer/state.ts b/src/renderer/state.ts
index b79484d3..09cfac5a 100644
--- a/src/renderer/state.ts
+++ b/src/renderer/state.ts
@@ -16,18 +16,18 @@ import {
   OutputEntry,
   OutputOptions,
   RunnableVersion,
+  SetFiddleOptions,
   Version,
   VersionSource,
   VersionState,
 } from '../interfaces';
 import { IpcEvents } from '../ipc-events';
-import { arrayToStringMap } from '../utils/array-to-stringmap';
 import { EditorBackup, getEditorBackup } from '../utils/editor-backup';
 import {
   createMosaicArrangement,
   getVisibleMosaics,
 } from '../utils/editors-mosaic-arrangement';
-import { getName } from '../utils/get-title';
+import { getName } from '../utils/get-name';
 import { normalizeVersion } from '../utils/normalize-version';
 import { isEditorBackup, isEditorId, isPanelId } from '../utils/type-checks';
 import {
@@ -124,7 +124,7 @@ export class AppState {
   @observable public versions: Record<
     string,
     RunnableVersion
-  > = arrayToStringMap(knownVersions);
+  > = Object.fromEntries(knownVersions.map((ver) => [ver.version, ver]));
   @observable public output: Array<OutputEntry> = [];
   @observable public localPath: string | undefined;
   @observable public genericDialogOptions: GenericDialogOptions = {
@@ -298,6 +298,33 @@ export class AppState {
     ]);
   }
 
+  /**
+   * @returns {string} the title, e.g. appname, fiddle name, state
+   */
+  @computed get title(): string {
+    const { gistId, isUnsaved, localPath, templateName } = this;
+    const tokens = [];
+
+    if (localPath) {
+      tokens.push(localPath);
+    } else if (templateName) {
+      tokens.push(templateName);
+    } else if (gistId) {
+      tokens.push(`gist.github.com/${gistId}`);
+    }
+
+    if (isUnsaved) {
+      tokens.push('Unsaved');
+    }
+
+    if (tokens.length > 0) {
+      tokens.unshift('-');
+    }
+
+    tokens.unshift('Electron Fiddle');
+    return tokens.join(' ');
+  }
+
   /**
    * Returns the current RunnableVersion or the first
    * one that can be found.
@@ -347,9 +374,7 @@ export class AppState {
     this.isUpdatingElectronVersions = true;
 
     try {
-      const versions = await getUpdatedElectronVersions();
-      this.versions = arrayToStringMap(versions);
-      await this.updateDownloadedVersionState();
+      this.setVersions(await getUpdatedElectronVersions());
     } catch (error) {
       console.warn(`State: Could not update Electron versions`, error);
     }
@@ -357,6 +382,11 @@ export class AppState {
     this.isUpdatingElectronVersions = false;
   }
 
+  public async getName() {
+    this.name ||= await getName(this);
+    return this.name;
+  }
+
   @action public hideChannels(channels: Array<ElectronReleaseChannel>) {
     this.channelsToShow = this.channelsToShow.filter(
       (ch) => !channels.includes(ch),
@@ -371,14 +401,6 @@ export class AppState {
     this.channelsToShow = [...s.values()];
   }
 
-  @action public async getName() {
-    if (!this.name) {
-      this.name = await getName(this);
-    }
-
-    return this.name;
-  }
-
   @action public toggleConsole() {
     this.isConsoleShowing = !this.isConsoleShowing;
   }
@@ -457,8 +479,13 @@ export class AppState {
 
   @action public addLocalVersion(input: Version) {
     addLocalVersion(input);
+    this.setVersions(getElectronVersions());
+  }
 
-    this.versions = arrayToStringMap(getElectronVersions());
+  @action private setVersions(versions: RunnableVersion[]) {
+    this.versions = Object.fromEntries(
+      versions.map((ver) => [ver.version, ver]),
+    );
     this.updateDownloadedVersionState();
   }
 
@@ -497,8 +524,7 @@ export class AppState {
       updatedVersions[version].state = VersionState.unknown;
     }
 
-    this.versions = updatedVersions;
-    this.updateDownloadedVersionState();
+    this.setVersions(Object.values(updatedVersions));
   }
 
   /**
@@ -561,7 +587,8 @@ export class AppState {
     // Should we update the editor?
     if (await isContentUnchanged(EditorId.main, this.version)) {
       const editorValues = await getTemplate(version);
-      await window.ElectronFiddle.app.replaceFiddle(editorValues, {});
+      const options: SetFiddleOptions = { templateName: version };
+      await window.ElectronFiddle.app.replaceFiddle(editorValues, options);
     }
 
     this.version = version;
diff --git a/src/renderer/versions.ts b/src/renderer/versions.ts
index 1b687ecb..8279c99f 100644
--- a/src/renderer/versions.ts
+++ b/src/renderer/versions.ts
@@ -133,6 +133,11 @@ function saveVersions(key: VersionKeys, versions: Array<Version>) {
   window.localStorage.setItem(key, stringified);
 }
 
+function sanitizeVersion(ver: RunnableVersion): RunnableVersion {
+  ver.version = normalizeVersion(ver.version);
+  return ver;
+}
+
 /**
  * Return both known as well as local versions.
  *
@@ -155,7 +160,7 @@ export function getElectronVersions(): Array<RunnableVersion> {
     };
   });
 
-  return [...known, ...local];
+  return [...known, ...local].map(sanitizeVersion);
 }
 
 /**
diff --git a/src/utils/array-to-stringmap.ts b/src/utils/array-to-stringmap.ts
deleted file mode 100644
index f8f53e1b..00000000
--- a/src/utils/array-to-stringmap.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { RunnableVersion } from '../interfaces';
-import { normalizeVersion } from './normalize-version';
-
-/**
- * Takes an array of GitHub releases and returns a StringMap of
- * Electron releases.
- *
- * @param {Array<RunnableVersion>} input
- * @returns {Record<string, RunnableVersion>}
- */
-export function arrayToStringMap(
-  input: Array<RunnableVersion>,
-): Record<string, RunnableVersion> {
-  const output = {};
-
-  input.forEach((version) => {
-    const versionNumber = normalizeVersion(version.version);
-    output[versionNumber] = version;
-  });
-
-  return output;
-}
diff --git a/src/utils/get-name.ts b/src/utils/get-name.ts
new file mode 100644
index 00000000..b16a1456
--- /dev/null
+++ b/src/utils/get-name.ts
@@ -0,0 +1,17 @@
+import { AppState } from '../renderer/state';
+
+/**
+ * Returns a name for this project
+ *
+ * @param {AppState} appState
+ * @returns {Promise<string>}
+ */
+export async function getName(appState: AppState): Promise<string> {
+  if (appState.localPath) {
+    const path = await import('path');
+    return path.basename(appState.localPath);
+  } else {
+    const namor = await import('namor');
+    return namor.generate({ words: 3, numbers: 0 });
+  }
+}
diff --git a/src/utils/get-title.ts b/src/utils/get-title.ts
deleted file mode 100644
index 5b959347..00000000
--- a/src/utils/get-title.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { AppState } from '../renderer/state';
-
-/**
- * Returns the window title
- *
- * @param {AppState} appState
- * @returns {string}
- */
-export function getTitle(appState: AppState): string {
-  const { gistId, localPath } = appState;
-  const gist = gistId ? `gist.github.com/${appState.gistId}` : '';
-  const local = localPath ? localPath : '';
-  const separator = gist && local ? ' ' : '';
-  const unsaved = !gist && !local ? 'Unsaved' : '';
-
-  return `Electron Fiddle - ${gist}${separator}${local}${unsaved}`;
-}
-
-/**
- * Returns a name for this project
- *
- * @param {AppState} appState
- * @returns {Promise<string>}
- */
-export async function getName(appState: AppState): Promise<string> {
-  if (appState.localPath) {
-    const path = await import('path');
-    return path.basename(appState.localPath);
-  } else {
-    const namor = await import('namor');
-    return namor.generate({ words: 3, numbers: 0 });
-  }
-}
diff --git a/tsconfig.json b/tsconfig.json
index 8c132529..a91e3ecd 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,7 +10,7 @@
     "preserveConstEnums": true,
     "sourceMap": true,
     "lib": [
-      "es2017",
+      "es2019",
       "dom",
       "WebWorker"
     ],
@@ -26,7 +26,7 @@
     "module": "commonjs",
     "moduleResolution": "node",
     "pretty": true,
-    "target": "es2017",
+    "target": "es2019",
     "jsx": "react",
     "typeRoots": [
       "./node_modules/@types"


Test patch:
diff --git a/tests/mocks/electron-versions.ts b/tests/mocks/electron-versions.ts
index 649861de..b86dee30 100644
--- a/tests/mocks/electron-versions.ts
+++ b/tests/mocks/electron-versions.ts
@@ -3,36 +3,33 @@ import {
   VersionSource,
   VersionState,
 } from '../../src/interfaces';
-import { arrayToStringMap } from '../../src/utils/array-to-stringmap';
 
-export const mockVersionsArray = [
-  {
+export const mockVersions: Record<string, RunnableVersion> = {
+  '2.0.3': {
+    source: VersionSource.remote,
     state: VersionState.ready,
     version: '2.0.3',
-    source: VersionSource.remote,
   },
-  {
+  '2.0.2': {
+    source: VersionSource.remote,
     state: VersionState.ready,
     version: '2.0.2',
-    source: VersionSource.remote,
   },
-  {
+  '2.0.1': {
+    source: VersionSource.remote,
     state: VersionState.ready,
     version: '2.0.1',
-    source: VersionSource.remote,
   },
-  {
+  '2.0.0': {
+    source: VersionSource.remote,
     state: VersionState.ready,
     version: '2.0.0',
-    source: VersionSource.remote,
   },
-  {
+  '1.8.7': {
+    source: VersionSource.remote,
     state: VersionState.ready,
     version: '1.8.7',
-    source: VersionSource.remote,
   },
-];
+};
 
-export const mockVersions: Record<string, RunnableVersion> = arrayToStringMap(
-  mockVersionsArray,
-);
+export const mockVersionsArray = Object.values(mockVersions);
diff --git a/tests/mocks/state.ts b/tests/mocks/state.ts
index 947e2f03..3aa0f646 100644
--- a/tests/mocks/state.ts
+++ b/tests/mocks/state.ts
@@ -6,6 +6,7 @@ export class MockState {
   @observable public isConsoleShowing = true;
   @observable public isGenericDialogShowing = false;
   @observable public isUsingSystemTheme = true;
+  @observable public title = 'Electron Fiddle';
   @observable public output = [];
   @observable public versionsToShow = [];
 
diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx
index 6f089e3d..376d8ac0 100644
--- a/tests/renderer/app-spec.tsx
+++ b/tests/renderer/app-spec.tsx
@@ -1,6 +1,7 @@
 import { App } from '../../src/renderer/app';
 import { AppState } from '../../src/renderer/state';
 import { EditorBackup } from '../../src/utils/editor-backup';
+import { waitFor } from '../../src/utils/wait-for';
 import { ElectronFiddleMock } from '../mocks/electron-fiddle';
 import { MockState } from '../mocks/state';
 import { EditorId } from '../../src/interfaces';
@@ -551,4 +552,21 @@ describe('App component', () => {
       });
     });
   });
+
+  describe('setupTitleListeners()', () => {
+    let app: App;
+
+    beforeEach(() => {
+      app = new App();
+      (app.state as Partial<AppState>) = new MockState();
+    });
+
+    it('updates the document title when state.title changes', async () => {
+      const title = 'Hello, World!';
+      app.setupTitleListeners();
+      (app.state.title as any) = title;
+      await waitFor(() => document.title?.length > 0);
+      expect(document.title).toMatch(title);
+    });
+  });
 });
diff --git a/tests/renderer/components/__snapshots__/chrome-mac-spec.tsx.snap b/tests/renderer/components/__snapshots__/chrome-mac-spec.tsx.snap
index fc0b5065..f0897fa3 100644
--- a/tests/renderer/components/__snapshots__/chrome-mac-spec.tsx.snap
+++ b/tests/renderer/components/__snapshots__/chrome-mac-spec.tsx.snap
@@ -6,7 +6,7 @@ exports[`Chrome-Mac component renders 1`] = `
   onDoubleClick={[Function]}
 >
   <small>
-    Electron Fiddle - Unsaved
+    Electron Fiddle
   </small>
 </div>
 `;
diff --git a/tests/renderer/components/chrome-mac-spec.tsx b/tests/renderer/components/chrome-mac-spec.tsx
index 14e11c89..80fd3f46 100644
--- a/tests/renderer/components/chrome-mac-spec.tsx
+++ b/tests/renderer/components/chrome-mac-spec.tsx
@@ -1,4 +1,5 @@
 import { mount, shallow } from 'enzyme';
+import { MockState } from '../../mocks/state';
 import * as React from 'react';
 import { IpcEvents } from '../../../src/ipc-events';
 
@@ -7,7 +8,7 @@ import { ipcRendererManager } from '../../../src/renderer/ipc';
 import { overridePlatform, resetPlatform } from '../../utils';
 
 describe('Chrome-Mac component', () => {
-  const store: any = {};
+  const store: any = new MockState();
 
   afterEach(() => resetPlatform());
 
diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts
index 585f6c20..3e15d524 100644
--- a/tests/renderer/state-spec.ts
+++ b/tests/renderer/state-spec.ts
@@ -31,7 +31,7 @@ import {
 } from '../../src/renderer/versions';
 import { createMosaicArrangement } from '../../src/utils/editors-mosaic-arrangement';
 import { waitFor } from '../../src/utils/wait-for';
-import { getName } from '../../src/utils/get-title';
+import { getName } from '../../src/utils/get-name';
 import { mockVersions, mockVersionsArray } from '../mocks/electron-versions';
 import { overridePlatform, resetPlatform } from '../utils';
 
@@ -42,8 +42,8 @@ jest.mock('../../src/renderer/content', () => ({
 jest.mock('../../src/renderer/binary', () => ({
   removeBinary: jest.fn(),
   setupBinary: jest.fn(),
-  getDownloadedVersions: jest.fn(),
-  getDownloadingVersions: jest.fn(),
+  getDownloadedVersions: jest.fn().mockResolvedValue([]),
+  getDownloadingVersions: jest.fn().mockReturnValue([]),
 }));
 jest.mock('../../src/renderer/fetch-types', () => ({
   updateEditorTypeDefinitions: jest.fn(),
@@ -65,17 +65,22 @@ jest.mock('../../src/renderer/versions', () => {
     getReleaseChannel,
   };
 });
-jest.mock('../../src/utils/get-title', () => ({
+jest.mock('../../src/utils/get-name', () => ({
   getName: jest.fn(),
 }));
 jest.mock('../../src/renderer/ipc');
 
 describe('AppState', () => {
-  let appState = new AppState();
+  let appState: AppState;
 
   beforeEach(() => {
+    (getDownloadedVersions as jest.Mock).mockResolvedValue([]);
+    (getDownloadingVersions as jest.Mock).mockReturnValue([]);
+    (getUpdatedElectronVersions as jest.Mock).mockResolvedValue(
+      mockVersionsArray,
+    );
+
     appState = new AppState();
-    appState.updateDownloadedVersionState = jest.fn();
     ipcRendererManager.removeAllListeners();
   });
 
@@ -436,13 +441,14 @@ describe('AppState', () => {
   });
 
   describe('hasVersion()', () => {
-    const UNKNOWN_VERSION = 'v999.99.99';
-    const KNOWN_VERSION = Object.keys(appState.versions).pop();
-
     it('returns false if state does not have that version', () => {
+      const UNKNOWN_VERSION = 'v999.99.99';
+
       expect(appState.hasVersion(UNKNOWN_VERSION)).toEqual(false);
     });
     it('returns true if state has that version', () => {
+      const KNOWN_VERSION = Object.keys(appState.versions).pop();
+
       expect(appState.hasVersion(KNOWN_VERSION!)).toBe(true);
     });
   });
@@ -524,22 +530,17 @@ describe('AppState', () => {
   });
 
   describe('updateDownloadedVersionState()', () => {
-    beforeEach(() => {
-      appState = new AppState();
-      ipcRendererManager.removeAllListeners();
-      (getDownloadingVersions as jest.Mock).mockReturnValue(['2.0.1']);
-      (getDownloadedVersions as jest.Mock).mockReturnValue(
-        Promise.resolve(['2.0.2']),
-      );
-    });
-
     it('downloads a version if necessary', async () => {
+      (getDownloadedVersions as jest.Mock).mockResolvedValue(['2.0.2']);
+
       await appState.updateDownloadedVersionState();
 
       expect(appState.versions['2.0.2'].state).toBe(VersionState.ready);
     });
 
     it('keeps downloading state intact', async () => {
+      (getDownloadingVersions as jest.Mock).mockReturnValue(['2.0.1']);
+
       await appState.updateDownloadedVersionState();
 
       expect(appState.versions['2.0.1'].state).toBe(VersionState.downloading);
@@ -717,4 +718,55 @@ describe('AppState', () => {
       expect(appState.acceleratorsToBlock).toEqual([]);
     });
   });
+
+  describe('title', () => {
+    const APPNAME = 'Electron Fiddle';
+
+    it('defaults to the appname', () => {
+      const expected = APPNAME;
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+
+    it('shows the name of local fiddles', () => {
+      const localPath = 'path/to/fiddle';
+      const expected = `${APPNAME} - ${localPath}`;
+      appState.localPath = localPath;
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+
+    it('shows the name of gist fiddles', () => {
+      const gistId = 'abcdef';
+      const expected = `${APPNAME} - gist.github.com/${gistId}`;
+      appState.gistId = gistId;
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+
+    it('prefers to display localPath', () => {
+      const gistId = 'abcdef';
+      const templateName = 'BrowserWindow';
+      const localPath = 'path/to/fiddle';
+      const expected = `${APPNAME} - ${localPath}`;
+      Object.assign(appState, { gistId, localPath, templateName });
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+
+    it('shows the name of template fiddles', () => {
+      const templateName = 'BrowserWindow';
+      const expected = `${APPNAME} - ${templateName}`;
+      appState.templateName = templateName;
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+
+    it('flags unsaved fiddles', () => {
+      const expected = `${APPNAME} - Unsaved`;
+      appState.isUnsaved = true;
+      const actual = appState.title;
+      expect(actual).toBe(expected);
+    });
+  });
 });
diff --git a/tests/utils/array-to-stringmap-spec.ts b/tests/utils/array-to-stringmap-spec.ts
deleted file mode 100644
index 4907cebc..00000000
--- a/tests/utils/array-to-stringmap-spec.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Version } from '../../src/interfaces';
-import { arrayToStringMap } from '../../src/utils/array-to-stringmap';
-
-describe('array-to-stringmap', () => {
-  it('correctly turns an array of versions into a stringmap', () => {
-    const input: Array<Partial<Version>> = [
-      {
-        version: 'v1.0.0',
-      },
-      {
-        version: 'v2.0.0',
-      },
-      {
-        version: '3.0.0',
-      },
-    ];
-    const output = arrayToStringMap(input as any);
-
-    expect(output).toEqual({
-      '1.0.0': {
-        version: 'v1.0.0',
-      },
-      '2.0.0': {
-        version: 'v2.0.0',
-      },
-      '3.0.0': {
-        version: '3.0.0',
-      },
-    });
-  });
-});
diff --git a/tests/utils/get-name-spec.ts b/tests/utils/get-name-spec.ts
new file mode 100644
index 00000000..cdfc3a4b
--- /dev/null
+++ b/tests/utils/get-name-spec.ts
@@ -0,0 +1,15 @@
+import { getName } from '../../src/utils/get-name';
+
+describe('get-name', () => {
+  describe('getName()', () => {
+    it('returns a random name', async () => {
+      const result = await getName({} as any);
+      expect(result).toBeTruthy();
+    });
+
+    it('returns a name from the local path if saved', async () => {
+      const result = await getName({ localPath: 'a/b/myFiddle' } as any);
+      expect(result).toBe('myFiddle');
+    });
+  });
+});
diff --git a/tests/utils/get-title-spec.ts b/tests/utils/get-title-spec.ts
deleted file mode 100644
index a615448b..00000000
--- a/tests/utils/get-title-spec.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { getName, getTitle } from '../../src/utils/get-title';
-
-describe('get-title', () => {
-  describe('getTitle()', () => {
-    it('returns a solid default title', () => {
-      const result = getTitle({} as any);
-      expect(result).toBe('Electron Fiddle - Unsaved');
-    });
-
-    it('returns a title for a Gist', () => {
-      const result = getTitle({ gistId: '123' } as any);
-      expect(result).toBe('Electron Fiddle - gist.github.com/123');
-    });
-
-    it('returns a title for a local fiddle', () => {
-      const result = getTitle({ localPath: 'a/b/fiddle' } as any);
-      expect(result).toBe('Electron Fiddle - a/b/fiddle');
-    });
-
-    it('returns a title for a local & gist fiddle', () => {
-      const result = getTitle({
-        localPath: 'a/b/fiddle',
-        gistId: '123',
-      } as any);
-      expect(result).toBe('Electron Fiddle - gist.github.com/123 a/b/fiddle');
-    });
-  });
-
-  describe('getName()', () => {
-    it('returns a random name', async () => {
-      const result = await getName({} as any);
-      expect(result).toBeTruthy();
-    });
-
-    it('returns a name from the local path if saved', async () => {
-      const result = await getName({ localPath: 'a/b/myFiddle' } as any);
-      expect(result).toBe('myFiddle');
-    });
-  });
-});