mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
fix(i18n): every language says one file and five files its own way
Finishes the plural work the previous wip commit started. t() picks a count string's form by the language's own Intl.PluralRules, and every locale now spells out the forms its language reaches with whole numbers, about 160 count strings in all: one, few and many in Russian, Ukrainian and Polish, one and few in Czech and Slovak, five forms in Arabic, one in the others, none in Japanese, Korean, Chinese, Thai, Vietnamese and Indonesian. Each language was translated and then reviewed on its own. English no longer reads "1 files", "1 updates available" or "photo(s)". Count labels shown next to a number now take that number too: the atlas and admin statistics, the trip cards, journey and shared trip pills, packing templates, receipts, storage usage, the Studio export note. A heading that shared its key with a counted label keeps a key of its own (the boarding pass heading, the Bookings tab), so languages that put a noun after a number in the singular keep a plural heading. The plugin hosts chip passed its count through .replace() after t() and never got a form; three toasts sent the count as text. Mail and push texts for shared photos and suppressed storage failures pick their form in code through pluralForm. A whole number sent as text counts too, which is how in-app notification params arrive, and the plural rule follows the language of the strings on screen while another one still loads. The parity check knows which forms each language needs and refuses missing and impossible ones. It also reads keys that contain a colon now: it skipped them, which hid 58 missing plugin permission texts in Catalan and 35 in Vietnamese, both translated. The client parity test covers every language of the registry instead of a hand-kept list of 19, and the placeholder spec lets a form spell its number out only where its category is that one number. Brazilian Portuguese error screens were in Breton, and an Arabic dashboard label held Hebrew text.
This commit is contained in:
@@ -1178,7 +1178,7 @@ describe('AdminPluginsPanel — compatible updates only', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 update available for your plugins.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1254,7 +1254,7 @@ describe('AdminPluginsPanel — update hold', () => {
|
||||
}))
|
||||
withToast()
|
||||
|
||||
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument()
|
||||
expect(await screen.findByText('1 update available for your plugins.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /update all/i }))
|
||||
|
||||
await waitFor(() => expect(updated).toEqual(['trek-ntfy']))
|
||||
|
||||
@@ -1285,7 +1285,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
|
||||
className={`${CHIP} ${hostCount > 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}>
|
||||
<Globe size={12} />
|
||||
{hostCount > 0
|
||||
? t('admin.plugins.allowedHosts.count').replace('{n}', String(hostCount))
|
||||
? t('admin.plugins.allowedHosts.count', { n: hostCount })
|
||||
: t('admin.plugins.allowedHosts.add')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -243,7 +243,7 @@ export default function PackingTemplateManager() {
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="min-w-0 flex-1 truncate text-start font-semibold text-content" style={fs(13, 'body')}>{tmpl.name}</button>
|
||||
)}
|
||||
<span className="max-sm:hidden">
|
||||
<StatusPill>{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}</StatusPill>
|
||||
<StatusPill>{tmpl.category_count} {t('admin.packingTemplates.categories', { count: tmpl.category_count })} · {tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}</StatusPill>
|
||||
</span>
|
||||
<Tooltip label={t('common.rename')}>
|
||||
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
|
||||
@@ -434,7 +434,7 @@ describe('AdminStoragePanel', () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compute now' }));
|
||||
await screen.findByText(/Usage computed/);
|
||||
expect(within(categoryRow('files')).getByText(/1 objects · 2\.0 KB/)).toBeInTheDocument();
|
||||
expect(within(categoryRow('files')).getByText(/1 object · 2\.0 KB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-020: Sync now runs the backfill — running line with counts, then the done line (50ms test poll)', async () => {
|
||||
|
||||
@@ -506,7 +506,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
<div className={DETAILS}>
|
||||
{rowUsage && (
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{t('storage.usage.line', { count: rowUsage.objects, size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
@@ -763,7 +763,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
{state.usage?.categories[category] && (
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.usage.line', {
|
||||
objects: String(state.usage.categories[category]!.objects),
|
||||
count: state.usage.categories[category]!.objects,
|
||||
size: formatBytes(state.usage.categories[category]!.bytes),
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -1150,7 +1150,7 @@ describe('CostsPanel — filtering the ledger', () => {
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' }))
|
||||
|
||||
expect(screen.getByText('Sunday, June 15')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 expenses')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 expense')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
// The 16th carries the payment, so it is filtered out with the day too.
|
||||
|
||||
@@ -871,7 +871,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}}
|
||||
>
|
||||
<Receipt size={12} className="text-content-muted" />
|
||||
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
|
||||
<span>{t('costs.receipts', { count: e.receipts!.length })}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -171,8 +171,8 @@ describe('CopyToTripModal', () => {
|
||||
renderModal({ onCopy });
|
||||
|
||||
fireEvent.click(await screen.findByText('Rome 2026'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined));
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 1 places', 'success', undefined);
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicate', 'info', undefined));
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 1 place', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-010: a no-op copy says so instead of staying silent', async () => {
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('ImportFromTripModal', () => {
|
||||
expect([...body.source_place_ids].sort()).toEqual([11, 12]);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('2 places added')).toBeInTheDocument());
|
||||
expect(screen.getByText(/1 were already on the list/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 was already on the list and stayed as it was/)).toBeInTheDocument();
|
||||
expect(onImported).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('SaveTripPlacesToListModal', () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(await screen.findByText('Favorites'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicate', 'info', undefined));
|
||||
expect(addToast).toHaveBeenCalledWith('Saved 1 to Favorites', 'success', undefined);
|
||||
});
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ describe('DawarichAtlasDialog', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ }))
|
||||
|
||||
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['NL']))
|
||||
})
|
||||
@@ -351,7 +351,7 @@ describe('DawarichAtlasDialog', () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countr/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-015: wishes reached on one day travel in one request, another day in its own', async () => {
|
||||
@@ -430,7 +430,7 @@ describe('DawarichAtlasDialog', () => {
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
setForcedOffline(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now'))
|
||||
expect(api.acceptAtlasCountries).not.toHaveBeenCalled()
|
||||
@@ -463,7 +463,7 @@ describe('DawarichAtlasDialog', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
|
||||
expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toHaveAttribute('aria-checked', 'true')
|
||||
@@ -572,9 +572,9 @@ describe('DawarichAtlasDialog', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ }))
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 country added'))
|
||||
// Marked in place rather than re-read: the reading costs an upstream request
|
||||
// and the only thing that changed is what the reader just pressed. The row
|
||||
// leaving the list is what says the write landed.
|
||||
@@ -582,7 +582,7 @@ describe('DawarichAtlasDialog', () => {
|
||||
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.queryByRole('checkbox', { name: 'Netherlands' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countr/ })).toBeNull()
|
||||
expect(repo.atlasSuggestions).toHaveBeenCalledTimes(1)
|
||||
// The Atlas keeps its own map and its own counts; without this the country
|
||||
// it was just told about only appears there after a reload.
|
||||
@@ -626,11 +626,11 @@ describe('DawarichAtlasDialog', () => {
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'France' }))
|
||||
expect(screen.getByRole('checkbox', { name: 'France' })).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ }))
|
||||
|
||||
// Belgium was never sent, so nothing has to be undone in the Atlas after.
|
||||
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['FR']))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 country added'))
|
||||
// And it is still on offer, still unticked, for the trip it does belong to.
|
||||
expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
@@ -295,9 +295,7 @@ export default function DawarichAtlasDialog({
|
||||
{country.cities.length > 0 && (
|
||||
<>
|
||||
<DawarichBadge icon={Building2} size="sm">
|
||||
{country.cities.length === 1
|
||||
? t('dawarich.atlas.citiesOne')
|
||||
: t('dawarich.atlas.cities', { count: country.cities.length })}
|
||||
{t('dawarich.atlas.cities', { count: country.cities.length })}
|
||||
</DawarichBadge>
|
||||
<span className="min-w-0 truncate text-caption text-content-muted">
|
||||
{cityLine(country.cities)}
|
||||
|
||||
@@ -345,7 +345,7 @@ describe('GalleryView', () => {
|
||||
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1))
|
||||
expect(uploadGalleryPhotos).toHaveBeenCalledTimes(1)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 photos uploaded', 'success', undefined)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 photo uploaded', 'success', undefined)
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||
} else {
|
||||
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
}
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
@@ -120,7 +120,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
{/* Header — the provider buttons live in the page header next to Upload */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}>
|
||||
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos')}
|
||||
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos', { count: allPhotos.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -382,7 +382,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
<span className="h-8 w-8 flex-none rounded-[9px]" style={{ background: pickGradient(trip.trip_id) }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{trip.title}</div>
|
||||
<div className="text-content-faint" style={fs(11.5)}>{trip.place_count || 0} {t('journey.synced.places')}</div>
|
||||
<div className="text-content-faint" style={fs(11.5)}>{trip.place_count || 0} {t('journey.synced.places', { count: trip.place_count || 0 })}</div>
|
||||
</div>
|
||||
<RowAction label={t('journey.trips.unlinkTrip')} onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -915,11 +915,11 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
<div class="cover-stats">
|
||||
<div>
|
||||
<div class="cover-stat-num">${sorted.length}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('dashboard.days'))}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('dashboard.days', { count: sorted.length }))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="cover-stat-num">${places?.length || 0}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('dashboard.places'))}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('dashboard.places', { count: places?.length || 0 }))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="cover-stat-num">${totalAssigned}</div>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function ApplyTemplateButton({ tripId, visibility, style, classNa
|
||||
<div style={{ flex: 1, textAlign: 'start' }}>
|
||||
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
|
||||
{tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
{tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 5 }}>
|
||||
<span style={{ fontSize: sz.count, color: 'var(--text-faint)' }}>{bagItems.length} {t('admin.packingTemplates.items')}</span>
|
||||
<span style={{ fontSize: sz.count, color: 'var(--text-faint)' }}>{bagItems.length} {t('admin.packingTemplates.items', { count: bagItems.length })}</span>
|
||||
<span style={{ fontSize: sz.weight, color: 'var(--text-muted)', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
{editingLimit && canEdit ? (
|
||||
|
||||
@@ -59,7 +59,7 @@ export function BagModal(S: PackingState) {
|
||||
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items', { count: unassigned.length })}</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function BagSidebar(S: PackingState) {
|
||||
<div style={{ ...hairline(bags.length === 0), display: 'flex', alignItems: 'center', gap: 6, padding: '11px 0' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)' }}>{t('packing.noBag')}</span>
|
||||
<span title={`${unassigned.length} ${t('admin.packingTemplates.items')}`} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 6px', borderRadius: 99, minWidth: 18, textAlign: 'center', background: 'var(--bg-tertiary)', color: 'var(--text-faint)', fontVariantNumeric: 'tabular-nums' }}>{unassigned.length}</span>
|
||||
<span title={`${unassigned.length} ${t('admin.packingTemplates.items', { count: unassigned.length })}`} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 6px', borderRadius: 99, minWidth: 18, textAlign: 'center', background: 'var(--bg-tertiary)', color: 'var(--text-faint)', fontVariantNumeric: 'tabular-nums' }}>{unassigned.length}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{kgOrG(unassignedWeight)}</span>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function PackingHeader(S: PackingState) {
|
||||
<Package size={13} className="text-content-faint" />
|
||||
<div style={{ flex: 1, textAlign: 'start' }}>
|
||||
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div>
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -621,7 +621,7 @@ describe('usePackingList — templates, import and signals', () => {
|
||||
await act(async () => { await result.current.handleApplyTemplate(2) })
|
||||
|
||||
expect(useTripStore.getState().packingItems).toContainEqual(fresh)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 items added from template', 'success', undefined)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 item added from template', 'success', undefined)
|
||||
expect(result.current.showTemplateDropdown).toBe(false)
|
||||
expect(result.current.applyingTemplate).toBe(false)
|
||||
})
|
||||
@@ -700,7 +700,7 @@ describe('usePackingList — templates, import and signals', () => {
|
||||
await act(async () => { await result.current.handleBulkImport() })
|
||||
|
||||
expect(useTripStore.getState().packingItems).toContainEqual(imported)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 items imported', 'success', undefined)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 item imported', 'success', undefined)
|
||||
expect(result.current.importText).toBe('')
|
||||
expect(result.current.showImportModal).toBe(false)
|
||||
})
|
||||
|
||||
@@ -527,7 +527,7 @@ describe('DayPlanSidebar', () => {
|
||||
// title uses an arrow icon, so its parts are separate text nodes.
|
||||
expect(screen.getByText('U2')).toBeInTheDocument()
|
||||
// Transfer counts stay out of the compact row — the chips say it all.
|
||||
expect(screen.queryByText(/1 transfers/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/1 transfer/)).not.toBeInTheDocument()
|
||||
// Clicking the row opens the journey view — not the edit form.
|
||||
await user.click(screen.getByText('Fernsehturm'))
|
||||
expect(onEditTransport).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1119,7 +1119,7 @@ describe('PlaceInspector', () => {
|
||||
const { openFile } = await import('../../utils/fileDownload');
|
||||
const spy = vi.spyOn({ openFile }, 'openFile');
|
||||
render(<PlaceInspector {...defaultProps} files={[placeFile()] as any} />);
|
||||
fireEvent.click(screen.getByText('1 files'));
|
||||
fireEvent.click(screen.getByText('1 file'));
|
||||
const link = await screen.findByText('map.pdf');
|
||||
fireEvent.click(link);
|
||||
// The click is handled without throwing; the row stays in the list.
|
||||
|
||||
@@ -262,7 +262,7 @@ function CategoryFilter(S: SidebarState) {
|
||||
if (categoryFilters.has(UNCATEGORIZED)) label = t('places.noCategory')
|
||||
else if (only) label = only.name
|
||||
} else if (categoryFilters.size > 1) {
|
||||
label = `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||
label = `${categoryFilters.size} ${t('places.categoriesSelected', { count: categoryFilters.size })}`
|
||||
}
|
||||
return (
|
||||
<FilterDropdown name={t('categories.title')} label={label} active={categoryFilters.size > 0} badge={categoryFilters.size}
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('TransitSearchPanel', () => {
|
||||
// Local Berlin times, U2 badge, 1 transfer.
|
||||
expect(await screen.findByText(/08:30 – 09:00/)).toBeInTheDocument()
|
||||
expect(screen.getByText('U2')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 transfers')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 transfer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-003: adding a route builds a transport payload with local times + endpoints', async () => {
|
||||
|
||||
@@ -611,7 +611,7 @@ describe('usePlacesSidebar list import', () => {
|
||||
await act(async () => { await S.handleListImport(); });
|
||||
|
||||
expect(sentEnrich).toBe(true);
|
||||
expect(addToast).toHaveBeenCalledWith('1 places imported from "Seoul"', 'success', undefined);
|
||||
expect(addToast).toHaveBeenCalledWith('1 place imported from "Seoul"', 'success', undefined);
|
||||
expect(pushUndo).toHaveBeenCalledWith('Naver Maps import', expect.any(Function));
|
||||
});
|
||||
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('RoadtripCorridorPanel', () => {
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// The difference between "no fuel here" and "nobody looked here".
|
||||
expect(screen.getByText('2 stretches could not be searched — the place search did not answer.')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 stretches could not be searched. The place search did not answer.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Aral')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export function StudioExport({
|
||||
|
||||
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
|
||||
{t('journey.studio.exportNote', {
|
||||
sheets: sheets.length,
|
||||
count: sheets.length,
|
||||
width: round1(box.width),
|
||||
height: round1(box.height),
|
||||
})}
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('TripMembersModal', () => {
|
||||
)
|
||||
);
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
expect(await screen.findByText(/2 persons/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/2 people/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-012: shows "you" label next to current user', async () => {
|
||||
@@ -526,7 +526,7 @@ describe('TripMembersModal', () => {
|
||||
// The guest carries a "Guest" badge.
|
||||
expect(screen.getAllByText('Guest').length).toBeGreaterThan(0);
|
||||
// The head band counts the owner and the real member (2), not the guest.
|
||||
expect(screen.getByText('2 persons')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 people')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Avatars and load failures (028-031) ───────────────────────────────────
|
||||
@@ -553,7 +553,7 @@ describe('TripMembersModal', () => {
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to load members', 'error', undefined));
|
||||
expect(screen.getByText('0 persons')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 people')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-031: a failing share-link load still offers link creation', async () => {
|
||||
|
||||
Reference in New Issue
Block a user