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:
2026-09-08 13:46:11 +05:00
parent fbf04d90db
commit 7f91366201
26 changed files with 8904 additions and 7077 deletions
+55
View File
@@ -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('голос самой вечности')));
});
}
+173
View File
@@ -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
View File
@@ -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);
});
}
+14 -3
View File
@@ -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);