function WordGuesserTests(wordChoices, fields) {
const reporter = new DOMReporter("test_container");
class TestRunner {
constructor(appName) {
this.root = new TestSuite(appName);
}
// Proxy to the root suite
expect(expectation) {
return this.root.expect(expectation);
}
describe(name) {
return this.root.describe(name);
}
report() {
console.debug("=".repeat(30));
this.root.report();
const stats = this.root.getStats();
console.debug("=".repeat(30));
console.debug(
`FINAL REPORT: ${stats.passed}/${stats.total} tests passed.`,
);
reporter.render(this.root);
reporter.renderFinalStats(stats);
reset(fields);
}
}
class TestSuite {
constructor(name) {
this.name = name;
this.tests = [];
this.subSuites = [];
}
// Create a new test within this suite
expect(expectation) {
const test = new Test(this.name).expect(expectation);
this.tests.push(test);
return test; // Returns the Test instance for further chaining (.value, .toBe, etc.)
}
// Create and return a nested suite
describe(name) {
const subSuite = new TestSuite(name);
this.subSuites.push(subSuite);
return subSuite;
}
// Recursive reporting
report(depth = 0) {
const indent = " ".repeat(depth);
this.tests.forEach((t) => {
const status = t.isPassed ? "✅" : "❌";
console.debug(`${indent} ${status} ${t.expectation}`);
if (!t.isPassed) {
let expected, got;
if (t.assertionType === "exception") {
expected = "Error satisfying validation logic";
got = t.actualResult;
} else {
expected = JSON.stringify(t.trialValue);
got = JSON.stringify(t.actualResult);
}
console.error(`${indent} Expected: ${expected} | Got: ${got}`);
}
});
this.subSuites.forEach((s) => s.report(depth + 1));
}
// Helper to aggregate total counts
getStats() {
let passed = this.tests.filter((t) => t.isPassed).length;
let total = this.tests.length;
this.subSuites.forEach((s) => {
const stats = s.getStats();
passed += stats.passed;
total += stats.total;
});
return { passed, total };
}
}
class Test {
constructor(name) {
this.name = name;
this.expectation = null;
this.sample = null;
this.trialValue = null;
this.actualResult = null;
this.isPassed = false;
this.errorCaught = null;
// -- Has been called?
this._trialValue = false;
this._sample = false;
this._callback = null;
this._threwError = null;
this._actualResult = null;
}
// Define the description of the test
expect(expectation) {
this.expectation = expectation;
return this;
}
// The value or function being tested
value(sample) {
this._sample = true;
this.sample = sample;
return this;
}
// The value to compare the result against
toBe(value) {
this._trialValue = true;
this.trialValue = value;
return this;
}
toThrow(validator) {
this._actualResult = true;
try {
if (typeof this.sample === "function") {
this.sample();
}
} catch (e) {
this.errorCaught = e;
this.isPassed = validator(e);
this.actualResult = e;
this._actualResult = true;
return this;
}
// If no error was thrown, the test fails
this.isPassed = false;
this.actualResult = "No error was thrown";
return this;
}
assert(callback) {
if (this._actualResult !== null) {
return this;
}
try {
if (typeof this.sample == "function") {
this.sample = this.sample();
}
if (typeof callback === "function") {
try {
this.actualResult = callback(this.sample);
} catch (e) {
this.threwError = false;
this.actualResult = e.message;
}
this._callback = true;
} else {
this.actualResult = this.sample;
this._callback = false;
}
this.isPassed =
JSON.stringify(this.actualResult) === JSON.stringify(this.trialValue);
} catch (e) {
this.actualResult = `Test Engine Error: ${e.message}`;
this.isPassed = false;
}
return this;
}
_reportFailure() {
console.error(
`Test Suite: ${this.name}\n`,
"Assertion Failed: ",
this.expectation,
"\n" + "Sample Input: ",
this.sample,
"\n" + "Expected Value: ",
this.trialValue,
"\n" + "Actual Result: ",
this.actualResult,
"\n",
);
}
reportFailure() {
console.error(
`Test Suite: ${this.name}\n`,
"Assertion Failed: ",
JSON.stringify(this.expectation),
"\n" + "Sample Input: ",
JSON.stringify(this.sample),
"\n" + "Expected Value: ",
JSON.stringify(this.trialValue),
"\n" + "Actual Result: ",
JSON.stringify(this.actualResult),
"\n",
);
}
}
let testNumber = 0;
function tests(word) {
testNumber++;
reset(fields);
const test = new TestRunner(`Word Guesser App Test ${testNumber}`);
const wordGuesser = new WordGuesser([word]);
const game = new Game([word], fields);
const validLetter = word[0];
const invalidLetter =
"abcdefghijklmnopqrstuvwxyz".split("").find((c) => !word.includes(c)) ||
"z";
// -- Test charsAt(char, chars)
const testCharsAt = test.describe("charsAt(char, chars)");
testCharsAt
.expect("No characters should be found")
.value({ char: "a", list: ["b", "c"] })
.toBe(false)
.assert((input) => charsAt(input.char, input.list));
testCharsAt
.expect("One character should be found at index 1")
.value({ char: "a", list: ["b", "a", "c"] })
.toBe([1])
.assert((input) => charsAt(input.char, input.list));
testCharsAt
.expect("Two characters should be found at indices 1 and 3")
.value({ char: "a", list: ["b", "a", "c", "a", "d"] })
.toBe([1, 3])
.assert((input) => charsAt(input.char, input.list));
// -- Test charInChars(char, chars)
const testCharInChars = test.describe("charsInChars(char, chars)");
testCharInChars
.expect("No characters should be found")
.value({ char: "a", list: ["b", "c"] })
.toBe(false)
.assert((input) => charInChars(input.char, input.list));
testCharInChars
.expect("One character should be found at index 1")
.value({ char: "a", list: ["b", "a", "c"] })
.toBe(true)
.assert((input) => charInChars(input.char, input.list));
testCharInChars
.expect("Two characters should be found at indices 1 and 3")
.value({ char: "a", list: ["b", "a", "c", "a", "d"] })
.toBe(true)
.assert((input) => charInChars(input.char, input.list));
const testWordGuesserLogic = test.describe("WordGuesser Class");
testWordGuesserLogic
.expect("gameOver should return false")
.value(() => wordGuesser.gameOver())
.toBe(false)
.assert();
testWordGuesserLogic
.expect("Current word to be placeholders")
.value(() => wordGuesser.getCurrentWord())
.toBe(Array(wordGuesser.getActualWord().length).fill("_"))
.assert();
testWordGuesserLogic
.expect("actual word to be " + word + " char list")
.value(() => wordGuesser.getActualWord())
.toBe(word.split(""))
.assert();
testWordGuesserLogic
.expect("actual word to be " + word)
.value(() => wordGuesser.getActualWord().join(""))
.toBe(word)
.assert();
testWordGuesserLogic
.expect("guesses")
.value(() => wordGuesser.guessCount())
.toBe(0)
.assert();
testWordGuesserLogic
.expect("to reject a bad guess")
.value(() => wordGuesser.guessLetter(invalidLetter))
.toThrow((err) => err instanceof BadGuessError);
testWordGuesserLogic
.expect("count bad guesses")
.value(() => wordGuesser.guessCount())
.toBe(1)
.assert();
const duplicateGuessError = (err) => {
return (
err instanceof Error && err.message.includes("already been guessed")
);
};
testWordGuesserLogic
.expect("ignore duplicate bad guesses")
.value(() => wordGuesser.guessLetter(invalidLetter))
.toThrow(duplicateGuessError);
testWordGuesserLogic
.expect("accepts a good guess and does not increase the guess count")
.value(() => {
try {
wordGuesser.guessLetter(validLetter);
} finally {
return wordGuesser.guessCount();
}
})
.toBe(1)
.assert();
testWordGuesserLogic
.expect("rejects duplicate good guesses")
.value(() => wordGuesser.guessLetter(validLetter))
.toThrow(duplicateGuessError);
testWordGuesserLogic
.expect("Updates current word")
.value(() => {
return wordGuesser.getCurrentWord()[0];
})
.toBe(validLetter)
.assert();
testWordGuesserLogic
.expect("Updates current word")
.value(() => {
return wordGuesser.getCurrentWord()[0];
})
.toBe(validLetter)
.assert();
const testLetter = (letter) => {
if (
!word.includes(letter) ||
wordGuesser.getCurrentWord().includes(letter)
)
return true;
const expectedCount = word.split("").filter((c) => c === letter).length;
const indexes = wordGuesser.guessLetter(letter);
if (indexes.length != expectedCount) throw new Error("Length mismatch");
if (
charsAt(letter, wordGuesser.getCurrentWord()).length !== expectedCount
)
throw new Error("Current word is not updated");
if (charsAt(letter, wordGuesser.getGuesses()) !== false)
throw new Error(`Guesses for '${letter}' are not false`);
return true;
};
const unGuessedLetters = [...new Set(word.split(""))].filter(
(c) => c !== validLetter,
);
const l1 = unGuessedLetters.length > 0 ? unGuessedLetters[0] : validLetter;
const l2 = unGuessedLetters.length > 1 ? unGuessedLetters[1] : l1;
testWordGuesserLogic
.expect(`Handles the letter '${l1}'`)
.value(() => testLetter(l1))
.toBe(true)
.assert();
testWordGuesserLogic
.expect(`Handles the letter '${l2}'`)
.value(() => testLetter(l2))
.toBe(true)
.assert();
testWordGuesserLogic
.expect("Handles game over")
.value(() => {
const testGame = new WordGuesser([word]);
const uniqueChars = [...new Set(word.split(""))];
uniqueChars.forEach((c) => testGame.guessLetter(c));
return testGame.gameOver();
})
.toBe(true)
.assert();
testWordGuesserLogic
.expect("Handles all guesses")
.value(() => {
const testGame = new WordGuesser([word]);
let validGuesses = [];
const alphabet = "abcdefghijklmnopqrstuvwxyz";
alphabet.split("").forEach((guess) => {
validGuesses.push(guess.toLowerCase());
validGuesses.push(guess.toUpperCase());
});
const goodGuesses = validGuesses.filter(
(badGuess) =>
charInChars(badGuess, testGame.getActualWord()) !== false,
);
const badGuesses = validGuesses.filter(
(badGuess) =>
charInChars(badGuess, testGame.getActualWord()) === false,
);
const failedBadGuesses = badGuesses.filter((badGuess) => {
try {
if (testGame.guessLetter(badGuess) !== false) return true;
} catch (e) {
if (
!(e instanceof BadGuessError) &&
!(e instanceof InvalidGuessError)
)
return true;
}
});
const failedGoodGuesses = goodGuesses.filter((goodGuess) => {
try {
testGame.guessLetter(goodGuess);
} catch (e) {
return true;
}
});
return failedGoodGuesses.concat(failedBadGuesses);
})
.toBe([])
.assert();
const testValidation = test.describe("Validation");
testValidation
.expect("to handle bad input")
.value(() => {
const badInput = "-0123456789!@#$%^&*()[]+=\\|/?,.{}\"'><:;".split("");
return badInput.filter((input) => {
try {
wordGuesser.guessLetter(input);
return true;
} catch (e) {
return false;
}
});
})
.toBe([])
.assert();
const testWordGuesserDOM = test.describe("DOM testing");
// Test for Button Element
testWordGuesserDOM
.expect("Field 'guess_button' should be an HTML Button Element")
.value(
fields.guessButton instanceof HTMLButtonElement ||
fields.guessButton?.type === "button",
)
.toBe(true)
.assert();
// -- Test the letter input
testWordGuesserDOM
.expect("Field 'guess_count' should be an input of type 'number'")
.value(
fields.guessCount instanceof HTMLInputElement &&
fields.guessCount.type === "number",
)
.toBe(true)
.assert();
testWordGuesserDOM
.expect("Field 'guess' should be an input of type 'text'")
.value(
fields.guessLetter instanceof HTMLInputElement &&
fields.guessLetter.type === "text",
)
.toBe(true)
.assert();
// Test for Generic DOM Elements (Paragraphs/Divs)
testWordGuesserDOM
.expect("Field 'currentWord' should be a valid DOM element")
.value(fields.currentWord instanceof HTMLElement)
.toBe(true)
.assert();
testWordGuesserDOM
.expect("Field 'guesses' should be a valid DOM element")
.value(fields.guesses instanceof HTMLElement)
.toBe(true)
.assert();
// -- Tareted tests
testWordGuesserLogic
.expect(
"Verify updateCurrentWord places letters at the exact provided indices",
)
.value(() => {
const logicTest = new WordGuesser([word]);
const firstLetter = logicTest.getActualWord()[0];
const indexes = charsAt(firstLetter, logicTest.getActualWord());
logicTest.updateCurrentWord(firstLetter, indexes);
return logicTest.getCurrentWord()[indexes[0]];
})
.toBe(word.charAt(0))
.assert();
testWordGuesserLogic
.expect("gameOver identifies completed word")
.value(() => {
const overTest = new WordGuesser([word]);
const uniqueChars = [...new Set(word.split(""))];
uniqueChars.forEach((c) => overTest.guessLetter(c));
return overTest.gameOver();
})
.toBe(true)
.assert();
// -- updateLetters
const testUpdateLetters = test.describe("Utility: updateLetters");
const letters = updateLetters(["A", "B", "C"]);
testUpdateLetters
.expect("updateLetters should append the correct number of LI elements")
.value(letters.children.length)
.toBe(3)
.assert();
testUpdateLetters
.expect("updateLetters should handle empty arrays without error")
.value(() => updateLetters([]).children.length)
.toBe(0)
.assert();
// -- Test suite for Game Class Logic (Integration Testing)
const testGameIntegration = test.describe("Game Class Integration");
testGameIntegration
.expect(
"Game should update the DOM 'guessCount' display when a guess is made",
)
.value(() => {
game.newGame();
game.guessLetter.value = invalidLetter;
game.guessButton.click();
return game.guessCount.value;
})
.toBe("1")
.assert();
testGameIntegration
.expect("Input field should be cleared after a guess is submitted")
.value(() => {
game.newGame();
game.guessLetter.value = validLetter;
game.guessButton.click();
return game.guessLetter.value;
})
.toBe("")
.assert();
testGameIntegration
.expect("Game should update notifications area on duplicate guess error")
.value(() => {
game.newGame();
game.guessLetter.value = invalidLetter;
game.guessButton.click();
game.guessLetter.value = invalidLetter;
game.guessButton.click();
return game.notifications.innerText.toLowerCase().includes("🔁");
})
.toBe(true)
.assert();
testGameIntegration
.expect(
"UI 'guesses' list should contain a matching number of LI items to the guess count",
)
.value(() => {
game.newGame();
game.guessLetter.value = invalidLetter;
game.guessButton.click();
return (
game.guesses.querySelectorAll("li").length === game.game.guessCount()
);
})
.toBe(true)
.assert();
testGameIntegration
.expect("Game should display '🔁' icon for duplicate bad guesses")
.value(() => {
game.newGame();
const badLetter = invalidLetter;
game.guessLetter.value = badLetter;
game.guessButton.click();
game.guessLetter.value = badLetter;
game.guessButton.click();
return game.notifications.innerText.includes("🔁");
})
.toBe(true)
.assert();
testGameIntegration
.expect("Game should display '💎' icon for duplicate good guesses")
.value(() => {
game.newGame();
const goodLetter = validLetter;
game.guessLetter.value = goodLetter;
game.guessButton.click();
game.guessLetter.value = goodLetter;
game.guessButton.click();
return game.notifications.innerText.includes("💎");
})
.toBe(true)
.assert();
test.report();
}
wordChoices.forEach(tests);
}
class DOMReporter {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.showFailedOnly = true;
}
render(rootSuite) {
if (!this.container) return;
this.container.appendChild(this.buildSuiteUI(rootSuite, 0));
}
buildSuiteUI(suite, depth) {
const suiteDiv = document.createElement("div");
suiteDiv.className = "suite-container";
suiteDiv.style.marginLeft = `${depth * 20}px`;
const title = document.createElement("h5");
title.innerText = `Suite: ${suite.name}`;
suiteDiv.appendChild(title);
const list = document.createElement("ul");
list.style.listStyle = "none";
list.style.padding = "0";
// Render Tests
suite.tests.forEach((test) => {
const item = document.createElement("li");
item.className = test.isPassed ? "test-pass" : "test-fail";
item.style.padding = "5px";
item.style.borderLeft = `4px solid ${test.isPassed ? "#4CAF50" : "#f44336"}`;
item.style.marginBottom = "5px";
item.style.backgroundColor = test.isPassed ? "#e8f5e9" : "#ffebee";
let content = `<span>${test.isPassed ? "✅" : "❌"} ${test.expectation}</span>`;
if (!test.isPassed) {
const expected = JSON.stringify(test.trialValue);
const got = JSON.stringify(test.actualResult);
content += `<div style="font-size: 0.8rem; color: #c62828; margin-top: 5px;">
<strong>Expected:</strong> ${expected} | <strong>Got:</strong> ${got}
</div>`;
} else {
if (this.showFailedOnly) return;
}
item.innerHTML = content;
list.appendChild(item);
});
suiteDiv.appendChild(list);
// Recursive call for sub-suites
suite.subSuites.forEach((sub) => {
suiteDiv.appendChild(this.buildSuiteUI(sub, depth + 1));
});
return suiteDiv;
}
renderFinalStats(stats) {
const summary = document.createElement("div");
summary.style.marginTop = "20px";
summary.style.padding = "10px";
summary.style.fontWeight = "bold";
summary.style.borderTop = "2px solid #ccc";
summary.innerText = `Final Result: ${stats.passed} / ${stats.total} Passed`;
summary.style.color = stats.passed === stats.total ? "#2e7d32" : "#c62828";
this.container.appendChild(summary);
}
}