Feet-and-inches entry: type 2' 7\" instead of 2.6 (#59) (#62)
Build image / build-and-push (push) Successful in 6s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #62.
This commit is contained in:
2026-07-21 05:08:57 +00:00
committed by steve
parent f208da94d6
commit 3ec77a1099
4 changed files with 301 additions and 73 deletions
+124 -8
View File
@@ -1,13 +1,15 @@
import { describe, expect, it } from 'vitest'
import {
cmFromDisplay,
cmFromFtIn,
cmFromMeters,
cmFromSpacing,
dimensionInputMode,
displayFromCm,
formatCm,
formatDimensionInput,
formatDimensions,
formatSpacing,
parseDimension,
spacingFromCm,
} from './units'
@@ -34,14 +36,12 @@ describe('cm conversions', () => {
expect(cmFromSpacing(1, 'imperial')).toBe(2.54)
})
it('round-trips display → cm → display at display precision', () => {
const imperial = [0.5, 1, 2.5, 4, 8, 12.5, 24]
for (const ft of imperial) {
expect(displayFromCm(cmFromDisplay(ft, 'imperial'), 'imperial')).toBe(ft)
it('round-trips typed → cm → displayed at display precision', () => {
for (const ft of [0.5, 1, 2.5, 4, 8, 12.5, 24]) {
expect(displayFromCm(parseDimension(String(ft), 'imperial')!, 'imperial')).toBe(ft)
}
const metric = [0.15, 1, 1.22, 3.05, 10, 24.5]
for (const m of metric) {
expect(displayFromCm(cmFromDisplay(m, 'metric'), 'metric')).toBe(m)
for (const m of [0.15, 1, 1.22, 3.05, 10, 24.5]) {
expect(displayFromCm(parseDimension(String(m), 'metric')!, 'metric')).toBe(m)
}
})
})
@@ -93,3 +93,119 @@ describe('plant spacing (small scale)', () => {
expect(spacingFromCm(30.48, 'imperial')).toBe(12) // an exactly-12in grid reads back as 12
})
})
describe('parseDimension (imperial compound entry)', () => {
const cm = (inches: number) => inches * 2.54
it('round-trips the whole-inch marks decimal feet could not represent', () => {
// The table from #59: at 0.1 ft granularity only 0", 6" and 12" were exact.
for (let inch = 0; inch <= 12; inch++) {
const input = `2' ${inch}"`
const parsed = parseDimension(input, 'imperial')
expect(parsed).toBeCloseTo(cm(24 + inch), 9)
expect(formatDimensionInput(parsed!, 'imperial')).toBe(`${inch === 12 ? 3 : 2} ${inch === 12 ? 0 : inch}`)
}
})
it('accepts both quote styles and the spelled-out units', () => {
const want = cm(31)
for (const input of [`2' 7"`, '2 7″', '2ft 7in', '2 ft 7 in', "2' 7", '31"', '31 inches', '2FT 7IN']) {
expect(parseDimension(input, 'imperial')).toBeCloseTo(want, 9)
}
})
it('still reads a bare number as feet', () => {
expect(parseDimension('2.5', 'imperial')).toBeCloseTo(cm(30), 9)
expect(parseDimension('24', 'imperial')).toBeCloseTo(cm(288), 9)
})
it('reads feet alone', () => {
expect(parseDimension("2'", 'imperial')).toBeCloseTo(cm(24), 9)
expect(parseDimension('2ft', 'imperial')).toBeCloseTo(cm(24), 9)
})
// The bug this issue would otherwise produce: attaching the sign to the feet
// term alone gives -2ft + 6in = -18", not -30".
it('applies the sign to the whole value, not just the feet', () => {
expect(parseDimension(`-2' 6"`, 'imperial')).toBeCloseTo(cm(-30), 9)
expect(parseDimension('2 6″', 'imperial')).toBeCloseTo(cm(-30), 9) // unicode minus
expect(parseDimension('-31"', 'imperial')).toBeCloseTo(cm(-31), 9)
expect(parseDimension('-2', 'imperial')).toBeCloseTo(cm(-24), 9)
})
it('rejects garbage rather than committing a zero', () => {
for (const input of ['', ' ', 'abc', `'`, `"`, `2' 7" 3`, '2 3', 'ft', '--2', '2..5']) {
expect(parseDimension(input, 'imperial')).toBeNull()
}
})
})
describe('parseDimension (metric)', () => {
it('takes decimal meters, with or without the unit', () => {
expect(parseDimension('1.22', 'metric')).toBe(122)
expect(parseDimension('1.22m', 'metric')).toBe(122)
expect(parseDimension('10 m', 'metric')).toBe(1000)
expect(parseDimension('-3', 'metric')).toBe(-300)
})
it('does not accept feet-and-inches syntax', () => {
expect(parseDimension(`2' 7"`, 'metric')).toBeNull()
expect(parseDimension('31"', 'metric')).toBeNull()
})
it('rejects garbage', () => {
expect(parseDimension('', 'metric')).toBeNull()
expect(parseDimension('abc', 'metric')).toBeNull()
})
})
describe('formatDimensionInput', () => {
it('renders imperial as feet and inches, carrying 12″ up', () => {
expect(formatDimensionInput(0, 'imperial')).toBe('0 0″')
expect(formatDimensionInput(30.48, 'imperial')).toBe('1 0″')
expect(formatDimensionInput(78.74, 'imperial')).toBe('2 7″')
expect(formatDimensionInput(-76.2, 'imperial')).toBe('-2 6″')
})
it('keeps one decimal inch, so a dragged position reads honestly', () => {
// A bed dragged to an arbitrary cm must not render as a whole inch — that's
// what would let a mere blur snap it.
expect(formatDimensionInput(80.90185676392574, 'imperial')).toBe('2 7.9″')
})
it('is the exact string parseDimension round-trips', () => {
for (const value of [0, 30.48, 78.74, 80.9, -76.2, 731.52]) {
const text = formatDimensionInput(value, 'imperial')
const back = parseDimension(text, 'imperial')
expect(formatDimensionInput(back!, 'imperial')).toBe(text)
}
})
it('leaves metric as decimal meters', () => {
expect(formatDimensionInput(122, 'metric')).toBe('1.22')
expect(formatDimensionInput(1000, 'metric')).toBe('10')
})
})
describe('negative values that round to zero', () => {
// A hair below zero is 0 0″. "-0 0″" would be both wrong and, since
// parseDimension gives -0, not something that round-trips.
it('does not sign a value that rounded to nothing', () => {
expect(formatDimensionInput(-0.1, 'imperial')).toBe('0 0″')
expect(formatDimensionInput(-0, 'imperial')).toBe('0 0″')
expect(formatCm(-0.1, 'imperial')).toBe('0 0″')
})
it('still signs a value that rounded to something', () => {
expect(formatDimensionInput(-1, 'imperial')).toBe('-0 0.4″')
expect(formatDimensionInput(-76.2, 'imperial')).toBe('-2 6″')
expect(formatCm(-76.2, 'imperial')).toBe('-2 6″')
})
})
describe('dimensionInputMode', () => {
it("gives imperial the full keyboard, since ' and \" are on no keypad", () => {
expect(dimensionInputMode('imperial')).toBe('text')
expect(dimensionInputMode('metric')).toBe('decimal')
})
})
+107 -13
View File
@@ -40,11 +40,6 @@ export function cmFromMeters(meters: number): number {
return roundCm(meters * CM_PER_METER)
}
/** A value typed in the given unit (meters, or feet) → centimeters. */
export function cmFromDisplay(value: number, unit: UnitPref): number {
return unit === 'imperial' ? cmFromFtIn(value) : cmFromMeters(value)
}
/** Centimeters → a number in the given unit, for prefilling an input field.
* Rounded so cm-quantization doesn't show through (e.g. 244cm shows as 8 ft,
* not 8.01): meters to the cm (2 dp), feet to 0.1 ft. */
@@ -56,17 +51,116 @@ export function displayFromCm(cm: number, unit: UnitPref): number {
return Math.round((cm / CM_PER_METER) * 100) / 100
}
// --- Dimension entry -------------------------------------------------------
// Imperial dimensions are typed the way people say them — 2' 7", not 2.6 — while
// a bare number still means feet so nothing anyone typed before this existed
// breaks. Metric entry is decimal meters, unchanged.
// Feet, then optionally inches. The inch mark is optional once feet are explicit,
// because "2' 7" is a natural thing to type and unambiguous.
const FT_IN_RE = /^(\d*\.?\d+)\s*(?:'|ft|feet|foot)(?:\s*(\d*\.?\d+)\s*(?:"|in|inch|inches)?)?$/
// Inches alone, where the mark is required — a bare number means feet.
const IN_ONLY_RE = /^(\d*\.?\d+)\s*(?:"|in|inch|inches)$/
// A bare decimal, which means feet (imperial) or meters (metric).
const BARE_RE = /^(\d*\.?\d+)$/
const METERS_RE = /^(\d*\.?\d+)\s*m?$/
/** Normalize the characters people actually paste: typographic quotes and the
* various unicode dashes, so text copied from anywhere parses like text typed
* here. Lowercased so "FT"/"In" work too. */
function normalizeEntry(s: string): string {
return s
.trim()
.replace(/[′’‵]/g, "'")
.replace(/[″”‶]/g, '"')
.replace(/[−–—]/g, '-')
.toLowerCase()
}
/** Split a leading sign off a normalized entry. The sign belongs to the WHOLE
* value, not just its first term: -2' 6" is (2ft + 6in) = 30in, not 2ft + 6in. */
function splitSign(s: string): { sign: number; rest: string } {
if (s.startsWith('-')) return { sign: -1, rest: s.slice(1).trim() }
if (s.startsWith('+')) return { sign: 1, rest: s.slice(1).trim() }
return { sign: 1, rest: s }
}
/**
* Parse a typed dimension into centimeters, or null if it isn't a dimension.
*
* Imperial accepts `2' 7"`, `2 7″`, `2ft 7in`, `2' 7`, `31"`, `2'`, and a bare
* `2.5` still meaning 2½ feet. Metric accepts decimal meters with an optional
* `m`. Null means "don't commit anything" — never a silent zero, which would
* turn a typo into a destructive edit.
*/
export function parseDimension(input: string, unit: UnitPref): number | null {
const { sign, rest } = splitSign(normalizeEntry(input))
if (rest === '') return null
if (unit !== 'imperial') {
const m = rest.match(METERS_RE)
return m ? sign * cmFromMeters(parseFloat(m[1])) : null
}
const bare = rest.match(BARE_RE)
if (bare) return sign * cmFromFtIn(parseFloat(bare[1]))
const ftIn = rest.match(FT_IN_RE)
if (ftIn) return sign * cmFromFtIn(parseFloat(ftIn[1]), ftIn[2] ? parseFloat(ftIn[2]) : 0)
const inOnly = rest.match(IN_ONLY_RE)
if (inOnly) return sign * cmFromFtIn(0, parseFloat(inOnly[1]))
return null
}
/**
* Centimeters → the string a dimension input shows, and the exact string
* parseDimension round-trips. Imperial reads `2 7″`; inches carry one decimal
* (`7 10.5″`) because 0.1″ is ~0.25 cm, so what's displayed stays honest about
* what's stored rather than rounding a dragged position to the nearest inch.
*/
export function formatDimensionInput(cm: number, unit: UnitPref): string {
if (unit !== 'imperial') return String(displayFromCm(cm, unit))
const { feet, inches } = feetAndInches(cm, 1)
// Only sign a value that actually rounded to something: a hair below zero is
// 0 0″, and "-0 0″" would be both wrong and un-round-trippable.
const sign = cm < 0 && (feet || inches) ? '-' : ''
return `${sign}${feet} ${inches}`
}
/**
* The inputMode a dimension field should ask for. Imperial entry needs ' and ",
* which no numeric keypad offers, so those fields get the full keyboard; metric
* is pure decimals and keeps the keypad. A bare number still means feet, so the
* keypad path stays usable either way.
*/
export function dimensionInputMode(unit: UnitPref): 'text' | 'decimal' {
return unit === 'imperial' ? 'text' : 'decimal'
}
/**
* Decompose centimeters into feet and inches, rounding inches to `decimals` and
* carrying 12″ up to the next foot. Magnitude only — the sign is the caller's to
* apply, and only once it knows the rounded result isn't zero.
*/
function feetAndInches(cm: number, decimals: number): { feet: number; inches: number } {
const scale = 10 ** decimals
const totalInches = Math.abs(cm) / CM_PER_INCH
let feet = Math.floor(totalInches / INCHES_PER_FOOT)
let inches = Math.round((totalInches - feet * INCHES_PER_FOOT) * scale) / scale
if (inches >= INCHES_PER_FOOT) {
feet += 1
inches = 0
}
return { feet, inches }
}
/** Centimeters → a human string in the given unit (e.g. "1.22 m" or "4 0″"). */
export function formatCm(cm: number, unit: UnitPref): string {
if (unit === 'imperial') {
const totalInches = cm / CM_PER_INCH
let feet = Math.floor(totalInches / INCHES_PER_FOOT)
let inches = Math.round(totalInches - feet * INCHES_PER_FOOT)
if (inches === INCHES_PER_FOOT) {
feet += 1
inches = 0
}
return `${feet} ${inches}`
const { feet, inches } = feetAndInches(cm, 0)
return `${cm < 0 && (feet || inches) ? '-' : ''}${feet} ${inches}`
}
const meters = Math.round((cm / CM_PER_METER) * 100) / 100
return `${meters} m`