DEV Community

Cover image for How to Create a Wordle with TDD in Javascript
Maxi Contieri
Maxi Contieri

Posted on • Updated on • Originally published at maximilianocontieri.com

How to Create a Wordle with TDD in Javascript

We keep practicing this amazing Kata and learning. You can follow the steps!

TL;DR: Javascript is also awesome for TDD

During January 2022 Wordle rush, I wrote an article describing how to create a Wordle with TDD using PHP.

A few months after, I transcribed the UI version of a Wordle created with Codex Artificial Intelligence.

I will combine both worlds to program as a Centaur.

I will also compare the process and output from different language versions.

This is the Javascript version.


Set Up

As usual, we will focus on the game business logic, knowing we can build the user interface with natural language commands.

In this article, I will use a repl.it with Jest.

Javascript has many Unit testing frameworks.

You can use whatever you like.

Let's begin...

Defining a word

Following the same principles as the previous article, we will start by defining a Wordle Word.

The smallest information amount in Wordle is a word.

We can argue that letter is smaller, but all needed letter protocol is already defined (we might be wrong).

A word is not a char(5).

A word is not an array.

A word is not a string.

This is a common mistake and a bijection violation.

A word and a string have different responsibilities, though they might intersect.

Mixing (accidental) implementation details with (essential) behavior is a widespread mistake.

So we need to define what is a word.

A word in Wordle is a valid 5-letter word.

Let's start with our happy path:

test("test01ValidWordLettersAreValid", async function() {
  const word = new Word('valid');
  expect(['v', 'a', 'l', 'i', 'd']).toStrictEqual(word.letters());
});
Enter fullscreen mode Exit fullscreen mode

We assert that prompting for letters in 'valid' returns an array of the letters.

This is the result:

Message: letters from word must be 'valid'
Stack Trace:
ReferenceError: Word is not defined
    at Object.<anonymous> (/home/runner/Wordle-TDD/_test_runnertest_suite.js:6:18)
    at Promise.then.completed (/home/runner/Wordle-TDD/node_modules/jest-circus/build/utils.js:333:28)    
Enter fullscreen mode Exit fullscreen mode

This is fine since we haven't defined what a word is.

Notice

  • This is a TDD Pattern.
  • We name the objects following their behavior even before they exist.
  • Word class is not defined yet.
  • Our Word's first trivial responsibility is to answer its letters.
  • This is not a getter. Every wordle word must answer its letters.
  • We don't care about letter sorting. That would be a premature optimization and gold plating scenario.
  • We start with a simple example. No duplicated.
  • We don't mess with word validation yet (the word might be XXXXX).
  • We can start with a simpler test validating word is created. This would violate the test structure that always requires an assertion.
  • Expected value should always be the first in the assertion.

Creating a Word

We need to create a Word with the letters() function.

class Word {
  letters() {
    return ['v', 'a', 'l', 'i', 'd'];
  }  
}
Enter fullscreen mode Exit fullscreen mode

Notice

  • We don't need constructors (yet).
  • We hardcode letters function since this is the simplest possible solution up to now.
  • Fake it till we make it.

We run all the tests (just 1) and we are OK.

✅  test01ValidWordLettersAreValid

  All tests have passed 1/1  
Enter fullscreen mode Exit fullscreen mode

Few Letters

Let's write another test:

test("test02FewWordLettersShouldRaiseException", async function() {
  expect(() => { 
    new Word('vali');                 
               }).toThrow(Error);
});
Enter fullscreen mode Exit fullscreen mode

The test fails as expected...

❌  test02FewWordLettersShouldRaiseException
Stack Trace:
Error: expect(received).toThrow(expected)

Expected constructor: Error

Received function did not throw
    at Object.toThrow (/home/runner/Wordle-TDD/_test_runnertest_suite.js:10:23)

✅  test01ValidWordLettersAreValid

  1/2 passed, see errors above  
Enter fullscreen mode Exit fullscreen mode

Notice

  • The first test passed
  • The second test is expected to throw an exception. Which it didn't.
  • We just declare a generic exception will be raised.
  • We just raise a generic Error.
  • Creating special exceptions is a code smell that pollutes namespaces. (unless we catch it, but this is not happening right now).

Changing the current implementation

We need to change our implementation to make test02 pass (and also test01).

class Word {
  constructor(word) {
    if (word.length < 5)
      throw new Error('Too few letters. Should be 5');
  }
  letters() {
      return ['v', 'a', 'l', 'i', 'd'];
  }  
}
Enter fullscreen mode Exit fullscreen mode

And the tests pass.


✅  test02FewWordLettersShouldRaiseException

✅  test01ValidWordLettersAreValid

  All tests have passed 2/2  
Enter fullscreen mode Exit fullscreen mode

Notice

  • We are not using the constructor argument to set up the actual letters (yet).
  • We just check for a few letters. not for too many since we don't have yet a covering test.
  • TDD requires full coverage. Adding another check without a test is a technique violation.

Checking Too Many Letters

Let's check for too many

test("test03TooManyWordLettersShouldRaiseException", async function() {
  expect(() => { 
    new Word('toolong');                 
               }).toThrow(Error);

});
Enter fullscreen mode Exit fullscreen mode

We run them:

❌  test03TooManyWordLettersShouldRaiseException
Stack Trace:
Error: expect(received).toThrow(expected)

Expected constructor: Error

Received function did not throw
    at Object.toThrow (/home/runner/Wordle-TDD/_test_runnertest_suite.js:10:23)

✅  test02FewWordLettersShouldRaiseException

✅  test01ValidWordLettersAreValid

  2/3 passed, see errors above  
Enter fullscreen mode Exit fullscreen mode

We add the validation:

class Word {
  constructor(letters) {
    if (letters.length < 5)
      throw new Error('Too few letters. Should be 5');
    if (letters.length > 5)
      throw new Error('Too many letters. Should be 5');
  }
  letters() {
      return ['v', 'a', 'l', 'i', 'd'];
  }  
}
Enter fullscreen mode Exit fullscreen mode

And all tests passed.

All tests have passed 3/3  
Enter fullscreen mode Exit fullscreen mode

Refactor (or not)

We can now make an (optional) refactor and change the function to assert a range instead of two boundaries.
We decide to leave it this way since it is more declarative.

We can also add a test for zero words following the Zombie methodology.

Let's do it.

test("test04EmptyLettersShouldRaiseException", async function() {
  expect(() => { 
    new Word('');                 
               }).toThrow(Error);

});
Enter fullscreen mode Exit fullscreen mode
✅  test04EmptyLettersShouldRaiseException

✅  test03TooManyWordLettersShouldRaiseException

✅  test02FewWordLettersShouldRaiseException

✅  test01ValidWordLettersAreValid

Enter fullscreen mode Exit fullscreen mode

it is no surprise the test passes since we already have a test covering this scenario.

As this test adds no value, we should remove it.


Valid Letter

Let's check now what is a valid letter:

test("test05InvalidLettersShouldRaiseException", async function() {
   expect(() => { 
    new Word('vali*');                 
               }).toThrow(Error);

});
Enter fullscreen mode Exit fullscreen mode

... and the test is broken since no assertion is raised.


❌  test05InvalidLettersShouldRaiseException
Stack Trace:
Error: expect(received).toThrow(expected)

Expected constructor: Error

Received function did not throw
Enter fullscreen mode Exit fullscreen mode

We need to correct the code...

class Word {
  constructor(word) {
    if (word.length < 5)
      throw new Error('Too few letters. Should be 5');
    if (word.length > 5)
      throw new Error('Too many letters. Should be 5');
    if (word.indexOf('*') > -1) 
      throw new Error('Word has invalid letters');
  }
}
Enter fullscreen mode Exit fullscreen mode

And all tests pass since we are clearly hardcoding.

All tests have passed 5/5  
Enter fullscreen mode Exit fullscreen mode

Notice

  • We hardcode the asterisc to be the only invalid character (as far as we know).
  • We can place the checking code before or after the previous validations. -- Until we have an invalid case (with invalid characters and invalid length) we cannot assume the expected behavior.

More Invalid

Let's add more invalid letters and correct the code.

test("test06PointShouldRaiseException", async function() {
   expect(() => { 
    new Word('val.d');                 
               }).toThrow(Error);

});

// Solution

 constructor(word) {
    if (word.indexOf('*') > -1) 
      throw new Error('Word has invalid letters');
    if (word.indexOf('.') > -1) 
      throw new Error('Word has invalid letters');
}
Enter fullscreen mode Exit fullscreen mode

Notice

  • We didn't write a more generic function (yet) since we cannot correct tests and refactor at the same time (the technique forbids us).

Refactor

All tests are ok.

We can refactor.

We replace the last two sentences.

class Word {
  constructor(word) {
    if (word.length < 5)
      throw new Error('Too few letters. Should be 5');
    if (word.length > 5)
      throw new Error('Too many letters. Should be 5');
    // Refactor  
    if (!word.match(/^[a-z]+$/i)) 
      throw new Error('word has invalid letters');
    //   
}
Enter fullscreen mode Exit fullscreen mode

Notice

  • We can refactor only if we don't change the tests at the same time.
  • The assertion checks only for uppercase letters. Since we are dealing with these examples up to now.
  • We defer design decisions as much as possible.
  • We defined a regular expression based on English Letters. We are pretty sure it won't accept Spanish (ñ), German(ë), etc.

As a checkpoint, we have only five letter words from now on.

Lets assert on letters() function.

We left it hard coded.

TDD Opens many paths.

We need to keep track of all of them until we open new ones.

Comparing Words

We need to compare words

test("test07TwoWordsAreNotTheSame", async function() {
    const firstWord = new Word('valid');
    const secondWord = new Word('happy');
    expect(firstWord).not.toStrictEqual(secondWord);
});

test("test08TwoWordsAreTheSame", async function() {
    const firstWord = new Word('valid');
    const secondWord = new Word('valid');
    expect(firstWord).toStrictEqual(secondWord);
});
Enter fullscreen mode Exit fullscreen mode

And test fails.

Let's use the parameter we are sending to them.

class Word {
  constructor(word) { 
    // ...
    this._word = word;
  }
  letters() {
      return ['v', 'a', 'l', 'i', 'd'];
  }  
}

Enter fullscreen mode Exit fullscreen mode
✅  test08TwoWordsAreTheSame

✅  test07TwoWordsAreNotTheSame

✅  test06PointShouldRaiseException

✅  test05InvalidLettersShouldRaiseException

✅  test04EmptyLettersShouldRaiseException

✅  test03TooManyWordLettersShouldRaiseException

✅  test02FewWordLettersShouldRaiseException

✅  test01ValidWordLettersAreValid

  All tests have passed 8/8  
Enter fullscreen mode Exit fullscreen mode

Notice

  • We store the letters and this is enough for object comparison (it might depend on the language).
  • letters() function is still hardcoded

More Words

We add a different word for letters comparison.

Remember letters() was hardcoded until now.

test("test09LettersForGrassWord", async function() {
  const grassWord = new Word('grass'); 
  expect(['g','r','a','s','s']).toStrictEqual(grassWord.letters());
});
Enter fullscreen mode Exit fullscreen mode

And the test fails as expected.

❌  test09LettersForGrassWord
Stack Trace:
Error: expect(received).toStrictEqual(expected) // deep equality

- Expected  - 4
+ Received  + 4

  Array [
-   "v",
+   "g",
+   "r",
    "a",
-   "l",
-   "i",
-   "d",
+   "s",
+   "s",
  ]
    at Object.toStrictEqual (/home/runner/Wordle-TDD/_test_runnertest_suite.js:9:37)
Enter fullscreen mode Exit fullscreen mode

Notice

  • It is very important to check for equality/inequality instead of assertTrue() since many IDEs open a comparison tool based on the objects.

  • This is another reason to use IDEs and never text editors.

Let's change the letters() function since we've been faking it.

class Word {
  letters() {
      return this._word.split("");
  }  
}
Enter fullscreen mode Exit fullscreen mode

Comparing different cases

We need to make sure comparisons are not case-sensitive.

test("test10ComparisonIsCaseInsensitve", async function() {
    const firstWord = new Word('valid');
    const secondWord = new Word('VALID');
    expect(firstWord).toStrictEqual(secondWord); 
});
Enter fullscreen mode Exit fullscreen mode

Test fails.

We need to take a decision.

We decide all our domains will be lowercase.

We will not allow uppercase letters despite the UI having caps.

We won't do magic conversions.

We change the test to catch invalid uppercase letters and fix them.

test("test10NoUppercaseAreAllowed", async function() {
   expect(() => { 
    new Word('vAliD');                 
               }).toThrow(Error);
});

class Word {
  constructor(word) {
    // We remove the /i modifier on the regular expression  
    if (!word.match(/^[a-z]+$/)) 
      throw new Error('word has invalid letters');   
  }
Enter fullscreen mode Exit fullscreen mode

English Dictionary

Our words are in a bijection with English Wordle words. or not?

Let's try a non-English word

test("test11XXXXIsnotAValidWord", async function() {
  expect(() => { 
    new Word('XXXXX');                 
               }).toThrow(Error);
});
Enter fullscreen mode Exit fullscreen mode

This test fails.

We are not catching invalid English 5-letter words.

Notice

  • We need to make a decision. According to our bijection, there's an external dictionary asserting valid words.

  • We can validate with the dictionary upon word creation. But we want the dictionary to store valid wordle words. No strings.

  • It is an egg-chicken problem.

  • We decide to deal with invalid words in the dictionary and not the Wordle word.

  • We remove the test.

  • We will find a better way in a few moments.


Wordle Game

Let's create the game.

We start talking about a game that does not exist.

test("test11EmptyGameHasNoWinner", async function() {
  const game = new Game()
  expect(false).toStrictEqual(game.hasWon());
});
Enter fullscreen mode Exit fullscreen mode

Test fails.

We need to create the class and the function.

Creating Game Objects

class Game {
  hasWon() {
      return false;
  }  
}
Enter fullscreen mode Exit fullscreen mode

Words Attempted

We implement words attempted.

And the simplest solution.

Hardcoding as always.

test("test12EmptyGameWordsAttempted", async function() {
  const game = new Game()
  expect([]).toStrictEqual(game.wordsAttempted());
});

class Game {
  wordsAttempted() {
    return [];
  }
}
Enter fullscreen mode Exit fullscreen mode

✅  test12EmptyGameWordsAttempted
...
  All tests have passed 12/12  
Enter fullscreen mode Exit fullscreen mode

Start guessing

test("test13TryOneWordAndRecordIt", async function() {
  var game = new Game();
  game.addAttempt(new Word('loser'));
  expect([new Word('loser')]).toStrictEqual(game.wordsAttempted());   
});

class Game {
  constructor() {
    this._attempts = [];
  }
  hasWon() {
      return false;
  }
  wordsAttempted() {
    return this._attempts;
  }
  addAttempt(word) {
    this._attempts.push(word);    
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice

  • We store the attempts locally and add the attempt and also change wordsAttempted() real implementation.

Has Lost

We can implement hasLost() if it misses 6 attempts.

With the simplest implementation as usual.

test("test14TryOneWordAndDontLooseYet", async function() {
  const game = new Game();
  game.addAttempt(new Word('loser'));
  expect(false).toStrictEqual(game.hasLost());   
});

class Game { 
  hasLost() {
      return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice

  • We are learning the rules as our model grows.

We lose the game

As always. We stop faking it and decide to make it.

test("test15TryFiveWordsLoses", async function() {
  const game = new Game([new Word('loser'), new Word('music')], new Word('music'));
  game.addAttempt(new Word('loser'));
  game.addAttempt(new Word('loser'));
  game.addAttempt(new Word('loser'));
  game.addAttempt(new Word('loser'));
  game.addAttempt(new Word('loser'));
  expect(false).toStrictEqual(game.hasLost());  
  // last attempt
  game.addAttempt(new Word('loser'));
  expect(true).toStrictEqual(game.hasLost());  
});

class Game {
  hasLost() {
    return this._attempts.length > 5;
  }
}
Enter fullscreen mode Exit fullscreen mode

We Play by the Dictionary

We have most of the mechanics.

Let's add valid words dictionary and play invalid.

test("test16TryToPlayInvalid", async function() {
  const game = new Game([]);  
  expect(() => { 
    game.addAttempt(new Word('xxxxx'));            
               }).toThrow(Error);
});
Enter fullscreen mode Exit fullscreen mode

The test fails as expected

We fix it.

class Game {
  constructor(validWords) {
    this._attempts = [];
    this._validWords = validWords;
  }   
  addAttempt(word) {
    if (!this._validWords.some(validWord => validWord.sameAs(word))) {
      throw new Error(word.letters() + " is not a valid word");
    }
    this._attempts.push(word);    
  }
}

// fix previous tests
// change 

const game = new Game([]);

// to 

const game = new Game([new Word('loser')]);

Also add: 
Class Word {
 sameAs(word) {
    return word.word() == this.word();
  }
}


Enter fullscreen mode Exit fullscreen mode

and the test is fixed, but...

  test16TryToPlayInvalid

❌  test15TryFiveWordsLoses
Stack Trace:
TypeError: Cannot read properties of undefined (reading 'includes')

❌  test14TryOneWordAndDontLooseYet
Stack Trace:
TypeError: Cannot read properties of undefined (reading 'includes') 

❌  test13TryOneWordAndRecordIt
Stack Trace:
TypeError: Cannot read properties of undefined (reading 'includes')

✅  test12EmptyGameWordsAttempted

✅  test10EmptyGameHasNoWinner

  12/15 passed, see errors above  
Enter fullscreen mode Exit fullscreen mode

Notice

  • test13, test14, and test15 were previously working.
  • Now, they are broken since we added a new business rule.
  • We need to pass the dictionary when creating the game.
  • We fix the three of them by adding an array with the words we will use.
  • It is a good sign our setup gets complex to keep creating valid scenarios.

Play to Win

Now, we play to win

We add the test and need to change hasWon() accordingly.

test("test17GuessesWord", async function() {
  const words = [new Word('happy')];
  const correctWord = new Word('happy');
  const game = new Game(words, correctWord);  
  expect(game.hasWon()).toStrictEqual(false);
  game.addAttempt(new Word('happy'));
  expect(game.hasWon()).toStrictEqual(true);
});

// we need to store the correct word
class Game {
  constructor(validWords, correctWord) {
    this._attempts = [];
    this._validWords = validWords;
    this._correctWord = correctWord;
  }
  hasWon() {
    return this._attempts.some(attempt => attempt.sameAs(this._correctWord)); 
}

Enter fullscreen mode Exit fullscreen mode

Notice

  • We use no flags to check if someone has won. We can directly check it.
  • We don't care if it has won in a previous attempt.
  • We make an addParameter refactor with this new element to previous game definitions.

Correct Word

We added the Correct Word.

We need to assert this word is in the dictionary.

test("test18CorrectWordNotInDictionary", async function() {
  const words = [new Word('happy')];
  const correctWord = new Word('heros');  
   expect(() => { 
     new Game(words, correctWord);                 
               }).toThrow(Error);
});

class Game {
  constructor(validWords, correctWord) {
    if (!validWords.some(validWord => validWord.sameAs(correctWord)))
      throw new Error("Correct word " + word.word() + " is not a valid word");  
  }
Enter fullscreen mode Exit fullscreen mode

Notice

  • We needed to change all previous games since we needed to pass the winner game before the start
  • This is a good side effect since it favors complete and immutable objects.

✅  test18CorrectWordNotInDictionary
...

✅  test01ValidWordLettersAreValid

  All tests have passed 17/17  

Enter fullscreen mode Exit fullscreen mode

Lost, Won, both?

What happens if we win in the final attempt?

Zombies ask us always to check for (B)boundaries where bugs hide.

test("test19TryFiveWordsWins", async function() {
  const game = new Game([new Word('loser'),new Word('heros')],new Word('heros'));
  game.addAttempt(new Word('loser'));
  game.addAttempt(new Word('loser'));
  game.addAttempt(new Word('loser'));
  game.addAttempt(new Word('loser'));
  game.addAttempt(new Word('loser'));
  expect(false).toStrictEqual(game.hasLost());  
  expect(false).toStrictEqual(game.hasWon());  
  // last attempt
  game.addAttempt(new Word('heros'));
  expect(false).toStrictEqual(game.hasLost());  
  expect(true).toStrictEqual(game.hasWon());  
});

// And the correction

hasLost() {
    return !this.hasWon() && this._attempts.length > 5;
  }
Enter fullscreen mode Exit fullscreen mode

We have all the mechanics.

Letter Positions

Let's add the letter's positions.

We can do it in Word class.

test("test20LettersDoNotMatch", async function() {
  const firstWord = new Word('trees');
  const secondWord = new Word('valid');
  expect([]).toStrictEqual(firstWord.matchesPositionWith(secondWord));
});
Enter fullscreen mode Exit fullscreen mode

As usual, we get an undefined function error:

❌  test20LettersDoNotMatch
Stack Trace:
TypeError: firstWord.matchesPositionWith is not a function

Enter fullscreen mode Exit fullscreen mode

Let's fake it as usual.

class Word {
  matchesPositionWith(correctWord) {
    return [];    
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice

  • Names are always very important.
  • We can name the parameter anotherWord.
  • We prefer correctWord.
  • We are aware we will soon need a complicated algorithm and roles should be clear and contextual.

Match

Let's match

test("test21MatchesFirstLetter", async function() {
  const guessWord = new Word('trees');
  const correctWord = new Word('table');
  expect([1]).toStrictEqual(guessWord.matchesPositionWith(correctWord));
});
Enter fullscreen mode Exit fullscreen mode

Fails.

We need to define it better

This is a good enough algorithm.

Ugly and imperative

We will refactor it later, for sure.

matchesPositionWith(correctWord) {
   var positions = [];
   for (var currentPosition = 0; 
      currentPosition < this.letters().length; 
      currentPosition++) {
       if (this.letters()[currentPosition] == correctWord.letters()[currentPosition]) {
             positions.push(currentPosition + 1); 
             //Humans start counting on 1
       }
   }
   return positions;
}
Enter fullscreen mode Exit fullscreen mode

And all the tests pass.

Notice

  • Matching property is not symmetric

Incorrect Positions

Now we need the final steps.

Matching in incorrect positions.

and always the simplest solution...

test("test23MatchesIncorrectPositions", async function() {
  const guessWord = new Word('trees');
  const correctWord = new Word('drama');
  expect([2]).toStrictEqual(guessWord.matchesPositionWith(correctWord));
  expect([]).toStrictEqual(guessWord.matchesIncorrectPositionWith(correctWord));
});

// The simplest solution

class Word {
  matchesIncorrectPositionWith(correctWord) {
     return [];
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice

  • By adding these safe, zero cases we miss many usual bugs.

A more spicy test case.

test("test24MatchesIncorrectPositionsWithMatch", async function() {
  const guessWord = new Word('alarm');
  const correctWord = new Word('drama');
  expect([3]).toStrictEqual(guessWord.matchesPositionWith(correctWord));
  expect([1, 4, 5]).toStrictEqual(guessWord.matchesIncorrectPositionWith(correctWord));
  // A*ARM vs *RAMA
  expect([3]).toStrictEqual(correctWord.matchesPositionWith(guessWord));
  expect([2, 4, 5]).toStrictEqual(correctWord.matchesIncorrectPositionWith(guessWord));
});
Enter fullscreen mode Exit fullscreen mode

Let's go for the implementation

 class Word {
  matchesIncorrectPositionWith(correctWord) {
      var positions = [];
      for (var currentPosition = 0; currentPosition < 5; currentPosition++) {
        if (correctWord.letters().includes(this.letters()[currentPosition])) {
          positions.push(currentPosition + 1);
        }
      }
      return positions.filter(function(position) {
        return !this.matchesPositionWith(correctWord).includes(position);
     }.bind(this));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That's it.

We have implemented a very small model with all meaningful rules.

All tests have passed 21/21  
Enter fullscreen mode Exit fullscreen mode

Playing with real examples

test("test20220911", async function() {
  const correctWord = new Word('tibia');
    // Sorry for the spoiler
  const words = [
    // all the words I've tried
    new Word('paper'), 
    new Word('tools'),
    new Word('music'),
    new Word('think'), 
    new Word('twins'),
    new Word('tight'),
    // plus the winning word
    correctWord
  ];

  const game = new Game(words, correctWord);  
  expect(game.hasWon()).toStrictEqual(false);
  expect(game.hasLost()).toStrictEqual(false);
  // P(A)PER vs TIBIA
  game.addAttempt(new Word('paper'));
  expect([]).toStrictEqual((new Word('paper')).matchesPositionWith(correctWord));
  expect([2]).toStrictEqual((new Word('paper')).matchesIncorrectPositionWith(correctWord));
  // [T]OOLS vs TIBIA
  expect([1]).toStrictEqual((new Word('tools')).matchesPositionWith(correctWord));
  expect([]).toStrictEqual((new Word('tools')).matchesIncorrectPositionWith(correctWord));  
  game.addAttempt(new Word('tools'));
  // MUS[I]C vs TIBIA
  expect([4]).toStrictEqual((new Word('music')).matchesPositionWith(correctWord));
  expect([]).toStrictEqual((new Word('music')).matchesIncorrectPositionWith(correctWord));
  game.addAttempt(new Word('music'));
  // [T]H(I)NK vs TIBIA
  expect([1]).toStrictEqual((new Word('think')).matchesPositionWith(correctWord));
  expect([3]).toStrictEqual((new Word('think')).matchesIncorrectPositionWith(correctWord));
  game.addAttempt(new Word('think'));
  // [T]W(I)NS vs TIBIA
  expect([1]).toStrictEqual((new Word('twins')).matchesPositionWith(correctWord));
  expect([3]).toStrictEqual((new Word('twins')).matchesIncorrectPositionWith(correctWord));  
  game.addAttempt(new Word('twins'));  
  expect(game.hasWon()).toStrictEqual(false);
  expect(game.hasLost()).toStrictEqual(false);
  // [T][I]GHT vs TIBIA
  expect([1, 2]).toStrictEqual((new Word('tight')).matchesPositionWith(correctWord));
  expect([]).toStrictEqual((new Word('tight')).matchesIncorrectPositionWith(correctWord));  

  game.addAttempt(new Word('tight'));
  expect(game.hasWon()).toStrictEqual(false);
  expect(game.hasLost()).toStrictEqual(true);
});
Enter fullscreen mode Exit fullscreen mode

2022-09-11

2022-09-12

(You will find more daily examples in the repo)

Playing by the complex rules

I was very happy with my working wordle.

Then I read about its complex rules

Learning new rules is not a problem when we have TDD.

Let's cover the examples from the article

test("test25VeryComplexWrongPositions", async function() {

  const guessWord = new Word('geese');
  const correctWord = new Word('those');
  expect([4, 5]).toStrictEqual(guessWord.matchesPositionWith(correctWord));
  expect(['s','e']).toStrictEqual(guessWord.lettersAtCorrectPosition(correctWord));
  expect([]).toStrictEqual(guessWord.lettersAtWrongtPosition(correctWord));
  expect([]).toStrictEqual(guessWord.matchesIncorrectPositionWith(correctWord));
  // GEE[S][E] vs THOSE

  const anotherGuessWord = new Word('added');
  const anotherCorrectWord = new Word('dread');
  expect([5]).toStrictEqual(anotherGuessWord.matchesPositionWith(anotherCorrectWord));
  expect(['d']).toStrictEqual(anotherGuessWord.lettersAtCorrectPosition(anotherCorrectWord));
  expect(['a', 'd', 'e']).toStrictEqual(anotherGuessWord.lettersAtWrongtPosition(anotherCorrectWord));
  expect([1, 2, 4]).toStrictEqual(anotherGuessWord.matchesIncorrectPositionWith(anotherCorrectWord));
  // (A)(D)D(E)[D] vs DREAD

  const yetAnotherGuessWord = new Word('mamma');
  const yetAnotherCorrectWord = new Word('maxim');
  expect([1, 2]).toStrictEqual(yetAnotherGuessWord.matchesPositionWith(yetAnotherCorrectWord));
  expect(['m', 'a']).toStrictEqual(yetAnotherGuessWord.lettersInCorrectPosition(yetAnotherCorrectWord));
  expect(['m']).toStrictEqual(yetAnotherGuessWord.lettersAtWrongtPosition(yetAnotherCorrectWord));
  expect([3]).toStrictEqual(yetAnotherGuessWord.matchesIncorrectPositionWith(yetAnotherCorrectWord));
  // [M][A](M)MA vs MAXIM
});
Enter fullscreen mode Exit fullscreen mode

Let's steal the algorithm from the article

matchesIncorrectPositionWith(correctWord) {     
    const correctPositions = this.matchesPositionWith(correctWord);
    var incorrectPositions = [];
    var correctWordLetters = correctWord.letters();
    var ownWordLetters = this.letters();
    for (var currentPosition = 0; currentPosition < 5; currentPosition++) {
      if (correctPositions.includes(currentPosition + 1)) {
        // We can use these wildcards since they are no valid letters
        correctWordLetters.splice(currentPosition, 1, '*');
        ownWordLetters.splice(currentPosition, 1, '+');
      }
    }    
    for (var currentPosition = 0; currentPosition < 5; currentPosition++) {
      const positionInCorrectWord = correctWordLetters.indexOf(ownWordLetters[currentPosition]);
      if (positionInCorrectWord != -1) {        
        correctWordLetters.splice(positionInCorrectWord, 1, '*');
        ownWordLetters.splice(currentPosition, 1, '+');
        incorrectPositions.push(currentPosition + 1); 
      }
    }    
    return incorrectPositions;
  }
Enter fullscreen mode Exit fullscreen mode

We need to add another funcion (will be usefull for keyboard colors)

lettersAtCorrectPosition(correctWord) {
    return this.matchesPositionWith(correctWord).map(position => this.letters()[position -1 ]);
}

lettersAtWrongtPosition(correctWord) {
    return this.matchesIncorrectPositionWith(correctWord).map(position => this.letters()[position -1]);
}
Enter fullscreen mode Exit fullscreen mode

Notice

  • The algorithm changes a copy of the correct word by placing '*' when the correct position matches
  • It also hides the visited letters by changing to a special (an invalid) '+'
DREAD vs ADDED
DREA* vs ADDE+
DRE** vs +DDE+
*RE** vs ++DE+
*R*** vs ++D++
Enter fullscreen mode Exit fullscreen mode

Conclusions

This solution is different and more complete than the previous one.

The wordle rules have not changed.

According to David Farley, we need to be experts at learning.

And we learn by practicing katas like this one.

We end up with 2 compact classes where we defined our business model.

This small model has a real 1:1 bijection in the MAPPER to the real world.

It is ready to evolve.

This game is a metaphor for real software engineering.

Hope you find it interesting and follow the kata with me.

Try it out!

You can play around with the wroking repl.it

Next Steps

  • Combine this solution with the AI-Generated

  • Use a real dictionary

  • Change the language and alphabet.

  • Change the rules to a different wordle

Top comments (3)

Collapse
 
devarshishimpi profile image
Devarshi Shimpi

Thank You!!

Collapse
 
loucyx profile image
Lou Cyx
  • In JavaScript, strings are iterable, so you don't need to turn them into arrays to access indexes on them.
  • If you avoid the unnecessary abstraction of Word and just use string, the code is simplified and easier to read. You can then use includes instead of that weird combination of some with sameAs. Even if you want to keep the Word class, you should at least make use of toString() and valueOf() to make it work with includes+map.
  • Is true people start counting from 1, but that's a UI layer concern, not a data concern, so ideally you should just keep indexes from 0.
  • If you avoid classes and exceptions altogether, the code becomes way more flexible, reusable and testable.

To support this last claim, here's a functional approach to solve the same problem (in a spoiler to make the comment more readable)
  • First we create a getExactMatchingIndexes curried function, which only responsibility is to take a wanted and received strings, and returns an array with the indexes of the letters that match exactly (position and character):
const getExactMatchingIndexes = wanted => received =>
    [...wanted].reduce(
        (matches, letter, index) =>
            letter === received[index] ? [...matches, index] : matches,
        [],
    );
Enter fullscreen mode Exit fullscreen mode
  • Now we create a getMatchingIndexes curried function, which also takes wanted and received, but this time returns all the indexes that match, no matter the position:
const getMatchingIndexes = wanted => received =>
    [...wanted].reduce(
        ([received, matches], letter, index) =>
            received.includes(letter)
                ? [received.replace(letter, "_"), [...matches, index]]
                : [received, matches],
        [received, []],
    )[1];
Enter fullscreen mode Exit fullscreen mode
  • Next, a getLooseMatchingIndexes curried function which makes use of the previous two, filtering from getMatchingIndexes the ones in getExactMatchingIndexes:
const getLooseMatchingIndexes = wanted => {
    const wantedExactMatchingIndexes = getExactMatchingIndexes(wanted);
    const wantedMatchingIndexes = getMatchingIndexes(wanted);

    return received => {
        const exactMatches = wantedExactMatchingIndexes(received);

        return wantedMatchingIndexes(received).filter(
            index => !exactMatches.includes(index),
        );
    };
};
Enter fullscreen mode Exit fullscreen mode
  • Finally, we create an indexesToLetters util to turn any of the previous number arrays into string arrays with the characters on them:
const indexesToLetters = indexesFunction => wanted => {
    const wantedIndexesFunction = indexesFunction(wanted);

    return received =>
        wantedIndexesFunction(received).map(index => wanted[index]);
};

const getExactMatchingLetters = indexesToLetters(getExactMatchingIndexes);
const getMatchingLetters = indexesToLetters(getMatchingIndexes);
const getLooseMatchingLetters = indexesToLetters(getLooseMatchingIndexes);
Enter fullscreen mode Exit fullscreen mode
  • That would be enough to solve the problem of matching strings, but you might want to keep having something similar to the Word class and have a single place where you set the wanted string and then you check for any received one, this can be easily achieved by creating a function that uses all the previously mentioned functions in an object:
export const testWord = wanted => ({
    getExactMatchingIndexes: getExactMatchingIndexes(wanted),
    getMatchingIndexes: getMatchingIndexes(wanted),
    getLooseMatchingIndexes: getLooseMatchingIndexes(wanted),
    getExactMatchingLetters: getExactMatchingLetters(wanted),
    getMatchingLetters: getMatchingLetters(wanted),
    getLooseMatchingLetters: getLooseMatchingLetters(wanted),
});
Enter fullscreen mode Exit fullscreen mode
  • You then can use this from the game like this:
const alarm = testWord("alarm");

alarm.getExactMatchingIndexes("drama"); // [2]
alarm.getMatchingIndexes("drama"); // [0, 2, 3, 4]
alarm.getLooseMatchingIndexes("drama"); // [0, 3, 4]
alarm.getExactMatchingLetters("drama"); // ["a"]
alarm.getMatchingLetters("drama"); // ["a", "a", "r", "m"]
alarm.getLooseMatchingLetters("drama"); // ["a", "r", "m"]
Enter fullscreen mode Exit fullscreen mode

As I mentioned before, you might notice testWord doesn't do any kind of validation, but that's because the validation doesn't belong here. We should make sure the strings are valid way before we reach this functions, and if in the future we want to use this same functions for a game with more letters, and other characters, we don't need to change anything.

Now, for the actual game, the changes are pretty similar to what we did with the Word class:

const includes = array => item => array.includes(item);

const attempt =
    ({ attempts, ...state }) =>
    word => ({
        ...state,
        attempts: [...attempts, word],
    });

const hasWon = ({ attempts, word }) => includes(attempts)(word);

const hasLost = ({ attempts, tries, word }) =>
    !hasWon({ attempts, word }) && attempts.length > tries;

const game = state => ({
    attempt: attempt(state),
    hasWon: hasWon(state),
    hasLost: hasLost(state),
});
Enter fullscreen mode Exit fullscreen mode

And finally, you can have a main function that puts everything together, and in it, you validate the dictionary of strings so only valid strings are used, and so on. Using the game function above you can update the state without having to worry about mutations.


The long story short is that with TDD we should embrace small functions with a single responsibility over classes. This makes the code easier to read, easier to test and easier to reuse.

Cheers!

Collapse
 
mcsee profile image
Maxi Contieri • Edited

Hi

Thank you for you kind comments.
As I've said before. I don't know Javascirpt, so these comments help me improve.

-I didn't know strings were iterable. I will change them.
-a Word is not a string. And the abstraction is really necessary since it exists in real world.
-I don't see data or data concerns. I Just see words. and we count words starting from 1. Ask any wordle player. Data is accidental. Behavior is essential.
-Classes map real world concepts to design. I program in OOP Style. I can make a procedural or functional wordle without classes and exceptions. It was not the goal of the excercise.
-We can use TDD to create great functional code as you showed. I tried to make the same in OOP way.

Cheers