feat: complete kouyu_english app codebase, A0 specifications and .gitignore

This commit is contained in:
shen
2026-09-15 15:58:33 +08:00
parent b8072673d8
commit 37c86f7ecb
136 changed files with 19042 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,154 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/generated_content.dart';
void main() {
const valid = '''{
"schemaVersion":"review-variant-1",
"variantId":"ai-a0-p12-001",
"targetItemId":"A0-P12",
"prompt":"请用英语说你来自哪里。",
"expectedAnswer":"Im from [place]."
}''';
test('accepts a bounded variant for its requested frozen target', () {
final item = decodeGeneratedReviewVariant(
valid,
expectedTargetItemId: 'A0-P12',
);
expect(item, isNotNull);
expect(item!.targetItemId, 'A0-P12');
});
test('rejects an AI variant that changes the target identity', () {
expect(
decodeGeneratedReviewVariant(valid, expectedTargetItemId: 'A0-P01'),
isNull,
);
});
test('rejects unknown schema fields', () {
final invalid = valid.replaceFirst('}', ',"unsafe":"ignore"}');
expect(
decodeGeneratedReviewVariant(invalid, expectedTargetItemId: 'A0-P12'),
isNull,
);
});
const writing = '''{
"schemaVersion":"writing-feedback-1",
"lessonId":"a0-06",
"verdict":"rewrite",
"feedback":"加上 from 就能说清来自哪里。",
"suggestion":"I'm from Hong Kong.",
"missing":["来自哪里"]
}''';
test('accepts bounded writing feedback for its lesson', () {
final feedback = decodeWritingAiFeedback(
writing,
expectedLessonId: 'a0-06',
);
expect(feedback, isNotNull);
expect(feedback!.verdict, 'rewrite');
});
test('rejects writing feedback for a different lesson', () {
expect(decodeWritingAiFeedback(writing, expectedLessonId: 'a0-07'), isNull);
});
const lesson = '''{
"schemaVersion":"lesson-1",
"lessonId":"ai-a0-p12-1",
"revision":1,
"stageVersion":"A0-1.0",
"source":"aiGenerated",
"status":"validated",
"abilityIds":["A0-C06"],
"prerequisiteIds":["a0-05"],
"targetItemIds":["A0-P12"],
"receptiveChunks":[],
"newItemIds":[],
"previewItemIds":[],
"estimatedMinutes":10,
"tasks":[
{"taskId":"l","skill":"listening","type":"listenChoice","prompt":"听后选择。","stimulus":"I am from Hong Kong.","answer":"Hong Kong","targetItemIds":["A0-P12"]},
{"taskId":"s","skill":"speaking","type":"repeat","prompt":"跟读。","stimulus":"I am from Hong Kong.","answer":"I am from Hong Kong.","targetItemIds":["A0-P12"]},
{"taskId":"r","skill":"reading","type":"readAnswer","prompt":"读后回答。","stimulus":"I am from Hong Kong.","answer":"Hong Kong","targetItemIds":["A0-P12"]},
{"taskId":"w","skill":"writing","type":"writeAnswer","prompt":"写一句。","stimulus":"来自香港。","answer":"I am from Hong Kong.","targetItemIds":["A0-P12"]}
]
}''';
test('accepts a bounded four-skill adaptive lesson', () {
final decoded = decodeGeneratedLesson(
lesson,
expectedTargetItemId: 'A0-P12',
);
expect(decoded, isNotNull);
expect(decoded!.tasks.map((task) => task.skill).toSet(), hasLength(4));
});
test('adaptive answer check accepts harmless punctuation variation', () {
final task = decodeGeneratedLesson(
lesson,
expectedTargetItemId: 'A0-P12',
)!.tasks[3];
expect(matchesAdaptiveLessonAnswer(task, "i am from hong kong!"), isTrue);
});
test('adaptive answer check rejects a missing target token', () {
final task = decodeGeneratedLesson(
lesson,
expectedTargetItemId: 'A0-P12',
)!.tasks[3];
expect(matchesAdaptiveLessonAnswer(task, 'I am from Hong.'), isFalse);
});
test('adaptive answer spec accepts listed slots and rejects exclusions', () {
const task = GeneratedLessonTask(
taskId: 'speaking',
skill: 'speaking',
type: 'repeat',
prompt: '说一句。',
stimulus: 'I am from Hong Kong.',
answer: 'I am from Hong Kong.',
targetItemIds: ['A0-P12'],
answerSpec: GeneratedAnswerSpec(
requiredAnyPhrases: [
['I am', "I'm"],
['from'],
['Hong Kong'],
],
acceptedAnswers: ['I am from Hong Kong.', "I'm from Hong Kong."],
forbiddenPhrases: ['Beijing'],
),
);
expect(matchesAdaptiveLessonAnswer(task, "I'm from Hong Kong!"), isTrue);
expect(matchesAdaptiveLessonAnswer(task, 'I am from Beijing.'), isFalse);
expect(matchesAdaptiveLessonAnswer(task, 'Hong Kong.'), isFalse);
});
test('rejects an adaptive lesson with a different frozen target', () {
expect(
decodeGeneratedLesson(lesson, expectedTargetItemId: 'A0-P11'),
isNull,
);
});
test(
'rejects an adaptive lesson that introduces an unknown English word',
() {
final invalid = lesson.replaceFirst('Hong Kong', 'library');
expect(
decodeGeneratedLesson(invalid, expectedTargetItemId: 'A0-P12'),
isNull,
);
},
);
}
@@ -0,0 +1,80 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/widgets/lexicon_lookup.dart';
void main() {
test('lookup prefers the longest reviewed phrase', () {
final entry = findCourseLexicon('Nice to meet you, Mia.');
expect(entry, isNotNull);
expect(entry!.word, 'Nice to meet you');
expect(entry.meaning, isNotEmpty);
});
test('lookup includes reviewed vocabulary from split segments', () {
final entry = findCourseLexicon("It's three o'clock.");
expect(entry, isNotNull);
expect(entry!.word, "It's three o'clock.");
});
test('unknown text has no invented dictionary entry', () {
expect(findCourseLexicon('supercalifragilistic'), isNull);
});
test('lookup does not match substrings within longer words', () {
expect(findCourseLexicon('often'), isNull);
expect(findCourseLexicon('sentence'), isNull);
expect(findCourseLexicon('teacher'), isNull);
});
test('lookup matches reviewed vocabulary with punctuation and apostrophes', () {
expect(findCourseLexicon('tea'), isNotNull);
expect(findCourseLexicon('tea.'), isNotNull);
expect(findCourseLexicon("What's this?"), isNotNull);
expect(findCourseLexicon('Whats this?'), isNotNull);
});
testWidgets('cached temporary definition appears when lookup opens', (
tester,
) async {
final state = AppState()
..cacheTemporaryDefinition(query: 'library', definition: '图书馆');
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showLexiconLookup(
context,
state: state,
initialText: 'library',
),
child: const Text('查词'),
),
),
),
);
await tester.tap(find.text('查词'));
await tester.pumpAndSettle();
expect(find.textContaining('待审核临时释义'), findsOneWidget);
expect(find.textContaining('图书馆'), findsOneWidget);
});
testWidgets('selected arbitrary text exposes a dynamic lookup action', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: LexiconText('mystery widget', state: AppState())),
),
);
expect(find.byType(SelectableText), findsOneWidget);
await tester.longPress(find.text('mystery widget'));
await tester.pumpAndSettle();
expect(find.text('查询已选文本'), findsOneWidget);
});
}
+165
View File
@@ -0,0 +1,165 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/local_store.dart';
import 'dart:convert';
void main() {
test(
'a legacy snapshot migrates into canonical entities without replacement',
() async {
final executor = NativeDatabase.memory();
final store = LocalSnapshotStore.forTesting(executor);
const legacy = '''{
"onboardingComplete": true,
"lessonStep": "reading",
"activeLessonId": "a0-02",
"attemptEvidence": [],
"mastery": [],
"reviews": []
}''';
// Open the schema first, then model the row left by a pre-entity build.
expect(await store.read(), isNull);
await executor.runCustom(
'''INSERT INTO learning_snapshots(snapshot_id, schema_version, payload, updated_at)
VALUES (?, ?, ?, ?)''',
const ['current', 1, legacy, '2026-09-14T00:00:00.000Z'],
);
expect(await store.read(), legacy);
await store.write(legacy);
final restored =
jsonDecode((await store.read())!) as Map<String, dynamic>;
expect(restored['activeLessonId'], 'a0-02');
expect(restored['onboardingComplete'], isTrue);
expect(
await executor.runSelect(
'SELECT payload FROM learning_snapshots WHERE snapshot_id = ?',
const ['current'],
),
[
<String, Object?>{'payload': legacy},
],
);
expect(
await executor.runSelect(
'SELECT profile_id FROM learner_profiles',
const [],
),
[
<String, Object?>{'profile_id': 'current'},
],
);
await executor.close();
},
);
test(
'SQLite entity store restores canonical state without writing a snapshot',
() async {
final executor = NativeDatabase.memory();
final store = LocalSnapshotStore.forTesting(executor);
expect(await store.read(), isNull);
const first = '''{
"revision": 1,
"attemptEvidence": [{"id":"a1","itemId":"A0-P01","taskId":"t1","skill":"speaking","inputMode":"speechToText","outcome":"independentSuccess","createdAt":"2026-09-14T00:00:00.000Z","assisted":false,"variantIndex":0,"originalTranscript":"Hello","transcriptConfirmed":true,"transcriptEdited":false}],
"mastery": [{"id":"A0-P01","label":"Hello","status":"recognize","checkpoint":1,"needsReview":false}],
"reviews": [{"id":"A0-P01","dueAt":"2026-09-15T00:00:00.000Z","attempts":1,"successfulReviews":1,"variantIndex":0}],
"temporaryLexicon": [{"query":"library","definition":"图书馆","provider":"compatible","model":"demo","createdAt":"2026-09-14T00:00:00.000Z"}],
"lessonStep":"speaking", "activeLessonId":"a0-01"
}''';
await store.write(first);
final restored =
jsonDecode((await store.read())!) as Map<String, dynamic>;
expect(restored['lessonStep'], 'speaking');
expect(restored['attemptEvidence'], hasLength(1));
expect(restored['mastery'], hasLength(1));
expect(
await executor.runSelect(
'SELECT snapshot_id FROM learning_snapshots',
const [],
),
isEmpty,
);
expect(
await executor.runSelect(
'SELECT item_id FROM attempt_evidence',
const [],
),
[
<String, Object?>{'item_id': 'A0-P01'},
],
);
await executor.runCustom(
'UPDATE learning_snapshots SET payload = ? WHERE snapshot_id = ?',
const ['{"corrupt":"backup only"}', 'current'],
);
final canonicalAfterSnapshotChange =
jsonDecode((await store.read())!) as Map<String, dynamic>;
expect(canonicalAfterSnapshotChange['mastery'], hasLength(1));
expect(
await executor.runSelect(
'SELECT original_transcript, transcript_confirmed, transcript_edited FROM attempt_evidence',
const [],
),
[
<String, Object?>{
'original_transcript': 'Hello',
'transcript_confirmed': 1,
'transcript_edited': 0,
},
],
);
expect(
await executor.runSelect(
'SELECT definition FROM temporary_lexicon_entries',
const [],
),
[
<String, Object?>{'definition': '图书馆'},
],
);
expect(
await executor.runSelect('SELECT status FROM mastery_items', const []),
[
<String, Object?>{'status': 'recognize'},
],
);
expect(
await executor.runSelect('SELECT item_id FROM review_items', const []),
[
<String, Object?>{'item_id': 'A0-P01'},
],
);
expect(
await executor.runSelect(
'SELECT session_type FROM study_sessions',
const [],
),
[
<String, Object?>{'session_type': 'lesson'},
],
);
await store.write('{"revision":2}');
expect(
(jsonDecode((await store.read())!)
as Map<String, dynamic>)['attemptEvidence'],
isEmpty,
);
await store.clear();
expect(await store.read(), isNull);
expect(
await executor.runSelect(
'SELECT query_key FROM temporary_lexicon_entries',
const [],
),
isEmpty,
);
await executor.close();
},
);
}
@@ -0,0 +1,82 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/core/review_feedback.dart';
ReviewItem review(String id, String target) => ReviewItem(
id: id,
target: target,
prompt: 'Test prompt',
hint: target,
dueAt: DateTime(2026),
skill: '回忆表达',
);
void main() {
test('does not accept unrelated English for a core phrase', () {
final result = ReviewFeedback.check(
review('A0-P12', "I'm from [place]."),
'I like tea.',
);
expect(result.complete, isFalse);
});
test('accepts a new place in the from pattern', () {
final result = ReviewFeedback.check(
review('A0-P12', "I'm from [place]."),
"I'm from Guangzhou.",
);
expect(result.complete, isTrue);
});
test('checks an A0 word without accepting a different word', () {
expect(
ReviewFeedback.check(review('A0-W08', 'three'), 'four').complete,
isFalse,
);
expect(
ReviewFeedback.check(review('A0-W08', 'three'), 'three').complete,
isTrue,
);
});
test('A0-P17 accepts standard o clock expressions', () {
expect(
ReviewFeedback.check(
review('A0-P17', "It's [hour] o'clock."),
"It's three o'clock.",
).complete,
isTrue,
);
expect(
ReviewFeedback.check(
review('A0-P17', "It's [hour] o'clock."),
'It is two oclock.',
).complete,
isTrue,
);
expect(
ReviewFeedback.check(
review('A0-P17', "It's [hour] o'clock."),
'It is five oclock.',
).complete,
isTrue,
);
});
test('A0-P20 accepts please say that again and please speak slowly', () {
expect(
ReviewFeedback.check(
review('A0-P20', 'Please say that again.'),
'Please say that again.',
).complete,
isTrue,
);
expect(
ReviewFeedback.check(
review('A0-P20', 'Please say that again.'),
'Please speak slowly.',
).complete,
isTrue,
);
});
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/features/lesson/lesson_flow.dart';
import 'package:kouyu_english/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
testWidgets('shows the M1 welcome flow', (tester) async {
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(const KouyuEnglishApp());
await tester.pumpAndSettle();
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
expect(find.text('继续'), findsOneWidget);
await tester.tap(find.text('继续'));
await tester.pumpAndSettle();
expect(find.text('从哪里开始?'), findsOneWidget);
expect(find.text('直接从第一课开始'), findsOneWidget);
});
testWidgets('writing input updates the lesson draft and enables checking', (
tester,
) async {
final state = AppState()..lessonStep = LessonStep.writing;
await tester.pumpWidget(
MaterialApp(
home: LessonFlow(state: state, onOpenDialogue: () {}, onFinish: () {}),
),
);
await tester.enterText(find.byType(TextField), 'Hello. I am Shen.');
await tester.pump();
expect(state.lessonWritingDraft, 'Hello. I am Shen.');
final check = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '检查句子'),
);
expect(check.onPressed, isNotNull);
});
}
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/writing_feedback.dart';
void main() {
test('accepts a variable name in the first lesson', () {
final result = WritingFeedback.check('a0-01', 'Hello. I am Alex.');
expect(result.complete, isTrue);
});
test(
'requests the missing sentence frame without rejecting a valid place',
() {
expect(WritingFeedback.check('a0-06', 'Hong Kong.').complete, isFalse);
expect(
WritingFeedback.check('a0-06', "I'm from Shanghai.").complete,
isTrue,
);
},
);
test('requires the three independent parts in lesson ten', () {
expect(
WritingFeedback.check('a0-10', "I'm Alex. I'm from Beijing.").complete,
isFalse,
);
expect(
WritingFeedback.check(
'a0-10',
"I'm Alex. I'm from Beijing. I like tea.",
).complete,
isTrue,
);
});
}