diff --git a/packages/editor/src/components/editor/floorplan-panel.test.ts b/packages/editor/src/components/editor/floorplan-panel.test.ts index 68b6a5b00f..9e05b54231 100644 --- a/packages/editor/src/components/editor/floorplan-panel.test.ts +++ b/packages/editor/src/components/editor/floorplan-panel.test.ts @@ -2,7 +2,11 @@ import { expect, test } from 'bun:test' import { GuideScaleReference } from '@pascal-app/core' import { z } from 'zod' import { createGuideScaleReference, formatReferenceScaleLabel } from '../../lib/reference-scale' -import { formatFloorplanNumber, parseReferenceScaleLength } from './floorplan-panel' +import { + formatFloorplanNumber, + getReferenceScaleInputError, + parseReferenceScaleLength, +} from './floorplan-panel' const Core106GuideScaleReference = z.object({ start: z.tuple([z.number(), z.number()]), @@ -104,6 +108,14 @@ test('oversized legacy reference labels bypass parsing within the input budget', test('reference-scale labels tolerate malformed persisted values', () => { expect(formatReferenceScaleLabel(runtimeScaleReference({ label: 42 }), 'en')).toBe('42') expect(formatReferenceScaleLabel(runtimeScaleReference({ label: null }), 'en')).toBe('') + expect( + formatReferenceScaleLabel(runtimeScaleReference({ label: { toString: null } }), 'en'), + ).toBe('') + expect( + formatReferenceScaleLabel(runtimeScaleReference({ label: Object.create(null) }), 'en'), + ).toBe('') + expect(formatReferenceScaleLabel(runtimeScaleReference({ label: Symbol('x') }), 'en')).toBe('') + expect(formatReferenceScaleLabel(runtimeScaleReference({ label: ['1 m'] }), 'en')).toBe('') expect( formatReferenceScaleLabel( runtimeScaleReference({ @@ -164,3 +176,45 @@ test('reference-scale schema caps persisted display lengths', () => { ).success, ).toBe(false) }) + +test('reference-scale calibration rejects lengths the schema would not accept', () => { + expect(getReferenceScaleInputError('1000000', 'meters', 'en')).toBeNull() + expect(getReferenceScaleInputError('2.75', 'feet', 'en')).toBeNull() + expect(getReferenceScaleInputError('1000001', 'meters', 'en')).toBe( + 'Length must be at most 1000000 m.', + ) + expect(getReferenceScaleInputError('1e9', 'centimeters', 'en')).toBe( + 'Length must be at most 1000000 cm.', + ) + expect(getReferenceScaleInputError('0', 'meters', 'en')).toBe('Length must be greater than 0.') + expect(getReferenceScaleInputError('', 'meters', 'en')).toBe('Enter the real length of the line.') +}) + +test('reference-scale creation drops display fields outside the schema range', () => { + const base = { + start: [0, 0] as [number, number], + end: [1, 0] as [number, number], + realLengthMeters: 2_000_000, + measuredLengthUnits: 1, + metersPerUnit: 2_000_000, + } + const oversized = createGuideScaleReference(base, 2_000_000, 'meters') + expect(oversized.displayLength).toBeUndefined() + expect(oversized.displayUnit).toBeUndefined() + expect(oversized.label).toBe('2000000 m') + expect(GuideScaleReference.safeParse(oversized).success).toBe(true) + + const atCap = createGuideScaleReference(base, 1_000_000, 'meters') + expect(atCap.displayLength).toBe(1_000_000) + expect(GuideScaleReference.safeParse(atCap).success).toBe(true) +}) + +test('legacy reference-scale labels accept whitespace runs between digit groups', () => { + const label = (value: string) => + formatReferenceScaleLabel(runtimeScaleReference({ label: value }), 'en') + expect(label('1 234,5 m')).toBe('1234.5 m') + expect(label('1 \u00a0234 cm')).toBe('1234 cm') + expect(label('12\u202f345\t\t678 ft')).toBe('12345678 ft') + expect(label('1 234 m')).toBe('1234 m') + expect(label('1 234 ')).toBe('1 234 ') +}) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 2734b764c7..25be8c8af1 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -126,6 +126,8 @@ import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measureme import { createGuideScaleReference, formatReferenceScaleLabel, + isValidReferenceScaleDisplayLength, + MAX_REFERENCE_SCALE_DISPLAY_LENGTH, type ReferenceScaleUnit, } from '../../lib/reference-scale' import { snapRegisteredDraftPoint } from '../../lib/registered-draft-snap' @@ -2487,6 +2489,23 @@ export function parseReferenceScaleLength( ) } +export function getReferenceScaleInputError( + raw: string, + unit: ReferenceScaleUnit, + locale: string, +): string | null { + if (raw.trim() === '') return 'Enter the real length of the line.' + const displayLength = parseReferenceScaleLength(raw, unit, locale) + if (displayLength === null || Number.isNaN(displayLength)) { + return `Enter a length like 3.5, 180cm or 5'11".` + } + if (!(displayLength > 0)) return 'Length must be greater than 0.' + if (!isValidReferenceScaleDisplayLength(displayLength)) { + return `Length must be at most ${formatFloorplanNumber(MAX_REFERENCE_SCALE_DISPLAY_LENGTH, locale)} ${REFERENCE_SCALE_LINGO_UNIT[unit]}.` + } + return null +} + function referenceScaleLengthHint( raw: string, unit: ReferenceScaleUnit, @@ -7168,7 +7187,7 @@ export function FloorplanPanel({ } const displayLength = parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit, locale) - if (!(displayLength && displayLength > 0)) { + if (displayLength === null || !isValidReferenceScaleDisplayLength(displayLength)) { return } @@ -11218,7 +11237,7 @@ export function FloorplanPanel({ const pendingReferenceDisplayLength = parseReferenceScaleLength(referenceScaleValue, referenceScaleUnit, locale) ?? Number.NaN const pendingReferenceRealLengthMeters = - pendingReferenceScale && pendingReferenceDisplayLength > 0 + pendingReferenceScale && isValidReferenceScaleDisplayLength(pendingReferenceDisplayLength) ? convertReferenceLengthToMeters(pendingReferenceDisplayLength, referenceScaleUnit) : null const pendingReferenceMetersPerUnit = @@ -11229,14 +11248,11 @@ export function FloorplanPanel({ pendingReferenceScale && pendingReferenceRealLengthMeters ? pendingReferenceRealLengthMeters / pendingReferenceScale.measuredLengthUnits : null - const referenceScaleInputError = - referenceScaleValue.trim() === '' - ? 'Enter the real length of the line.' - : Number.isNaN(pendingReferenceDisplayLength) - ? `Enter a length like 3.5, 180cm or 5'11".` - : pendingReferenceDisplayLength > 0 - ? null - : 'Length must be greater than 0.' + const referenceScaleInputError = getReferenceScaleInputError( + referenceScaleValue, + referenceScaleUnit, + locale, + ) const referenceScaleHint = referenceScaleInputError ? null : referenceScaleLengthHint(referenceScaleValue, referenceScaleUnit, locale) @@ -11395,7 +11411,7 @@ export function FloorplanPanel({
{pendingReferenceImageScaleFactor ? `Image will scale ${formatFloorplanNumber(pendingReferenceImageScaleFactor, locale, 3)}x from the first point.` - : 'Enter a length greater than 0.'} + : 'Enter a valid length to preview the scale.'}
diff --git a/packages/editor/src/lib/reference-scale.ts b/packages/editor/src/lib/reference-scale.ts index c27bec1ba6..4d1f266686 100644 --- a/packages/editor/src/lib/reference-scale.ts +++ b/packages/editor/src/lib/reference-scale.ts @@ -4,6 +4,13 @@ export type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches' const MAX_REFERENCE_SCALE_LABEL_LENGTH = 64 +/** Mirrors `GuideScaleReference.displayLength`'s `.max()` in core. */ +export const MAX_REFERENCE_SCALE_DISPLAY_LENGTH = 1_000_000 + +export function isValidReferenceScaleDisplayLength(value: number): boolean { + return Number.isFinite(value) && value > 0 && value <= MAX_REFERENCE_SCALE_DISPLAY_LENGTH +} + const REFERENCE_SCALE_UNIT_LABELS: Record = { meters: 'm', centimeters: 'cm', @@ -21,10 +28,12 @@ function getReferenceScaleUnitLabel(unit: unknown): string | null { } function parseLegacyScaleLabel(label: string): { unit: string; value: number } | null { - const match = label.trim().match(/^([+−-]?[\d.,]+(?:[\s\u00a0\u202f][\d.,]+)*)\s*(m|cm|ft|in)$/i) + // `\s` covers NBSP and narrow NBSP; the whitespace run and digit group are + // disjoint, so each repetition has exactly one way to match. + const match = label.trim().match(/^([+−-]?[\d.,]+(?:\s+[\d.,]+)*)\s*(m|cm|ft|in)$/i) if (!match) return null - const compact = match[1]!.replace(/[\s\u00a0\u202f]/g, '').replace('−', '-') + const compact = match[1]!.replace(/\s/g, '').replace('−', '-') const lastDot = compact.lastIndexOf('.') const lastComma = compact.lastIndexOf(',') const separatorIndex = Math.max(lastDot, lastComma) @@ -50,12 +59,9 @@ export function createGuideScaleReference( displayUnit: ReferenceScaleUnit, ): GuideScaleReference { const unitLabel = REFERENCE_SCALE_UNIT_LABELS[displayUnit] - return { - ...reference, - displayLength, - displayUnit, - label: formatScaleValue(displayLength, unitLabel, 'en'), - } + const label = formatScaleValue(displayLength, unitLabel, 'en') + if (!isValidReferenceScaleDisplayLength(displayLength)) return { ...reference, label } + return { ...reference, displayLength, displayUnit, label } } export function formatReferenceScaleLabel(reference: GuideScaleReference, locale: string): string { @@ -69,7 +75,7 @@ export function formatReferenceScaleLabel(reference: GuideScaleReference, locale } const label: unknown = reference.label - if (typeof label !== 'string') return label == null ? '' : String(label) + if (typeof label !== 'string') return typeof label === 'number' ? String(label) : '' if (label.length > MAX_REFERENCE_SCALE_LABEL_LENGTH) return label const parsed = parseLegacyScaleLabel(label)