Prepare IDE beta 6 with Turkish native dialogs and map errors
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { EN_SURFACES } from '@/i18n/en-surfaces'
|
||||
import { TR_SURFACES } from '@/i18n/tr-surfaces'
|
||||
import type { StarmapGraph } from '@/types/hermes'
|
||||
|
||||
import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code'
|
||||
import { decodeShareCode, encodeShareCode, ShareCodeError, shareCodeErrorText } from './share-code'
|
||||
|
||||
function sampleGraph(): StarmapGraph {
|
||||
return {
|
||||
@@ -154,6 +156,36 @@ describe('share-code', () => {
|
||||
expect(() => decodeShareCode('')).toThrow(ShareCodeError)
|
||||
})
|
||||
|
||||
it('formats real decoding failures in the selected language without changing valid codes', () => {
|
||||
const graph = sampleGraph()
|
||||
const valid = encodeShareCode(graph)
|
||||
|
||||
const capture = (code: string): unknown => {
|
||||
try { decodeShareCode(code) } catch (error) { return error }
|
||||
throw new Error('Expected a decoding failure')
|
||||
}
|
||||
|
||||
const invalid = capture('not a real code !!!')
|
||||
expect(shareCodeErrorText(invalid, TR_SURFACES.mapErrors)).toBe('Bu metin bir harita koduna benzemiyor.')
|
||||
expect(shareCodeErrorText(invalid, EN_SURFACES.mapErrors)).toBe("That doesn't look like a map code.")
|
||||
|
||||
const raw = atob(valid.slice(3).replace(/-/g, '+').replace(/_/g, '/'))
|
||||
const expected = raw.charCodeAt(0)
|
||||
const actual = (expected + 1) % 256
|
||||
|
||||
const otherVersion = 'HML' + btoa(String.fromCharCode(actual) + raw.slice(1))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
|
||||
expect(shareCodeErrorText(capture(otherVersion), TR_SURFACES.mapErrors))
|
||||
.toBe(TR_SURFACES.mapErrors.version(actual, expected))
|
||||
const middle = Math.floor(valid.length / 2)
|
||||
const corrupted = valid.slice(0, middle) + (valid[middle] === 'A' ? 'B' : 'A') + valid.slice(middle + 1)
|
||||
expect(shareCodeErrorText(capture(corrupted), TR_SURFACES.mapErrors)).toBe(TR_SURFACES.mapErrors.corrupted)
|
||||
expect(shareCodeErrorText(new Error('internal details'), TR_SURFACES.mapErrors)).toBe('Harita kodu okunamadı.')
|
||||
expect(encodeShareCode(graph)).toBe(valid)
|
||||
expect(decodeShareCode(valid).nodes).toHaveLength(graph.nodes.length)
|
||||
})
|
||||
|
||||
it('rejects a corrupted (bit-flipped) code', () => {
|
||||
const code = encodeShareCode(sampleGraph())
|
||||
// Flip a mid-payload char (trailing base64 bits can be dropped on decode).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EN_SURFACES } from '@/i18n/en-surfaces'
|
||||
import { type BitReader, type BitWriter, createLoadout, Dict, idxOf, indexBits, LoadoutError } from '@/lib/loadout'
|
||||
import type { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes'
|
||||
|
||||
@@ -168,6 +169,13 @@ function readGraph(r: BitReader): StarmapGraph {
|
||||
|
||||
export class ShareCodeError extends LoadoutError {}
|
||||
|
||||
export function shareCodeErrorText(error: unknown, messages: typeof EN_SURFACES.mapErrors): string {
|
||||
if (!(error instanceof ShareCodeError) || !error.info) {return messages.unknown}
|
||||
const info = error.info
|
||||
|
||||
return info.kind === 'version' ? messages.version(info.actual, info.expected) : messages[info.kind]
|
||||
}
|
||||
|
||||
const codec = createLoadout<StarmapGraph>({
|
||||
error: ShareCodeError,
|
||||
noun: 'map code',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
import { TR_SURFACES } from '@/i18n/tr-surfaces'
|
||||
|
||||
import { decodeShareCode, shareCodeErrorText } from './share-code'
|
||||
import { ShareControls } from './share-controls'
|
||||
|
||||
describe('ShareControls', () => {
|
||||
@@ -8,17 +12,35 @@ describe('ShareControls', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('opens its dialog when the trigger has a tooltip', async () => {
|
||||
render(<ShareControls shareCode="map-code" />)
|
||||
it.each([
|
||||
['en', 'Import / export map'],
|
||||
['tr', 'Haritayı içe / dışa aktar']
|
||||
])('opens its dialog with a tooltip in %s', async (locale, title) => {
|
||||
render(<I18nProvider configClient={null} initialLocale={locale}><ShareControls shareCode="map-code" /></I18nProvider>)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Import / export map' })
|
||||
const trigger = screen.getByRole('button', { name: title })
|
||||
|
||||
fireEvent.pointerMove(trigger, { pointerType: 'mouse' })
|
||||
expect((await screen.findByRole('tooltip')).textContent).toContain('Import / export map')
|
||||
expect((await screen.findByRole('tooltip')).textContent).toContain(title)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByRole('heading', { name: 'Import / export map' })).toBeTruthy()
|
||||
expect(within(dialog).getByRole('heading', { name: title })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps an invalid pasted code editable and displays the Turkish decoder error', async () => {
|
||||
render(<I18nProvider configClient={null} initialLocale="tr"><ShareControls onImport={code => {
|
||||
try { decodeShareCode(code);
|
||||
|
||||
return null } catch (error) { return shareCodeErrorText(error, TR_SURFACES.mapErrors) }
|
||||
}} /></I18nProvider>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Haritayı içe / dışa aktar' }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
const input = within(dialog).getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'not a valid code !!!' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle', exact: true }))
|
||||
expect(within(dialog).getByText('Bu metin bir harita koduna benzemiyor.')).toBeTruthy()
|
||||
expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants'
|
||||
import { clamp, distToSegmentSq, fitScale, fitViewport, nodeRadius } from './geometry'
|
||||
import { NodeContextMenu, type NodeMenuTarget } from './node-context-menu'
|
||||
import { drawScene, drawScramble } from './render'
|
||||
import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code'
|
||||
import { decodeShareCode, encodeShareCode, shareCodeErrorText } from './share-code'
|
||||
import { ShareControls } from './share-controls'
|
||||
import { buildSimulation } from './simulation'
|
||||
import { formatDate } from './text'
|
||||
@@ -198,10 +198,10 @@ export function StarMap({
|
||||
|
||||
return null
|
||||
} catch (err) {
|
||||
return err instanceof ShareCodeError ? err.message : 'Could not read that map code.'
|
||||
return shareCodeErrorText(err, t.surfaces.mapErrors)
|
||||
}
|
||||
},
|
||||
[onImport]
|
||||
[onImport, t.surfaces.mapErrors]
|
||||
)
|
||||
|
||||
// Mark the canvas dirty and wake the (otherwise-idle) render loop.
|
||||
|
||||
@@ -97,5 +97,13 @@ export const EN_SURFACES = {
|
||||
permanentMemory: 'This memory is removed permanently.',
|
||||
timeline: 'Timeline scrubber',
|
||||
pause: 'Pause',
|
||||
playTimeline: 'Play timeline'
|
||||
playTimeline: 'Play timeline',
|
||||
mapErrors: {
|
||||
invalid: "That doesn't look like a map code.",
|
||||
short: 'Map code is too short to be valid.',
|
||||
corrupted: 'Map code looks corrupted (checksum mismatch).',
|
||||
malformed: 'Map code is malformed.',
|
||||
version: (actual: number, expected: number) => `Map code is version ${actual}; this build reads version ${expected}.`,
|
||||
unknown: 'Could not read that map code.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +76,13 @@ export const TR_SURFACES: typeof EN_SURFACES = {
|
||||
permanentMemory: 'Bu bellek kalıcı olarak silinir.',
|
||||
timeline: 'Zaman çizelgesinde gezin',
|
||||
pause: 'Duraklat',
|
||||
playTimeline: 'Zaman çizelgesini oynat'
|
||||
playTimeline: 'Zaman çizelgesini oynat',
|
||||
mapErrors: {
|
||||
invalid: 'Bu metin bir harita koduna benzemiyor.',
|
||||
short: 'Harita kodu geçerli olamayacak kadar kısa.',
|
||||
corrupted: 'Harita kodu bozulmuş görünüyor; sağlama toplamı eşleşmiyor.',
|
||||
malformed: 'Harita kodunun biçimi geçersiz.',
|
||||
version: (actual: number, expected: number) => `Harita kodunun sürümü ${actual}; bu uygulama ${expected} sürümünü okuyabiliyor.`,
|
||||
unknown: 'Harita kodu okunamadı.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,15 @@ function checksum16(buf: Uint8Array): number {
|
||||
return (h >>> 0) & 0xffff
|
||||
}
|
||||
|
||||
export class LoadoutError extends Error {}
|
||||
export type LoadoutErrorInfo =
|
||||
| { kind: 'invalid' | 'short' | 'corrupted' | 'malformed' }
|
||||
| { kind: 'version'; actual: number; expected: number }
|
||||
|
||||
export class LoadoutError extends Error {
|
||||
constructor(message: string, readonly info?: LoadoutErrorInfo) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export interface Loadout<T> {
|
||||
decode(code: string): T
|
||||
@@ -202,7 +210,7 @@ export interface LoadoutSpec<T> {
|
||||
/** Noun for user-facing error messages, e.g. 'map code'. Default: 'code'. */
|
||||
noun?: string
|
||||
/** Error subclass to throw, so callers can `instanceof` their own type. */
|
||||
error?: new (message: string) => LoadoutError
|
||||
error?: new (message: string, info?: LoadoutErrorInfo) => LoadoutError
|
||||
}
|
||||
|
||||
const HEAD_BYTES = 3 // 8-bit version + 16-bit checksum
|
||||
@@ -239,7 +247,7 @@ export function createLoadout<T>(spec: LoadoutSpec<T>): Loadout<T> {
|
||||
const raw = cleaned.startsWith(spec.prefix) ? cleaned.slice(spec.prefix.length) : cleaned
|
||||
|
||||
if (!raw) {
|
||||
throw new Err(`That doesn't look like a ${noun}.`)
|
||||
throw new Err(`That doesn't look like a ${noun}.`, { kind: 'invalid' })
|
||||
}
|
||||
|
||||
let framed: Uint8Array
|
||||
@@ -247,11 +255,11 @@ export function createLoadout<T>(spec: LoadoutSpec<T>): Loadout<T> {
|
||||
try {
|
||||
framed = fromBase64Url(raw)
|
||||
} catch {
|
||||
throw new Err(`That doesn't look like a ${noun}.`)
|
||||
throw new Err(`That doesn't look like a ${noun}.`, { kind: 'invalid' })
|
||||
}
|
||||
|
||||
if (framed.length <= HEAD_BYTES) {
|
||||
throw new Err(`${Noun} is too short to be valid.`)
|
||||
throw new Err(`${Noun} is too short to be valid.`, { kind: 'short' })
|
||||
}
|
||||
|
||||
const head = new BitReader(framed.subarray(0, HEAD_BYTES))
|
||||
@@ -259,19 +267,21 @@ export function createLoadout<T>(spec: LoadoutSpec<T>): Loadout<T> {
|
||||
const storedSum = head.uint(16)
|
||||
|
||||
if (version !== spec.version) {
|
||||
throw new Err(`${Noun} is version ${version}; this build reads version ${spec.version}.`)
|
||||
throw new Err(`${Noun} is version ${version}; this build reads version ${spec.version}.`, {
|
||||
kind: 'version', actual: version, expected: spec.version
|
||||
})
|
||||
}
|
||||
|
||||
const payload = framed.subarray(HEAD_BYTES)
|
||||
|
||||
if (checksum16(payload) !== storedSum) {
|
||||
throw new Err(`${Noun} looks corrupted (checksum mismatch).`)
|
||||
throw new Err(`${Noun} looks corrupted (checksum mismatch).`, { kind: 'corrupted' })
|
||||
}
|
||||
|
||||
try {
|
||||
return spec.read(new BitReader(inflateSync(payload)))
|
||||
} catch (err) {
|
||||
throw new Err(err instanceof Error ? `${Noun} is malformed: ${err.message}` : `${Noun} is malformed.`)
|
||||
throw new Err(err instanceof Error ? `${Noun} is malformed: ${err.message}` : `${Noun} is malformed.`, { kind: 'malformed' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user