diff --git a/CHANGELOG.md b/CHANGELOG.md index a78635816..8ebcf9fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ All notable changes to Bambuddy will be documented in this file. - Link button correctly disabled when no unlinked spools are available in Spoolman - Toast notification shown on successful/failed spool linking - Added `/api/v1/spoolman/spools/linked` endpoint returning map of linked spool tags to IDs +- **Complete German Translations**: + - All UI strings now fully translated to German (1800+ translation keys) + - Pages translated: Settings, Archives, File Manager, Queue, Printers, Profiles, Projects, Stats, Maintenance, Camera, Groups, Users, Login, Setup, Stream Overlay + - Components translated: ConfirmModal, LinkSpoolModal, FilamentHoverCard, Layout + - Added locale parity test to ensure English and German stay in sync ## [0.1.6.2] - 2026-02-02 diff --git a/frontend/src/__tests__/components/ConfirmModal.test.tsx b/frontend/src/__tests__/components/ConfirmModal.test.tsx index 4f360753e..a8882a11c 100644 --- a/frontend/src/__tests__/components/ConfirmModal.test.tsx +++ b/frontend/src/__tests__/components/ConfirmModal.test.tsx @@ -131,7 +131,7 @@ describe('ConfirmModal', () => { it('shows default loading text when loadingText not provided', () => { render(); - expect(screen.getByText('Processing...')).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); }); it('disables buttons when loading', () => { diff --git a/frontend/src/__tests__/components/LinkSpoolModal.test.tsx b/frontend/src/__tests__/components/LinkSpoolModal.test.tsx index c9da66686..d7738e24a 100644 --- a/frontend/src/__tests__/components/LinkSpoolModal.test.tsx +++ b/frontend/src/__tests__/components/LinkSpoolModal.test.tsx @@ -78,7 +78,8 @@ describe('LinkSpoolModal', () => { render(); await waitFor(() => { - expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); + // Look for the title in h2 element + expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument(); }); }); @@ -127,13 +128,13 @@ describe('LinkSpoolModal', () => { render(); await waitFor(() => { - expect(screen.getByText('No unlinked spools found in Spoolman.')).toBeInTheDocument(); + expect(screen.getByText('No unlinked spools available')).toBeInTheDocument(); }); }); it('does not render when isOpen is false', () => { render(); - expect(screen.queryByText('Link to Spoolman')).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /link to spoolman/i })).not.toBeInTheDocument(); }); }); @@ -160,7 +161,7 @@ describe('LinkSpoolModal', () => { expect(screen.getByText('PLA Red')).toBeInTheDocument(); }); - const linkButton = screen.getByRole('button', { name: /link spool/i }); + const linkButton = screen.getByRole('button', { name: /link to spoolman/i }); expect(linkButton).toBeDisabled(); // Select a spool @@ -182,7 +183,7 @@ describe('LinkSpoolModal', () => { fireEvent.click(screen.getByText('PLA Red')); // Click link button - fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i })); await waitFor(() => { expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid); @@ -197,7 +198,7 @@ describe('LinkSpoolModal', () => { }); fireEvent.click(screen.getByText('PLA Red')); - fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i })); await waitFor(() => { expect(mockShowToast).toHaveBeenCalledWith( @@ -215,7 +216,7 @@ describe('LinkSpoolModal', () => { }); fireEvent.click(screen.getByText('PLA Red')); - fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i })); await waitFor(() => { expect(defaultProps.onClose).toHaveBeenCalled(); @@ -233,7 +234,7 @@ describe('LinkSpoolModal', () => { }); fireEvent.click(screen.getByText('PLA Red')); - fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i })); await waitFor(() => { expect(mockShowToast).toHaveBeenCalledWith( @@ -260,7 +261,7 @@ describe('LinkSpoolModal', () => { render(); await waitFor(() => { - expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument(); }); // Click the backdrop (the element with bg-black/60) @@ -275,7 +276,7 @@ describe('LinkSpoolModal', () => { render(); await waitFor(() => { - expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument(); }); // Find and click the X button in the header diff --git a/frontend/src/__tests__/i18n/locales.test.ts b/frontend/src/__tests__/i18n/locales.test.ts new file mode 100644 index 000000000..be676d6e7 --- /dev/null +++ b/frontend/src/__tests__/i18n/locales.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import en from '../../i18n/locales/en'; +import de from '../../i18n/locales/de'; + +/** + * Recursively extracts all keys from a nested object as dot-notation paths. + * Example: { foo: { bar: 'baz' } } => ['foo.bar'] + */ +const getKeys = (obj: object, prefix = ''): string[] => { + return Object.entries(obj).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof value === 'object' && value !== null + ? getKeys(value, path) + : [path]; + }); +}; + +describe('i18n locale parity', () => { + const enKeys = new Set(getKeys(en)); + const deKeys = new Set(getKeys(de)); + + it('German locale has all English keys', () => { + const missingInGerman = [...enKeys].filter((k) => !deKeys.has(k)).sort(); + expect(missingInGerman, `Missing ${missingInGerman.length} key(s) in German locale`).toEqual([]); + }); + + it('English locale has all German keys', () => { + const missingInEnglish = [...deKeys].filter((k) => !enKeys.has(k)).sort(); + expect(missingInEnglish, `Missing ${missingInEnglish.length} key(s) in English locale`).toEqual([]); + }); + + it('both locales have the same number of keys', () => { + expect(enKeys.size).toBe(deKeys.size); + }); +}); diff --git a/frontend/src/__tests__/pages/CameraPage.test.tsx b/frontend/src/__tests__/pages/CameraPage.test.tsx index 734f5f82c..c83520cc9 100644 --- a/frontend/src/__tests__/pages/CameraPage.test.tsx +++ b/frontend/src/__tests__/pages/CameraPage.test.tsx @@ -11,6 +11,8 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '../../contexts/ThemeContext'; import { ToastProvider } from '../../contexts/ToastContext'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../i18n'; // Mock navigator.sendBeacon which isn't available in jsdom vi.stubGlobal('navigator', { @@ -39,15 +41,17 @@ function renderCameraPage(printerId: number) { return rtlRender( - - - - - } /> - - - - + + + + + + } /> + + + + + ); } @@ -94,8 +98,9 @@ describe('CameraPage', () => { renderCameraPage(1); await waitFor(() => { - expect(screen.getByText('Live')).toBeInTheDocument(); - expect(screen.getByText('Snapshot')).toBeInTheDocument(); + // Check for translation key or translated text + expect(screen.getByText(/Live|camera\.live/)).toBeInTheDocument(); + expect(screen.getByText(/Snapshot|camera\.snapshot/)).toBeInTheDocument(); }); }); @@ -124,7 +129,8 @@ describe('CameraPage', () => { renderCameraPage(0); await waitFor(() => { - expect(screen.getByText('Invalid printer ID')).toBeInTheDocument(); + // Check for translation key or translated text + expect(screen.getByText(/Invalid printer ID|camera\.invalidPrinterId/)).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx index c71b527b4..775a88216 100644 --- a/frontend/src/components/ConfirmModal.tsx +++ b/frontend/src/components/ConfirmModal.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { AlertTriangle, Loader2 } from 'lucide-react'; import { Card, CardContent } from './Card'; import { Button } from './Button'; @@ -18,14 +19,18 @@ interface ConfirmModalProps { export function ConfirmModal({ title, message, - confirmText = 'Confirm', - cancelText = 'Cancel', + confirmText, + cancelText, variant = 'default', isLoading = false, loadingText, onConfirm, onCancel, }: ConfirmModalProps) { + const { t } = useTranslation(); + const resolvedConfirmText = confirmText ?? t('common.confirm'); + const resolvedCancelText = cancelText ?? t('common.cancel'); + const resolvedLoadingText = loadingText ?? t('common.loading'); // Close on Escape key (but not while loading) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -70,7 +75,7 @@ export function ConfirmModal({
diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index 22f6ddc88..9aa637037 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -1,30 +1,28 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react'; import { api } from '../api/client'; import type { Archive } from '../api/client'; import { Button } from './Button'; -const FAILURE_REASONS = [ - 'Adhesion failure', - 'Spaghetti / Detached', - 'Layer shift', - 'Clogged nozzle', - 'Filament runout', - 'Warping', - 'Stringing', - 'Under-extrusion', - 'Power failure', - 'User cancelled', - 'Other', -]; +// Keys for failure reasons - translated at render time +const FAILURE_REASON_KEYS = [ + 'adhesionFailure', + 'spaghettiDetached', + 'layerShift', + 'cloggedNozzle', + 'filamentRunout', + 'warping', + 'stringing', + 'underExtrusion', + 'powerFailure', + 'userCancelled', + 'other', +] as const; -const ARCHIVE_STATUSES = [ - { value: 'completed', label: 'Completed' }, - { value: 'failed', label: 'Failed' }, - { value: 'aborted', label: 'Cancelled' }, - { value: 'printing', label: 'Printing' }, -]; +// Keys for archive statuses - translated at render time +const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const; interface EditArchiveModalProps { archive: Archive; @@ -33,6 +31,8 @@ interface EditArchiveModalProps { } export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) { + const { t } = useTranslation(); + // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -205,7 +205,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr > {/* Header */}
-

Edit Archive

+

{t('editArchive.title')}