feat(app): tags keep their case; dictionary tags read JLPT-N5, Noun

A tag is still its name, but no longer in lowercase: the name keeps the
case it was written in, and names that differ only in case are one tag
(ADR 0004, rule 2, amended). Two such tags from two devices fold into the
one with the smaller id, its case with it. Tags stored before stay
lowercase until renamed.

Words from the dictionary are tagged JLPT-N5 to JLPT-N1 and with their
word class (Noun, Verb, i-Adjective, na-Adjective, ...). Anki tags and
PDF chapter tags arrive as written.

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 10:48:06 +02:00
commit c20578e421
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 148 additions and 95 deletions

View file

@ -171,7 +171,7 @@ void main() {
Set<String> namesOf(Card card) => {
for (final tag in tagging.of(card.id)) tag.name,
};
expect(namesOf(cat), {'animal', 'jlpt::n5'});
expect(namesOf(cat), {'animal', 'JLPT::N5'});
expect(namesOf(dog), {'animal'});
expect(namesOf(cloze), isEmpty);
});
@ -197,9 +197,9 @@ void main() {
Set<String> namesOf(Card card) => {
for (final tag in tagging.of(card.id)) tag.name,
};
expect(namesOf(cat), {'animal', 'jlpt::n5'});
expect(namesOf(dog), {'animal', 'animals'});
expect(namesOf(list), {'animals'});
expect(namesOf(cat), {'animal', 'JLPT::N5'});
expect(namesOf(dog), {'animal', 'Animals'});
expect(namesOf(list), {'Animals'});
});
test('tells how far it got', () async {

View file

@ -147,25 +147,25 @@ void main() {
expect(
[for (final tag in tagging.of(made.first.id)) tag.name],
[
'jlpt-n5',
'verb',
'JLPT-N5',
'Verb',
],
);
expect(
[for (final tag in tagging.of(made.last.id)) tag.name],
[
'jlpt-n5',
'noun',
'JLPT-N5',
'Noun',
],
);
expect((await cards.allCards()).every((card) => card.imported), isTrue);
});
test('names the tags by level and word class', () {
expect(tagNamesOf(taberu), ['jlpt-n5', 'verb']);
expect(tagNamesOf(taberu), ['JLPT-N5', 'Verb']);
expect(
tagNamesOf(word('1', 'x', pos: ['adj-na', 'n'])),
['na-adjective', 'noun'],
['na-Adjective', 'Noun'],
);
expect(tagNamesOf(word('2', 'y', pos: ['vt'])), isEmpty);
});

View file

@ -196,7 +196,7 @@ void main() {
final card = (await cards.cardsOf('japanese')).single;
expect(card.content.source, (pdfId: id, page: 5));
expect(card.content.front.plainText, 'は marks the topic');
expect((await tags.read()).of(card.id).single.name, 'particles');
expect((await tags.read()).of(card.id).single.name, 'Particles');
expect(find.text('Page 5 · 1'), findsOneWidget);
});

View file

@ -38,7 +38,7 @@ void main() {
bloc.add(
const TagsEventCreateRequested((name: 'Verb', colorName: 'fern')),
);
final created = await showing([('verb', 'fern')]);
final created = await showing([('Verb', 'fern')]);
bloc.add(
TagsEventUpdateRequested(
created.tagging.all.single.id,

View file

@ -10,21 +10,23 @@ void main() {
expect(decodeTagPayload(encodeTagPayload(content)), content);
});
test('stores the name in lowercase, without spaces around it', () {
test('stores the name in its case, without spaces around it', () {
final payload = encodeTagPayload((name: ' JLPT N5 ', colorName: 'fern'));
expect(decodePayload(payload, newestSchema: tagSchemaVersion), {
'schemaVersion': 1,
'name': 'jlpt n5',
'name': 'JLPT N5',
'color': 'fern',
});
});
test('reads a name another app version wrote in capitals in '
'lowercase', () {
final payload = encodePayload(1, {'name': 'Verb', 'color': 'fern'});
test('reads a name as another device wrote it, tidied', () {
final payload = encodePayload(1, {
'name': ' Verb form',
'color': 'fern',
});
expect(decodeTagPayload(payload).name, 'verb');
expect(decodeTagPayload(payload).name, 'Verb form');
});
test('refuses a blank name, a newer schema and a blanked tag', () {

View file

@ -38,11 +38,11 @@ void main() {
Iterable<String> names(Tagging tagging) => tagging.all.map((tag) => tag.name);
group('Creating', () {
test('stores the name in lowercase with its colour', () async {
test('stores the name in its case with its colour', () async {
final id = await repository.create(' Verb ', colorName: 'fern');
expect((await repository.read()).all, [
Tag(id: id, name: 'verb', colorName: 'fern'),
Tag(id: id, name: 'Verb', colorName: 'fern'),
]);
});
@ -52,12 +52,13 @@ void main() {
expect((await repository.read()).all.single.colorName, 'cobalt');
});
test('a name that exists already is that tag: nothing new is '
'stored', () async {
test('a name that exists already, in any case, is that tag: nothing '
'new is stored, and it keeps its case', () async {
final id = await repository.create('verb');
expect(await repository.create('VERB'), id);
expect(gateway.writes, hasLength(1));
expect(names(await repository.read()), ['verb']);
});
test('refuses a blank name', () async {
@ -66,12 +67,18 @@ void main() {
});
});
test('lists the tags by name', () async {
test('lists the tags by name, whatever their case', () async {
await repository.create('verb');
await repository.create('adjective');
await repository.create('noun');
await repository.create('Noun');
await repository.create('JLPT-N5');
expect(names(await repository.read()), ['adjective', 'noun', 'verb']);
expect(names(await repository.read()), [
'adjective',
'JLPT-N5',
'Noun',
'verb',
]);
});
test('gives a card the tags chosen and takes away the others', () async {
@ -93,7 +100,7 @@ void main() {
await repository.update(id, name: 'Verbs', colorName: 'plum');
expect((await repository.read()).all, [
Tag(id: id, name: 'verbs', colorName: 'plum'),
Tag(id: id, name: 'Verbs', colorName: 'plum'),
]);
});
@ -135,6 +142,31 @@ void main() {
expect(gateway.deleted, [mine]);
});
test('names that differ only in case are one name: the tag with the '
'smaller id keeps its case', () async {
final mine = await repository.create('noun');
gateway.receiveRemoteTag(
'tag-000',
encodeTagPayload((name: 'Noun', colorName: 'plum')),
);
expect((await repository.read()).all, [
const Tag(id: 'tag-000', name: 'Noun', colorName: 'plum'),
]);
expect(gateway.deleted, [mine]);
});
test('changing only the case of a name renames the tag', () async {
final id = await repository.create('noun');
await repository.update(id, name: 'Noun', colorName: 'cobalt');
expect((await repository.read()).all, [
Tag(id: id, name: 'Noun', colorName: 'cobalt'),
]);
expect(gateway.deleted, isEmpty);
});
test('a folded tag is blanked before it is deleted', () async {
final mine = await repository.create('verb');
gateway.receiveRemoteTag(