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 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + 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); + }); +}