Files
pansy/web/src/lib/seedPacket.test.ts
T
steveandClaude Opus 4.8 e0d78b891b
Build image / build-and-push (push) Successful in 23s
Gadfly review (reusable) / review (pull_request) Successful in 10m32s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m33s
Seed-packet scan UI: camera/upload → proposal → confirm (#102)
The seed-packet capture backend has been live since #94 (POST /seed-lots/scan
and /seed-lots/from-packet, with /capabilities reporting `vision`), but there was
no UI, so the feature Steve asked for — "take a picture of a packet and it
auto-creates" — was unreachable. This adds the mobile-first UI.

- Entry point in the Plants catalog ("Scan a packet"), shown only where
  `capabilities.vision` is on — so it's never a dead button on an instance with
  no vision model. Adds `vision` to the capabilities schema (both flags now
  default false, so a partial response hides a feature rather than failing parse).
- ScanPacketModal: two phases in one dialog. Capture uses a file input with
  `accept="image/*" capture="environment"` so a phone opens the camera directly
  (also picks an existing photo; HEIC is handled server-side). Review shows the
  extracted fields read-only for context, then an editable confirm — pick a ranked
  existing candidate (with its match reason) or create a new variety prefilled from
  the packet. Nothing commits blind, which is the whole point of the backend's
  never-auto-create design.
- New data layer (lib/seedPacket.ts): zod schemas mirroring the backend
  (SeedPacket / PacketProposal / PacketResult), the scan + confirm mutations, and
  the pure prefill helpers (newPlantDefaults / lotDefaults) — unit-tested, since
  the prefill mapping (unknown category, missing spacing, seed-count→unit) is
  where the bugs hide.
- api.ts learns to send FormData as multipart (no JSON content-type) for the
  scan upload, via a new api.postForm.

Frontend only; no backend or route change. Last open child of epic #96.

Live camera→vision→proposal verification needs a vision-configured instance
(the flow is model-gated and unreachable locally, same as the assistant);
the confirm half and all prefill logic are covered by tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 12:52:21 -04:00

121 lines
3.6 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
lotDefaults,
newPlantDefaults,
packetProposalSchema,
seedPacketSchema,
PACKET_PLANT_COLOR,
PACKET_PLANT_ICON,
} from './seedPacket'
// A full proposal as the backend sends it, for the schema + prefill tests.
const proposal = {
packet: {
species: 'garlic',
variety: 'Music',
category: 'vegetable',
vendor: "Johnny's",
sku: 'G-123',
lotCode: 'L9',
packedForYear: 2026,
daysToMaturity: 90,
spacingCm: 15,
seedCount: 12,
},
candidates: [
{
plant: {
id: 7,
name: 'Music',
category: 'vegetable',
spacingCm: 15,
color: '#fff',
icon: '🧄',
notes: '',
version: 1,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
reason: 'exact name',
},
],
suggestedName: 'Music',
suggestedCategory: 'vegetable',
}
describe('seedPacketSchema', () => {
it('fills defaults for a sparse packet (only what was printed)', () => {
// A packet where the model only read a species — everything else absent.
const p = seedPacketSchema.parse({ species: 'basil' })
expect(p.species).toBe('basil')
expect(p.variety).toBe('')
expect(p.spacingCm).toBeNull()
expect(p.seedCount).toBeNull()
expect(p.packedForYear).toBeNull()
})
})
describe('packetProposalSchema', () => {
it('parses a full proposal including candidates', () => {
const p = packetProposalSchema.parse(proposal)
expect(p.candidates).toHaveLength(1)
expect(p.candidates[0].plant.id).toBe(7)
expect(p.candidates[0].reason).toBe('exact name')
expect(p.suggestedName).toBe('Music')
})
it('defaults candidates to empty when absent', () => {
const p = packetProposalSchema.parse({ packet: { species: 'kale' } })
expect(p.candidates).toEqual([])
expect(p.suggestedName).toBe('')
})
})
describe('newPlantDefaults', () => {
it('prefills name/category/spacing/days/vendor from the proposal', () => {
const np = newPlantDefaults(packetProposalSchema.parse(proposal))
expect(np.name).toBe('Music')
expect(np.category).toBe('vegetable')
expect(np.spacingCm).toBe(15)
expect(np.daysToMaturity).toBe(90)
expect(np.vendor).toBe("Johnny's")
// No icon/color on a packet — placeholders the user can change later.
expect(np.icon).toBe(PACKET_PLANT_ICON)
expect(np.color).toBe(PACKET_PLANT_COLOR)
})
it('falls back to a safe category and default spacing when the packet lacks them', () => {
const np = newPlantDefaults(
packetProposalSchema.parse({
packet: { species: 'mystery' },
suggestedName: 'mystery',
suggestedCategory: 'not-a-real-category',
}),
)
// An unknown category must not leak through — CreatePlant would reject it.
expect(np.category).toBe('vegetable')
// No printed spacing → the same default the manual form uses.
expect(np.spacingCm).toBe(30)
expect(np.daysToMaturity).toBeNull()
})
})
describe('lotDefaults', () => {
it('reads a seed count as "<n> seeds"', () => {
const lot = lotDefaults(seedPacketSchema.parse(proposal.packet))
expect(lot.quantity).toBe(12)
expect(lot.unit).toBe('seeds')
expect(lot.vendor).toBe("Johnny's")
expect(lot.sku).toBe('G-123')
expect(lot.lotCode).toBe('L9')
expect(lot.packedForYear).toBe(2026)
})
it('defaults to one packet when no seed count is printed', () => {
const lot = lotDefaults(seedPacketSchema.parse({ species: 'basil' }))
expect(lot.quantity).toBe(1)
expect(lot.unit).toBe('packets')
expect(lot.packedForYear).toBeNull()
})
})