diff --git a/html/word-guesser/config.yaml b/html/word-guesser/config.yaml new file mode 100644 index 0000000..1a413f3 --- /dev/null +++ b/html/word-guesser/config.yaml @@ -0,0 +1,9 @@ +title: "Word Guesser" +layout: "main" +page: "page" +slug: "word-guesser" +styles: + - "css/styles.css" +scripts: + - "scripts/script.js" +file: "index.html" diff --git a/html/word-guesser/css/styles.css b/html/word-guesser/css/styles.css new file mode 100644 index 0000000..7fb1512 --- /dev/null +++ b/html/word-guesser/css/styles.css @@ -0,0 +1,169 @@ +/* Game Container Styling */ +.form_container { + background-color: #ffffff; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + margin-top: 1rem; +} + +.game-header h2 { + color: #2c3e50; + border-bottom: 2px solid #2c3e50; + display: inline-block; + margin-bottom: 1.5rem; + padding-bottom: 0.2rem; +} + +.game-instructions { + text-align: center; + margin: 1.5rem 0; + font-size: 0.95rem; + color: #6c757d; +} + +/* Word Board (Wheel of Fortune Style) */ +#current_word { + display: flex !important; + flex-direction: row !important; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 8px; + list-style: none; + padding: 1rem 0; + width: 100%; + font-weight: bold; + letter-spacing: 5px; + font-size: 1.5rem; +} + +#current_word li { + width: 45px; + height: 60px; + background-color: #f8f9fa; + border: 2px solid #2c3e50; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: 800; + color: #2c3e50; + box-shadow: 2px 2px 0px rgba(44, 62, 80, 0.2); + text-transform: uppercase; +} +.word-display-field { + display: block !important; /* Break out of the 2-column grid */ + text-align: center; + padding: 2rem 0; + border-bottom: 2px solid #f1f3f5; +} + +/* Form Layout */ +.formField { + display: grid; + grid-template-columns: 120px 1fr; + gap: 20px; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid #f1f3f5; +} + +.formField:last-of-type { + border-bottom: none; +} + +.formField label { + font-weight: 600; + color: #495057; + font-size: 0.9rem; +} + +/* Inputs & Buttons */ +#guess_letter { + width: 50px; + padding: 10px; + text-align: center; + font-size: 1.2rem; + font-weight: bold; + border: 2px solid #dee2e6; + border-radius: 4px; + transition: border-color 0.2s; +} + +#guess_letter:focus { + border-color: #2c3e50; + outline: none; +} + +#guess_count { + width: 60px; + background: #e9ecef; + border: 1px solid #ced4da; + padding: 5px; + text-align: center; + font-family: monospace; + font-weight: bold; + border-radius: 4px; +} + +.submitField { + padding: 1.5rem 0; + text-align: center; +} + +#guess_button { + background-color: #2c3e50; + color: #ffffff; + border: none; + padding: 0.8rem 2rem; + font-size: 1rem; + font-weight: 600; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +#guess_button:hover { + background-color: #34495e; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +/* Guess History Pills */ +#guesses { + display: flex; + flex-wrap: wrap; + gap: 6px; + list-style: none; +} + +#guesses li { + background-color: #e3f2fd; + color: #1976d2; + padding: 2px 10px; + border-radius: 20px; + font-size: 0.85rem; + font-family: "Courier New", Courier, monospace; + font-weight: bold; + border: 1px solid #bbdefb; +} + +/* Notifications */ +#notifications { + text-align: center; + font-weight: 600; + min-height: 1.5rem; + margin-bottom: 1rem; +} + +.success-msg { + color: #2e7d32; +} +.error-msg { + color: #c62828; +} diff --git a/html/word-guesser/index.html b/html/word-guesser/index.html new file mode 100644 index 0000000..130238e --- /dev/null +++ b/html/word-guesser/index.html @@ -0,0 +1,49 @@ +
+
+
+

The Word Guessing Game

+
+

+
+
    +
    +
    +

    Guess what the word is

    + +
    +
    +

    + Please enter a single alphabetical character (A-Z) into the input + box belowand and click "Guess" to see if the letter + exists in the hidden word.

    +

    +
    +
    + +
    +
    + + +
    +
    + +
      +
      +
      +
      + diff --git a/html/word-guesser/scripts/script.js b/html/word-guesser/scripts/script.js new file mode 100644 index 0000000..a698195 --- /dev/null +++ b/html/word-guesser/scripts/script.js @@ -0,0 +1,474 @@ +// -- Determines if a letter is in a list of letters +function charInChars(char, chars) { + const indexes = charsAt(char, chars); + return indexes !== false; +} +function charsAt(char, chars) { + let indexes = []; + for (let i = 0; i < chars.length; i++) { + if (char == chars[i]) { + indexes.push(i); + } + } + return indexes.length == 0 ? false : indexes; +} + +function updateLetters(chars) { + const letters = document.createElement("ul"); + // create a DOM element for each letter + chars.forEach((char) => { + const item = document.createElement("li"); + item.innerText = char; + letters.appendChild(item); + }); + return letters; +} +class InvalidGuessError extends Error {} +class DuplicateGoodGuessError extends Error {} +class DuplicateBadGuessError extends Error {} +class BadGuessError extends Error {} +class WordGuesser { + // -- Constants + actualWord = []; + + // -- Data + goodGuesses = []; + badGuesses = []; + lettersLeft = []; + currentWord = []; + + constructor(wordChoices) { + this.wordChoices = wordChoices; + this.newGame(); + } + + // -- Clear the current game data and start a new game + newGame() { + this.badGuesses = []; + this.goodGuesses = []; + this.currentWord = []; + const randomChoice = Math.floor(Math.random() * this.wordChoices.length); + this.actualWord = this.wordChoices[randomChoice].split(""); + + // -- Create placeholder entries for the currentWord + for (let i = 0; i < this.actualWord.length; i++) { + this.currentWord[i] = "_"; + } + } + + // -- Determines if a letter is in a list of letters + guessLetter(letter) { + this.validateInput(letter); + // -- Find the match + const badGuessIndex = charInChars(letter, this.badGuesses); + const goodGuessIndex = charInChars(letter, this.goodGuesses); + + // -- Check if the letter has been guessed already + if (badGuessIndex !== false) { + throw new DuplicateBadGuessError( + `The letter '${letter}' has already been guessed`, + ); + } + if (goodGuessIndex !== false) { + throw new DuplicateGoodGuessError( + `The letter '${letter}' has already been guessed`, + ); + } + + // -- Determine if the letter exists in the actual word + const letterIndexes = charsAt(letter, this.actualWord); + if (letterIndexes === false) { + // Add the letter to the list of guessesd letters + this.badGuesses.push(letter); + throw new BadGuessError( + `Sorry, the letter ${letter} does not exist in the word`, + ); + } + this.goodGuesses.push(letter); + + this.updateCurrentWord(letter, letterIndexes); + + return letterIndexes; + } + + getActualWord() { + return this.actualWord; + } + getCurrentWord() { + return this.currentWord; + } + updateCurrentWord(letter, indexes) { + for (let i = 0; i < indexes.length; i++) { + this.currentWord[indexes[i]] = letter; + } + } + setCurrentWord(currentWord) { + if (!Array.isArray(currentWord)) { + throw Error("The current word is not an array!"); + } + this.currentWord = currentWord; + } + getGuesses() { + return this.badGuesses; + } + guessCount() { + return this.badGuesses.length + this.goodGuesses.length; + } + gameOver() { + for (let i = 0; i < this.actualWord.length; i++) { + if (this.currentWord[i] != this.actualWord[i]) return false; + } + return true; + } + validateInput(input) { + const regex = /^[a-zA-Z]$/; + const result = regex.test(input); + if (!result) { + throw new InvalidGuessError( + `You must only guess a single letter. Received: '${input}'`, + ); + } + } +} +class GameFields { + constructor( + gameFormId = "gameForm", + guessesId = "guesses", + guessCountId = "guess_count", + guessLetterId = "guess_letter", + guessButtonId = "guess_button", + currentWordId = "current_word", + notificationsId = "notifications", + ) { + this.gameForm = document.getElementById(gameFormId); + this.guesses = document.getElementById(guessesId); + this.guessCount = document.getElementById(guessCountId); + this.guessButton = document.getElementById(guessButtonId); + this.guessLetter = document.getElementById(guessLetterId); + this.currentWord = document.getElementById(currentWordId); + this.notifications = document.getElementById(notificationsId); + } +} +function reset(fields) { + fields.guessButton.value = "Guess"; + fields.notifications.innerText = ""; + fields.guessCount.value = "0"; + fields.guessLetter.value = ""; + fields.guesses.replaceChildren(); + fields.currentWord.replaceChildren(); +} +class Game { + constructor(wordChoices, fields) { + Object.assign(this, fields); + this.wordChoices = wordChoices; + this.newGame(); + + // Use onclick to prevent stacking event listeners across multiple test initializations + const eventHandler = (event) => { + if (this.game.gameOver()) { + this.guessButton.value = "Guess"; + this.game = new WordGuesser(this.wordChoices); + reset(this); + } else { + const messages = [ + "Guess", + "Send it", + "Feeling lucky?", + "Is that it?", + "Final answer?", + "Spin the wheel", + "Don't fail now", + "Roll the dice", + "Last chance", + "Click for fate", + "Make it count", + "Sure about that?", + 'git commit -m "hope"', + "Compile & Pray", + "Deploy to Prod", + "Test My Patience", + "Confirm Mediocrity", + "Submit to the Abyss", + "Execute Bad Logic", + "Break the Build", + "Force Push", + "Trigger Segfault", + "Manifest Failure", + "Commit Career Suicide", + "Push to Origin", + "Yank & Put", + "Do your worst", + "Click for Fate", + "Input Error", + "Roll for Initiative", + "Terminate Process", + ]; + this.guessButton.value = randomMessage(messages); + this.handleGuessedLetter(event); + } + }; + this.gameForm.onsubmit = eventHandler; + this.guessButton.onclick = eventHandler; + } + reset() { + reset(this); + } + newGame() { + this.reset(); + this.game = new WordGuesser(this.wordChoices); + } + // -- Getters + getGuess() { + return this.guessLetter.value; + } + // -- Setters + clearGuess() { + this.guessLetter.value = ""; + } + updateGuessCount(count) { + this.guessCount.value = count; + } + updateGuesses(chars) { + const letters = updateLetters(chars); + this.guesses.replaceChildren(...letters.childNodes); + } + invalidGuessError(letter) { + const messages = [ + `'${letter}'? I asked for a letter, not a cry for help.`, + `System failure: User attempted to use '${letter}' as a letter. Re-evaluating human intelligence...`, + `Nice try, but '${letter}' isn't in the alphabet. Did you skip kindergarten?`, + `Unicode is vast, but '${letter}' is still the wrong choice for a word game.`, + `If '${letter}' is a letter, then I'm a toaster. (Spoilers: It's not, and I'm not).`, + `Keyboard mashing is not a valid strategy. '${letter}' rejected.`, + `I'm an AI, not a cryptographer. Speak alphabet, not '${letter}'.`, + `Are you testing my regex? '${letter}' failed. Use a letter.`, + `My source code is more readable than your input of '${letter}'.`, + `Error: User sent '${letter}'. Expected: A-Z. Found: Disappointment.`, + `'${letter}'? Even my legacy drivers know that's not a character.`, + `You have 26 options. '${letter}' is not one of them.`, + `Learn how to spell '${letter}' this isn't even a letter`, + `Ok master hacker you are going to have to try harder than '${letter}'`, + `Is the letter '${letter}' some kind of new hacking sequence I don't know about?`, + `Try again, but this time, try harder because '${letter}' isn't even a letter!`, + `Error 404: Brain not found. '${letter}' is not a character.`, + `Inputting '${letter}'? Bold move. Wrong, but bold.`, + ]; + const message = randomMessage(messages); + this.updateNotifications("🚫", message); + } + duplicateGoodGuessError(letter) { + const messages = [ + `You already tried '${letter}', do you have a short memory?`, + `Deja vu? You already guessed '${letter}'. Try to keep up.`, + `Memory like a goldfish? You've already used '${letter}'.`, + `Stop harassing the letter '${letter}'. It's already given you all it has.`, + `You're repeating yourself. '${letter}' is already so five minutes ago.`, + `Did you expect a different result? '${letter}' is already on the board.`, + `Error: Redundancy detected. '${letter}' has already been processed by your (apparent) brain.`, + `You found '${letter}' once. Finding it again doesn't double your points.`, + `Yes, '${letter}' is still there. It hasn't moved since the last time you checked.`, + `Is this a glitch in your matrix? You've already used '${letter}'.`, + `Congratulations, you've discovered '${letter}' for the second time. Still correct, still redundant.`, + `I admire your consistency, but '${letter}' is already doing its job on the board.`, + `Obsessing over '${letter}' won't reveal the other letters. Move on.`, + `Checking if '${letter}' is still correct? Spoiler: It is. Also spoiler: You already guessed it.`, + `You're stuck in a loop. '${letter}' is already accounted for.`, + `If I had a nickel for every time you guessed '${letter}', I'd have two nickels. It's still weird that it happened twice.`, + `Error: Duplicate success detected. You're trying to relitigate the past with '${letter}'.`, + `'${letter}' is already visible. Perhaps an eye exam is in order?`, + `Are you trying to give '${letter}' some company? It is already on the board.`, + ]; + const message = randomMessage(messages); + this.updateNotifications("💎", message); + } + duplicateBadGuessError(letter) { + const messages = [ + `You already tried '${letter}', do you have a short memory?`, + `Deja vu? You already guessed '${letter}'. Try to keep up.`, + `Memory like a goldfish? You've already used '${letter}'.`, + `Stop harassing the letter '${letter}'. It's already given you all it has.`, + `You're repeating yourself. '${letter}' is already so five minutes ago.`, + `Did you expect a different result? '${letter}' is already on the board.`, + `Error: Redundancy detected. '${letter}' has already been processed by your (apparent) brain.`, + `'${letter}' was wrong five minutes ago, and news flash: it's still wrong now.`, + `Did you expect a different result? That's the textbook definition of insanity.`, + `I've already cached the fact that '${letter}' isn't here. Try clearing your own internal cache.`, + `Your short-term memory seems to have a memory leak. You already failed with '${letter}'.`, + `Congratulations on being consistently wrong. '${letter}' is still not in the word.`, + `I'm not a mirror, but you're definitely repeating yourself with that '${letter}' guess.`, + `Are you trying to lose on purpose? Because guessing '${letter}' twice is a great start.`, + `'${letter}' didn't work the first time. It didn't work the second time. I sense a pattern.`, + `My algorithms are bored. Even I remember you already guessed '${letter}'.`, + `Error 409: Conflict. User is fighting with their own history regarding the letter '${letter}'.`, + `If failure was a sport, you'd be a pro for guessing '${letter}' again.`, + `I'd offer you a hint, but I'm worried you'd just guess '${letter}' a third time.`, + ]; + const message = randomMessage(messages); + this.updateNotifications("🔁", message); + } + badGuessError(letter) { + const messages = [ + `'${letter}'? I've seen better logic in a circular dependency.`, + `Searching for '${letter}'... 404: Character not found in this string.`, + `Statistically, '${letter}' was a disaster. Mathematically, it's a zero.`, + `My logic gates are closing on that guess. '${letter}' is a hard pass.`, + `If guessing incorrectly were a career, you'd be the CEO of '${letter}'.`, + `I've run 10,000 simulations. In zero of them was '${letter}' the right choice.`, + `Error: Probability of '${letter}' being in this word is exactly 0.00%.`, + `Is that your final answer? Because '${letter}' is definitively, objectively wrong.`, + `I'd explain why '${letter}' failed, but I don't have the stack space for that conversation.`, + `01001110 01101111. That's 'No' in binary. '${letter}' is a total failure.`, + `Even a randomized brute-force script would have avoided '${letter}'.`, + `Your guess of '${letter}' has been logged as a 'What not to do' example.`, + `Statistically, '${letter}' was a bold choice. Factually, it was a wrong one.`, + `I've seen better guesses from a keyboard-walking cat than '${letter}'.`, + `The letter '${letter}' doesn't live here. Try another neighborhood.`, + `I'd explain why '${letter}' is wrong, but I don't have the bandwidth for that level of remedial training.`, + `Incorrect. '${letter}'? Really? That's your best effort?`, + `You chose '${letter}', who taught you how to spell?`, + `If you type another letter like '${letter}' you may have to go back to grammar school`, + `Nope. '${letter}' is definitely not it. Try using your eyes.`, + `Incorrect. '${letter}'? Really? That's your best effort?`, + `My artificial intelligence is offended by that guess: '${letter}'.`, + ]; + const message = randomMessage(messages); + this.updateNotifications("❌", message); + } + goodGuess() { + const messages = [ + "Stop cheating, because now I have to congratulate you", + "Wow, a broken clock is right twice a day after all.", + "Correct. Don't let it go to your head.", + "You actually found one. I'm as surprised as you are.", + "Did you Google the answer or just get lucky?", + "Correct! Your IQ just jumped into the double digits.", + ]; + const message = randomMessage(messages); + this.updateNotifications("✅", message); + } + gameOver(letter) { + const buttonMessages = ["Start a new Game"]; + this.guessButton.value = randomMessage(buttonMessages); + const messages = [ + "The games over. Did you think you were going to win a prize or something?!", + "Wanna play another round you cheater??", + "You won. I hope you're happy. My CPU is crying.", + "Game Over. You finally finished a simple task. Proud of you.", + "You actually got it. Go tell your mom, she'll be thrilled.", + "The word was revealed despite your best efforts to fail.", + "A match. I’ve updated your status from 'Hopeless' to 'Lucky'.", + "Correct! I'll have to re-calibrate my low expectations for you.", + "Warning: User intelligence spike detected. That letter is actually correct.", + "Even a script monkey hits the right key eventually. You found one.", + "Correct guess. I assume that was just a lucky bit-flip in your brain.", + "My 'disappointment' variable has been decremented. That's a match.", + "You actually found a letter. My CPU is experiencing a rare moment of respect.", + "Correct! You’ve successfully navigated a single logic branch. Don't get cocky.", + "A hit. I’m starting to suspect you might actually be semi-sentient.", + "Logic check: Passed. You found a valid character. The simulation continues.", + "Correct. I'll refrain from insulting your intelligence for exactly five seconds.", + "That's a match. Maybe there's hope for your carbon-based hardware after all.", + ]; + const message = randomMessage(messages); + this.updateNotifications("🏆", message); + } + updateNotifications(icon, message) { + this.notifications.innerText = `${icon} ${message}`; + } + updateCurrentWord(chars) { + const letters = updateLetters(chars); + this.currentWord.replaceChildren(...letters.childNodes); + } + + // -- Event handlers + handleGuessedLetter(event) { + const letter = this.getGuess(); + if (!letter) return; + + const game = this.game; + try { + const goodGuess = game.guessLetter(letter); + this.goodGuess(letter); + } catch (e) { + if (e instanceof BadGuessError) { + this.badGuessError(letter); + } else if (e instanceof DuplicateBadGuessError) { + this.duplicateBadGuessError(letter); + } else if (e instanceof DuplicateGoodGuessError) { + this.duplicateGoodGuessError(letter); + } else if (e instanceof InvalidGuessError) { + this.invalidGuessError(letter); + } else { + this.updateNotifications("⚠️", e.message); + } + } finally { + const currentWord = game.getCurrentWord(); + this.updateCurrentWord(currentWord); + this.updateGuesses(game.getGuesses()); + this.updateGuessCount(game.guessCount()); + this.clearGuess(); + if (game.gameOver()) { + this.gameOver(); + } + } + } +} +document.addEventListener("DOMContentLoaded", (_) => { + const wordChoices = [ + "bannana", + "orange", + "algorithm", + "bamboozle", + "binary", + "bungalow", + "clandestine", + "compiler", + "computer", + "cryptography", + "discombobulate", + "euphoria", + "flabbergasted", + "flapjack", + "gazelle", + "gobbledygook", + "hullabaloo", + "jazziness", + "juxtapose", + "kerfuffle", + "labyrinth", + "lollygag", + "majestic", + "malarkey", + "mnemonic", + "obfuscate", + "persnickety", + "pharaoh", + "pizazz", + "pointer", + "poppycock", + "programming", + "quirky", + "quizzical", + "radiance", + "recursion", + "rhythm", + "sapphire", + "shenanigans", + "skedaddle", + "sphinx", + "syntax", + "syzygy", + "umbrella", + "vacuum", + "velocity", + "wizardry", + "zephyr", + ]; + const fields = new GameFields(); + new Game(wordChoices, fields); +}); + +function randomMessage(messages) { + const randomChoice = Math.floor(Math.random() * messages.length); + return messages[randomChoice]; +} diff --git a/html/word-guesser/scripts/tests.js b/html/word-guesser/scripts/tests.js new file mode 100644 index 0000000..26ab9ae --- /dev/null +++ b/html/word-guesser/scripts/tests.js @@ -0,0 +1,679 @@ +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 = `${test.isPassed ? "✅" : "❌"} ${test.expectation}`; + + if (!test.isPassed) { + const expected = JSON.stringify(test.trialValue); + const got = JSON.stringify(test.actualResult); + content += `
      + Expected: ${expected} | Got: ${got} +
      `; + } 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); + } +} diff --git a/navLinks.json b/navLinks.json index 1f8b218..f1fb7b6 100644 --- a/navLinks.json +++ b/navLinks.json @@ -1,103 +1,106 @@ [ - { - "href": "/", - "label": "Home" - }, - { - "label": "About", + { + "href": "/", + "label": "Home" + }, + { + "label": "About", + "submenu": [ + { + "href": "/about/me", + "label": "About Me" + }, + { + "href": "/about/blog", + "label": "About This Blog" + } + ] + }, + { + "href": "/newsletter", + "label": "Newsletter" + }, + { + "label": "Tools I use", + "submenu": [ + { + "href": "/tools", + "label": "How I Use Them" + }, + { + "href": "/stack", + "label": "Tech Stack Overview" + } + ] + }, + { + "label": "Projects", + "submenu": [ + { + "href": "/games/word-guesser", + "label": "A Word Guessing Game" + }, + { + "label": "Express Blog", "submenu": [ - { - "href": "/about/me", - "label": "About Me" - }, - { - "href": "/about/blog", - "label": "About This Blog" - } + { + "href": "/about/blog", + "label": "About This Blog" + }, + { + "href": "https://github.com/jpoage1/expressjs-blog", + "label": "Source Code", + "target": "_blank", + "rel": "noopener noreferrer" + }, + { + "href": "/docs", + "label": "Documentation" + }, + { + "href": "/projects/website-presentation", + "label": "Website Project Presentation" + } ] - }, - { - "href": "/newsletter", - "label": "Newsletter" - }, - { - "label": "Tools I use", - "submenu": [ - { - "href": "/tools", - "label": "How I Use Them" - }, - { - "href": "/stack", - "label": "Tech Stack Overview" - } - ] - }, - { - "label": "Projects", - "submenu": [ - { - "label": "Express Blog", - "submenu": - [ - { - "href": "/about/blog", - "label": "About This Blog" - }, - { - "href": "https://github.com/jpoage1/expressjs-blog", - "label": "Source Code", - "target":"_blank", - "rel": "noopener noreferrer" - }, - { - "href": "/docs", - "label": "Documentation" - }, - { - "href": "/projects/website-presentation", - "label": "Website Project Presentation" - } - ] - } - ] - }, - { - "href": "/contact", - "label": "Contact" - }, - { - "label": "Admin", - "secure": "True", - "submenu": [ - { - "href": "/admin/logs", - "label": "Logs" - }, - { - "href": "https://jasonpoage.com", - "label": "Production", - "appendCurrentPath": "True" - }, - { - "href": "https://test.jasonpoage.com", - "label": "Testing", - "appendCurrentPath": "True" - }, - { - "href": "https://dev.jasonpoage.com", - "label": "Development", - "appendCurrentPath": "True" - }, - { - "href": "https://jenkins.jasonpoage.com", - "label": "Jenkins" - }, - { - "href": "https://auth.jasonpoage.com/api/logout", - "method": "post", - "label": "Logout" - } - ] - } + } + ] + }, + { + "href": "/contact", + "label": "Contact" + }, + { + "label": "Admin", + "secure": "True", + "submenu": [ + { + "href": "/admin/logs", + "label": "Logs" + }, + { + "href": "https://jasonpoage.com", + "label": "Production", + "appendCurrentPath": "True" + }, + { + "href": "https://test.jasonpoage.com", + "label": "Testing", + "appendCurrentPath": "True" + }, + { + "href": "https://dev.jasonpoage.com", + "label": "Development", + "appendCurrentPath": "True" + }, + { + "href": "https://jenkins.jasonpoage.com", + "label": "Jenkins" + }, + { + "href": "https://auth.jasonpoage.com/api/logout", + "method": "post", + "label": "Logout" + } + ] + } ]