Seed shelf UI: source links, lots, and what's left (#51) (#68)
Build image / build-and-push (push) Successful in 11s
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #68.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
attributableLots,
|
||||
formatCost,
|
||||
formatQuantity,
|
||||
formatUnitCost,
|
||||
lotState,
|
||||
lotsByPlant,
|
||||
safeExternalUrl,
|
||||
summarizeLots,
|
||||
type SeedLot,
|
||||
} from './seedLots'
|
||||
|
||||
function lot(over: Partial<SeedLot> = {}): SeedLot {
|
||||
return {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
plantId: 1,
|
||||
vendor: '',
|
||||
sourceUrl: '',
|
||||
sku: '',
|
||||
lotCode: '',
|
||||
quantity: 100,
|
||||
unit: 'seeds',
|
||||
notes: '',
|
||||
version: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
used: 0,
|
||||
remaining: 100,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('safeExternalUrl', () => {
|
||||
// The server already scheme-checks this, but a link is rendered from whatever
|
||||
// the client was handed. "The backend validated it" is not a reason to hand
|
||||
// javascript: to an anchor tag.
|
||||
it('accepts http and https', () => {
|
||||
expect(safeExternalUrl('https://www.johnnyseeds.com/x')).toBe('https://www.johnnyseeds.com/x')
|
||||
expect(safeExternalUrl('http://example.com/')).toBe('http://example.com/')
|
||||
})
|
||||
|
||||
it('refuses everything else', () => {
|
||||
for (const bad of [
|
||||
'',
|
||||
'javascript:alert(1)',
|
||||
'JavaScript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'file:///etc/passwd',
|
||||
'//evil.example.com',
|
||||
'/seeds/garlic',
|
||||
'not a url',
|
||||
]) {
|
||||
expect(safeExternalUrl(bad)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('lotState', () => {
|
||||
it('reads the state, not just the number', () => {
|
||||
expect(lotState(lot({ quantity: 100, remaining: 100 }))).toBe('ok')
|
||||
expect(lotState(lot({ quantity: 100, remaining: 20 }))).toBe('low')
|
||||
expect(lotState(lot({ quantity: 100, remaining: 0 }))).toBe('empty')
|
||||
// Planting more than you recorded buying is real, and worth showing plainly
|
||||
// rather than clamping to zero.
|
||||
expect(lotState(lot({ quantity: 100, remaining: -5 }))).toBe('over')
|
||||
})
|
||||
|
||||
it('has nothing to say about a lot with no recorded quantity', () => {
|
||||
expect(lotState(lot({ quantity: 0, remaining: 0 }))).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeLots', () => {
|
||||
it('totals remaining and reports the worst state', () => {
|
||||
const s = summarizeLots([
|
||||
lot({ id: 1, quantity: 100, remaining: 80 }),
|
||||
lot({ id: 2, quantity: 50, remaining: 5 }),
|
||||
])
|
||||
expect(s.remaining).toBe(85)
|
||||
expect(s.unit).toBe('seeds')
|
||||
expect(s.state).toBe('low') // the one that needs attention wins
|
||||
})
|
||||
|
||||
it("drops the unit when lots don't agree, rather than summing dishonestly", () => {
|
||||
const s = summarizeLots([
|
||||
lot({ id: 1, unit: 'seeds', quantity: 100, remaining: 100 }),
|
||||
lot({ id: 2, unit: 'grams', quantity: 10, remaining: 10 }),
|
||||
])
|
||||
expect(s.unit).toBeNull()
|
||||
})
|
||||
|
||||
it('says nothing for a plant with no lots', () => {
|
||||
expect(summarizeLots([])).toEqual({ remaining: 0, unit: null, state: 'unknown' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cost formatting', () => {
|
||||
it('renders whole currency from cents', () => {
|
||||
expect(formatCost(499)).toBe('$4.99')
|
||||
expect(formatCost(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('gives per-unit cost extra precision when it would round to nothing', () => {
|
||||
expect(formatUnitCost(lot({ costCents: 499, quantity: 100 }))).toBe('$0.050/seed')
|
||||
expect(formatUnitCost(lot({ costCents: 1200, quantity: 4, unit: 'packets' }))).toBe('$3.00/packet')
|
||||
})
|
||||
|
||||
it('has no per-unit cost without both halves', () => {
|
||||
expect(formatUnitCost(lot({ costCents: undefined }))).toBeNull()
|
||||
expect(formatUnitCost(lot({ costCents: 499, quantity: 0 }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lotsByPlant', () => {
|
||||
it('groups, and copes with nothing', () => {
|
||||
const grouped = lotsByPlant([lot({ id: 1, plantId: 7 }), lot({ id: 2, plantId: 7 }), lot({ id: 3, plantId: 9 })])
|
||||
expect(grouped.get(7)?.length).toBe(2)
|
||||
expect(grouped.get(9)?.length).toBe(1)
|
||||
expect(lotsByPlant(undefined).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('attributableLots', () => {
|
||||
// An exhausted lot is still offered: the count records what was written down,
|
||||
// not a fact about the packet, so refusing to attribute a planting because the
|
||||
// number says zero would make a wrong number harder to correct, not easier.
|
||||
it('offers exhausted lots, just not first', () => {
|
||||
const empty = lot({ id: 1, remaining: 0 })
|
||||
const full = lot({ id: 2, remaining: 100 })
|
||||
expect(attributableLots([empty, full]).map((l) => l.id)).toEqual([2, 1])
|
||||
expect(attributableLots([empty]).length).toBe(1)
|
||||
})
|
||||
|
||||
it("doesn't mutate its input", () => {
|
||||
const lots = [lot({ id: 1, remaining: 0 }), lot({ id: 2, remaining: 100 })]
|
||||
attributableLots(lots)
|
||||
expect(lots.map((l) => l.id)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatQuantity', () => {
|
||||
it('keeps whole counts whole and fractional ones readable', () => {
|
||||
expect(formatQuantity(100)).toBe('100')
|
||||
expect(formatQuantity(12.5)).toBe('12.5')
|
||||
expect(formatQuantity(-15)).toBe('-15') // over-planted lots go negative
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user