Prepare IDE beta 6 with Turkish native dialogs and map errors

This commit is contained in:
2026-09-06 05:34:34 +03:00
parent 7724d65e92
commit 75afd01b4b
15 changed files with 265 additions and 57 deletions
@@ -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 !!!')
})
})
+3 -3
View File
@@ -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.
+9 -1
View File
@@ -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.'
}
}
+9 -1
View File
@@ -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ı.'
}
}
+18 -8
View File
@@ -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' })
}
}