feat: Random.org dice and daily word matrix, interpreter voices, curated lexicon
Wire cleromancy rolls and the once-per-UTC-day 000-999 word shuffle to RANDOM.ORG JSON-RPC, with a local CSPRNG fallback. Add settings for interpreter personas and a cleaner default oracle prompt. Replace proper names and bureaucratic filler in the 1000-word lexicon with sacred symbols.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:words999/models/history_entry_model.dart';
|
||||
import 'package:words999/models/interpreter_persona.dart';
|
||||
import 'package:words999/services/ai_oracle_service.dart';
|
||||
import 'package:words999/services/settings_repository.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await SettingsRepository().init();
|
||||
await SettingsRepository().setPersonaId(InterpreterPersona.defaultId);
|
||||
await SettingsRepository().setRandomOrgApiKey('');
|
||||
});
|
||||
|
||||
test('unknown persona id falls back to clear oracle', () {
|
||||
expect(InterpreterPersona.byId('nope').id, InterpreterPersona.clear.id);
|
||||
});
|
||||
|
||||
test('settings persist the selected interpreter voice', () async {
|
||||
await SettingsRepository().setPersonaId(InterpreterPersona.orc.id);
|
||||
expect(SettingsRepository().persona.id, InterpreterPersona.orc.id);
|
||||
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'oracle_interpreter_persona_v1': InterpreterPersona.techpriest.id,
|
||||
});
|
||||
await SettingsRepository().init();
|
||||
expect(SettingsRepository().persona.title, 'Техножрец');
|
||||
});
|
||||
|
||||
test('interpretation prompt uses the selected voice and asks for a concrete step', () async {
|
||||
await SettingsRepository().setPersonaId(InterpreterPersona.analyst.id);
|
||||
final messages = AiOracleService().buildInterpretationMessages(
|
||||
question: 'Стоит ли уходить?',
|
||||
words: const [
|
||||
SessionWord(
|
||||
word: 'огонь',
|
||||
sessionNumber: 0,
|
||||
partOfSpeech: 'существительное',
|
||||
category: 'Образ / Архетип',
|
||||
symbolicMeaning: 'Очищающее пламя',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(messages.first.role.name, 'system');
|
||||
expect(messages.first.content, contains('психоаналитик'));
|
||||
expect(messages.last.content, contains('проверяемый шаг'));
|
||||
expect(messages.last.content, contains('ОГОНЬ'));
|
||||
expect(messages.last.content, contains('Стоит ли уходить?'));
|
||||
expect(messages.first.content.toLowerCase(), isNot(contains('голос самой вечности')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:words999/services/settings_repository.dart';
|
||||
import 'package:words999/services/true_random_service.dart';
|
||||
|
||||
void main() {
|
||||
group('parseRandomOrgPlain', () {
|
||||
test('reads three d10 faces', () {
|
||||
expect(parseRandomOrgPlain('7\n4\n2\n', 3), [7, 4, 2]);
|
||||
});
|
||||
|
||||
test('rejects quota / error pages', () {
|
||||
expect(
|
||||
() => parseRandomOrgPlain('Error: Quota exceeded', 3),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseRandomOrgSequence', () {
|
||||
test('reads a permutation', () {
|
||||
expect(parseRandomOrgSequence('2\n0\n1\n', 0, 2), [2, 0, 1]);
|
||||
});
|
||||
|
||||
test('rejects duplicates', () {
|
||||
expect(
|
||||
() => parseRandomOrgSequence('0\n0\n1\n', 0, 2),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseRandomOrgJsonRpc', () {
|
||||
test('reads generateIntegers payload', () {
|
||||
const body = '''
|
||||
{"jsonrpc":"2.0","result":{"random":{"data":[0,9,5]},"bitsUsed":12},"id":1}
|
||||
''';
|
||||
expect(parseRandomOrgJsonRpc(body, 3), [0, 9, 5]);
|
||||
});
|
||||
|
||||
test('rejects API error objects', () {
|
||||
expect(
|
||||
() => parseRandomOrgJsonRpc(
|
||||
'{"jsonrpc":"2.0","error":{"code":401,"message":"bad key"},"id":1}',
|
||||
3,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('TrueRandomService', () {
|
||||
test('uses RANDOM.ORG HTTP when the client succeeds', () async {
|
||||
final client = MockClient((request) async {
|
||||
expect(request.url.host, 'www.random.org');
|
||||
expect(request.url.path, '/integers/');
|
||||
expect(request.url.queryParameters['num'], '3');
|
||||
expect(request.url.queryParameters['min'], '0');
|
||||
expect(request.url.queryParameters['max'], '9');
|
||||
return http.Response('1\n8\n3\n', 200);
|
||||
});
|
||||
final service = TrueRandomService(
|
||||
client: client,
|
||||
apiKey: '',
|
||||
minInterval: Duration.zero,
|
||||
);
|
||||
final roll = await service.take(3);
|
||||
expect(roll.digits, [1, 8, 3]);
|
||||
expect(roll.source, RandomSource.randomOrg);
|
||||
expect(roll.asNumber, 183);
|
||||
});
|
||||
|
||||
test('falls back to local CSPRNG when RANDOM.ORG fails', () async {
|
||||
final client = MockClient((request) async {
|
||||
return http.Response('Error: Service unavailable', 503);
|
||||
});
|
||||
final service = TrueRandomService(
|
||||
client: client,
|
||||
apiKey: '',
|
||||
timeout: const Duration(milliseconds: 200),
|
||||
minInterval: Duration.zero,
|
||||
);
|
||||
final roll = await service.take(3);
|
||||
expect(roll.digits.length, 3);
|
||||
expect(roll.digits.every((d) => d >= 0 && d <= 9), isTrue);
|
||||
expect(roll.source, RandomSource.local);
|
||||
});
|
||||
|
||||
test('uses JSON-RPC when an API key is set', () async {
|
||||
final client = MockClient((request) async {
|
||||
expect(request.url.host, 'api.random.org');
|
||||
expect(request.method, 'POST');
|
||||
return http.Response(
|
||||
'{"jsonrpc":"2.0","result":{"random":{"data":[9,0,1]}},"id":1}',
|
||||
200,
|
||||
);
|
||||
});
|
||||
final service = TrueRandomService(
|
||||
client: client,
|
||||
apiKey: 'dev-key',
|
||||
minInterval: Duration.zero,
|
||||
);
|
||||
final roll = await service.take(3);
|
||||
expect(roll.digits, [9, 0, 1]);
|
||||
expect(roll.source, RandomSource.randomOrg);
|
||||
});
|
||||
|
||||
test('production instance reads API key from settings, not the hashed dashboard value', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await SettingsRepository().init();
|
||||
await SettingsRepository().setRandomOrgApiKey(
|
||||
'11111111-2222-4333-8444-555555555555',
|
||||
);
|
||||
|
||||
String? sentBody;
|
||||
final client = MockClient((request) async {
|
||||
sentBody = request.body;
|
||||
expect(request.url.host, 'api.random.org');
|
||||
return http.Response(
|
||||
'{"jsonrpc":"2.0","result":{"random":{"data":[2,2,8]}},"id":1}',
|
||||
200,
|
||||
);
|
||||
});
|
||||
final service = TrueRandomService(
|
||||
client: client,
|
||||
minInterval: Duration.zero,
|
||||
);
|
||||
final roll = await service.take(3);
|
||||
expect(roll.digits, [2, 2, 8]);
|
||||
expect(sentBody, contains('11111111-2222-4333-8444-555555555555'));
|
||||
expect(sentBody, isNot(contains('hashedApiKey')));
|
||||
});
|
||||
|
||||
test('permutation uses generateIntegers without replacement', () async {
|
||||
String? body;
|
||||
final client = MockClient((request) async {
|
||||
expect(request.url.host, 'api.random.org');
|
||||
body = request.body;
|
||||
return http.Response(
|
||||
'{"jsonrpc":"2.0","result":{"random":{"data":[3,0,2,1]}},"id":1}',
|
||||
200,
|
||||
);
|
||||
});
|
||||
final service = TrueRandomService(
|
||||
client: client,
|
||||
apiKey: 'dev-key',
|
||||
minInterval: Duration.zero,
|
||||
);
|
||||
final shuffle = await service.permutation(min: 0, max: 3);
|
||||
expect(body, contains('"replacement":false'));
|
||||
expect(body, contains('"n":4'));
|
||||
expect(shuffle.source, RandomSource.randomOrg);
|
||||
expect(shuffle.order, [3, 0, 2, 1]);
|
||||
expect(isPermutation(shuffle.order, 0, 3), isTrue);
|
||||
});
|
||||
|
||||
test('permutation falls back locally when RANDOM.ORG fails', () async {
|
||||
final client = MockClient((request) async {
|
||||
return http.Response('Error: Service unavailable', 503);
|
||||
});
|
||||
final service = TrueRandomService(
|
||||
client: client,
|
||||
apiKey: 'dev-key',
|
||||
minInterval: Duration.zero,
|
||||
);
|
||||
final shuffle = await service.permutation(min: 0, max: 9);
|
||||
expect(shuffle.source, RandomSource.local);
|
||||
expect(isPermutation(shuffle.order, 0, 9), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
+13
-2
@@ -1,19 +1,30 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:words999/main.dart';
|
||||
import 'package:words999/services/settings_repository.dart';
|
||||
import 'package:words999/services/words_repository.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App smoke test - launch oracle and verify navigation', (WidgetTester tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await WordsRepository().init();
|
||||
await WordsRepository().init(remoteDailyShuffle: false);
|
||||
await SettingsRepository().init();
|
||||
await SettingsRepository().setRandomOrgApiKey('');
|
||||
await tester.pumpWidget(const Words999App());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
// Verify app title and 2 destinations
|
||||
expect(find.text('Оракул 999'), findsOneWidget);
|
||||
expect(find.text('Оракул'), findsOneWidget);
|
||||
expect(find.text('История'), findsOneWidget);
|
||||
expect(find.text('Настройки'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Настройки'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Голос толкователя'), findsOneWidget);
|
||||
expect(find.text('Ясный оракул'), findsOneWidget);
|
||||
expect(find.text('Древний орк-оракул'), findsOneWidget);
|
||||
expect(find.text('Техножрец'), findsOneWidget);
|
||||
expect(find.text('Психоаналитик'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ void main() {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
repo = WordsRepository();
|
||||
await repo.init();
|
||||
await repo.init(remoteDailyShuffle: false);
|
||||
});
|
||||
|
||||
test('Dataset contains exactly 1000 words with unique randomized session numbers 000 to 999', () {
|
||||
@@ -33,14 +33,25 @@ void main() {
|
||||
expect(seenSessionNumbers.length, equals(1000));
|
||||
});
|
||||
|
||||
test('Reshuffling changes the order / mapping of words to session numbers', () {
|
||||
repo.reshuffleSessionNumbers();
|
||||
test('Reshuffling changes the order / mapping of words to session numbers', () async {
|
||||
await repo.reshuffleSessionNumbers();
|
||||
expect(repo.totalWords, equals(1000));
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
expect(repo.getWordBySessionNumber(i)?.sessionNumber, equals(i));
|
||||
}
|
||||
});
|
||||
|
||||
test('Same UTC day reuses the persisted word matrix', () async {
|
||||
final first = [
|
||||
for (int i = 0; i < 8; i++) repo.getWordBySessionNumber(i)!.word,
|
||||
];
|
||||
await repo.init(remoteDailyShuffle: false);
|
||||
expect([
|
||||
for (int i = 0; i < 8; i++) repo.getWordBySessionNumber(i)!.word,
|
||||
], first);
|
||||
expect(repo.dailyMappingDate, WordsRepository.utcDateStamp());
|
||||
});
|
||||
|
||||
test('Search by number string returns correct session word', () {
|
||||
final results0 = repo.search('0');
|
||||
expect(results0.isNotEmpty, isTrue);
|
||||
|
||||
Reference in New Issue
Block a user