fix(app): the dictionary adds each word once, says when it can't, and Look up tags hold

- A word tapped twice, or added while a level is being added, makes one
  card: adds run one at a time, and the word counts as in the studyset
  at once. The adder reads the studyset's cards once rather than for
  every word.
- Adding that fails says so, and a level shows "Making the cards…" once
  its words have come.
- Finding a studyset's dictionary tells finding, none and unreachable
  apart: the dictionary screen says there is none or offers Retry, and
  an instance unreachable at start is asked again once a sync goes
  through (Look up and From the dictionary appear then).
- Look up's tags wait on the card by name and are made when it is saved:
  leaving the card at once no longer saves it untagged, a word replaced
  leaves no tag behind, and a new word replaces any level or word class
  on the card, pinned ones too. Look up marks words already made into
  cards of the studyset.
- The tag picker's Create keeps the case typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 12:45:18 +02:00
commit 49e10895f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -448,15 +448,16 @@ void main() {
'',
).copyWith(type: CardType.vocabulary, meanings: const ['a meaning']);
Future<Set<String>> namesOf(CardComposerState state) async {
Future<Set<String>> namesOf(CardDraft draft) async {
final tagging = await tags.read();
return {
for (final id in state.byId('new')!.tagIds) tagging.byId(id)!.name,
for (final id in draft.tagIds) tagging.byId(id)!.name,
...draft.newTagNames,
};
}
test("fills the card and gives it the word's tags, made where "
'missing, and saved with it', () async {
test("fills the card and gives it the word's tags: those that exist at "
'once, the others made when it is saved', () async {
final noun = await tags.create('Noun');
start();
await loaded();
@ -470,11 +471,17 @@ void main() {
'Noun',
]),
);
final state = await until((s) => s.byId('new')?.tagIds.length == 3);
// Once the tags are read, the one that exists is the card's own.
final state = await until(
(s) =>
s.byId('new')?.content.front == text('食べる') &&
s.byId('new')!.tagIds.isNotEmpty,
);
expect(state.byId('new')!.content.front, text('食べる'));
expect(await namesOf(state), {'JLPT-N5', 'Verb', 'Noun'});
expect(state.byId('new')!.tagIds, contains(noun));
final draft = state.byId('new')!;
expect(draft.tagIds, {noun});
expect(draft.newTagNames, ['JLPT-N5', 'Verb']);
expect((await tags.read()).named('Verb'), isNull);
bloc.add(const CardComposerEventLeft('new'));
final saved = await until(
@ -485,36 +492,70 @@ void main() {
{for (final tag in tagging.of(saved.byId('new')!.cardId!)) tag.name},
{'JLPT-N5', 'Verb', 'Noun'},
);
expect(saved.byId('new')!.newTagNames, isEmpty);
});
test('again swaps the tags the word before brought, and keeps the '
"person's own", () async {
test("again replaces the tags a word brings and keeps the person's "
'own; the word replaced leaves no tag behind', () async {
final mine = await tags.create('Food');
final verb = await tags.create('Verb');
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventTagsChanged('new', {mine, verb}))
..add(CardComposerEventTagsChanged('new', {mine}))
..add(
CardComposerEventLookedUp('new', word('食べる'), const [
'JLPT-N5',
'Verb',
]),
)
..add(CardComposerEventLookedUp('new', word('寿司'), const ['Noun']));
final state = await until(
(s) =>
s.byId('new')?.content.front == text('寿司') &&
s.tagging.all.length == 2,
);
expect(state.byId('new')!.tagIds, {mine});
expect(await namesOf(state.byId('new')!), {'Food', 'Noun'});
expect((await tags.read()).named('JLPT-N5'), isNull);
expect(state.byId('new')!.tagIds, isNot(contains(verb)));
});
test('with tags pinned, the next card keeps the tags but takes the '
"next word's level and class", () async {
final mine = await tags.create('Lesson 3');
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('first'))
..add(CardComposerEventTagsChanged('first', {mine}))
..add(
CardComposerEventLookedUp('first', word('寿司'), const [
'JLPT-N5',
'Noun',
]),
)
..add(const CardComposerEventPinToggled(CardPin.tags))
..add(const CardComposerEventDraftAdded('second', after: 'first'))
..add(
CardComposerEventLookedUp('second', word('走る'), const [
'JLPT-N3',
'Verb',
]),
);
await until((s) => s.byId('new')?.tagIds.length == 3);
final level = (await tags.read()).named('JLPT-N5')!.id;
bloc.add(
CardComposerEventLookedUp('new', word('寿司'), const ['Noun']),
);
final swapped = await until(
(s) => !(s.byId('new')?.tagIds.contains(level) ?? true),
final state = await until(
(s) =>
s.byId('second')?.content.front == text('走る') &&
s.tagging.all.isNotEmpty,
);
expect(swapped.byId('new')!.content.front, text('寿司'));
// Verb was the person's before the first word came, so it stays.
expect(await namesOf(swapped), {'Food', 'Verb', 'Noun'});
expect(await namesOf(state.byId('second')!), {
'Lesson 3',
'JLPT-N3',
'Verb',
});
});
});