Files
pansy/web/src/lib/units.test.ts
T
steveandClaude Fable 5 27f658c1f7
Build image / build-and-push (push) Successful in 2m55s
Gadfly review (reusable) / review (pull_request) Successful in 8m59s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m59s
Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers
- Garden and plant dialogs keep centimeters as the source of truth
  (LengthField in lib/units.ts): a no-change Save no longer rewrites
  900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and
  writing bogus history entries on the way.
- The UI stamps every date with the browser's local day (lib/dates.ts).
  Journal notes already did; plop placement, fill and removal now do too,
  so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an
  optional plantedAt; API and agent callers still default to UTC today.
- Removing an object that holds plants asks first and says how many go
  with it. An empty one still goes straight away (one Undo restores it).
- The expanded plant card's action row wraps instead of clipping "Delete".
- Monogram lettering switches to a dark ink on pale marker colors (garlic,
  cabbage, marigold) instead of near-white on near-white.
- Copy-as-plan proposes the next free year and warns when the typed name
  already exists, so two gardens can't both read as "the 2027 plan".
- Plan cards show the base name with a "2027 plan" tag, so the year — the
  point of the name — survives truncation.
- A rejected model spec now says which model and why: a wrapped
  ErrInvalidInput's reason reaches the client as the 400's message, and the
  Settings field shows it inline instead of toasting "invalid input".

Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved,
which only passed while the real date was before 2026-08-01.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:11:12 -04:00

277 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import {
cmFromFtIn,
convertDimensionField,
dimensionField,
editDimensionField,
editSpacingField,
spacingField,
cmFromMeters,
cmFromSpacing,
dimensionInputMode,
displayFromCm,
formatCm,
formatDimensionInput,
formatDimensions,
formatFtIn,
formatLength,
formatSize,
formatSpacing,
parseDimension,
spacingFromCm,
} from './units'
describe('cm conversions', () => {
// Every cm column is REAL, so entry is exact — no whole-cm quantization. A 1 ft
// grid is 30.48 cm, not 30, or the error accumulates across the garden (#47).
it('feet+inches → exact cm', () => {
expect(cmFromFtIn(1)).toBe(30.48)
expect(cmFromFtIn(4)).toBe(121.92)
expect(cmFromFtIn(8)).toBe(243.84)
expect(cmFromFtIn(4, 6)).toBe(137.16)
})
it('meters → exact cm', () => {
expect(cmFromMeters(10)).toBe(1000)
expect(cmFromMeters(1.22)).toBe(122)
expect(cmFromMeters(0.305)).toBe(30.5)
})
it('rounds away float dust, not precision', () => {
// 12 × 2.54 is 30.479999999999997 in IEEE-754; 1 ft must read as 30.48.
expect(cmFromFtIn(0, 12)).toBe(30.48)
expect(cmFromSpacing(12, 'imperial')).toBe(30.48)
expect(cmFromSpacing(1, 'imperial')).toBe(2.54)
})
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)
}
for (const m of [0.15, 1, 1.22, 3.05, 10, 24.5]) {
expect(displayFromCm(parseDimension(String(m), 'metric')!, 'metric')).toBe(m)
}
})
})
describe('displayFromCm', () => {
it('metric keeps cm precision', () => {
expect(displayFromCm(1000, 'metric')).toBe(10)
expect(displayFromCm(122, 'metric')).toBe(1.22)
})
it('imperial rounds to 0.1 ft so integers stay clean', () => {
expect(displayFromCm(122, 'imperial')).toBe(4) // not 4.0026
expect(displayFromCm(244, 'imperial')).toBe(8) // not 8.01
})
})
describe('formatCm', () => {
it('metric shows meters', () => {
expect(formatCm(122, 'metric')).toBe('1.22 m')
expect(formatCm(1000, 'metric')).toBe('10 m')
})
it('imperial shows feet and inches, carrying 12″ up', () => {
expect(formatCm(122, 'imperial')).toBe('4 0″')
expect(formatCm(244, 'imperial')).toBe('8 0″')
expect(formatCm(137, 'imperial')).toBe('4 6″')
})
it('formats width × height', () => {
expect(formatDimensions(122, 244, 'imperial')).toBe('4 0″ × 8 0″')
})
})
describe('plant spacing (small scale)', () => {
it('formats cm in metric, whole inches in imperial', () => {
expect(formatSpacing(15, 'metric')).toBe('15 cm')
expect(formatSpacing(15, 'imperial')).toBe('6″') // 15/2.54 = 5.9 → 6
expect(formatSpacing(30, 'imperial')).toBe('12″')
})
it('parses an entered value back to exact cm', () => {
expect(cmFromSpacing(15, 'metric')).toBe(15)
expect(cmFromSpacing(6, 'imperial')).toBe(15.24)
})
it('prefills an input from cm', () => {
expect(spacingFromCm(15, 'metric')).toBe(15)
expect(spacingFromCm(30, 'imperial')).toBe(11.8) // 30/2.54 = 11.81 → 11.8
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')
})
})
describe('formatFtIn', () => {
it('drops zero parts the way a gardener says it', () => {
expect(formatFtIn(91)).toBe('3')
expect(formatFtIn(183)).toBe('6')
expect(formatFtIn(40)).toBe('14″')
expect(formatFtIn(20)).toBe('8″')
expect(formatFtIn(200)).toBe('67″')
expect(formatFtIn(300)).toBe('910″')
})
it('rounds to the nearest inch and carries 12″ up', () => {
expect(formatFtIn(30.48)).toBe('1')
expect(formatFtIn(29.5)).toBe('1') // 11.6″ → 12″ → 1
expect(formatFtIn(0)).toBe('0″')
})
it('signs only a value that rounds to something', () => {
expect(formatFtIn(-91)).toBe('-3')
expect(formatFtIn(-0.1)).toBe('0″')
})
})
describe('formatSize / formatLength', () => {
it('reads feet compactly and meters as before', () => {
expect(formatSize(91, 183, 'imperial')).toBe('3 × 6')
expect(formatSize(91, 183, 'metric')).toBe('0.91 m × 1.83 m')
expect(formatLength(1219, 'imperial')).toBe('40')
})
})
describe('LengthField', () => {
it('keeps the stored centimeters through a unit switch and back', () => {
let f = dimensionField(900, 'imperial')
expect(f.text).toBe('29 6.3″')
f = convertDimensionField(f, 'metric')
expect(f.text).toBe('9')
f = convertDimensionField(f, 'imperial')
expect(f).toEqual({ text: '29 6.3″', cm: 900 })
})
it('moves the centimeters only when the text is edited', () => {
expect(editDimensionField("15' 6\"", 'imperial').cm).toBe(472.44)
const typo = editDimensionField('nope', 'imperial')
expect(typo.cm).toBeNull()
// A typo survives a unit switch as typed rather than turning into a number.
expect(convertDimensionField(typo, 'metric')).toEqual(typo)
})
it('spacing: 45 cm reads 17.7 in and stays 45 cm until typed over', () => {
const f = spacingField(45, 'imperial')
expect(f).toEqual({ text: '17.7', cm: 45 })
expect(editSpacingField('18', 'imperial').cm).toBe(45.72)
expect(editSpacingField('', 'imperial').cm).toBeNull()
expect(editSpacingField('25', 'metric').cm).toBe(25)
})
})