fix(app): a path import that fails says why in the app's language

The importer said what was wrong with a file in English sentences built
in the data layer, the last English left in a German app. It now names
the problem, the step it is in and what the problem names (a field, a
type, a number), and the studyset screen puts it into words: "Step 2:
unknown "type": quiz." or "Schritt 2: unbekannter „type“: quiz.". The
format's own field names stay as they are in the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-07 03:01:12 +02:00
commit 1921043925
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 670 additions and 68 deletions

View file

@ -832,5 +832,32 @@
"homeWidgetPomodoro": "Pomodoro-Timer",
"pomodoroAlerts": "Pomodoro-Meldungen",
"pomodoroUntil": "Bis {time}",
"pomodoroLeft": "Noch {left}"
"pomodoroLeft": "Noch {left}",
"pathImportAtStep": "Schritt {step}: {problem}",
"pathImportNotJson": "Das ist kein JSON: {detail}",
"pathImportNoPath": "ein „path“-Objekt fehlt.",
"pathImportNoTitle": "der Pfad hat keinen „title“.",
"pathImportDescriptionNotText": "„description“ des Pfads ist kein Text.",
"pathImportNotAnObject": "kein Objekt.",
"pathImportUnknownStepType": "unbekannter „type“: {detail}.",
"pathImportRightOptionMissing": "die richtige Option {detail} fehlt unter den Optionen.",
"pathImportRightOptionsMissing": "eine genannte richtige Option fehlt unter den Optionen.",
"pathImportNoPairing": "kein „correct_pairing“.",
"pathImportPairingTargetMissing": "Elemente werden einem fehlenden Ziel zugeordnet: {detail}.",
"pathImportPairingIncomplete": "jedes Element braucht genau ein Ziel.",
"pathImportNoPassage": "keine „passage“.",
"pathImportBlanksMismatch": "für jedes ___ in der Passage braucht es ein Wort in „blanks“.",
"pathImportBlockWithoutType": "ein Block ohne „type“.",
"pathImportMalformedLevel": "eine Überschrift mit fehlerhafter Ebene.",
"pathImportMalformedListItem": "ein fehlerhafter Listenpunkt.",
"pathImportMalformedMath": "ein fehlerhafter Formelblock.",
"pathImportMalformedCode": "ein fehlerhafter Codeblock.",
"pathImportUnknownBlock": "ein unbekannter Block: {detail}.",
"pathImportTextlessBlock": "ein „{detail}“ ohne „text“ oder „runs“.",
"pathImportMalformedRun": "ein fehlerhafter Textabschnitt.",
"pathImportListExpected": "eine Liste wurde erwartet.",
"pathImportNotText": "„{detail}“ muss Text sein.",
"pathImportTooFew": "es braucht mindestens zwei „{detail}“.",
"pathImportMalformedNumber": "eine fehlerhafte Zahl.",
"pathImportTitleNotText": "ein Titel muss Text sein."
}

View file

@ -997,5 +997,41 @@
"pomodoroUntil": "Until {time}",
"@pomodoroUntil": {"placeholders": {"time": {"type": "String"}}},
"pomodoroLeft": "{left} left",
"@pomodoroLeft": {"placeholders": {"left": {"type": "String"}}}
"@pomodoroLeft": {"placeholders": {"left": {"type": "String"}}},
"pathImportAtStep": "Step {step}: {problem}",
"@pathImportAtStep": {"placeholders": {"step": {"type": "int"}, "problem": {"type": "String"}}},
"pathImportNotJson": "This is no JSON: {detail}",
"@pathImportNotJson": {"placeholders": {"detail": {"type": "String"}}},
"pathImportNoPath": "a \"path\" object is missing.",
"pathImportNoTitle": "the path has no \"title\".",
"pathImportDescriptionNotText": "the path's \"description\" is no text.",
"pathImportNotAnObject": "no object.",
"pathImportUnknownStepType": "unknown \"type\": {detail}.",
"@pathImportUnknownStepType": {"placeholders": {"detail": {"type": "String"}}},
"pathImportRightOptionMissing": "its right option {detail} is not among its options.",
"@pathImportRightOptionMissing": {"placeholders": {"detail": {"type": "String"}}},
"pathImportRightOptionsMissing": "it names a right option that is not among its options.",
"pathImportNoPairing": "no \"correct_pairing\".",
"pathImportPairingTargetMissing": "it pairs items with a target that isn't there: {detail}.",
"@pathImportPairingTargetMissing": {"placeholders": {"detail": {"type": "String"}}},
"pathImportPairingIncomplete": "every item must be paired with one target.",
"pathImportNoPassage": "no \"passage\".",
"pathImportBlanksMismatch": "it needs one word in \"blanks\" for each ___ in its passage.",
"pathImportBlockWithoutType": "a block without \"type\".",
"pathImportMalformedLevel": "a heading with a malformed level.",
"pathImportMalformedListItem": "a malformed list item.",
"pathImportMalformedMath": "a malformed math block.",
"pathImportMalformedCode": "a malformed code block.",
"pathImportUnknownBlock": "an unknown block: {detail}.",
"@pathImportUnknownBlock": {"placeholders": {"detail": {"type": "String"}}},
"pathImportTextlessBlock": "a {detail} without \"text\" or \"runs\".",
"@pathImportTextlessBlock": {"placeholders": {"detail": {"type": "String"}}},
"pathImportMalformedRun": "a malformed run of text.",
"pathImportListExpected": "a list was expected.",
"pathImportNotText": "\"{detail}\" must be text.",
"@pathImportNotText": {"placeholders": {"detail": {"type": "String"}}},
"pathImportTooFew": "it needs at least two \"{detail}\".",
"@pathImportTooFew": {"placeholders": {"detail": {"type": "String"}}},
"pathImportMalformedNumber": "a malformed number.",
"pathImportTitleNotText": "a title must be text."
}

View file

@ -4988,6 +4988,168 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{left} left'**
String pomodoroLeft(String left);
/// No description provided for @pathImportAtStep.
///
/// In en, this message translates to:
/// **'Step {step}: {problem}'**
String pathImportAtStep(int step, String problem);
/// No description provided for @pathImportNotJson.
///
/// In en, this message translates to:
/// **'This is no JSON: {detail}'**
String pathImportNotJson(String detail);
/// No description provided for @pathImportNoPath.
///
/// In en, this message translates to:
/// **'a \"path\" object is missing.'**
String get pathImportNoPath;
/// No description provided for @pathImportNoTitle.
///
/// In en, this message translates to:
/// **'the path has no \"title\".'**
String get pathImportNoTitle;
/// No description provided for @pathImportDescriptionNotText.
///
/// In en, this message translates to:
/// **'the path\'s \"description\" is no text.'**
String get pathImportDescriptionNotText;
/// No description provided for @pathImportNotAnObject.
///
/// In en, this message translates to:
/// **'no object.'**
String get pathImportNotAnObject;
/// No description provided for @pathImportUnknownStepType.
///
/// In en, this message translates to:
/// **'unknown \"type\": {detail}.'**
String pathImportUnknownStepType(String detail);
/// No description provided for @pathImportRightOptionMissing.
///
/// In en, this message translates to:
/// **'its right option {detail} is not among its options.'**
String pathImportRightOptionMissing(String detail);
/// No description provided for @pathImportRightOptionsMissing.
///
/// In en, this message translates to:
/// **'it names a right option that is not among its options.'**
String get pathImportRightOptionsMissing;
/// No description provided for @pathImportNoPairing.
///
/// In en, this message translates to:
/// **'no \"correct_pairing\".'**
String get pathImportNoPairing;
/// No description provided for @pathImportPairingTargetMissing.
///
/// In en, this message translates to:
/// **'it pairs items with a target that isn\'t there: {detail}.'**
String pathImportPairingTargetMissing(String detail);
/// No description provided for @pathImportPairingIncomplete.
///
/// In en, this message translates to:
/// **'every item must be paired with one target.'**
String get pathImportPairingIncomplete;
/// No description provided for @pathImportNoPassage.
///
/// In en, this message translates to:
/// **'no \"passage\".'**
String get pathImportNoPassage;
/// No description provided for @pathImportBlanksMismatch.
///
/// In en, this message translates to:
/// **'it needs one word in \"blanks\" for each ___ in its passage.'**
String get pathImportBlanksMismatch;
/// No description provided for @pathImportBlockWithoutType.
///
/// In en, this message translates to:
/// **'a block without \"type\".'**
String get pathImportBlockWithoutType;
/// No description provided for @pathImportMalformedLevel.
///
/// In en, this message translates to:
/// **'a heading with a malformed level.'**
String get pathImportMalformedLevel;
/// No description provided for @pathImportMalformedListItem.
///
/// In en, this message translates to:
/// **'a malformed list item.'**
String get pathImportMalformedListItem;
/// No description provided for @pathImportMalformedMath.
///
/// In en, this message translates to:
/// **'a malformed math block.'**
String get pathImportMalformedMath;
/// No description provided for @pathImportMalformedCode.
///
/// In en, this message translates to:
/// **'a malformed code block.'**
String get pathImportMalformedCode;
/// No description provided for @pathImportUnknownBlock.
///
/// In en, this message translates to:
/// **'an unknown block: {detail}.'**
String pathImportUnknownBlock(String detail);
/// No description provided for @pathImportTextlessBlock.
///
/// In en, this message translates to:
/// **'a {detail} without \"text\" or \"runs\".'**
String pathImportTextlessBlock(String detail);
/// No description provided for @pathImportMalformedRun.
///
/// In en, this message translates to:
/// **'a malformed run of text.'**
String get pathImportMalformedRun;
/// No description provided for @pathImportListExpected.
///
/// In en, this message translates to:
/// **'a list was expected.'**
String get pathImportListExpected;
/// No description provided for @pathImportNotText.
///
/// In en, this message translates to:
/// **'\"{detail}\" must be text.'**
String pathImportNotText(String detail);
/// No description provided for @pathImportTooFew.
///
/// In en, this message translates to:
/// **'it needs at least two \"{detail}\".'**
String pathImportTooFew(String detail);
/// No description provided for @pathImportMalformedNumber.
///
/// In en, this message translates to:
/// **'a malformed number.'**
String get pathImportMalformedNumber;
/// No description provided for @pathImportTitleNotText.
///
/// In en, this message translates to:
/// **'a title must be text.'**
String get pathImportTitleNotText;
}
class _AppLocalizationsDelegate

View file

@ -3017,4 +3017,108 @@ class AppLocalizationsDe extends AppLocalizations {
String pomodoroLeft(String left) {
return 'Noch $left';
}
@override
String pathImportAtStep(int step, String problem) {
return 'Schritt $step: $problem';
}
@override
String pathImportNotJson(String detail) {
return 'Das ist kein JSON: $detail';
}
@override
String get pathImportNoPath => 'ein „path“-Objekt fehlt.';
@override
String get pathImportNoTitle => 'der Pfad hat keinen „title“.';
@override
String get pathImportDescriptionNotText =>
'„description“ des Pfads ist kein Text.';
@override
String get pathImportNotAnObject => 'kein Objekt.';
@override
String pathImportUnknownStepType(String detail) {
return 'unbekannter „type“: $detail.';
}
@override
String pathImportRightOptionMissing(String detail) {
return 'die richtige Option $detail fehlt unter den Optionen.';
}
@override
String get pathImportRightOptionsMissing =>
'eine genannte richtige Option fehlt unter den Optionen.';
@override
String get pathImportNoPairing => 'kein „correct_pairing“.';
@override
String pathImportPairingTargetMissing(String detail) {
return 'Elemente werden einem fehlenden Ziel zugeordnet: $detail.';
}
@override
String get pathImportPairingIncomplete =>
'jedes Element braucht genau ein Ziel.';
@override
String get pathImportNoPassage => 'keine „passage“.';
@override
String get pathImportBlanksMismatch =>
'für jedes ___ in der Passage braucht es ein Wort in „blanks“.';
@override
String get pathImportBlockWithoutType => 'ein Block ohne „type“.';
@override
String get pathImportMalformedLevel =>
'eine Überschrift mit fehlerhafter Ebene.';
@override
String get pathImportMalformedListItem => 'ein fehlerhafter Listenpunkt.';
@override
String get pathImportMalformedMath => 'ein fehlerhafter Formelblock.';
@override
String get pathImportMalformedCode => 'ein fehlerhafter Codeblock.';
@override
String pathImportUnknownBlock(String detail) {
return 'ein unbekannter Block: $detail.';
}
@override
String pathImportTextlessBlock(String detail) {
return 'ein „$detail“ ohne „text“ oder „runs“.';
}
@override
String get pathImportMalformedRun => 'ein fehlerhafter Textabschnitt.';
@override
String get pathImportListExpected => 'eine Liste wurde erwartet.';
@override
String pathImportNotText(String detail) {
return '„$detail“ muss Text sein.';
}
@override
String pathImportTooFew(String detail) {
return 'es braucht mindestens zwei „$detail“.';
}
@override
String get pathImportMalformedNumber => 'eine fehlerhafte Zahl.';
@override
String get pathImportTitleNotText => 'ein Titel muss Text sein.';
}

View file

@ -2987,6 +2987,109 @@ class AppLocalizationsEn extends AppLocalizations {
String pomodoroLeft(String left) {
return '$left left';
}
@override
String pathImportAtStep(int step, String problem) {
return 'Step $step: $problem';
}
@override
String pathImportNotJson(String detail) {
return 'This is no JSON: $detail';
}
@override
String get pathImportNoPath => 'a \"path\" object is missing.';
@override
String get pathImportNoTitle => 'the path has no \"title\".';
@override
String get pathImportDescriptionNotText =>
'the path\'s \"description\" is no text.';
@override
String get pathImportNotAnObject => 'no object.';
@override
String pathImportUnknownStepType(String detail) {
return 'unknown \"type\": $detail.';
}
@override
String pathImportRightOptionMissing(String detail) {
return 'its right option $detail is not among its options.';
}
@override
String get pathImportRightOptionsMissing =>
'it names a right option that is not among its options.';
@override
String get pathImportNoPairing => 'no \"correct_pairing\".';
@override
String pathImportPairingTargetMissing(String detail) {
return 'it pairs items with a target that isn\'t there: $detail.';
}
@override
String get pathImportPairingIncomplete =>
'every item must be paired with one target.';
@override
String get pathImportNoPassage => 'no \"passage\".';
@override
String get pathImportBlanksMismatch =>
'it needs one word in \"blanks\" for each ___ in its passage.';
@override
String get pathImportBlockWithoutType => 'a block without \"type\".';
@override
String get pathImportMalformedLevel => 'a heading with a malformed level.';
@override
String get pathImportMalformedListItem => 'a malformed list item.';
@override
String get pathImportMalformedMath => 'a malformed math block.';
@override
String get pathImportMalformedCode => 'a malformed code block.';
@override
String pathImportUnknownBlock(String detail) {
return 'an unknown block: $detail.';
}
@override
String pathImportTextlessBlock(String detail) {
return 'a $detail without \"text\" or \"runs\".';
}
@override
String get pathImportMalformedRun => 'a malformed run of text.';
@override
String get pathImportListExpected => 'a list was expected.';
@override
String pathImportNotText(String detail) {
return '\"$detail\" must be text.';
}
@override
String pathImportTooFew(String detail) {
return 'it needs at least two \"$detail\".';
}
@override
String get pathImportMalformedNumber => 'a malformed number.';
@override
String get pathImportTitleNotText => 'a title must be text.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -50,7 +50,7 @@ class PathListBloc(
title: '',
steps: 0,
skippedBlocks: 0,
failure: error.message,
failure: error,
),
),
);
@ -154,7 +154,7 @@ typedef ImportResult = ({
String title,
int steps,
int skippedBlocks,
String? failure,
PathImportException? failure,
});
/// A studyset's paths, as the list shows them.

View file

@ -35,13 +35,52 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
// "image" and "lottie" are left out, keeping an "alt" caption: their files
// would have to be fetched from the web, which the app never does.
/// A file in the import format that couldn't be read: [message] says why.
/// What is wrong with a file in the import format.
enum PathImportProblem() {
notJson,
noPath,
noTitle,
descriptionNotText,
notAnObject,
unknownStepType,
rightOptionMissing,
rightOptionsMissing,
noPairing,
pairingTargetMissing,
pairingIncomplete,
noPassage,
blanksMismatch,
blockWithoutType,
malformedLevel,
malformedListItem,
malformedMath,
malformedCode,
unknownBlock,
textlessBlock,
malformedRun,
listExpected,
notText,
tooFew,
malformedNumber,
titleNotText,
}
/// A file in the import format that couldn't be read: the [problem], the
/// [step] it is in, and what it names ([detail]: a field, a type, a
/// number); the app says it in its language.
final class const PathImportException(
/// What is wrong, and where.
final String message,
) implements Exception {
/// What is wrong.
final PathImportProblem problem, {
/// The step it is in, counting from 1; null for the path itself.
final int? step,
/// What the problem names, as written in the file.
final String detail = '',
}) implements Exception {
@override
String toString() => 'PathImportException: $message';
String toString() =>
'PathImportException: ${problem.name} in ${step ?? 'the path'}: $detail';
}
/// A path read from the import format, not stored yet.
@ -81,33 +120,33 @@ ParsedPath parsePathImport(String source) {
try {
json = jsonDecode(source);
} on FormatException catch (error) {
throw PathImportException('This is no JSON: ${error.message}.');
throw PathImportException(PathImportProblem.notJson, detail: error.message);
}
final path = switch (json) {
{'path': final Map<String, Object?> path} => path,
_ => throw const PathImportException('A "path" object is missing.'),
_ => throw const PathImportException(PathImportProblem.noPath),
};
final title = switch (path['title']) {
final String title when title.trim().isNotEmpty => title,
_ => throw const PathImportException('The path has no "title".'),
_ => throw const PathImportException(PathImportProblem.noTitle),
};
final blocks = _Blocks();
final steps = [
for (final (index, step) in _list(
path['steps'],
'the path\'s "steps"',
null,
).indexed)
_step(step, index + 1, blocks),
];
return (
content: LearningPathContent(
title: title,
secondaryTitle: _optionalString(path['secondary_title'], 'the path'),
secondaryTitle: _optionalString(path['secondary_title'], null),
description: switch (path['description']) {
null => RichContent.empty(),
final String text => RichContent.plain(text),
_ => throw const PathImportException(
'The path\'s "description" is no text.',
PathImportProblem.descriptionNotText,
),
},
).normalized,
@ -121,10 +160,10 @@ ParsedPath parsePathImport(String source) {
int number,
_Blocks blocks,
) {
final where = 'step $number';
final where = number;
final step = switch (json) {
final Map<String, Object?> step => step,
_ => throw PathImportException('The $where is no object.'),
_ => throw PathImportException(PathImportProblem.notAnObject, step: where),
};
final kind = switch (step['type']) {
'info' => StepKind.info,
@ -133,7 +172,9 @@ ParsedPath parsePathImport(String source) {
'drag_and_drop' => StepKind.dragAndDrop,
'fill_in_the_blank' => StepKind.fillInTheBlank,
final type => throw PathImportException(
'The $where has an unknown "type": $type.',
PathImportProblem.unknownStepType,
step: where,
detail: '$type',
),
};
final exercise = switch (kind) {
@ -143,7 +184,9 @@ ParsedPath parsePathImport(String source) {
final correct = _int(step['correct_option_index'], where);
if (correct >= options.length) {
throw PathImportException(
"The $where's right option $correct is not among its options.",
PathImportProblem.rightOptionMissing,
step: where,
detail: '$correct',
);
}
return SingleChoice(options: options, correct: correct);
@ -156,7 +199,8 @@ ParsedPath parsePathImport(String source) {
};
if (correct.any((index) => index >= options.length)) {
throw PathImportException(
'The $where names a right option that is not among its options.',
PathImportProblem.rightOptionsMissing,
step: where,
);
}
return MultipleChoice(options: options, correct: correct);
@ -168,14 +212,17 @@ ParsedPath parsePathImport(String source) {
final pairing = switch (step['correct_pairing']) {
final Map<String, Object?> pairing => pairing,
_ => throw PathImportException(
'The $where has no "correct_pairing".',
PathImportProblem.noPairing,
step: where,
),
};
for (final MapEntry(key: target, value: placed) in pairing.entries) {
final at = int.tryParse(target);
if (at == null || at >= targets.length) {
throw PathImportException(
"The $where pairs items with a target that isn't there: $target.",
PathImportProblem.pairingTargetMissing,
step: where,
detail: target,
);
}
for (final item in _list(placed, where)) {
@ -185,7 +232,8 @@ ParsedPath parsePathImport(String source) {
if (targetOf.length != items.length ||
targetOf.keys.any((item) => item >= items.length)) {
throw PathImportException(
'The $where must pair every item with one target.',
PathImportProblem.pairingIncomplete,
step: where,
);
}
return DragAndDrop(
@ -199,7 +247,10 @@ ParsedPath parsePathImport(String source) {
StepKind.fillInTheBlank => () {
final passage = switch (step['passage']) {
final String passage => passage,
_ => throw PathImportException('The $where has no "passage".'),
_ => throw PathImportException(
PathImportProblem.noPassage,
step: where,
),
};
final exercise = FillInTheBlank(
passage: passage,
@ -207,7 +258,8 @@ ParsedPath parsePathImport(String source) {
);
if (!exercise.isComplete) {
throw PathImportException(
'The $where needs one word in "blanks" for each ___ in its passage.',
PathImportProblem.blanksMismatch,
step: where,
);
}
return exercise;
@ -230,7 +282,7 @@ class _Blocks() {
/// How many blocks were left out.
int skipped = 0;
RichContent read(Object? json, String where) {
RichContent read(Object? json, int? where) {
if (json == null) {
return RichContent.empty();
}
@ -245,10 +297,13 @@ class _Blocks() {
}
/// The editor's nodes for [block]; null for a block left out.
List<Map<String, Object?>>? _nodes(Object? block, String where) {
List<Map<String, Object?>>? _nodes(Object? block, int? where) {
final type = switch (block) {
{'type': final String type} => type,
_ => throw PathImportException('The $where has a block without "type".'),
_ => throw PathImportException(
PathImportProblem.blockWithoutType,
step: where,
),
};
final json = block as Map<String, Object?>;
switch (type) {
@ -259,7 +314,10 @@ class _Blocks() {
final level = switch (json['level']) {
final int level when level >= 1 && level <= 6 => level,
null => 2,
_ => throw PathImportException('The $where has a malformed level.'),
_ => throw PathImportException(
PathImportProblem.malformedLevel,
step: where,
),
};
return [
{
@ -280,7 +338,8 @@ class _Blocks() {
final String text => _plain(text),
{'runs': final Object? runs} => _runs(runs, where),
_ => throw PathImportException(
'The $where has a malformed list item.',
PathImportProblem.malformedListItem,
step: where,
),
},
},
@ -289,7 +348,10 @@ class _Blocks() {
case 'math':
final formula = json['formula'];
if (formula is! String) {
throw PathImportException('The $where has a malformed math block.');
throw PathImportException(
PathImportProblem.malformedMath,
step: where,
);
}
return [
{
@ -300,7 +362,10 @@ class _Blocks() {
case 'code':
final source = json['code'];
if (source is! String) {
throw PathImportException('The $where has a malformed code block.');
throw PathImportException(
PathImportProblem.malformedCode,
step: where,
);
}
return [
{
@ -334,14 +399,18 @@ class _Blocks() {
_ => null,
};
default:
throw PathImportException('The $where has an unknown block: $type.');
throw PathImportException(
PathImportProblem.unknownBlock,
step: where,
detail: type,
);
}
}
Map<String, Object?> _text(
String type,
Map<String, Object?> json,
String where,
int? where,
) => {
'type': type,
'data': {
@ -349,7 +418,9 @@ class _Blocks() {
{'text': final String text} => _plain(text),
{'runs': final Object? runs} => _runs(runs, where),
_ => throw PathImportException(
'The $where has a $type without "text" or "runs".',
PathImportProblem.textlessBlock,
step: where,
detail: type,
),
},
},
@ -359,7 +430,7 @@ class _Blocks() {
if (text.isNotEmpty) {'insert': text},
];
List<Map<String, Object?>> _runs(Object? json, String where) => [
List<Map<String, Object?>> _runs(Object? json, int? where) => [
for (final run in _list(json, where))
switch (run) {
{'math': final String formula} => {
@ -376,38 +447,52 @@ class _Blocks() {
case final attributes when attributes.isNotEmpty)
'attributes': attributes,
},
_ => throw PathImportException('The $where has a malformed run.'),
_ => throw PathImportException(
PathImportProblem.malformedRun,
step: where,
),
},
];
}
List<Object?> _list(Object? json, String where) => switch (json) {
List<Object?> _list(Object? json, int? where) => switch (json) {
final List<Object?> list => list,
null => const [],
_ => throw PathImportException('In $where, a list was expected.'),
_ => throw PathImportException(PathImportProblem.listExpected, step: where),
};
List<String> _strings(Object? json, String where, String field) {
List<String> _strings(Object? json, int? where, String field) {
final strings = [
for (final value in _list(json, where))
switch (value) {
final String text => text,
_ => throw PathImportException('The $where\'s "$field" must be text.'),
_ => throw PathImportException(
PathImportProblem.notText,
step: where,
detail: field,
),
},
];
if (strings.length < 2 && field != 'blanks' && field != 'items') {
throw PathImportException('The $where needs at least two "$field".');
throw PathImportException(
PathImportProblem.tooFew,
step: where,
detail: field,
);
}
return strings;
}
int _int(Object? json, String where) => switch (json) {
int _int(Object? json, int? where) => switch (json) {
final int number when number >= 0 => number,
_ => throw PathImportException('The $where has a malformed number.'),
_ => throw PathImportException(
PathImportProblem.malformedNumber,
step: where,
),
};
String? _optionalString(Object? json, String where) => switch (json) {
String? _optionalString(Object? json, int? where) => switch (json) {
null => null,
final String text => text,
_ => throw PathImportException('In $where, a title must be text.'),
_ => throw PathImportException(PathImportProblem.titleNotText, step: where),
};

View file

@ -0,0 +1,51 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart';
/// What is wrong with an import file, in the app's language.
extension PathImportTexts on AppLocalizations {
/// What [error] says is wrong, with the step it is in.
String pathImportProblemText(PathImportException error) {
final detail = error.detail;
final problem = switch (error.problem) {
PathImportProblem.notJson => pathImportNotJson(detail),
PathImportProblem.noPath => pathImportNoPath,
PathImportProblem.noTitle => pathImportNoTitle,
PathImportProblem.descriptionNotText => pathImportDescriptionNotText,
PathImportProblem.notAnObject => pathImportNotAnObject,
PathImportProblem.unknownStepType => pathImportUnknownStepType(detail),
PathImportProblem.rightOptionMissing => pathImportRightOptionMissing(
detail,
),
PathImportProblem.rightOptionsMissing => pathImportRightOptionsMissing,
PathImportProblem.noPairing => pathImportNoPairing,
PathImportProblem.pairingTargetMissing => pathImportPairingTargetMissing(
detail,
),
PathImportProblem.pairingIncomplete => pathImportPairingIncomplete,
PathImportProblem.noPassage => pathImportNoPassage,
PathImportProblem.blanksMismatch => pathImportBlanksMismatch,
PathImportProblem.blockWithoutType => pathImportBlockWithoutType,
PathImportProblem.malformedLevel => pathImportMalformedLevel,
PathImportProblem.malformedListItem => pathImportMalformedListItem,
PathImportProblem.malformedMath => pathImportMalformedMath,
PathImportProblem.malformedCode => pathImportMalformedCode,
PathImportProblem.unknownBlock => pathImportUnknownBlock(detail),
PathImportProblem.textlessBlock => pathImportTextlessBlock(detail),
PathImportProblem.malformedRun => pathImportMalformedRun,
PathImportProblem.listExpected => pathImportListExpected,
PathImportProblem.notText => pathImportNotText(detail),
PathImportProblem.tooFew => pathImportTooFew(detail),
PathImportProblem.malformedNumber => pathImportMalformedNumber,
PathImportProblem.titleNotText => pathImportTitleNotText,
};
final sentence = switch (error.step) {
final step? => pathImportAtStep(step, problem),
null => problem,
};
// The problems read as the rest of a sentence; standing alone, the
// first letter goes up.
return sentence.isEmpty
? sentence
: sentence[0].toUpperCase() + sentence.substring(1);
}
}

View file

@ -21,6 +21,7 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart';
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
@ -120,7 +121,9 @@ class const StudysetScreen({
context.read<ContentTabCubit>().show(StudysetTab.paths);
}
final message = switch (result.failure) {
final failure? => l10n.pathImportFailed(failure),
final failure? => l10n.pathImportFailed(
l10n.pathImportProblemText(failure),
),
null => [
l10n.pathImported(result.title, result.steps),
if (result.skippedBlocks > 0)

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
@ -116,7 +117,7 @@ void main() {
bloc.add(const PathListEventImportRequested('{'));
final failed = await until((s) => s.importResult?.failure != null);
expect(failed.importResult!.failure, contains('JSON'));
expect(failed.importResult!.failure?.problem, PathImportProblem.notJson);
});
test('creating a path hands back its id, and lists it', () async {

View file

@ -1,9 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_learning_path_gateway.dart';
@ -158,49 +161,76 @@ void main() {
});
group('refuses, saying why,', () {
void refuses(String source, String reason) => expect(
() => parsePathImport(source),
throwsA(
isA<PathImportException>().having(
(e) => e.message,
'message',
contains(reason),
),
),
);
void refuses(String source, PathImportProblem problem, {int? step}) =>
expect(
() => parsePathImport(source),
throwsA(
isA<PathImportException>()
.having((e) => e.problem, 'problem', problem)
.having((e) => e.step, 'step', step),
),
);
test('what is no JSON', () => refuses('{', 'JSON'));
test('what is no JSON', () => refuses('{', PathImportProblem.notJson));
test('a path without a title', () {
refuses('{"path": {"steps": []}}', 'title');
refuses('{"path": {"steps": []}}', PathImportProblem.noTitle);
});
test('a step of an unknown type', () {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "quiz"}]}}',
'step 1',
PathImportProblem.unknownStepType,
step: 1,
);
});
test('a right option that is not there', () {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", '
'"type": "single_choice", "options": ["a", "b"], '
'"correct_option_index": 5}]}}',
'step 1',
'"type": "single_choice", "options": ["a", "b"], '
'"correct_option_index": 5}]}}',
PathImportProblem.rightOptionMissing,
step: 1,
);
});
test('blanks that do not fit the gaps', () {
refuses(
'{"path": {"title": "x", "steps": [{"title": "a", '
'"type": "fill_in_the_blank", "passage": "a ___ b ___", '
'"blanks": ["x"]}]}}',
'step 1',
'"type": "fill_in_the_blank", "passage": "a ___ b ___", '
'"blanks": ["x"]}]}}',
PathImportProblem.blanksMismatch,
step: 1,
);
});
});
test("says what is wrong in the app's language", () {
const atStep = PathImportException(
PathImportProblem.unknownStepType,
step: 2,
detail: 'quiz',
);
const ofPath = PathImportException(PathImportProblem.noTitle);
final english = lookupAppLocalizations(const Locale('en', 'GB'));
final german = lookupAppLocalizations(const Locale('de'));
expect(
english.pathImportProblemText(atStep),
'Step 2: unknown "type": quiz.',
);
expect(english.pathImportProblemText(ofPath), 'The path has no "title".');
expect(
german.pathImportProblemText(atStep),
'Schritt 2: unbekannter „type“: quiz.',
);
expect(
german.pathImportProblemText(ofPath),
'Der Pfad hat keinen „title“.',
);
});
test('imports a path with its steps into a studyset', () async {
final paths = LearningPathRepository(FakeLearningPathGateway());
addTearDown(paths.dispose);