diff --git a/.github/workflows/board_generator.yaml b/.github/workflows/board_generator.yaml
new file mode 100644
index 000000000..3677a5edb
--- /dev/null
+++ b/.github/workflows/board_generator.yaml
@@ -0,0 +1,21 @@
+name: board_generator
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+on:
+ pull_request:
+ paths:
+ - "packages/board_generator/**"
+ - ".github/workflows/board_generator.yaml"
+ branches:
+ - main
+
+jobs:
+ build:
+ uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1
+ with:
+ dart_sdk: stable
+ working_directory: packages/board_generator
+ coverage_excludes: "**/*.g.dart"
diff --git a/packages/board_generator/.gitignore b/packages/board_generator/.gitignore
new file mode 100644
index 000000000..526da1584
--- /dev/null
+++ b/packages/board_generator/.gitignore
@@ -0,0 +1,7 @@
+# See https://www.dartlang.org/guides/libraries/private-files
+
+# Files and directories created by pub
+.dart_tool/
+.packages
+build/
+pubspec.lock
\ No newline at end of file
diff --git a/packages/board_generator/README.md b/packages/board_generator/README.md
new file mode 100644
index 000000000..6e0c32707
--- /dev/null
+++ b/packages/board_generator/README.md
@@ -0,0 +1,43 @@
+# Board Generator
+
+[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
+[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason)
+[![License: MIT][license_badge]][license_link]
+
+Crossword board generator.
+
+## Usage ๐ป
+
+**โ In order to start using Board Generator you must have the [Dart SDK][dart_install_link] installed on your machine.**
+
+Run it as a dart program:
+
+```sh
+dart main.dart
+```
+
+## Running Tests ๐งช
+
+To run all unit tests:
+
+```sh
+dart pub global activate coverage 1.2.0
+dart test --coverage=coverage
+dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info
+```
+
+To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov).
+
+```sh
+# Generate Coverage Report
+genhtml coverage/lcov.info -o coverage/
+
+# Open Coverage Report
+open coverage/index.html
+```
+
+[dart_install_link]: https://dart.dev/get-dart
+[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
+[license_link]: https://opensource.org/licenses/MIT
+[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
+[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis
diff --git a/packages/board_generator/analysis_options.yaml b/packages/board_generator/analysis_options.yaml
new file mode 100644
index 000000000..799268d3e
--- /dev/null
+++ b/packages/board_generator/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:very_good_analysis/analysis_options.5.1.0.yaml
diff --git a/packages/board_generator/coverage_badge.svg b/packages/board_generator/coverage_badge.svg
new file mode 100644
index 000000000..499e98ce2
--- /dev/null
+++ b/packages/board_generator/coverage_badge.svg
@@ -0,0 +1,20 @@
+
diff --git a/packages/board_generator/lib/board_generator.dart b/packages/board_generator/lib/board_generator.dart
new file mode 100644
index 000000000..dd55a7866
--- /dev/null
+++ b/packages/board_generator/lib/board_generator.dart
@@ -0,0 +1,24 @@
+import 'dart:collection';
+
+import 'package:board_generator/models/data_model.dart';
+
+/// Generates a crossword.
+Iterable generateCrosswords(List wordList) sync* {
+ final queue = Queue>()
+ ..addFirst(
+ Crossword(
+ (b) => b..candidates.addAll(wordList),
+ ).generate().iterator,
+ );
+
+ while (queue.isNotEmpty) {
+ final iterator = queue.removeFirst();
+ if (iterator.moveNext()) {
+ final crossword = iterator.current;
+ yield crossword;
+ queue
+ ..addFirst(iterator)
+ ..addFirst(crossword.generate().iterator);
+ }
+ }
+}
diff --git a/packages/board_generator/lib/main.dart b/packages/board_generator/lib/main.dart
new file mode 100644
index 000000000..caa759806
--- /dev/null
+++ b/packages/board_generator/lib/main.dart
@@ -0,0 +1,16 @@
+import 'package:board_generator/board_generator.dart';
+
+/// The list of all words that can be used to generate a crossword.
+final allWords = [
+ 'winter',
+ 'spring',
+ 'summer',
+];
+
+void main(List args) {
+ final iter = generateCrosswords(allWords);
+ final completeCrossword = iter.last;
+
+ // ignore: avoid_print
+ print(completeCrossword);
+}
diff --git a/packages/board_generator/lib/models/data_model.dart b/packages/board_generator/lib/models/data_model.dart
new file mode 100644
index 000000000..0a192f3b2
--- /dev/null
+++ b/packages/board_generator/lib/models/data_model.dart
@@ -0,0 +1,260 @@
+library data_model;
+
+import 'package:built_collection/built_collection.dart';
+import 'package:built_value/built_value.dart';
+import 'package:characters/characters.dart';
+
+part 'data_model.g.dart';
+
+///{@template crossword}
+/// A crossword puzzle data model.
+/// {@endtemplate}
+abstract class Crossword implements Built {
+ ///{@macro crossword}
+ factory Crossword([void Function(CrosswordBuilder) updates]) = _$Crossword;
+
+ Crossword._();
+
+ /// The list of unused candidate words that can be added to this crossword.
+ BuiltList get candidates;
+
+ /// The list of down words, by their starting point location
+ BuiltMap get downWords;
+
+ /// The list of across words, by their starting point location
+ BuiltMap get acrossWords;
+
+ /// The characters by location. Useful for displaying the crossword,
+ /// or checking the proposed solution.
+ BuiltMap get characters;
+
+ /// Checks if this crossword is valid.
+ bool get valid {
+ for (final entry in characters.entries) {
+ final location = entry.key;
+ final character = entry.value;
+
+ // All characters must be a part of an across or down word.
+ if (character.acrossWord == null && character.downWord == null) {
+ return false;
+ }
+
+ // Characters above and below this character must be related
+ // by a vertical word
+ final characterNorth =
+ characters[location.rebuild((b) => b..down = location.down - 1)];
+ if (characterNorth != null) {
+ if (character.downWord == null) return false;
+ if (characterNorth.downWord != character.downWord) return false;
+ }
+
+ final characterSouth =
+ characters[location.rebuild((b) => b..down = location.down + 1)];
+ if (characterSouth != null) {
+ if (character.downWord == null) return false;
+ if (characterSouth.downWord != character.downWord) return false;
+ }
+
+ // Characters to the left and right of this character must be
+ // related by a horizontal word
+ final characterEast =
+ characters[location.rebuild((b) => b..across = location.across - 1)];
+ if (characterEast != null) {
+ if (character.acrossWord == null) return false;
+ if (characterEast.acrossWord != character.acrossWord) return false;
+ }
+
+ final characterWest =
+ characters[location.rebuild((b) => b..across = location.across + 1)];
+ if (characterWest != null) {
+ if (character.acrossWord == null) return false;
+ if (characterWest.acrossWord != character.acrossWord) return false;
+ }
+ }
+
+ return true;
+ }
+
+ /// Adds a word across to the crossword puzzle, if it fits.
+ Crossword? addAcrossWord({required Location location, required String word}) {
+ final wordCharacters = word.characters;
+
+ for (final (index, character) in wordCharacters.indexed) {
+ final characterLocation =
+ location.rebuild((b) => b.across = b.across! + index);
+ final target = characters[characterLocation];
+ if (target != null) {
+ if (target.character != character) return null;
+ if (target.acrossWord != null) return null;
+ }
+ }
+
+ final updated = rebuild((b) => b.acrossWords.addAll({location: word}));
+ if (updated.valid) return updated;
+ return null;
+ }
+
+ /// Adds a word down to the crossword puzzle, if it fits.
+ Crossword? addDownWord({required Location location, required String word}) {
+ final wordCharacters = word.characters;
+
+ for (final (index, character) in wordCharacters.indexed) {
+ final characterLocation =
+ location.rebuild((b) => b.down = b.down! + index);
+ final target = characters[characterLocation];
+ if (target != null) {
+ if (target.character != character) return null;
+ if (target.downWord != null) return null;
+ }
+ }
+
+ final updated = rebuild((b) => b.downWords.addAll({location: word}));
+ if (updated.valid) return updated;
+ return null;
+ }
+
+ /// Generates a crossword puzzle by adding words to the board.
+ Iterable generate() sync* {
+ // Nothing left to do if there are no candidate words
+ if (candidates.isEmpty) return;
+
+ // Split out the first word case
+ if (downWords.isEmpty && acrossWords.isEmpty) {
+ for (final word in candidates) {
+ final location = Location(
+ (b) => b
+ ..across = 0
+ ..down = 0,
+ );
+
+ yield rebuild((b) => b..candidates.remove(word))
+ .addAcrossWord(location: location, word: word)!;
+ }
+ return;
+ }
+
+ // All subsequent words are joined to previous words
+ for (final entry in characters.entries.toList()..shuffle()) {
+ // ignore locations that already join two words
+ if (entry.value.acrossWord != null && entry.value.downWord != null) {
+ continue;
+ }
+
+ // Filter down the candidate word list to those that contain the letter
+ // at the current location
+ final filteredCandidates = candidates.rebuild(
+ (b) => b.removeWhere(
+ (innerWord) => !innerWord.characters.contains(entry.value.character),
+ ),
+ );
+
+ // Attempt to place the filtered candidate words over the current
+ // location as a join point.
+ for (final word in filteredCandidates) {
+ for (final (index, character) in word.characters.indexed) {
+ if (character != entry.value.character) continue;
+
+ if (entry.value.acrossWord != null) {
+ // Adding a downWord
+ final candidate =
+ rebuild((b) => b..candidates.remove(word)).addDownWord(
+ location:
+ entry.key.rebuild((b) => b.down = entry.key.down - index),
+ word: word,
+ );
+
+ if (candidate != null) yield candidate;
+ } else {
+ // Adding an acrossWord
+ final candidate =
+ rebuild((b) => b..candidates.remove(word)).addAcrossWord(
+ location:
+ entry.key.rebuild((b) => b.across = entry.key.across - index),
+ word: word,
+ );
+
+ if (candidate != null) yield candidate;
+ }
+ }
+ }
+ }
+ }
+
+ @BuiltValueHook(finalizeBuilder: true)
+ static void _fillCharacters(CrosswordBuilder b) {
+ b.characters.clear();
+
+ for (final entry in b.acrossWords.build().entries) {
+ final location = entry.key;
+ final word = entry.value;
+ for (final (idx, character) in word.characters.indexed) {
+ final characterLocation =
+ location.rebuild((b) => b..across = location.across + idx);
+ b.characters.putIfAbsent(
+ characterLocation,
+ () => CrosswordCharacter(
+ (b) => b
+ ..acrossWord = word
+ ..character = character,
+ ),
+ );
+ }
+ }
+
+ for (final entry in b.downWords.build().entries) {
+ final location = entry.key;
+ final word = entry.value;
+ for (final (idx, character) in word.characters.indexed) {
+ final characterLocation =
+ location.rebuild((b) => b..down = location.down + idx);
+ b.characters.updateValue(
+ characterLocation,
+ (b) => b.rebuild((b) => b.downWord = word),
+ ifAbsent: () => CrosswordCharacter(
+ (b) => b
+ ..downWord = word
+ ..character = character,
+ ),
+ );
+ }
+ }
+ }
+}
+
+///{@template location}
+/// A location in the crossword puzzle.
+/// {@endtemplate}
+abstract class Location implements Built {
+ ///{@macro location}
+ factory Location([void Function(LocationBuilder) updates]) = _$Location;
+
+ Location._();
+
+ /// The horizontal part of the location.
+ int get across;
+
+ /// The vertical part of the location.
+ int get down;
+}
+
+///{@template crossword_character}
+/// A character in the crossword puzzle.
+/// {@endtemplate}
+abstract class CrosswordCharacter
+ implements Built {
+ ///{@macro crossword_character}
+ factory CrosswordCharacter([
+ void Function(CrosswordCharacterBuilder) updates,
+ ]) = _$CrosswordCharacter;
+
+ CrosswordCharacter._();
+
+ /// The character at this location.
+ String get character;
+
+ /// The across word that this character is a part of.
+ String? get acrossWord;
+
+ /// The down word that this character is a part of.
+ String? get downWord;
+}
diff --git a/packages/board_generator/lib/models/data_model.g.dart b/packages/board_generator/lib/models/data_model.g.dart
new file mode 100644
index 000000000..042e13457
--- /dev/null
+++ b/packages/board_generator/lib/models/data_model.g.dart
@@ -0,0 +1,369 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'data_model.dart';
+
+// **************************************************************************
+// BuiltValueGenerator
+// **************************************************************************
+
+class _$Crossword extends Crossword {
+ @override
+ final BuiltList candidates;
+ @override
+ final BuiltMap downWords;
+ @override
+ final BuiltMap acrossWords;
+ @override
+ final BuiltMap characters;
+
+ factory _$Crossword([void Function(CrosswordBuilder)? updates]) =>
+ (new CrosswordBuilder()..update(updates))._build();
+
+ _$Crossword._(
+ {required this.candidates,
+ required this.downWords,
+ required this.acrossWords,
+ required this.characters})
+ : super._() {
+ BuiltValueNullFieldError.checkNotNull(
+ candidates, r'Crossword', 'candidates');
+ BuiltValueNullFieldError.checkNotNull(downWords, r'Crossword', 'downWords');
+ BuiltValueNullFieldError.checkNotNull(
+ acrossWords, r'Crossword', 'acrossWords');
+ BuiltValueNullFieldError.checkNotNull(
+ characters, r'Crossword', 'characters');
+ }
+
+ @override
+ Crossword rebuild(void Function(CrosswordBuilder) updates) =>
+ (toBuilder()..update(updates)).build();
+
+ @override
+ CrosswordBuilder toBuilder() => new CrosswordBuilder()..replace(this);
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(other, this)) return true;
+ return other is Crossword &&
+ candidates == other.candidates &&
+ downWords == other.downWords &&
+ acrossWords == other.acrossWords &&
+ characters == other.characters;
+ }
+
+ @override
+ int get hashCode {
+ var _$hash = 0;
+ _$hash = $jc(_$hash, candidates.hashCode);
+ _$hash = $jc(_$hash, downWords.hashCode);
+ _$hash = $jc(_$hash, acrossWords.hashCode);
+ _$hash = $jc(_$hash, characters.hashCode);
+ _$hash = $jf(_$hash);
+ return _$hash;
+ }
+
+ @override
+ String toString() {
+ return (newBuiltValueToStringHelper(r'Crossword')
+ ..add('candidates', candidates)
+ ..add('downWords', downWords)
+ ..add('acrossWords', acrossWords)
+ ..add('characters', characters))
+ .toString();
+ }
+}
+
+class CrosswordBuilder implements Builder {
+ _$Crossword? _$v;
+
+ ListBuilder? _candidates;
+ ListBuilder get candidates =>
+ _$this._candidates ??= new ListBuilder();
+ set candidates(ListBuilder? candidates) =>
+ _$this._candidates = candidates;
+
+ MapBuilder? _downWords;
+ MapBuilder get downWords =>
+ _$this._downWords ??= new MapBuilder();
+ set downWords(MapBuilder? downWords) =>
+ _$this._downWords = downWords;
+
+ MapBuilder? _acrossWords;
+ MapBuilder get acrossWords =>
+ _$this._acrossWords ??= new MapBuilder();
+ set acrossWords(MapBuilder? acrossWords) =>
+ _$this._acrossWords = acrossWords;
+
+ MapBuilder? _characters;
+ MapBuilder get characters =>
+ _$this._characters ??= new MapBuilder();
+ set characters(MapBuilder? characters) =>
+ _$this._characters = characters;
+
+ CrosswordBuilder();
+
+ CrosswordBuilder get _$this {
+ final $v = _$v;
+ if ($v != null) {
+ _candidates = $v.candidates.toBuilder();
+ _downWords = $v.downWords.toBuilder();
+ _acrossWords = $v.acrossWords.toBuilder();
+ _characters = $v.characters.toBuilder();
+ _$v = null;
+ }
+ return this;
+ }
+
+ @override
+ void replace(Crossword other) {
+ ArgumentError.checkNotNull(other, 'other');
+ _$v = other as _$Crossword;
+ }
+
+ @override
+ void update(void Function(CrosswordBuilder)? updates) {
+ if (updates != null) updates(this);
+ }
+
+ @override
+ Crossword build() => _build();
+
+ _$Crossword _build() {
+ Crossword._fillCharacters(this);
+ _$Crossword _$result;
+ try {
+ _$result = _$v ??
+ new _$Crossword._(
+ candidates: candidates.build(),
+ downWords: downWords.build(),
+ acrossWords: acrossWords.build(),
+ characters: characters.build());
+ } catch (_) {
+ late String _$failedField;
+ try {
+ _$failedField = 'candidates';
+ candidates.build();
+ _$failedField = 'downWords';
+ downWords.build();
+ _$failedField = 'acrossWords';
+ acrossWords.build();
+ _$failedField = 'characters';
+ characters.build();
+ } catch (e) {
+ throw new BuiltValueNestedFieldError(
+ r'Crossword', _$failedField, e.toString());
+ }
+ rethrow;
+ }
+ replace(_$result);
+ return _$result;
+ }
+}
+
+class _$Location extends Location {
+ @override
+ final int across;
+ @override
+ final int down;
+
+ factory _$Location([void Function(LocationBuilder)? updates]) =>
+ (new LocationBuilder()..update(updates))._build();
+
+ _$Location._({required this.across, required this.down}) : super._() {
+ BuiltValueNullFieldError.checkNotNull(across, r'Location', 'across');
+ BuiltValueNullFieldError.checkNotNull(down, r'Location', 'down');
+ }
+
+ @override
+ Location rebuild(void Function(LocationBuilder) updates) =>
+ (toBuilder()..update(updates)).build();
+
+ @override
+ LocationBuilder toBuilder() => new LocationBuilder()..replace(this);
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(other, this)) return true;
+ return other is Location && across == other.across && down == other.down;
+ }
+
+ @override
+ int get hashCode {
+ var _$hash = 0;
+ _$hash = $jc(_$hash, across.hashCode);
+ _$hash = $jc(_$hash, down.hashCode);
+ _$hash = $jf(_$hash);
+ return _$hash;
+ }
+
+ @override
+ String toString() {
+ return (newBuiltValueToStringHelper(r'Location')
+ ..add('across', across)
+ ..add('down', down))
+ .toString();
+ }
+}
+
+class LocationBuilder implements Builder {
+ _$Location? _$v;
+
+ int? _across;
+ int? get across => _$this._across;
+ set across(int? across) => _$this._across = across;
+
+ int? _down;
+ int? get down => _$this._down;
+ set down(int? down) => _$this._down = down;
+
+ LocationBuilder();
+
+ LocationBuilder get _$this {
+ final $v = _$v;
+ if ($v != null) {
+ _across = $v.across;
+ _down = $v.down;
+ _$v = null;
+ }
+ return this;
+ }
+
+ @override
+ void replace(Location other) {
+ ArgumentError.checkNotNull(other, 'other');
+ _$v = other as _$Location;
+ }
+
+ @override
+ void update(void Function(LocationBuilder)? updates) {
+ if (updates != null) updates(this);
+ }
+
+ @override
+ Location build() => _build();
+
+ _$Location _build() {
+ final _$result = _$v ??
+ new _$Location._(
+ across: BuiltValueNullFieldError.checkNotNull(
+ across, r'Location', 'across'),
+ down: BuiltValueNullFieldError.checkNotNull(
+ down, r'Location', 'down'));
+ replace(_$result);
+ return _$result;
+ }
+}
+
+class _$CrosswordCharacter extends CrosswordCharacter {
+ @override
+ final String character;
+ @override
+ final String? acrossWord;
+ @override
+ final String? downWord;
+
+ factory _$CrosswordCharacter(
+ [void Function(CrosswordCharacterBuilder)? updates]) =>
+ (new CrosswordCharacterBuilder()..update(updates))._build();
+
+ _$CrosswordCharacter._(
+ {required this.character, this.acrossWord, this.downWord})
+ : super._() {
+ BuiltValueNullFieldError.checkNotNull(
+ character, r'CrosswordCharacter', 'character');
+ }
+
+ @override
+ CrosswordCharacter rebuild(
+ void Function(CrosswordCharacterBuilder) updates) =>
+ (toBuilder()..update(updates)).build();
+
+ @override
+ CrosswordCharacterBuilder toBuilder() =>
+ new CrosswordCharacterBuilder()..replace(this);
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(other, this)) return true;
+ return other is CrosswordCharacter &&
+ character == other.character &&
+ acrossWord == other.acrossWord &&
+ downWord == other.downWord;
+ }
+
+ @override
+ int get hashCode {
+ var _$hash = 0;
+ _$hash = $jc(_$hash, character.hashCode);
+ _$hash = $jc(_$hash, acrossWord.hashCode);
+ _$hash = $jc(_$hash, downWord.hashCode);
+ _$hash = $jf(_$hash);
+ return _$hash;
+ }
+
+ @override
+ String toString() {
+ return (newBuiltValueToStringHelper(r'CrosswordCharacter')
+ ..add('character', character)
+ ..add('acrossWord', acrossWord)
+ ..add('downWord', downWord))
+ .toString();
+ }
+}
+
+class CrosswordCharacterBuilder
+ implements Builder {
+ _$CrosswordCharacter? _$v;
+
+ String? _character;
+ String? get character => _$this._character;
+ set character(String? character) => _$this._character = character;
+
+ String? _acrossWord;
+ String? get acrossWord => _$this._acrossWord;
+ set acrossWord(String? acrossWord) => _$this._acrossWord = acrossWord;
+
+ String? _downWord;
+ String? get downWord => _$this._downWord;
+ set downWord(String? downWord) => _$this._downWord = downWord;
+
+ CrosswordCharacterBuilder();
+
+ CrosswordCharacterBuilder get _$this {
+ final $v = _$v;
+ if ($v != null) {
+ _character = $v.character;
+ _acrossWord = $v.acrossWord;
+ _downWord = $v.downWord;
+ _$v = null;
+ }
+ return this;
+ }
+
+ @override
+ void replace(CrosswordCharacter other) {
+ ArgumentError.checkNotNull(other, 'other');
+ _$v = other as _$CrosswordCharacter;
+ }
+
+ @override
+ void update(void Function(CrosswordCharacterBuilder)? updates) {
+ if (updates != null) updates(this);
+ }
+
+ @override
+ CrosswordCharacter build() => _build();
+
+ _$CrosswordCharacter _build() {
+ final _$result = _$v ??
+ new _$CrosswordCharacter._(
+ character: BuiltValueNullFieldError.checkNotNull(
+ character, r'CrosswordCharacter', 'character'),
+ acrossWord: acrossWord,
+ downWord: downWord);
+ replace(_$result);
+ return _$result;
+ }
+}
+
+// ignore_for_file: deprecated_member_use_from_same_package,type=lint
diff --git a/packages/board_generator/pubspec.yaml b/packages/board_generator/pubspec.yaml
new file mode 100644
index 000000000..4564e899f
--- /dev/null
+++ b/packages/board_generator/pubspec.yaml
@@ -0,0 +1,19 @@
+name: board_generator
+description: Crossword board generator.
+version: 0.1.0+1
+publish_to: none
+
+environment:
+ sdk: ">=3.3.0 <4.0.0"
+
+dependencies:
+ built_collection: ^5.1.1
+ built_value: ^8.9.1
+ characters: ^1.3.0
+
+dev_dependencies:
+ build_runner: ^2.4.8
+ built_value_generator: ^8.9.1
+ mocktail: ^1.0.3
+ test: ^1.25.2
+ very_good_analysis: ^5.1.0
\ No newline at end of file
diff --git a/packages/board_generator/test/board_generator_test.dart b/packages/board_generator/test/board_generator_test.dart
new file mode 100644
index 000000000..745c1246d
--- /dev/null
+++ b/packages/board_generator/test/board_generator_test.dart
@@ -0,0 +1,38 @@
+// ignore_for_file: prefer_const_constructors
+import 'package:board_generator/board_generator.dart';
+import 'package:test/test.dart';
+
+void main() {
+ const wordList = [
+ 'winter',
+ 'spring',
+ 'summer',
+ 'autumn',
+ 'interesting',
+ 'fascinating',
+ 'amazing',
+ 'wonderful',
+ 'beautiful',
+ 'gorgeous',
+ 'stunning',
+ 'breathtaking',
+ 'spectacular',
+ 'magnificent',
+ 'incredible',
+ ];
+
+ group('BoardGenerator', () {
+ test('generate crosswords', () {
+ var depth = 0;
+ for (final crossword in generateCrosswords(wordList)) {
+ depth++;
+ expect(crossword.valid, true);
+ expect(
+ crossword.acrossWords.length + crossword.downWords.length,
+ depth,
+ );
+ if (depth >= wordList.length) break;
+ }
+ });
+ });
+}
diff --git a/packages/board_generator/test/models/data_model_test.dart b/packages/board_generator/test/models/data_model_test.dart
new file mode 100644
index 000000000..68b02715d
--- /dev/null
+++ b/packages/board_generator/test/models/data_model_test.dart
@@ -0,0 +1,235 @@
+import 'package:board_generator/models/data_model.dart';
+import 'package:test/test.dart';
+
+void main() {
+ const wordList = [
+ 'winter',
+ 'spring',
+ 'summer',
+ 'autumn',
+ 'interesting',
+ 'fascinating',
+ 'amazing',
+ 'wonderful',
+ 'beautiful',
+ 'gorgeous',
+ 'stunning',
+ 'breathtaking',
+ 'spectacular',
+ 'magnificent',
+ 'incredible',
+ ];
+
+ test('Empty valid crossword', () {
+ final crossword = Crossword(
+ (b) => b
+ ..candidates.addAll([
+ 'that',
+ 'this',
+ 'with',
+ 'from',
+ 'your',
+ 'have',
+ 'more',
+ ]),
+ );
+
+ expect(crossword.acrossWords.isEmpty, equals(true));
+ expect(crossword.downWords.isEmpty, equals(true));
+ expect(crossword.characters.isEmpty, equals(true));
+ expect(crossword.valid, equals(true));
+ });
+
+ test('Minimal valid crossword', () {
+ final topLeft = Location(
+ (b) => b
+ ..across = 0
+ ..down = 0,
+ );
+
+ final crossword = Crossword(
+ (b) => b
+ ..acrossWords.addAll({topLeft: 'this'})
+ ..downWords.addAll({topLeft: 'that'}),
+ );
+
+ expect(crossword.acrossWords.isNotEmpty, true);
+ expect(crossword.acrossWords.length, 1);
+ expect(crossword.downWords.isNotEmpty, true);
+ expect(crossword.downWords.length, 1);
+ expect(crossword.characters.isNotEmpty, true);
+ expect(crossword.characters.length, 7);
+ expect(
+ crossword.characters[topLeft],
+ CrosswordCharacter(
+ (b) => b
+ ..acrossWord = 'this'
+ ..downWord = 'that'
+ ..character = 't',
+ ),
+ );
+ expect(crossword.valid, true);
+ });
+
+ test('Minimal invalid crossword', () {
+ final topLeft = Location(
+ (b) => b
+ ..across = 0
+ ..down = 0,
+ );
+ final oneDown = topLeft.rebuild((b) => b.down = 1);
+ final crossword = Crossword(
+ (b) => b..acrossWords.addAll({topLeft: 'this', oneDown: 'that'}),
+ );
+
+ expect(crossword.acrossWords.isNotEmpty, true);
+ expect(crossword.acrossWords.length, 2);
+ expect(crossword.downWords.isEmpty, true);
+ expect(crossword.characters.isNotEmpty, true);
+ expect(crossword.characters.length, 8);
+ expect(crossword.valid, false);
+ });
+
+ test('Adding across and down words', () {
+ // Empty crossword
+ final crossword0 = Crossword();
+
+ expect(crossword0.valid, true);
+
+ final topLeft = Location(
+ (b) => b
+ ..across = 0
+ ..down = 0,
+ );
+
+ final crossword1 =
+ crossword0.addAcrossWord(location: topLeft, word: 'this');
+ if (crossword1 == null) fail("crossword1 shouldn't be null");
+ expect(crossword1.valid, true);
+
+ final crossword2 = crossword1.addDownWord(location: topLeft, word: 'that');
+ if (crossword2 == null) fail("crossword2 shouldn't be null");
+ expect(crossword2.valid, true);
+ });
+
+ test('Fail on adding clashing across words', () {
+ // Empty crossword
+ final crossword0 = Crossword();
+
+ expect(crossword0.valid, true);
+
+ final topLeft = Location(
+ (b) => b
+ ..across = 0
+ ..down = 0,
+ );
+ final downOne = Location(
+ (b) => b
+ ..across = 0
+ ..down = 1,
+ );
+
+ final crossword1 =
+ crossword0.addAcrossWord(location: topLeft, word: 'this');
+ if (crossword1 == null) fail("crossword1 shouldn't be null");
+ expect(crossword1.valid, true);
+
+ final crossword2 =
+ crossword1.addAcrossWord(location: topLeft, word: 'that');
+ expect(crossword2 == null, true);
+
+ final crossword3 =
+ crossword1.addAcrossWord(location: downOne, word: 'other');
+ expect(crossword3 == null, true);
+ });
+
+ test('Fail on adding clashing down words', () {
+ // Empty crossword
+ final crossword0 = Crossword();
+
+ expect(crossword0.valid, true);
+
+ final topLeft = Location(
+ (b) => b
+ ..across = 0
+ ..down = 0,
+ );
+ final acrossOne = Location(
+ (b) => b
+ ..across = 1
+ ..down = 0,
+ );
+
+ final crossword1 = crossword0.addDownWord(location: topLeft, word: 'this');
+ if (crossword1 == null) fail("crossword1 shouldn't be null");
+ expect(crossword1.valid, true);
+
+ final crossword2 = crossword1.addDownWord(location: topLeft, word: 'that');
+ expect(crossword2 == null, true);
+
+ final crossword3 =
+ crossword1.addDownWord(location: acrossOne, word: 'other');
+ expect(crossword3 == null, true);
+ });
+
+ test('Crossword generation', () {
+ final crossword0 = Crossword(
+ (b) => b..candidates.addAll(wordList),
+ );
+
+ final crossword1 = crossword0.generate().first;
+ expect(crossword1.valid, true);
+ expect(crossword1.acrossWords.length, 1);
+ expect(crossword1.candidates.length, crossword0.candidates.length - 1);
+
+ final crossword2 = crossword1.generate().first;
+ expect(crossword2.valid, true);
+ expect(crossword2.acrossWords.length, 1);
+ expect(crossword2.downWords.length, 1);
+ expect(crossword2.candidates.length, crossword1.candidates.length - 1);
+
+ final crossword3 = crossword2.generate().first;
+ expect(crossword3.valid, true);
+ expect(crossword3.acrossWords.length + crossword3.downWords.length, 3);
+ expect(crossword3.candidates.length, crossword2.candidates.length - 1);
+
+ final crossword4 = crossword3.generate().first;
+ expect(crossword4.valid, true);
+ expect(crossword4.acrossWords.length + crossword4.downWords.length, 4);
+ expect(crossword4.candidates.length, crossword3.candidates.length - 1);
+
+ final crossword5 = crossword4.generate().first;
+ expect(crossword5.valid, true);
+ expect(crossword5.acrossWords.length + crossword5.downWords.length, 5);
+ expect(crossword5.candidates.length, crossword4.candidates.length - 1);
+
+ final crossword6 = crossword5.generate().first;
+ expect(crossword6.valid, true);
+ expect(crossword6.acrossWords.length + crossword6.downWords.length, 6);
+ expect(crossword6.candidates.length, crossword5.candidates.length - 1);
+
+ final crossword7 = crossword6.generate().first;
+ expect(crossword7.valid, true);
+ expect(crossword7.acrossWords.length + crossword7.downWords.length, 7);
+ expect(crossword7.candidates.length, crossword6.candidates.length - 1);
+
+ final iterable = crossword7.generate();
+ expect(iterable.isEmpty, false);
+ expect(iterable.isNotEmpty, true);
+
+ final iterator = iterable.iterator;
+ expect(iterator.moveNext(), true);
+ expect(iterator.current, isNotNull);
+ final crossword8 = iterator.current;
+ expect(crossword8.valid, true);
+ expect(crossword8.acrossWords.length + crossword8.downWords.length, 8);
+ expect(crossword8.candidates.length, crossword7.candidates.length - 1);
+
+ expect(iterator.moveNext(), true);
+ expect(iterator.current, isNotNull);
+ final crossword9 = iterator.current;
+ expect(crossword9.valid, true);
+ expect(crossword9.acrossWords.length + crossword9.downWords.length, 8);
+ expect(crossword9.candidates.length, crossword7.candidates.length - 1);
+ });
+}