diff --git a/lib/api/dio/sudoku_dio_client.dart b/lib/api/dio/sudoku_dio_client.dart index 960731c..8d0ff63 100644 --- a/lib/api/dio/sudoku_dio_client.dart +++ b/lib/api/dio/sudoku_dio_client.dart @@ -3,8 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:sudoku/api/api.dart'; import 'package:sudoku/api/dtos/dtos.dart'; import 'package:sudoku/env/env.dart'; -import 'package:sudoku/models/difficulty.dart'; -import 'package:sudoku/models/sudoku.dart'; +import 'package:sudoku/models/models.dart'; /// {@template sudoku_dio_client} /// An implemetation of the [SudokuAPI] using [Dio] as the http client. @@ -22,12 +21,15 @@ class SudokuDioClient extends SudokuAPI { final Dio dioClient; Map get _headers => { - 'x-api-key': Env.apiKey, - }; + 'x-api-key': Env.apiKey, + }; /// HTTP request path for creating sudoku static const createSudokuPath = '/createSudokuFlow'; + /// HTTP request path for generating hints + static const generateHintPath = '/generateHintFlow'; + @override Future createSudoku({required Difficulty difficulty}) async { try { @@ -60,4 +62,32 @@ class SudokuDioClient extends SudokuAPI { throw const SudokuAPIClientException(); } } + + @override + Future generateHint({required Sudoku sudoku}) async { + try { + final (puzzle, solution) = sudoku.toRawData(); + final response = await dioClient.post>( + generateHintPath, + data: GenerateHintRequestDto( + data: GenerateHintRequest(puzzle: puzzle, solution: solution), + ).toJson(), + options: Options( + contentType: Headers.jsonContentType, + headers: _headers, + ), + ); + + if (response.data == null) { + throw const SudokuAPIClientException(); + } + + final responseDto = GenerateHintResponseDto.fromJson(response.data!); + return responseDto.result.toHint(); + } on DioException catch (error) { + throw SudokuAPIClientException(error: error); + } catch (e) { + throw const SudokuAPIClientException(); + } + } } diff --git a/lib/api/dtos/create_sudoku_response_dto.dart b/lib/api/dtos/create_sudoku_response_dto.dart index 6db4649..0e5c1b2 100644 --- a/lib/api/dtos/create_sudoku_response_dto.dart +++ b/lib/api/dtos/create_sudoku_response_dto.dart @@ -33,6 +33,7 @@ class CreateSudokuResponseDto extends Equatable { @immutable @JsonSerializable() class CreateSudokuResponse extends Equatable { + /// {@macro create_sudoku_response} const CreateSudokuResponse({ required this.puzzle, required this.solution, diff --git a/lib/api/dtos/dtos.dart b/lib/api/dtos/dtos.dart index d6f13c6..97632ea 100644 --- a/lib/api/dtos/dtos.dart +++ b/lib/api/dtos/dtos.dart @@ -1,2 +1,4 @@ export 'create_sudoku_request_dto.dart'; export 'create_sudoku_response_dto.dart'; +export 'generate_hint_request_dto.dart'; +export 'generate_hint_response_dto.dart'; diff --git a/lib/api/dtos/generate_hint_request_dto.dart b/lib/api/dtos/generate_hint_request_dto.dart new file mode 100644 index 0000000..08e1036 --- /dev/null +++ b/lib/api/dtos/generate_hint_request_dto.dart @@ -0,0 +1,56 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'generate_hint_request_dto.g.dart'; + +/// {@template generate_hint_request_dto} +/// A DTO to send [GenerateHintRequest] object wrapped within `data` +/// for the generate hint http request. +/// {@endtemplate} +@immutable +@JsonSerializable(explicitToJson: true) +class GenerateHintRequestDto extends Equatable { + /// {@macro generate_hint_request_dto} + const GenerateHintRequestDto({ + required this.data, + }); + + /// The data to be sent over via http. + final GenerateHintRequest data; + + /// Converts [GenerateHintRequestDto] into [Map]. + Map toJson() => _$GenerateHintRequestDtoToJson(this); + + @override + List get props => [data]; +} + +/// {@template generate_hint_request} +/// Model for the generate hint http request. +/// {@endtemplate} +@immutable +@JsonSerializable() +class GenerateHintRequest extends Equatable { + /// {@macro generate_hint_request} + const GenerateHintRequest({ + required this.puzzle, + required this.solution, + }); + + /// Converts a [Map] object to a [GenerateHintRequest] instance. + factory GenerateHintRequest.fromJson(Map json) => + _$GenerateHintRequestFromJson(json); + + /// Current state of the puzzle. + final List> puzzle; + + /// Solution of the puzzle. + final List> solution; + + /// Converts [GenerateHintRequest] into [Map]. + Map toJson() => _$GenerateHintRequestToJson(this); + + @override + List get props => [puzzle, solution]; +} diff --git a/lib/api/dtos/generate_hint_request_dto.g.dart b/lib/api/dtos/generate_hint_request_dto.g.dart new file mode 100644 index 0000000..5ea9634 --- /dev/null +++ b/lib/api/dtos/generate_hint_request_dto.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file + +part of 'generate_hint_request_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GenerateHintRequestDto _$GenerateHintRequestDtoFromJson( + Map json) => + GenerateHintRequestDto( + data: GenerateHintRequest.fromJson(json['data'] as Map), + ); + +Map _$GenerateHintRequestDtoToJson( + GenerateHintRequestDto instance) => + { + 'data': instance.data.toJson(), + }; + +GenerateHintRequest _$GenerateHintRequestFromJson(Map json) => + GenerateHintRequest( + puzzle: (json['puzzle'] as List) + .map((e) => + (e as List).map((e) => (e as num).toInt()).toList()) + .toList(), + solution: (json['solution'] as List) + .map((e) => + (e as List).map((e) => (e as num).toInt()).toList()) + .toList(), + ); + +Map _$GenerateHintRequestToJson( + GenerateHintRequest instance) => + { + 'puzzle': instance.puzzle, + 'solution': instance.solution, + }; diff --git a/lib/api/dtos/generate_hint_response_dto.dart b/lib/api/dtos/generate_hint_response_dto.dart new file mode 100644 index 0000000..c57c445 --- /dev/null +++ b/lib/api/dtos/generate_hint_response_dto.dart @@ -0,0 +1,84 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:sudoku/models/models.dart'; + +part 'generate_hint_response_dto.g.dart'; + +/// {@template generate_hint_response_dto} +/// A DTO to receive [GenerateHintResponse] object wrapped within `data` +/// from the generate hint http request. +/// {@endtemplate} +@immutable +@JsonSerializable() +class GenerateHintResponseDto extends Equatable { + /// {@macro generate_hint_response_dto} + const GenerateHintResponseDto({ + required this.result, + }); + + /// Converts a [Map] json into a [GenerateHintResponseDto] instance. + factory GenerateHintResponseDto.fromJson(Map json) => + _$GenerateHintResponseDtoFromJson(json); + + /// The result to be received over via http. + final GenerateHintResponse result; + + @override + List get props => [result]; +} + +/// {@template generate_hint_response} +/// Model for the generate hint http response. +/// {@endtemplate} +@immutable +@JsonSerializable() +class GenerateHintResponse extends Equatable { + /// {@macro generate_hint_response} + const GenerateHintResponse({ + required this.cell, + required this.entry, + required this.observation, + required this.explanation, + required this.solution, + }); + + /// Converts a [Map] into a [GenerateHintResponse] instance. + factory GenerateHintResponse.fromJson(Map json) => + _$GenerateHintResponseFromJson(json); + + /// The position of the cell. + final List cell; + + /// The number to be put in the cell. + final int entry; + + /// The observation of the puzzle state for solving the cell. + final String observation; + + /// Explanation of the puzzle, and how to determine the solution. + final String explanation; + + /// The solution of the puzzle in a sentence. + final String solution; + + /// Converts the [GenerateHintResponse] to [Hint]. + Hint toHint() { + return Hint( + cell: Position(x: cell[0], y: cell[1]), + entry: entry, + observation: observation, + explanation: explanation, + solution: solution, + ); + } + + @override + List get props => [ + cell, + entry, + observation, + explanation, + solution, + ]; +} diff --git a/lib/api/dtos/generate_hint_response_dto.g.dart b/lib/api/dtos/generate_hint_response_dto.g.dart new file mode 100644 index 0000000..802502b --- /dev/null +++ b/lib/api/dtos/generate_hint_response_dto.g.dart @@ -0,0 +1,43 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file + +part of 'generate_hint_response_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GenerateHintResponseDto _$GenerateHintResponseDtoFromJson( + Map json) => + GenerateHintResponseDto( + result: + GenerateHintResponse.fromJson(json['result'] as Map), + ); + +Map _$GenerateHintResponseDtoToJson( + GenerateHintResponseDto instance) => + { + 'result': instance.result, + }; + +GenerateHintResponse _$GenerateHintResponseFromJson( + Map json) => + GenerateHintResponse( + cell: (json['cell'] as List) + .map((e) => (e as num).toInt()) + .toList(), + entry: (json['entry'] as num).toInt(), + observation: json['observation'] as String, + explanation: json['explanation'] as String, + solution: json['solution'] as String, + ); + +Map _$GenerateHintResponseToJson( + GenerateHintResponse instance) => + { + 'cell': instance.cell, + 'entry': instance.entry, + 'observation': instance.observation, + 'explanation': instance.explanation, + 'solution': instance.solution, + }; diff --git a/lib/api/sudoku_api.dart b/lib/api/sudoku_api.dart index 9131638..c8ca1e6 100644 --- a/lib/api/sudoku_api.dart +++ b/lib/api/sudoku_api.dart @@ -18,6 +18,12 @@ abstract class SudokuAPI { /// Throws a [SudokuInvalidRawDataException] when there's an error /// during converting the raw data into [Sudoku] object. Future createSudoku({required Difficulty difficulty}); + + /// Creates a [Hint] for the puzzle. + /// + /// Sends a HTTP request to the sudoku backend, which utilises + /// Firebase genkit to generate a hint based on current state of the puzzle. + Future generateHint({required Sudoku sudoku}); } /// {@template sudoku_api_client_exception} diff --git a/lib/models/hint.dart b/lib/models/hint.dart new file mode 100644 index 0000000..0e398b4 --- /dev/null +++ b/lib/models/hint.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.dart'; +import 'package:sudoku/models/models.dart'; + +/// {@template hint} +/// Model for a sudoku puzzle hint. +/// {@endtemplate} +@immutable +class Hint extends Equatable { + /// {@macro hint} + const Hint({ + required this.cell, + required this.entry, + required this.observation, + required this.explanation, + required this.solution, + }); + + /// Defines the position or the cell for the hint. + final Position cell; + + /// The number to be put in the cell. + final int entry; + + /// The observation of the puzzle state for solving the cell. + final String observation; + + /// Explanation of the puzzle, and how to determine the solution. + final String explanation; + + /// The solution of the puzzle in a sentence. + final String solution; + + @override + List get props => [ + cell, + entry, + observation, + explanation, + solution, + ]; +} diff --git a/lib/models/models.dart b/lib/models/models.dart index a6ca4f2..fc812b5 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -1,5 +1,6 @@ export 'block.dart'; export 'difficulty.dart'; +export 'hint.dart'; export 'json_map.dart'; export 'position.dart'; export 'sudoku.dart'; diff --git a/lib/models/sudoku.dart b/lib/models/sudoku.dart index 5998ceb..c6c8f25 100644 --- a/lib/models/sudoku.dart +++ b/lib/models/sudoku.dart @@ -115,10 +115,17 @@ class Sudoku extends Equatable { } /// Converts the specified sudoku to raw data. - List> toRawData() { + /// + /// Returns a record of puzzle and solution. + (List>, List>) toRawData() { final dimension = getDimesion(); - final rawData = List>.generate( + final rawPuzzleData = List>.generate( + dimension, + (_) => List.generate(dimension, (_) => -1), + ); + + final rawSolutionData = List>.generate( dimension, (_) => List.generate(dimension, (_) => -1), ); @@ -127,10 +134,11 @@ class Sudoku extends Equatable { final positionX = block.position.x; final positionY = block.position.y; - rawData[positionX][positionY] = block.currentValue; + rawPuzzleData[positionX][positionY] = block.currentValue; + rawSolutionData[positionX][positionY] = block.correctValue; } - return rawData; + return (rawPuzzleData, rawSolutionData); } /// Returns the list of [Block]s that belong to the same sub-grid as [block]. diff --git a/test/api/dio/sudoku_dio_client_test.dart b/test/api/dio/sudoku_dio_client_test.dart index 7d80ddb..36a3927 100644 --- a/test/api/dio/sudoku_dio_client_test.dart +++ b/test/api/dio/sudoku_dio_client_test.dart @@ -167,5 +167,105 @@ void main() { }, ); }); + + group('generateSudoku', () { + late Dio dioClient; + + setUp(() { + dioClient = MockDio(); + when( + () => dioClient.post>( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer( + (_) async => Future.value( + Response( + requestOptions: RequestOptions(), + data: { + 'result': { + 'cell': [0, 1], + 'entry': 5, + 'observation': 'some observation', + 'explanation': 'some explanation', + 'solution': 'some solution', + }, + }, + statusCode: 200, + ), + ), + ); + }); + + test('request body is correct', () async { + final subject = createSubject(dioClient: dioClient); + final response = await subject.generateHint(sudoku: sudoku3x3); + + expect(response, isA()); + verify( + () => dioClient.post>( + SudokuDioClient.generateHintPath, + data: { + 'data': { + 'puzzle': sudoku3x3.toRawData().$1, + 'solution': sudoku3x3.toRawData().$2, + }, + }, + options: any(named: 'options'), + ), + ).called(1); + }); + + test( + 'throws a SudokuAPIClientException when response data is null', + () async { + final subject = createSubject(dioClient: dioClient); + + when( + () => dioClient.post>( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer( + (_) async => Future.value( + Response( + requestOptions: RequestOptions(), + data: null, + statusCode: 200, + ), + ), + ); + + expect( + () async => subject.generateHint(sudoku: sudoku3x3), + throwsA(isA()), + ); + }, + ); + + test( + 'throws a SudokuAPIClientException when dio exception is thrown', + () async { + final subject = createSubject(dioClient: dioClient); + + when( + () => dioClient.post>( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenThrow( + DioException(requestOptions: RequestOptions()), + ); + + expect( + () async => subject.generateHint(sudoku: sudoku3x3), + throwsA(isA()), + ); + }, + ); + }); }); } diff --git a/test/api/dtos/generate_hint_request_dto_test.dart b/test/api/dtos/generate_hint_request_dto_test.dart new file mode 100644 index 0000000..4057418 --- /dev/null +++ b/test/api/dtos/generate_hint_request_dto_test.dart @@ -0,0 +1,138 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sudoku/api/dtos/dtos.dart'; + +void main() { + group('GenerateHintRequestDto', () { + GenerateHintRequestDto createSubject() { + return GenerateHintRequestDto( + data: GenerateHintRequest( + puzzle: const [ + [1], + [2], + ], + solution: const [ + [2], + [3], + ], + ), + ); + } + + test('constructor works correctly', () { + expect(createSubject, returnsNormally); + }); + + test('supports value equality', () { + expect(createSubject(), equals(createSubject())); + }); + + test('props are correct', () { + expect( + createSubject().props, + equals([ + GenerateHintRequest( + puzzle: const [ + [1], + [2], + ], + solution: const [ + [2], + [3], + ], + ), + ]), + ); + }); + + test('toJson works correctly', () { + expect( + createSubject().toJson(), + equals({ + 'data': { + 'puzzle': [ + [1], + [2], + ], + 'solution': [ + [2], + [3], + ], + }, + }), + ); + }); + }); + + group('GenerateHintRequest', () { + GenerateHintRequest createSubject() { + return GenerateHintRequest( + puzzle: const [ + [1], + [2], + ], + solution: const [ + [2], + [3], + ], + ); + } + + test('constructor works correctly', () { + expect(createSubject, returnsNormally); + }); + + test('supports value equality', () { + expect(createSubject(), equals(createSubject())); + }); + + test('props are correct', () { + expect( + createSubject().props, + equals([ + [ + [1], + [2], + ], + [ + [2], + [3], + ], + ]), + ); + }); + + test('fromJson works correctly', () { + expect( + GenerateHintRequest.fromJson(const { + 'puzzle': [ + [1], + [2], + ], + 'solution': [ + [2], + [3], + ], + }), + equals(createSubject()), + ); + }); + + test('toJson works correctly', () { + expect( + createSubject().toJson(), + equals({ + 'puzzle': [ + [1], + [2], + ], + 'solution': [ + [2], + [3], + ], + }), + ); + }); + }); +} diff --git a/test/api/dtos/generate_hint_response_dto_test.dart b/test/api/dtos/generate_hint_response_dto_test.dart new file mode 100644 index 0000000..2180cda --- /dev/null +++ b/test/api/dtos/generate_hint_response_dto_test.dart @@ -0,0 +1,122 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sudoku/api/dtos/dtos.dart'; +import 'package:sudoku/models/models.dart'; + +void main() { + group('GenerateHintResponseDto', () { + GenerateHintResponseDto createSubject() { + return GenerateHintResponseDto( + result: GenerateHintResponse( + cell: const [0, 1], + entry: 5, + observation: 'test observation', + explanation: 'test explanation', + solution: 'test solution', + ), + ); + } + + test('constructor works perfectly', () { + expect(createSubject, returnsNormally); + }); + + test('supports value equality', () { + expect(createSubject(), equals(createSubject())); + }); + + test('props are correct', () { + expect( + createSubject().props, + equals([ + GenerateHintResponse( + cell: const [0, 1], + entry: 5, + observation: 'test observation', + explanation: 'test explanation', + solution: 'test solution', + ), + ]), + ); + }); + + test('fromJson works as expected', () { + expect( + GenerateHintResponseDto.fromJson(const { + 'result': { + 'cell': [0, 1], + 'entry': 5, + 'observation': 'test observation', + 'explanation': 'test explanation', + 'solution': 'test solution', + }, + }), + equals(createSubject()), + ); + }); + }); + + group('GenerateHintResponse', () { + GenerateHintResponse createSubject() { + return GenerateHintResponse( + cell: const [0, 1], + entry: 5, + observation: 'test observation', + explanation: 'test explanation', + solution: 'test solution', + ); + } + + test('constructor works correctly', () { + expect(createSubject, returnsNormally); + }); + + test('supports value equality', () { + expect(createSubject(), equals(createSubject())); + }); + + test('props are correct', () { + expect( + createSubject().props, + equals([ + [0, 1], + 5, + 'test observation', + 'test explanation', + 'test solution', + ]), + ); + }); + + test('fromJson works as expected', () { + expect( + GenerateHintResponse.fromJson( + const { + 'cell': [0, 1], + 'entry': 5, + 'observation': 'test observation', + 'explanation': 'test explanation', + 'solution': 'test solution', + }, + ), + equals(createSubject()), + ); + }); + + test('toHint converts to hint object', () { + expect( + createSubject().toHint(), + equals( + Hint( + cell: Position(x: 0, y: 1), + entry: 5, + observation: 'test observation', + explanation: 'test explanation', + solution: 'test solution', + ), + ), + ); + }); + }); +} diff --git a/test/models/hint_test.dart b/test/models/hint_test.dart new file mode 100644 index 0000000..7c138a1 --- /dev/null +++ b/test/models/hint_test.dart @@ -0,0 +1,39 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sudoku/models/models.dart'; + +void main() { + group('Hint', () { + Hint createSubject() { + return Hint( + cell: Position(x: 0, y: 0), + entry: 5, + observation: 'test observation', + explanation: 'test explanation', + solution: 'test solution', + ); + } + + test('constructor works perfectly', () { + expect(createSubject, returnsNormally); + }); + + test('supports value equality', () { + expect(createSubject(), equals(createSubject())); + }); + + test('props are correct', () { + expect( + createSubject().props, + equals([ + Position(x: 0, y: 0), + 5, + 'test observation', + 'test explanation', + 'test solution', + ]), + ); + }); + }); +} diff --git a/test/models/sudoku_test.dart b/test/models/sudoku_test.dart index 66095cc..0082f05 100644 --- a/test/models/sudoku_test.dart +++ b/test/models/sudoku_test.dart @@ -233,11 +233,10 @@ void main() { }); group('toRawData', () { - test('converts the current sudoku into correct raw data', () { - expect( - sudoku.toRawData(), - equals(generatedRawData), - ); + test('converts the sudoku into record of puzzle and solution', () { + final rawData = sudoku.toRawData(); + expect(rawData.$1, equals(generatedRawData)); + expect(rawData.$2, equals(answerRawData)); }); });