Skip to content

Commit abb9983

Browse files
authored
Merge pull request #199 from ZeitOnline/fix-small-flechti-stuff
fix: solve some small flechti issues
2 parents 21eda51 + 09d0ae7 commit abb9983

9 files changed

Lines changed: 188 additions & 21 deletions

File tree

app/src/components/games/wortgeflecht/WordListEditor.svelte

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
wordCount: number;
88
totalLetters: number;
99
invalidInputWords: string[];
10+
duplicateInputWords: string[];
11+
tooShortInputWords: string[];
1012
generatorError: string | null;
1113
rowsError: string | null;
1214
isGenerating: boolean;
@@ -43,6 +45,8 @@
4345
wordCount={state.wordCount}
4446
totalLetters={state.totalLetters}
4547
invalidInputWords={state.invalidInputWords}
48+
duplicateInputWords={state.duplicateInputWords}
49+
tooShortInputWords={state.tooShortInputWords}
4650
generatorError={state.generatorError}
4751
rowsError={state.rowsError}
4852
isGenerating={state.isGenerating}

app/src/components/games/wortgeflecht/WordListFeedback.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
wordCount: number;
66
totalLetters: number;
77
invalidInputWords: string[];
8+
duplicateInputWords: string[];
9+
tooShortInputWords: string[];
810
generatorError: string | null;
911
rowsError: string | null;
1012
isGenerating: boolean;
@@ -16,6 +18,8 @@
1618
wordCount,
1719
totalLetters,
1820
invalidInputWords,
21+
duplicateInputWords,
22+
tooShortInputWords,
1923
generatorError,
2024
rowsError,
2125
isGenerating,
@@ -32,6 +36,16 @@
3236
Ungültige Einträge: {invalidInputWords.join(', ')}
3337
</p>
3438
{/if}
39+
{#if duplicateInputWords.length > 0}
40+
<p class="text-red-600 text-sm mt-z-ds-8">
41+
Doppelte Wörter: {duplicateInputWords.join(', ')}
42+
</p>
43+
{/if}
44+
{#if tooShortInputWords.length > 0}
45+
<p class="text-red-600 text-sm mt-z-ds-8">
46+
Zu kurze Wörter (mind. 4 Buchstaben): {tooShortInputWords.join(', ')}
47+
</p>
48+
{/if}
3549
{#if generatorError}
3650
<div class="border border-red-500 text-red-600 p-3 mt-z-ds-12 flex items-center gap-2">
3751
<IconHandler iconName="error" extraClasses="w-4 h-4" />

app/src/components/games/wortgeflecht/WortgeflechtGameForm.svelte

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@
2525
import {
2626
buildWortgeflechtPreviewFromRows,
2727
generateWortgeflechtLayout,
28-
parseWortgeflechtWords,
2928
toGridRows,
3029
type WortgeflechtWordPath,
3130
} from '$lib/games/wortgeflecht-generator';
3231
import {
32+
analyzeWortgeflechtGenerationInput,
3333
hasSameWordSetForWortgeflecht,
3434
normalizeWortgeflechtWordKey,
3535
normalizeWortgeflechtWordLines,
@@ -77,6 +77,8 @@
7777
let wordLines = $state<string[]>(['']);
7878
let generatorError = $state<string | null>(null);
7979
let invalidInputWords = $state<string[]>([]);
80+
let duplicateInputWords = $state<string[]>([]);
81+
let tooShortInputWords = $state<string[]>([]);
8082
let totalLetters = $state(0);
8183
let wordCount = $state(0);
8284
let isGenerating = $state(false);
@@ -107,11 +109,24 @@
107109
]),
108110
),
109111
);
112+
const invalidWordKeys = $derived(
113+
new Set(
114+
[...invalidInputWords, ...duplicateInputWords, ...tooShortInputWords]
115+
.map(normalizeWortgeflechtWordKey)
116+
.filter(Boolean),
117+
),
118+
);
110119
111120
function getWordRowStyle(line: string) {
112121
const key = normalizeWortgeflechtWordKey(line);
113122
if (!key) return '';
114-
return wordStyleByWord.get(key) ?? '';
123+
const styles = [];
124+
const baseStyle = wordStyleByWord.get(key);
125+
if (baseStyle) styles.push(baseStyle);
126+
if (invalidWordKeys.has(key)) {
127+
styles.push('outline: 2px solid #dc2626; outline-offset: -2px;');
128+
}
129+
return styles.join(' ');
115130
}
116131
117132
// svelte-ignore state_referenced_locally
@@ -240,10 +255,13 @@
240255
}
241256
242257
function refreshWordStats() {
243-
const parsed = parseWortgeflechtWords(wordLines.join('\n'));
258+
const analysis = analyzeWortgeflechtGenerationInput(wordLines);
259+
const { parsed } = analysis;
244260
wordCount = parsed.words.length;
245261
totalLetters = parsed.totalLetters;
246262
invalidInputWords = parsed.invalidWords;
263+
duplicateInputWords = analysis.duplicateWords;
264+
tooShortInputWords = analysis.tooShortWords;
247265
}
248266
249267
function updateWordLine(index: number, value: string) {
@@ -469,6 +487,8 @@
469487
wordCount,
470488
totalLetters,
471489
invalidInputWords,
490+
duplicateInputWords,
491+
tooShortInputWords,
472492
generatorError,
473493
rowsError,
474494
isGenerating,

app/src/components/games/wortgeflecht/WortgeflechtGeneratorEditor.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
wordCount: number;
3838
totalLetters: number;
3939
invalidInputWords: string[];
40+
duplicateInputWords: string[];
41+
tooShortInputWords: string[];
4042
generatorError: string | null;
4143
rowsError: string | null;
4244
isGenerating: boolean;
@@ -56,6 +58,8 @@
5658
wordCount,
5759
totalLetters,
5860
invalidInputWords,
61+
duplicateInputWords,
62+
tooShortInputWords,
5963
generatorError,
6064
rowsError,
6165
isGenerating,
@@ -110,6 +114,8 @@
110114
wordCount,
111115
totalLetters,
112116
invalidInputWords,
117+
duplicateInputWords,
118+
tooShortInputWords,
113119
generatorError,
114120
rowsError,
115121
isGenerating,

app/src/lib/__tests__/wortgeflecht-utils.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22
import {
3+
analyzeWortgeflechtGenerationInput,
34
hasSameWordSetForWortgeflecht,
45
normalizeWortgeflechtWordLines,
56
normalizeWortgeflechtWordLineValue,
@@ -8,8 +9,8 @@ import {
89
import type { WortgeflechtLetterRow } from '$lib/games/wortgeflecht';
910

1011
describe('wortgeflecht-utils', () => {
11-
it('keeps single line input unchanged (preserve ß)', () => {
12-
expect(normalizeWortgeflechtWordLineValue('süßlich')).toBe('süßlich');
12+
it('normalizes input to lowercase while preserving ß', () => {
13+
expect(normalizeWortgeflechtWordLineValue('SÜẞLICH')).toBe('süßlich');
1314
});
1415

1516
it('normalizes row list to keep one trailing empty line', () => {
@@ -89,4 +90,27 @@ describe('wortgeflecht-utils', () => {
8990
const result = validateWortgeflechtGenerationInput(withDuplicate);
9091
expect(result.error).toBe('Doppelte Wörter sind nicht erlaubt.');
9192
});
93+
94+
it('collects duplicate words case-insensitively for live feedback', () => {
95+
const result = analyzeWortgeflechtGenerationInput(['Abcd', ' efgh ', 'abcd', '']);
96+
expect(result.duplicateWords).toEqual(['Abcd']);
97+
});
98+
99+
it('rejects generation input with words shorter than four letters', () => {
100+
const result = validateWortgeflechtGenerationInput([
101+
'ABCDE',
102+
'FGH',
103+
'IJKL',
104+
'MNOP',
105+
'QRST',
106+
'UVWX',
107+
'YZAB',
108+
'CDEF',
109+
'GHIJ',
110+
'KLMN',
111+
'OPQR',
112+
'STUV',
113+
]);
114+
expect(result.error).toBe('Jedes Wort muss mindestens 4 Buchstaben haben.');
115+
});
92116
});

app/src/lib/__tests__/wortgeflecht.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, describe, expect, it, vi } from 'vitest';
22
import {
33
fetchWortgeflechtLettersByGameId,
4+
replaceWortgeflechtLettersByGameId,
45
sortWortgeflechtRowsByWordThenLetter,
56
type WortgeflechtLetterRow,
67
} from '$lib/games/wortgeflecht';
@@ -71,4 +72,55 @@ describe('wortgeflecht helpers', () => {
7172
{ word: 'wasser', letter: 's', cx: 8, cy: 2 },
7273
]);
7374
});
75+
76+
it('normalizes words to lowercase before inserting wortgeflecht rows', async () => {
77+
const fetchMock = vi
78+
.fn()
79+
.mockResolvedValueOnce(
80+
new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } }),
81+
)
82+
.mockResolvedValueOnce(new Response(null, { status: 204 }))
83+
.mockResolvedValueOnce(
84+
new Response(JSON.stringify([{ id: 21, word: 'süßlich' }]), {
85+
status: 200,
86+
headers: { 'content-type': 'application/json' },
87+
}),
88+
)
89+
.mockResolvedValueOnce(new Response(null, { status: 204 }));
90+
vi.stubGlobal('fetch', fetchMock);
91+
92+
await replaceWortgeflechtLettersByGameId({
93+
gameId: '00000000-0000-0000-0000-000000000001',
94+
rows: [
95+
{ word: 'SÜẞLICH', letter: 's', cx: 1, cy: 1 },
96+
{ word: 'SÜẞLICH', letter: 'ü', cx: 1, cy: 2 },
97+
],
98+
});
99+
100+
expect(fetchMock).toHaveBeenNthCalledWith(
101+
3,
102+
expect.stringContaining('/game_word'),
103+
expect.objectContaining({
104+
method: 'POST',
105+
body: JSON.stringify([
106+
{
107+
game_id: '00000000-0000-0000-0000-000000000001',
108+
word: 'süßlich',
109+
},
110+
]),
111+
}),
112+
);
113+
114+
expect(fetchMock).toHaveBeenNthCalledWith(
115+
4,
116+
expect.stringContaining('/game_letter'),
117+
expect.objectContaining({
118+
method: 'POST',
119+
body: JSON.stringify([
120+
{ word_id: 21, letter: 's', cx: 1, cy: 1 },
121+
{ word_id: 21, letter: 'ü', cx: 1, cy: 2 },
122+
]),
123+
}),
124+
);
125+
});
74126
});

app/src/lib/games/ui-config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ export const GAME_UI_CONFIG: GameUiConfig[] = [
7171
href: '/wortgeflecht',
7272
logo: WortgeflechtLogo,
7373
status: {
74-
tag: 'coming soon',
75-
color: 'gray',
74+
tag: 'live',
75+
color: 'green',
7676
},
7777
},
7878
];

app/src/lib/games/wortgeflecht-utils.ts

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,48 @@
11
import type { WortgeflechtLetterRow } from '$lib/games/wortgeflecht';
22
import { parseWortgeflechtWords } from '$lib/games/wortgeflecht-generator';
33

4-
export const normalizeWortgeflechtWordLineValue = (value: string) => value;
4+
const MIN_WORTGEFLECHT_WORD_LENGTH = 4;
5+
6+
export const normalizeWortgeflechtWordLineValue = (value: string) => value.toLocaleLowerCase('de-DE');
57
export const normalizeWortgeflechtWordKey = (value: string) => value.trim().toLocaleLowerCase('de-DE');
68

9+
const getUniqueWordsByKey = (words: string[]) => {
10+
const wordsByKey = new Map<string, string>();
11+
12+
for (const word of words) {
13+
const trimmedWord = word.trim();
14+
const key = normalizeWortgeflechtWordKey(trimmedWord);
15+
if (!trimmedWord || wordsByKey.has(key)) continue;
16+
wordsByKey.set(key, trimmedWord);
17+
}
18+
19+
return Array.from(wordsByKey.values());
20+
};
21+
22+
export const analyzeWortgeflechtGenerationInput = (wordLines: string[]) => {
23+
const parsed = parseWortgeflechtWords(wordLines.join('\n'));
24+
const tooShortWords = getUniqueWordsByKey(
25+
parsed.words.filter(word => Array.from(word.trim()).length < MIN_WORTGEFLECHT_WORD_LENGTH),
26+
);
27+
28+
const wordCountByKey = new Map<string, number>();
29+
for (const word of parsed.words) {
30+
const key = normalizeWortgeflechtWordKey(word);
31+
if (!key) continue;
32+
wordCountByKey.set(key, (wordCountByKey.get(key) ?? 0) + 1);
33+
}
34+
35+
const duplicateWords = getUniqueWordsByKey(
36+
parsed.words.filter(word => (wordCountByKey.get(normalizeWortgeflechtWordKey(word)) ?? 0) > 1),
37+
);
38+
39+
return {
40+
parsed,
41+
tooShortWords,
42+
duplicateWords,
43+
};
44+
};
45+
746
export const normalizeWortgeflechtWordLines = (lines: string[]) => {
847
const next = lines.map(line => line ?? '');
948
if (next.length === 0) next.push('');
@@ -42,36 +81,42 @@ export const hasSameWordSetForWortgeflecht = ({
4281
};
4382

4483
export const validateWortgeflechtGenerationInput = (wordLines: string[]) => {
45-
const parsed = parseWortgeflechtWords(wordLines.join('\n'));
84+
const analysis = analyzeWortgeflechtGenerationInput(wordLines);
85+
const { parsed, tooShortWords, duplicateWords } = analysis;
4686

4787
if (parsed.words.length === 0) {
4888
return {
49-
parsed,
89+
...analysis,
5090
error: 'Bitte mindestens ein Wort eingeben (ein Wort pro Zeile).',
5191
};
5292
}
5393
if (parsed.invalidWords.length > 0) {
5494
return {
55-
parsed,
95+
...analysis,
5696
error: 'Ungültige Zeichen gefunden. Erlaubt sind nur Buchstaben (inkl. ÄÖÜẞ).',
5797
};
5898
}
59-
if (parsed.totalLetters !== 48) {
99+
if (tooShortWords.length > 0) {
60100
return {
61-
parsed,
62-
error: `Die Wörter müssen zusammen genau 48 Buchstaben ergeben (aktuell: ${parsed.totalLetters}).`,
101+
...analysis,
102+
error: `Jedes Wort muss mindestens ${MIN_WORTGEFLECHT_WORD_LENGTH} Buchstaben haben.`,
63103
};
64104
}
65-
const uniqueWordKeys = Array.from(new Set(parsed.words.map(normalizeWortgeflechtWordKey)));
66-
if (uniqueWordKeys.length !== parsed.words.length) {
105+
if (duplicateWords.length > 0) {
67106
return {
68-
parsed,
107+
...analysis,
69108
error: 'Doppelte Wörter sind nicht erlaubt.',
70109
};
71110
}
111+
if (parsed.totalLetters !== 48) {
112+
return {
113+
...analysis,
114+
error: `Die Wörter müssen zusammen genau 48 Buchstaben ergeben (aktuell: ${parsed.totalLetters}).`,
115+
};
116+
}
72117

73118
return {
74-
parsed,
119+
...analysis,
75120
error: null,
76121
};
77122
};

0 commit comments

Comments
 (0)