From 100f341c3e1cd57b8979b8466079474b924570af Mon Sep 17 00:00:00 2001 From: "Felix W. Dekker" Date: Tue, 12 Nov 2019 01:53:57 +0100 Subject: [PATCH] Clean up and test input history Fixes #75. --- package.json | 2 +- src/main/js/InputHistory.ts | 114 ++++++++++++++++++++ src/main/js/Persistence.ts | 2 +- src/main/js/Shell.ts | 5 +- src/main/js/Terminal.ts | 116 +-------------------- src/test/InputHistory.spec.ts | 189 ++++++++++++++++++++++++++++++++++ src/test/Path.spec.ts | 6 +- 7 files changed, 314 insertions(+), 120 deletions(-) create mode 100644 src/main/js/InputHistory.ts create mode 100644 src/test/InputHistory.spec.ts diff --git a/package.json b/package.json index b96853e..67b344d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fwdekker.com", - "version": "1.12.0", + "version": "1.12.1", "description": "The source code of [my personal website](https://fwdekker.com/).", "author": "Felix W. Dekker", "repository": { diff --git a/src/main/js/InputHistory.ts b/src/main/js/InputHistory.ts new file mode 100644 index 0000000..f4796bb --- /dev/null +++ b/src/main/js/InputHistory.ts @@ -0,0 +1,114 @@ +import {IllegalArgumentError} from "./Shared"; + + +/** + * A history of inputs that grows downwards and can be accessed in sequence relative to the newest entry. + * + * An input history keeps a "read index" that starts at `-1`. After entries have been added, calling `previous` will + * increase the read index to `0` and return the newest entry. Calling `previous` again will increase the read index to + * `1` and return the first-to-last entry. Calling `next` at this point will decrease the read index to `0` and will + * return the last entry again. Adding a new entry to the history resets the read index to `-1`. Calling `next` while + * the read index is at `-1` will return an empty string without decrementing the read index further. Calling `previous` + * at the highest possible index will return the first entry without incrementing the read index further. + */ +export class InputHistory { + /** + * The list of previous input. + */ + private readonly _entries: string[]; + /** + * The current index that the history is being read from. + */ + private index: number; + + + /** + * Constructs a new input history. + * + * @param history the records currently in the history + */ + constructor(history: string[] = []) { + this._entries = history; + this.index = -1; + } + + + /** + * Returns a copy of the entries in this history. + */ + get entries(): string[] { + return this._entries.slice(); + } + + + /** + * Adds a new input to the bottom of the history and resets the read index. + * + * @param entry the entry to add + */ + add(entry: string): void { + if (entry.trim() !== "" && entry.trim() !== this._entries[0]?.trim()) + this._entries.unshift(entry); + + this.resetIndex(); + } + + /** + * Removes all entries from the history and resets the read index. + */ + clear(): void { + this._entries.length = 0; + this.resetIndex(); + } + + /** + * Returns the entry at the given index, or an empty string if the index is negative. + * + * @param index the index to return the entry of, where `0` is the newest entry and `-1` returns an empty string + * @throws if the index is out of bounds and not `-1` + */ + get(index: number): string { + if (index < -1 || index >= this._entries.length) + throw new IllegalArgumentError(`Index '${index}' is out of bounds.`); + + if (index === -1) + return ""; + + return this._entries[index]; + } + + /** + * Returns the next (newer) entry in the history, or an empty string if the read index has gone past the newest + * entry. + * + * The read counter is decremented if possible. + */ + next(): string { + this.index--; + if (this.index < -1) + this.index = -1; + + return this.get(this.index); + } + + /** + * Returns the previous (older) entry in the history, or the oldest entry if the read index is already at the oldest + * entry. + * + * The read counter is incremented if possible. + */ + previous(): string { + this.index++; + if (this.index >= this._entries.length) + this.index = this._entries.length - 1; + + return this.get(this.index); + } + + /** + * Resets the read index without changing any entries. + */ + resetIndex(): void { + this.index = -1; + } +} diff --git a/src/main/js/Persistence.ts b/src/main/js/Persistence.ts index a37921b..8e8eada 100644 --- a/src/main/js/Persistence.ts +++ b/src/main/js/Persistence.ts @@ -1,8 +1,8 @@ import * as Cookies from "js-cookie"; import {Environment} from "./Environment"; import {Directory, FileSystem, Node} from "./FileSystem"; +import {InputHistory} from "./InputHistory"; import {UserList} from "./UserList"; -import {InputHistory} from "./Terminal"; /** diff --git a/src/main/js/Shell.ts b/src/main/js/Shell.ts index a642063..fbceae7 100644 --- a/src/main/js/Shell.ts +++ b/src/main/js/Shell.ts @@ -1,10 +1,11 @@ import {Commands} from "./Commands"; import {Environment} from "./Environment"; import {Directory, FileSystem, Path} from "./FileSystem"; +import {InputHistory} from "./InputHistory"; import {InputParser} from "./InputParser"; import {Persistence} from "./Persistence"; import {asciiHeaderHtml, IllegalStateError} from "./Shared"; -import {EscapeCharacters, InputHistory} from "./Terminal"; +import {EscapeCharacters} from "./Terminal"; import {UserList} from "./UserList"; import {StreamSet} from "./Stream"; @@ -144,7 +145,7 @@ export class Shell { return 0; } - this.inputHistory.addEntry(inputString); + this.inputHistory.add(inputString); let input; try { diff --git a/src/main/js/Terminal.ts b/src/main/js/Terminal.ts index 754db39..29bb8f0 100644 --- a/src/main/js/Terminal.ts +++ b/src/main/js/Terminal.ts @@ -1,3 +1,4 @@ +import {InputHistory} from "./InputHistory"; import {Persistence} from "./Persistence"; import {escapeHtml, moveCaretToEndOf, parseCssPixels} from "./Shared"; import {Shell} from "./Shell"; @@ -288,11 +289,11 @@ export class Terminal { switch (event.key.toLowerCase()) { case "arrowup": - this.inputText = this.inputHistory.previousEntry(); + this.inputText = this.inputHistory.previous(); window.setTimeout(() => moveCaretToEndOf(this.input), 0); break; case "arrowdown": - this.inputText = this.inputHistory.nextEntry(); + this.inputText = this.inputHistory.next(); window.setTimeout(() => moveCaretToEndOf(this.input), 0); break; case "tab": @@ -332,114 +333,3 @@ export enum EscapeCharacters { */ ShowInput = "\u0003" } - - -/** - * A history of inputs that grows downwards and can be accessed in sequence relative to the newest entry. - * - * An input history keeps a "read index" that starts at `-1`. After entries have been added, calling `previousEntry` - * will increase the read index to `0` and return the newest entry. Calling `previousEntry` again will increase the read - * index to `1` and return the first-to-last entry. Calling `nextEntry` at this point will decrease the read index to - * `0` and will return the last entry again. Adding a new entry to the history resets the read index to `-1`. Calling - * `nextEntry` while the read index is at `-1` will return an empty string without decrementing the read index further. - * Calling `previousEntry` at the highest possible index will return the first entry without incrementing the read index - * further. - */ -export class InputHistory { - /** - * The list of previous input. - */ - private readonly _entries: string[]; - /** - * The current index that the history is being read from. - */ - private index: number; - - - /** - * Constructs a new input history. - * - * @param history the records currently in the history - */ - constructor(history: string[] = []) { - this._entries = history; - this.index = -1; - } - - - /** - * Returns a copy of the entries in this history. - */ - get entries(): string[] { - return this._entries.slice(); - } - - - /** - * Adds a new input to the bottom of the history and resets the read index. - * - * @param entry the entry to add - */ - addEntry(entry: string): void { - if (entry.trim() !== "" && entry.trim() !== this._entries[0]?.trim()) - this._entries.unshift(entry); - - this.index = -1; - } - - /** - * Removes all entries from the history and resets the read index. - */ - clear(): void { - this._entries.length = 0; - this.index = -1; - } - - /** - * Returns the entry at the given index, or an empty string if the index is negative. - * - * @param index the index to return the entry of, where `0` is the newest entry and `-1` returns an empty string - * @throws if the index is out of bounds and not `-1` - */ - getEntry(index: number): string { - if (index === -1) - return ""; - - return this._entries[index]; - } - - /** - * Returns the next (newer) entry in the history, or an empty string if the read index has gone past the newest - * entry. - * - * The read counter is decremented if possible. - */ - nextEntry(): string { - this.index--; - if (this.index < -1) - this.index = -1; - - return this.getEntry(this.index); - } - - /** - * Returns the previous (older) entry in the history, or the oldest entry if the read index is already at the oldest - * entry. - * - * The read counter is incremented if possible. - */ - previousEntry(): string { - this.index++; - if (this.index >= this._entries.length) - this.index = this._entries.length - 1; - - return this.getEntry(this.index); - } - - /** - * Resets the read index without changing any entries. - */ - resetIndex(): void { - this.index = -1; - } -} diff --git a/src/test/InputHistory.spec.ts b/src/test/InputHistory.spec.ts new file mode 100644 index 0000000..daa453d --- /dev/null +++ b/src/test/InputHistory.spec.ts @@ -0,0 +1,189 @@ +import "mocha"; +import {expect} from "chai"; + +import {InputHistory} from "../main/js/InputHistory"; + + +describe("input history", () => { + let history: InputHistory; + + + beforeEach(() => { + history = new InputHistory(); + }); + + + describe("entries", () => { + it("returns a copy of the entries", () => { + history.add("old"); + + history.entries[0] = "new"; + + expect(history.entries[0]).to.equal("old"); + }); + }); + + describe("add", () => { + it("does not add empty entries", () => { + history.add(""); + + expect(history.entries.length).to.equal(0); + }); + + it("does not add duplicate entries", () => { + history.add("command"); + history.add("command"); + + expect(history.entries.length).to.equal(1); + }); + + it("does not add duplicate entries, ignoring leading and trailing whitespace", () => { + history.add("command "); + history.add(" command"); + + expect(history.entries.length).to.equal(1); + }); + + it("adds entries including leading and trailing whitespace", () => { + history.add(" command "); + + expect(history.entries[0]).to.equal(" command "); + }); + + it("resets the index", () => { + history.add("command1"); + history.add("command2"); + history.previous(); + + history.add("command3"); + + expect(history.previous()).to.equal("command3"); + }); + }); + + describe("clear", () => { + it("removes all entries", () => { + history.add("command1"); + history.add("command2"); + + history.clear(); + + expect(history.entries).to.have.length(0); + }); + + it("resets the index", () => { + history.add("command1"); + history.add("command2"); + history.previous(); + history.previous(); + + history.clear(); + + expect(history.previous()).to.equal(""); + }); + }); + + describe("get", () => { + it("throws an error if the index is below -1", () => { + expect(() => history.get(-2)).to.throw(); + }); + + it("throws an error if the index is greater than or equal to the history size", () => { + history.add("command1"); + history.add("command2"); + + expect(() => history.get(2)).to.throw(); + }); + + it("returns an empty string if the index is -1 and the history is empty", () => { + expect(history.get(-1)).to.equal(""); + }); + + it("returns an empty string if the index is -1 and the history is non-empty", () => { + history.add("command1"); + history.add("command2"); + + expect(history.get(-1)).to.equal(""); + }); + + it("returns the most recent entry at index 0", () => { + history.add("command1"); + history.add("command2"); + + expect(history.get(0)).to.equal("command2"); + }); + + it("returns the least recent entry at the highest index", () => { + history.add("command1"); + history.add("command2"); + + expect(history.get(1)).to.equal("command1"); + }); + }); + + describe("next", () => { + beforeEach(() => { + history.add("command1"); + history.add("command2"); + history.add("command3"); + }); + + + it("returns an empty string at the first call", () => { + expect(history.next()).to.equal(""); + }); + + it("returns an empty string if the index is -1", () => { + history.next(); + history.next(); + history.next(); + + expect(history.next()).to.equal(""); + }); + + it("returns an empty string if the index is currently at the most recent entry", () => { + history.previous(); + + expect(history.next()).to.equal(""); + }); + + it("returns the most recent entry if the index is currently at the second-most recent", () => { + history.previous(); + history.previous(); + + expect(history.next()).to.equal("command3"); + }); + }); + + describe("previous", () => { + beforeEach(() => { + history.add("command1"); + history.add("command2"); + history.add("command3"); + }); + + + it("returns the newest entry at the first call", () => { + expect(history.previous()).to.equal("command3"); + }); + + it("returns the second-newest entry at the second call", () => { + history.previous(); + + expect(history.previous()).to.equal("command2"); + }); + + it("always returns the oldest entry once the index has reached the oldest entry", () => { + history.previous(); + history.previous(); + history.previous(); + history.previous(); + + expect(history.previous()).to.equal("command1"); + }); + }); + + describe("resetIndex", () => { + // Covered indirectly in `add` + }); +}); diff --git a/src/test/Path.spec.ts b/src/test/Path.spec.ts index b1c0244..220bdbc 100644 --- a/src/test/Path.spec.ts +++ b/src/test/Path.spec.ts @@ -110,7 +110,7 @@ describe("paths", () => { describe("ancestors", () => { it("returns an empty list for the root path", () => { - expect(new Path("/").ancestors.map(it => it.toString())).to.be.empty; + expect(new Path("/").ancestors.map(it => it.toString())).to.have.length(0); }); it("returns the root path for a path to a subdirectory of root", () => { @@ -152,11 +152,11 @@ describe("paths", () => { describe("ancestors until", () => { it("returns no ancestors between root and itself", () => { - expect(new Path("/").getAncestorsUntil(new Path("/"))).to.be.empty; + expect(new Path("/").getAncestorsUntil(new Path("/"))).to.have.length(0); }); it("returns no ancestors between a non-root and itself", () => { - expect(new Path("/dir/file").getAncestorsUntil(new Path("/dir/file"))).to.be.empty; + expect(new Path("/dir/file").getAncestorsUntil(new Path("/dir/file"))).to.have.length(0); }); it("returns the root parent if there are no other ancestors in between", () => {