diff --git a/assets/content/companion_lines.json b/assets/content/companion_lines.json deleted file mode 100644 index 930a8fc3..00000000 --- a/assets/content/companion_lines.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "lessonComplete": [ - "Nice brew! Lesson done.", - "You're steeping nicely.", - "That's the good stuff — keep going.", - "Smooth pour. On to the next." - ], - "moduleComplete": [ - "Whole module brewed. Proud of you!", - "Full pot finished — that's mastery.", - "You roasted the whole module. Bravo!" - ], - "courseComplete": [ - "Beans to brew — you did the whole thing." - ], - "keepSharpComplete": [ - "That's today's practice — streak safe.", - "Done for today. Sharp as ever.", - "Practice complete. See you tomorrow!" - ], - "challengeComplete": [ - "You actually brewed it. That is the whole point.", - "Logged. Your palate did the work today.", - "That cup taught you something no lesson could.", - "Nice one. Coffee is learned one cup at a time." - ], - "streakMilestone": [ - "Day after day — that's how flavour builds.", - "A streak worth brewing about!", - "Showing up is the secret ingredient." - ] -} \ No newline at end of file diff --git a/assets/content/generated/companion_lines.json b/assets/content/generated/companion_lines.json new file mode 100644 index 00000000..3806cc97 --- /dev/null +++ b/assets/content/generated/companion_lines.json @@ -0,0 +1,98 @@ +{ + "generated": "Generated by tool/extract_content.js from the prototype source. Do not edit by hand — rerun the script instead.", + "source": "customize.jsx", + "bank": "companion_lines", + "schemaVersion": 2, + "items": [ + { + "id": "rl-nice-brew", + "occasion": "lessonComplete", + "text": "Nice brew! Lesson done." + }, + { + "id": "rl-steeping-nicely", + "occasion": "lessonComplete", + "text": "You're steeping nicely." + }, + { + "id": "rl-good-stuff", + "occasion": "lessonComplete", + "text": "That's the good stuff — keep going." + }, + { + "id": "rl-smooth-pour", + "occasion": "lessonComplete", + "text": "Smooth pour. On to the next." + }, + { + "id": "rl-whole-module", + "occasion": "moduleComplete", + "text": "Whole module brewed. Proud of you!" + }, + { + "id": "rl-full-pot", + "occasion": "moduleComplete", + "text": "Full pot finished — that's mastery." + }, + { + "id": "rl-roasted-module", + "occasion": "moduleComplete", + "text": "You roasted the whole module. Bravo!" + }, + { + "id": "rl-beans-to-brew", + "occasion": "courseComplete", + "text": "Beans to brew — you did the whole thing." + }, + { + "id": "rl-streak-safe", + "occasion": "keepSharpComplete", + "text": "That's today's practice — streak safe." + }, + { + "id": "rl-sharp-as-ever", + "occasion": "keepSharpComplete", + "text": "Done for today. Sharp as ever." + }, + { + "id": "rl-see-you-tomorrow", + "occasion": "keepSharpComplete", + "text": "Practice complete. See you tomorrow!" + }, + { + "id": "rl-actually-brewed", + "occasion": "challengeComplete", + "text": "You actually brewed it. That is the whole point." + }, + { + "id": "rl-palate-did-work", + "occasion": "challengeComplete", + "text": "Logged. Your palate did the work today." + }, + { + "id": "rl-cup-taught-you", + "occasion": "challengeComplete", + "text": "That cup taught you something no lesson could." + }, + { + "id": "rl-one-cup-at-a-time", + "occasion": "challengeComplete", + "text": "Nice one. Coffee is learned one cup at a time." + }, + { + "id": "rl-day-after-day", + "occasion": "streakMilestone", + "text": "Day after day — that's how flavour builds." + }, + { + "id": "rl-worth-brewing", + "occasion": "streakMilestone", + "text": "A streak worth brewing about!" + }, + { + "id": "rl-showing-up", + "occasion": "streakMilestone", + "text": "Showing up is the secret ingredient." + } + ] +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6d2e95aa..f6304198 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -46,6 +46,12 @@ You can always edit this file by hand instead — the helpers just save effort. ### Changed +- **Roasty can speak a language other than English.** His lines were the one + piece of reader-facing prose no language folder could reach; they now load + like every other content file. Each line carries an id of its own, so a + language fills the same slots English has and fixing an English typo sends + only that line back for re-reading (ADR-0029). + ### Fixed --- diff --git a/docs/adr/0029-a-language-ships-the-same-number-of-roastys-lines-as-english.md b/docs/adr/0029-a-language-ships-the-same-number-of-roastys-lines-as-english.md new file mode 100644 index 00000000..dfa1cd9d --- /dev/null +++ b/docs/adr/0029-a-language-ships-the-same-number-of-roastys-lines-as-english.md @@ -0,0 +1,36 @@ +# ADR-0029: A language ships the same number of Roasty's lines as English + +- **Status:** accepted +- **Date:** 2026-09-17 + +## Context + +Roasty answers six moments with a handful of interchangeable lines each — four +ways to say "lesson done", three for "module done" — and picks one at random. +Making them reachable by a language folder +([#604](https://github.com/maximsan/brewpath/issues/604)) forced a shape, and +the shape decides whether a language may ship a different number of them. + +Storing each occasion's lines as one list would let Polish ship two where +English has four, because the overlay replaces a field wholesale. But a list is +one field, and the translation marks are per field (ADR-0026), so fixing one +English typo would mark every line in that occasion as needing another read. + +## Decision + +One record per line, each with an id of its own. A language fills the same +slots English has. + +An id is assigned once and kept when the line's words are edited: that is what +holds a translation onto its line, and what makes a typo fix re-stale one line +instead of its neighbours. + +## Consequences + +A translator who wants fewer lines than English cannot have them; they +translate all of them, loosely if need be. Nothing counts Roasty's quips, so +the cost is a translator's freedom, not a reader's experience. + +Lifting this later means letting a language drop a line — a rule the overlay +does not have today, where empty text means omit rather than fall back to +English. It is additive, so it can wait until a language actually asks. diff --git a/lib/features/companion/data/companion_lines_repository.dart b/lib/features/companion/data/companion_lines_repository.dart index 49e8cc6d..be6a9b81 100644 --- a/lib/features/companion/data/companion_lines_repository.dart +++ b/lib/features/companion/data/companion_lines_repository.dart @@ -1,21 +1,27 @@ -import 'dart:convert'; - import 'package:brew_path/features/companion/domain/companion_lines.dart'; +import 'package:brew_path/shared/content/content_language.dart'; +import 'package:brew_path/shared/models/content/companion_line.dart'; +import 'package:brew_path/shared/repositories/bank_loader.dart'; import 'package:flutter/services.dart'; -/// Loads and caches the companion's speech lines from the bundled -/// `companion_lines.json` asset. +/// Loads and caches the companion's speech lines from the `companion_lines` +/// bank, so a language folder reaches them like any other content (#604). class CompanionLinesRepository { - static const String _assetPath = 'assets/content/companion_lines.json'; - CompanionLines? _cache; /// Loads and caches the companion lines. - Future getLines() async { - final cached = _cache; - if (cached != null) return cached; - final raw = await rootBundle.loadString(_assetPath); - final json = jsonDecode(raw) as Map; - return _cache = CompanionLines.fromJson(json); - } + /// + /// [language] and [bundle] exist for tests staging a folder English does not + /// ship yet; production reads the active language off the real bundle. + Future getLines({ + ContentLanguage language = activeContentLanguage, + AssetBundle? bundle, + }) async => _cache ??= CompanionLines.fromRecords( + await loadBank( + 'companion_lines', + CompanionLine.fromJson, + language: language, + bundle: bundle, + ), + ); } diff --git a/lib/features/companion/domain/companion_lines.dart b/lib/features/companion/domain/companion_lines.dart index f718e607..83c668ea 100644 --- a/lib/features/companion/domain/companion_lines.dart +++ b/lib/features/companion/domain/companion_lines.dart @@ -1,23 +1,33 @@ import 'dart:math'; import 'package:brew_path/features/companion/domain/companion_reaction.dart'; +import 'package:brew_path/shared/models/content/companion_line.dart'; +import 'package:brew_path/shared/repositories/content_assembly.dart'; /// Speech-line content for the companion, keyed by `CompanionReaction.name`. /// Each key holds a list of interchangeable variants; [lineFor] picks one at -/// random so repeated moments feel varied. Loaded from -/// `assets/content/companion_lines.json`. +/// random so repeated moments feel varied. class CompanionLines { /// Creates a [CompanionLines] from a `reaction-name -> variants` map. const CompanionLines(this._byReaction); - /// Parses the decoded `companion_lines.json` object. - factory CompanionLines.fromJson(Map json) { - final map = >{}; - for (final entry in json.entries) { - final variants = (entry.value as List).cast(); - map[entry.key] = variants; + /// Groups the `companion_lines` bank by the reaction each line answers. + /// + /// An occasion naming no reaction throws: the line would never be spoken, + /// and a companion that quietly says less is indistinguishable from one with + /// fewer lines authored. + factory CompanionLines.fromRecords(List records) { + final byReaction = >{}; + for (final record in records) { + if (!_reactionNames.contains(record.occasion)) { + throw ContentFormatException( + 'companion line "${record.id}" answers "${record.occasion}", which ' + 'is not a CompanionReaction — rename it or add the reaction', + ); + } + byReaction.putIfAbsent(record.occasion, () => []).add(record.text); } - return CompanionLines(map); + return CompanionLines(byReaction); } final Map> _byReaction; @@ -30,5 +40,9 @@ class CompanionLines { return variants[(random ?? _shared).nextInt(variants.length)]; } + static final Set _reactionNames = { + for (final reaction in CompanionReaction.values) reaction.name, + }; + static final Random _shared = Random(); } diff --git a/lib/features/monetization/domain/owned_term.g.dart b/lib/features/monetization/domain/owned_term.g.dart index 3b6a8728..7793ad21 100644 --- a/lib/features/monetization/domain/owned_term.g.dart +++ b/lib/features/monetization/domain/owned_term.g.dart @@ -10,7 +10,7 @@ part of 'owned_term.dart'; // ignore_for_file: type=lint, type=warning /// The term the learner currently holds, or null when they hold none. /// -/// Asked of the store, not of [PurchasedTerm], which records only what *this +/// Asked of the store, not of `PurchasedTerm`, which records only what *this /// session* bought — so it survives a restart and a plan changed elsewhere. @ProviderFor(ownedTerm) @@ -18,7 +18,7 @@ final ownedTermProvider = OwnedTermProvider._(); /// The term the learner currently holds, or null when they hold none. /// -/// Asked of the store, not of [PurchasedTerm], which records only what *this +/// Asked of the store, not of `PurchasedTerm`, which records only what *this /// session* bought — so it survives a restart and a plan changed elsewhere. final class OwnedTermProvider @@ -31,7 +31,7 @@ final class OwnedTermProvider with $FutureModifier, $FutureProvider { /// The term the learner currently holds, or null when they hold none. /// - /// Asked of the store, not of [PurchasedTerm], which records only what *this + /// Asked of the store, not of `PurchasedTerm`, which records only what *this /// session* bought — so it survives a restart and a plan changed elsewhere. OwnedTermProvider._() : super( diff --git a/lib/shared/models/content/companion_line.dart b/lib/shared/models/content/companion_line.dart new file mode 100644 index 00000000..75c824d5 --- /dev/null +++ b/lib/shared/models/content/companion_line.dart @@ -0,0 +1,24 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'companion_line.freezed.dart'; +part 'companion_line.g.dart'; + +/// One thing Roasty can say, as the extractor emits it. +/// +/// [id] is assigned once and kept when [text] is edited — that is what holds a +/// translation onto its line across a re-authoring (ADR-0026). +@freezed +abstract class CompanionLine with _$CompanionLine { + /// Creates a [CompanionLine]. + const factory CompanionLine({ + required String id, + + /// The `CompanionReaction` this line answers, by enum name. + required String occasion, + required String text, + }) = _CompanionLine; + + /// Creates a [CompanionLine] from decoded JSON. + factory CompanionLine.fromJson(Map json) => + _$CompanionLineFromJson(json); +} diff --git a/lib/shared/models/content/companion_line.freezed.dart b/lib/shared/models/content/companion_line.freezed.dart new file mode 100644 index 00000000..17323df4 --- /dev/null +++ b/lib/shared/models/content/companion_line.freezed.dart @@ -0,0 +1,285 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'companion_line.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$CompanionLine { + + String get id;/// The `CompanionReaction` this line answers, by enum name. + String get occasion; String get text; +/// Create a copy of CompanionLine +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CompanionLineCopyWith get copyWith => _$CompanionLineCopyWithImpl(this as CompanionLine, _$identity); + + /// Serializes this CompanionLine to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CompanionLine&&(identical(other.id, id) || other.id == id)&&(identical(other.occasion, occasion) || other.occasion == occasion)&&(identical(other.text, text) || other.text == text)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,occasion,text); + +@override +String toString() { + return 'CompanionLine(id: $id, occasion: $occasion, text: $text)'; +} + + +} + +/// @nodoc +abstract mixin class $CompanionLineCopyWith<$Res> { + factory $CompanionLineCopyWith(CompanionLine value, $Res Function(CompanionLine) _then) = _$CompanionLineCopyWithImpl; +@useResult +$Res call({ + String id, String occasion, String text +}); + + + + +} +/// @nodoc +class _$CompanionLineCopyWithImpl<$Res> + implements $CompanionLineCopyWith<$Res> { + _$CompanionLineCopyWithImpl(this._self, this._then); + + final CompanionLine _self; + final $Res Function(CompanionLine) _then; + +/// Create a copy of CompanionLine +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? occasion = null,Object? text = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,occasion: null == occasion ? _self.occasion : occasion // ignore: cast_nullable_to_non_nullable +as String,text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CompanionLine]. +extension CompanionLinePatterns on CompanionLine { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CompanionLine value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CompanionLine() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CompanionLine value) $default,){ +final _that = this; +switch (_that) { +case _CompanionLine(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CompanionLine value)? $default,){ +final _that = this; +switch (_that) { +case _CompanionLine() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String occasion, String text)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CompanionLine() when $default != null: +return $default(_that.id,_that.occasion,_that.text);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String occasion, String text) $default,) {final _that = this; +switch (_that) { +case _CompanionLine(): +return $default(_that.id,_that.occasion,_that.text);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String occasion, String text)? $default,) {final _that = this; +switch (_that) { +case _CompanionLine() when $default != null: +return $default(_that.id,_that.occasion,_that.text);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _CompanionLine implements CompanionLine { + const _CompanionLine({required this.id, required this.occasion, required this.text}); + factory _CompanionLine.fromJson(Map json) => _$CompanionLineFromJson(json); + +@override final String id; +/// The `CompanionReaction` this line answers, by enum name. +@override final String occasion; +@override final String text; + +/// Create a copy of CompanionLine +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CompanionLineCopyWith<_CompanionLine> get copyWith => __$CompanionLineCopyWithImpl<_CompanionLine>(this, _$identity); + +@override +Map toJson() { + return _$CompanionLineToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CompanionLine&&(identical(other.id, id) || other.id == id)&&(identical(other.occasion, occasion) || other.occasion == occasion)&&(identical(other.text, text) || other.text == text)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,occasion,text); + +@override +String toString() { + return 'CompanionLine(id: $id, occasion: $occasion, text: $text)'; +} + + +} + +/// @nodoc +abstract mixin class _$CompanionLineCopyWith<$Res> implements $CompanionLineCopyWith<$Res> { + factory _$CompanionLineCopyWith(_CompanionLine value, $Res Function(_CompanionLine) _then) = __$CompanionLineCopyWithImpl; +@override @useResult +$Res call({ + String id, String occasion, String text +}); + + + + +} +/// @nodoc +class __$CompanionLineCopyWithImpl<$Res> + implements _$CompanionLineCopyWith<$Res> { + __$CompanionLineCopyWithImpl(this._self, this._then); + + final _CompanionLine _self; + final $Res Function(_CompanionLine) _then; + +/// Create a copy of CompanionLine +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? occasion = null,Object? text = null,}) { + return _then(_CompanionLine( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,occasion: null == occasion ? _self.occasion : occasion // ignore: cast_nullable_to_non_nullable +as String,text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/shared/models/content/companion_line.g.dart b/lib/shared/models/content/companion_line.g.dart new file mode 100644 index 00000000..531300f9 --- /dev/null +++ b/lib/shared/models/content/companion_line.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'companion_line.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_CompanionLine _$CompanionLineFromJson(Map json) => + _CompanionLine( + id: json['id'] as String, + occasion: json['occasion'] as String, + text: json['text'] as String, + ); + +Map _$CompanionLineToJson(_CompanionLine instance) => + { + 'id': instance.id, + 'occasion': instance.occasion, + 'text': instance.text, + }; diff --git a/prototype/customize.jsx b/prototype/customize.jsx index d7bed20c..0be1c4cc 100755 --- a/prototype/customize.jsx +++ b/prototype/customize.jsx @@ -571,6 +571,31 @@ function RoastyStudio({ roastyCfg, onApply, onClose, onMoodPlayer, showMoodPlaye ); } +// ── Roasty's spoken lines (the art lives in roasty.jsx) ── +// An id is assigned once and kept when the text is edited: that is what +// holds a translation onto its line through a re-authoring. +window.ROASTY_LINES = [ + { id: 'rl-nice-brew', occasion: 'lessonComplete', text: 'Nice brew! Lesson done.' }, + { id: 'rl-steeping-nicely', occasion: 'lessonComplete', text: "You're steeping nicely." }, + { id: 'rl-good-stuff', occasion: 'lessonComplete', text: "That's the good stuff — keep going." }, + { id: 'rl-smooth-pour', occasion: 'lessonComplete', text: 'Smooth pour. On to the next.' }, + { id: 'rl-whole-module', occasion: 'moduleComplete', text: 'Whole module brewed. Proud of you!' }, + { id: 'rl-full-pot', occasion: 'moduleComplete', text: "Full pot finished — that's mastery." }, + { id: 'rl-roasted-module', occasion: 'moduleComplete', text: 'You roasted the whole module. Bravo!' }, + { id: 'rl-beans-to-brew', occasion: 'courseComplete', text: 'Beans to brew — you did the whole thing.' }, + { id: 'rl-streak-safe', occasion: 'keepSharpComplete', text: "That's today's practice — streak safe." }, + { id: 'rl-sharp-as-ever', occasion: 'keepSharpComplete', text: 'Done for today. Sharp as ever.' }, + { id: 'rl-see-you-tomorrow', occasion: 'keepSharpComplete', text: 'Practice complete. See you tomorrow!' }, + { id: 'rl-actually-brewed', occasion: 'challengeComplete', text: 'You actually brewed it. That is the whole point.' }, + { id: 'rl-palate-did-work', occasion: 'challengeComplete', text: 'Logged. Your palate did the work today.' }, + { id: 'rl-cup-taught-you', occasion: 'challengeComplete', text: 'That cup taught you something no lesson could.' }, + { id: 'rl-one-cup-at-a-time',occasion: 'challengeComplete', text: 'Nice one. Coffee is learned one cup at a time.' }, + { id: 'rl-day-after-day', occasion: 'streakMilestone', text: "Day after day — that's how flavour builds." }, + { id: 'rl-worth-brewing', occasion: 'streakMilestone', text: 'A streak worth brewing about!' }, + { id: 'rl-showing-up', occasion: 'streakMilestone', text: 'Showing up is the secret ingredient.' }, +]; + + // ─────────────────────────────────────────────────────────── // MOOD PLAYER — Roasty centered; tap an emotion to see them react. // ─────────────────────────────────────────────────────────── diff --git a/test/unit/features/companion/companion_lines_bank_test.dart b/test/unit/features/companion/companion_lines_bank_test.dart new file mode 100644 index 00000000..c58bc34d --- /dev/null +++ b/test/unit/features/companion/companion_lines_bank_test.dart @@ -0,0 +1,114 @@ +// Roasty's lines as a bank: a language folder reaches them, and the committed +// bank still answers every reaction a surface can fire (#604). The folder is +// staged in a fake bundle because English is the only one that ships. +import 'dart:convert'; +import 'dart:io'; + +import 'package:brew_path/features/companion/data/companion_lines_repository.dart'; +import 'package:brew_path/features/companion/domain/companion_reaction.dart'; +import 'package:brew_path/shared/content/content_language.dart'; +import 'package:brew_path/shared/repositories/bank_envelope.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +const _polish = ContentLanguage(code: 'pl', speechTag: 'pl-PL'); +const _bank = 'companion_lines'; + +class _FakeBundle extends CachingAssetBundle { + _FakeBundle(this.files); + + final Map files; + + @override + Future load(String key) async { + final contents = files[key]; + if (contents == null) throw Exception('no asset staged at $key'); + return ByteData.sublistView(Uint8List.fromList(utf8.encode(contents))); + } +} + +String _envelope(List> items) => jsonEncode({ + 'bank': _bank, + 'schemaVersion': contentSchemaVersion, + 'items': items, +}); + +_FakeBundle _bundle({List>? polish}) => _FakeBundle({ + 'assets/content/generated/$_bank.json': _envelope([ + {'id': 'rl-nice-brew', 'occasion': 'lessonComplete', 'text': 'Nice brew!'}, + ]), + if (polish != null) 'assets/content/l10n/pl/$_bank.json': _envelope(polish), +}); + +List> _committed() { + final file = File(p.join('assets/content/generated', '$_bank.json')); + final envelope = jsonDecode(file.readAsStringSync()) as Map; + return (envelope['items'] as List).cast>(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('a language folder reaches Roasty', () { + test('English is what Roasty says with no folder over it', () async { + final lines = await CompanionLinesRepository().getLines( + bundle: _bundle(), + ); + + expect(lines.lineFor(CompanionReaction.lessonComplete), 'Nice brew!'); + }); + + test('a staged folder changes what Roasty says', () async { + final lines = await CompanionLinesRepository().getLines( + language: _polish, + bundle: _bundle( + polish: [ + {'id': 'rl-nice-brew', 'text': 'Nieźle zaparzone!'}, + ], + ), + ); + + expect( + lines.lineFor(CompanionReaction.lessonComplete), + 'Nieźle zaparzone!', + ); + }); + + test('a line the folder omits stays English', () async { + final lines = await CompanionLinesRepository().getLines( + language: _polish, + bundle: _bundle( + polish: [ + {'id': 'rl-nice-brew', 'occasion': 'lessonComplete'}, + ], + ), + ); + + expect(lines.lineFor(CompanionReaction.lessonComplete), 'Nice brew!'); + }); + }); + + group('the committed bank', () { + test('answers every reaction a surface can fire', () { + final occasions = _committed() + .map((record) => record['occasion']) + .toSet(); + + for (final reaction in CompanionReaction.values) { + expect( + occasions, + contains(reaction.name), + reason: 'no line answers ${reaction.name}', + ); + } + }); + + test('gives every line an id of its own', () { + final ids = _committed().map((record) => record['id']).toList(); + + expect(ids, everyElement(isA())); + expect(ids.toSet(), hasLength(ids.length)); + }); + }); +} diff --git a/test/unit/features/companion/companion_lines_test.dart b/test/unit/features/companion/companion_lines_test.dart index 398b397e..81b9351d 100644 --- a/test/unit/features/companion/companion_lines_test.dart +++ b/test/unit/features/companion/companion_lines_test.dart @@ -2,38 +2,58 @@ import 'dart:math'; import 'package:brew_path/features/companion/domain/companion_lines.dart'; import 'package:brew_path/features/companion/domain/companion_reaction.dart'; +import 'package:brew_path/shared/models/content/companion_line.dart'; +import 'package:brew_path/shared/repositories/content_assembly.dart'; import 'package:flutter_test/flutter_test.dart'; +CompanionLine _line(String id, String occasion, String text) => + CompanionLine(id: id, occasion: occasion, text: text); + void main() { group('CompanionLines', () { - test('fromJson parses reaction-keyed variant lists', () { - final lines = CompanionLines.fromJson(const { - 'lessonComplete': ['a', 'b'], - 'challengeComplete': ['c'], - }); + test('fromRecords groups a bank by the reaction each line answers', () { + final lines = CompanionLines.fromRecords([ + _line('rl-a', 'lessonComplete', 'a'), + _line('rl-b', 'lessonComplete', 'b'), + _line('rl-c', 'challengeComplete', 'c'), + ]); + + expect(lines.lineFor(CompanionReaction.challengeComplete), 'c'); + }); + + test('fromRecords refuses an occasion that names no reaction', () { expect( - lines.lineFor(CompanionReaction.challengeComplete), - 'c', + () => CompanionLines.fromRecords([ + _line('rl-typo', 'lessonComplet', 'a'), + ]), + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf(contains('rl-typo'), contains('lessonComplet')), + ), + ), ); }); test('lineFor returns null when a reaction has no authored lines', () { - final lines = CompanionLines.fromJson(const { - 'lessonComplete': ['a'], - }); + final lines = CompanionLines.fromRecords([ + _line('rl-a', 'lessonComplete', 'a'), + ]); expect(lines.lineFor(CompanionReaction.moduleComplete), isNull); }); test('lineFor picks a variant deterministically with a seeded Random', () { - final lines = CompanionLines.fromJson(const { - 'lessonComplete': ['a', 'b', 'c'], - }); + final lines = CompanionLines.fromRecords([ + _line('rl-a', 'lessonComplete', 'a'), + _line('rl-b', 'lessonComplete', 'b'), + _line('rl-c', 'lessonComplete', 'c'), + ]); final picked = lines.lineFor( CompanionReaction.lessonComplete, random: Random(1), ); expect(['a', 'b', 'c'], contains(picked)); - // Same seed -> same pick (stable selection). expect( lines.lineFor(CompanionReaction.lessonComplete, random: Random(1)), picked, diff --git a/test/unit/tool/extract_content_test.dart b/test/unit/tool/extract_content_test.dart index 50a96a47..21f0207f 100644 --- a/test/unit/tool/extract_content_test.dart +++ b/test/unit/tool/extract_content_test.dart @@ -44,6 +44,7 @@ const _expectedBanks = [ 'companion_hats.json', 'companion_gear.json', 'companion_sprouts.json', + 'companion_lines.json', ]; /// The entry whose rounds live in a second source behind a `window` getter — diff --git a/test/widget/features/learn/module_complete_screen_test.dart b/test/widget/features/learn/module_complete_screen_test.dart index 46606fa7..8068469e 100644 --- a/test/widget/features/learn/module_complete_screen_test.dart +++ b/test/widget/features/learn/module_complete_screen_test.dart @@ -76,7 +76,7 @@ void main() { ).overrideWith((ref) async => offer), streakProvider.overrideWith((ref) => 0), companionLinesProvider.overrideWith( - (ref) => CompanionLines.fromJson(const { + (ref) => const CompanionLines({ 'moduleComplete': ['Whole module brewed!'], }), ), diff --git a/tool/extract_content.js b/tool/extract_content.js index 084bbccf..9fbf8dff 100644 --- a/tool/extract_content.js +++ b/tool/extract_content.js @@ -222,6 +222,11 @@ function main(argv) { "SPROUT_OPTS", "customize.jsx", ), + companionLines: evaluateDeclaration( + customize, + "ROASTY_LINES", + "customize.jsx", + ), visualGuideCards: evaluateDeclaration( data, "VISUAL_GUIDE_CARDS", @@ -290,6 +295,10 @@ function main(argv) { bank("companion_hats", "customize.jsx", derived.companionHats), bank("companion_gear", "customize.jsx", derived.companionGear), bank("companion_sprouts", "customize.jsx", derived.companionSprouts), + // Words, not an axis: what Roasty *says*, one record per line so a + // language folder reaches each one and a typo fixed in one leaves its + // neighbours' translations alone (#604). + bank("companion_lines", "customize.jsx", derived.companionLines), // Two sources, honestly: identity, unlock and the meta table are authored // in `data.jsx`, the words in `practical.jsx`, and a guide is only whole // once they are joined.