diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..c6de29ad --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.vinxi/ +.output/ +src/wasm-pkg/ +src/routeTree.gen.ts +bun.lock +*.timestamp_*.js diff --git a/app/package.json b/app/package.json new file mode 100644 index 00000000..ce3b2432 --- /dev/null +++ b/app/package.json @@ -0,0 +1,37 @@ +{ + "name": "cash-register-app", + "private": true, + "type": "module", + "scripts": { + "wasm:build": "cd ../cash-register-wasm && wasm-pack build --target bundler --out-dir ../app/src/wasm-pkg", + "wasm:test": "cd ../cash-register-wasm && cargo test", + "dev": "bun run wasm:build && vite dev --port 3456", + "build": "bun run wasm:build && vite build", + "start": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@tanstack/react-router": "latest", + "@tanstack/react-start": "latest", + "@tanstack/router-plugin": "^1.132.0", + "react": "^19", + "react-dom": "^19", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.6.0", + "zod": "^3" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^25.6.0", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5", + "jsdom": "^29.0.2", + "typescript": "^5", + "vite": "^7", + "vite-tsconfig-paths": "^5", + "vitest": "^3" + } +} diff --git a/app/src/components/ErrorBanner.tsx b/app/src/components/ErrorBanner.tsx new file mode 100644 index 00000000..09cee5e4 --- /dev/null +++ b/app/src/components/ErrorBanner.tsx @@ -0,0 +1,17 @@ +type ErrorBannerProps = { + message: string + onDismiss?: () => void +} + +export function ErrorBanner({ message, onDismiss }: ErrorBannerProps) { + return ( +
+ {message} + {onDismiss ? ( + + ) : null} +
+ ) +} diff --git a/app/src/components/FileUpload.tsx b/app/src/components/FileUpload.tsx new file mode 100644 index 00000000..088abf2c --- /dev/null +++ b/app/src/components/FileUpload.tsx @@ -0,0 +1,79 @@ +import { useCallback, useRef, useState } from "react" + +type FileUploadProps = { + onFileLoaded: (content: string, fileName: string) => void +} + +export function FileUpload({ onFileLoaded }: FileUploadProps) { + const [fileName, setFileName] = useState(null) + const [isDragging, setIsDragging] = useState(false) + const inputRef = useRef(null) + + const handleFile = useCallback( + (file: File) => { + const reader = new FileReader() + reader.onload = (e) => { + const content = e.target?.result as string + setFileName(file.name) + onFileLoaded(content, file.name) + } + reader.readAsText(file) + }, + [onFileLoaded], + ) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) { + handleFile(file) + } + }, + [handleFile], + ) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + handleFile(file) + } + }, + [handleFile], + ) + + const isLoaded = fileName !== null + + return ( +
{ + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onClick={() => inputRef.current?.click()} + > +
{isLoaded ? "\u{1F4C4}" : "\u{1F4C1}"}
+
+ {isLoaded ? ( + {fileName} + ) : ( + <> + Drop file here or click to browse + + )} +
+ +
+ ) +} diff --git a/app/src/components/ResultsTable.skeleton.tsx b/app/src/components/ResultsTable.skeleton.tsx new file mode 100644 index 00000000..c8b63cba --- /dev/null +++ b/app/src/components/ResultsTable.skeleton.tsx @@ -0,0 +1,36 @@ +export function ResultsTableSkeleton() { + return ( +
+
+ + + + + + + + + + + {[1, 2, 3].map((i) => ( + + + + + + + ))} + +
#InputOutputStrategy
+
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/app/src/components/ResultsTable.tsx b/app/src/components/ResultsTable.tsx new file mode 100644 index 00000000..3a155ee0 --- /dev/null +++ b/app/src/components/ResultsTable.tsx @@ -0,0 +1,58 @@ +import type { ProcessResponse } from "../lib/schemas" + +type ResultsTableProps = { + response: ProcessResponse +} + +export function ResultsTable({ response }: ResultsTableProps) { + return ( +
+
+ + + + + + + + + + + {response.results.map((result) => ( + + + + + + + ))} + +
#InputOutputStrategy
{result.line}{result.input} + {result.error ? ( + {result.error} + ) : ( + result.output + )} + + {result.strategy ? ( + + {result.strategy} + + ) : null} +
+
+
+
+ + {response.total_lines} processed +
+
+ + {response.error_count} errors +
+
+
+ ) +} diff --git a/app/src/components/RulesBuilder.skeleton.tsx b/app/src/components/RulesBuilder.skeleton.tsx new file mode 100644 index 00000000..9145ff40 --- /dev/null +++ b/app/src/components/RulesBuilder.skeleton.tsx @@ -0,0 +1,18 @@ +export function RulesBuilderSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/app/src/components/RulesBuilder.tsx b/app/src/components/RulesBuilder.tsx new file mode 100644 index 00000000..2074dc41 --- /dev/null +++ b/app/src/components/RulesBuilder.tsx @@ -0,0 +1,175 @@ +import type { Condition, Rule, RulesConfig } from "../lib/schemas" + +type RulesBuilderProps = { + config: RulesConfig + supportedCurrencies: string[] + supportedStrategies: string[] + onChange: (config: RulesConfig) => void +} + +const CONDITION_TYPES = [ + { value: "divisible_by", label: "divisible by" }, + { value: "amount_range", label: "in range" }, + { value: "always", label: "always" }, +] as const + +const STRATEGY_LABELS: Record = { + minimal: "Minimal change", + random: "Random denominations", +} + +function strategyLabel(name: string): string { + return STRATEGY_LABELS[name] ?? name +} + +export function RulesBuilder({ + config, + supportedCurrencies, + supportedStrategies, + onChange, +}: RulesBuilderProps) { + const updateCurrency = (currency: string) => { + onChange({ ...config, currency }) + } + + const updateDefaultStrategy = (default_strategy: string) => { + onChange({ ...config, default_strategy }) + } + + const updateRule = (index: number, rule: Rule) => { + const rules = config.rules.map((r, i) => (i === index ? rule : r)) + onChange({ ...config, rules }) + } + + const removeRule = (index: number) => { + const rules = config.rules.filter((_, i) => i !== index) + onChange({ ...config, rules }) + } + + const addRule = () => { + const newRule: Rule = { + condition: { type: "divisible_by", divisor: 3 }, + strategy: "random", + } + onChange({ ...config, rules: [...config.rules, newRule] }) + } + + const updateConditionType = (index: number, conditionType: Condition["type"]) => { + const rule = config.rules[index] + const condition: Condition = + conditionType === "divisible_by" + ? { type: "divisible_by", divisor: 3 } + : conditionType === "amount_range" + ? { type: "amount_range" } + : { type: "always" } + updateRule(index, { ...rule, condition }) + } + + return ( +
+
+ + +
+ +
+ + +
+ +
+
+ Rules +
+
+ {config.rules.map((rule, i) => ( +
+
+ Rule {i + 1} + +
+
+ When owed is + + {rule.condition.type === "divisible_by" ? ( + { + const divisor = parseInt(e.target.value, 10) + if (!isNaN(divisor) && divisor > 0) { + updateRule(i, { + ...rule, + condition: { type: "divisible_by", divisor }, + }) + } + }} + /> + ) : null} +
+
+ then use + +
+
+ ))} + +
+
+
+ ) +} diff --git a/app/src/components/__tests__/ErrorBanner.test.tsx b/app/src/components/__tests__/ErrorBanner.test.tsx new file mode 100644 index 00000000..4fa95965 --- /dev/null +++ b/app/src/components/__tests__/ErrorBanner.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen } from "@testing-library/react" +import { ErrorBanner } from "../ErrorBanner" + +describe("ErrorBanner", () => { + it("renders message text", () => { + render() + expect(screen.getByText("Something broke")).toBeInTheDocument() + }) + + it("does not render dismiss button when onDismiss is not provided", () => { + render() + expect(screen.queryByRole("button")).not.toBeInTheDocument() + }) + + it("renders dismiss button when onDismiss is provided", () => { + render( {}} />) + expect(screen.getByRole("button")).toBeInTheDocument() + }) + + it("calls onDismiss when dismiss button is clicked", () => { + const onDismiss = vi.fn() + render() + screen.getByRole("button").click() + expect(onDismiss).toHaveBeenCalledOnce() + }) +}) diff --git a/app/src/components/__tests__/FileUpload.test.tsx b/app/src/components/__tests__/FileUpload.test.tsx new file mode 100644 index 00000000..55c6b55f --- /dev/null +++ b/app/src/components/__tests__/FileUpload.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen } from "@testing-library/react" +import { FileUpload } from "../FileUpload" + +describe("FileUpload", () => { + it("renders drop zone with default text", () => { + render( {}} />) + expect(screen.getByText(/Drop file here/i)).toBeInTheDocument() + expect(screen.getByText(/click to browse/i)).toBeInTheDocument() + }) + + it("calls onFileLoaded with file content when file is selected", async () => { + const onFileLoaded = vi.fn() + const { container } = render() + + const fileContent = "2.12,3.00\n1.97,2.00" + const file = new File([fileContent], "transactions.csv", { type: "text/csv" }) + const input = container.querySelector('input[type="file"]') as HTMLInputElement + + // Manually dispatch a change event with the file + Object.defineProperty(input, "files", { value: [file], configurable: true }) + input.dispatchEvent(new Event("change", { bubbles: true })) + + // FileReader is async, wait for it + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(onFileLoaded).toHaveBeenCalledWith(fileContent, "transactions.csv") + }) + + it("shows the file name after a file is loaded", async () => { + const { container } = render( {}} />) + + const file = new File(["content"], "my-file.txt", { type: "text/plain" }) + const input = container.querySelector('input[type="file"]') as HTMLInputElement + Object.defineProperty(input, "files", { value: [file], configurable: true }) + input.dispatchEvent(new Event("change", { bubbles: true })) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(screen.getByText("my-file.txt")).toBeInTheDocument() + }) +}) diff --git a/app/src/components/__tests__/ResultsTable.test.tsx b/app/src/components/__tests__/ResultsTable.test.tsx new file mode 100644 index 00000000..a9e59356 --- /dev/null +++ b/app/src/components/__tests__/ResultsTable.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest" +import { render, screen } from "@testing-library/react" +import { ResultsTable } from "../ResultsTable" +import type { ProcessResponse } from "../../lib/schemas" + +function makeResponse(overrides: Partial = {}): ProcessResponse { + return { + success: true, + results: [ + { + line: 1, + input: "2.12,3.00", + output: "3 quarters,1 dime,3 pennies", + strategy: "minimal", + error: null, + }, + { + line: 2, + input: "3.33,5.00", + output: "1 dollar,1 quarter,6 nickels,12 pennies", + strategy: "random", + error: null, + }, + ], + total_lines: 2, + error_count: 0, + ...overrides, + } +} + +describe("ResultsTable", () => { + it("renders each result row with line number, input, and output", () => { + render() + + expect(screen.getByText("1")).toBeInTheDocument() + expect(screen.getByText("2")).toBeInTheDocument() + expect(screen.getByText("2.12,3.00")).toBeInTheDocument() + expect(screen.getByText("3.33,5.00")).toBeInTheDocument() + expect(screen.getByText("3 quarters,1 dime,3 pennies")).toBeInTheDocument() + expect( + screen.getByText("1 dollar,1 quarter,6 nickels,12 pennies"), + ).toBeInTheDocument() + }) + + it("renders strategy badges", () => { + render() + + expect(screen.getByText("minimal")).toBeInTheDocument() + expect(screen.getByText("random")).toBeInTheDocument() + }) + + it("renders error text for error rows", () => { + const response = makeResponse({ + success: false, + results: [ + { + line: 1, + input: "bad line", + output: null, + strategy: null, + error: "Parse error on line 1", + }, + ], + total_lines: 1, + error_count: 1, + }) + render() + + expect(screen.getByText("Parse error on line 1")).toBeInTheDocument() + }) + + it("renders summary stats", () => { + render() + + expect(screen.getByText(/2 processed/)).toBeInTheDocument() + expect(screen.getByText(/0 errors/)).toBeInTheDocument() + }) + + it("shows error count correctly", () => { + const response = makeResponse({ + total_lines: 5, + error_count: 2, + }) + render() + + expect(screen.getByText(/5 processed/)).toBeInTheDocument() + expect(screen.getByText(/2 errors/)).toBeInTheDocument() + }) + + it("renders empty state when no results", () => { + const response = makeResponse({ + results: [], + total_lines: 0, + error_count: 0, + }) + const { container } = render() + + expect(container.querySelectorAll("tbody tr")).toHaveLength(0) + }) +}) diff --git a/app/src/components/__tests__/RulesBuilder.test.tsx b/app/src/components/__tests__/RulesBuilder.test.tsx new file mode 100644 index 00000000..1a0139cf --- /dev/null +++ b/app/src/components/__tests__/RulesBuilder.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { RulesBuilder } from "../RulesBuilder" +import type { RulesConfig } from "../../lib/schemas" + +const defaultConfig: RulesConfig = { + currency: "USD", + default_strategy: "minimal", + rules: [ + { condition: { type: "divisible_by", divisor: 3 }, strategy: "random" }, + ], +} + +const CURRENCIES = ["USD", "EUR"] +const STRATEGIES = ["minimal", "random"] + +describe("RulesBuilder", () => { + it("renders currency dropdown with all supported currencies", () => { + render( + {}} + />, + ) + + const currencySelect = screen.getByLabelText(/Currency/i) as HTMLSelectElement + expect(currencySelect.value).toBe("USD") + expect(screen.getByRole("option", { name: "USD" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "EUR" })).toBeInTheDocument() + }) + + it("renders default strategy dropdown with label options", () => { + render( + {}} + />, + ) + + expect(screen.getByLabelText(/Default Strategy/i)).toBeInTheDocument() + expect( + screen.getAllByRole("option", { name: /Minimal change/i }).length, + ).toBeGreaterThan(0) + expect( + screen.getAllByRole("option", { name: /Random denominations/i }).length, + ).toBeGreaterThan(0) + }) + + it("renders existing rules", () => { + render( + {}} + />, + ) + + expect(screen.getByText(/Rule 1/)).toBeInTheDocument() + expect(screen.getByText(/When owed is/)).toBeInTheDocument() + expect(screen.getByDisplayValue("3")).toBeInTheDocument() + }) + + it("calls onChange when currency changes", () => { + const onChange = vi.fn() + render( + , + ) + + const currencySelect = screen.getByLabelText(/Currency/i) + fireEvent.change(currencySelect, { target: { value: "EUR" } }) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ currency: "EUR" }), + ) + }) + + it("adds a new rule when Add Rule is clicked", () => { + const onChange = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole("button", { name: /Add Rule/i })) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + rules: expect.arrayContaining([ + expect.anything(), + expect.objectContaining({ + condition: expect.objectContaining({ type: "divisible_by" }), + }), + ]), + }), + ) + const lastCall = onChange.mock.calls[0][0] + expect(lastCall.rules).toHaveLength(2) + }) + + it("removes a rule when remove button is clicked", () => { + const onChange = vi.fn() + render( + , + ) + + const removeButton = screen.getByRole("button", { name: "×" }) + fireEvent.click(removeButton) + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ rules: [] }), + ) + }) + + it("updates divisor when number input changes", () => { + const onChange = vi.fn() + render( + , + ) + + const divisorInput = screen.getByDisplayValue("3") + fireEvent.change(divisorInput, { target: { value: "5" } }) + + const lastCall = onChange.mock.calls[0][0] + expect(lastCall.rules[0].condition).toEqual({ + type: "divisible_by", + divisor: 5, + }) + }) + + it("ignores invalid divisor values", () => { + const onChange = vi.fn() + render( + , + ) + + const divisorInput = screen.getByDisplayValue("3") + fireEvent.change(divisorInput, { target: { value: "0" } }) + fireEvent.change(divisorInput, { target: { value: "abc" } }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it("changes condition type when dropdown changes", () => { + const onChange = vi.fn() + render( + , + ) + + const conditionSelect = screen.getByDisplayValue("divisible by") + fireEvent.change(conditionSelect, { target: { value: "always" } }) + + const lastCall = onChange.mock.calls[0][0] + expect(lastCall.rules[0].condition).toEqual({ type: "always" }) + }) +}) diff --git a/app/src/lib/__tests__/build-config.test.ts b/app/src/lib/__tests__/build-config.test.ts new file mode 100644 index 00000000..bf50a4e6 --- /dev/null +++ b/app/src/lib/__tests__/build-config.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest" +import { buildConfigJson } from "../build-config" +import type { RulesConfig } from "../schemas" + +describe("buildConfigJson", () => { + it("serializes a simple config", () => { + const config: RulesConfig = { + currency: "USD", + default_strategy: "minimal", + rules: [], + } + const json = buildConfigJson(config) + expect(JSON.parse(json)).toEqual(config) + }) + + it("serializes config with divisible_by rule", () => { + const config: RulesConfig = { + currency: "USD", + default_strategy: "minimal", + rules: [ + { condition: { type: "divisible_by", divisor: 3 }, strategy: "random" }, + ], + } + const json = buildConfigJson(config) + const parsed = JSON.parse(json) + expect(parsed.rules[0].condition.type).toBe("divisible_by") + expect(parsed.rules[0].condition.divisor).toBe(3) + }) + + it("serializes EUR config", () => { + const config: RulesConfig = { + currency: "EUR", + default_strategy: "minimal", + rules: [], + } + const json = buildConfigJson(config) + expect(JSON.parse(json).currency).toBe("EUR") + }) +}) diff --git a/app/src/lib/__tests__/schemas.test.ts b/app/src/lib/__tests__/schemas.test.ts new file mode 100644 index 00000000..352dc94b --- /dev/null +++ b/app/src/lib/__tests__/schemas.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest" +import { + ProcessRequestSchema, + ProcessResponseSchema, + RulesConfigSchema, + ConditionSchema, +} from "../schemas" + +describe("ProcessRequestSchema", () => { + it("accepts valid request", () => { + const result = ProcessRequestSchema.parse({ + fileContent: "2.12,3.00", + rulesConfigJson: "{}", + }) + expect(result.fileContent).toBe("2.12,3.00") + expect(result.rulesConfigJson).toBe("{}") + }) + + it("rejects empty fileContent", () => { + expect(() => + ProcessRequestSchema.parse({ fileContent: "", rulesConfigJson: "{}" }), + ).toThrow() + }) + + it("rejects empty rulesConfigJson", () => { + expect(() => + ProcessRequestSchema.parse({ fileContent: "x", rulesConfigJson: "" }), + ).toThrow() + }) + + it("rejects missing fields", () => { + expect(() => ProcessRequestSchema.parse({ fileContent: "x" })).toThrow() + }) +}) + +describe("ProcessResponseSchema", () => { + it("parses successful response", () => { + const raw = { + success: true, + results: [ + { + line: 1, + input: "2.12,3.00", + output: "3 quarters,1 dime,3 pennies", + strategy: "minimal", + error: null, + }, + ], + total_lines: 1, + error_count: 0, + } + const result = ProcessResponseSchema.parse(raw) + expect(result.results).toHaveLength(1) + expect(result.results[0].strategy).toBe("minimal") + expect(result.results[0].error).toBeNull() + }) + + it("parses response with errors", () => { + const raw = { + success: false, + results: [ + { + line: 1, + input: "bad line", + output: null, + strategy: null, + error: "Parse error", + }, + ], + total_lines: 1, + error_count: 1, + } + const result = ProcessResponseSchema.parse(raw) + expect(result.results[0].error).toBe("Parse error") + expect(result.results[0].output).toBeNull() + }) +}) + +describe("RulesConfigSchema", () => { + it("parses full config", () => { + const result = RulesConfigSchema.parse({ + currency: "USD", + default_strategy: "minimal", + rules: [ + { condition: { type: "divisible_by", divisor: 3 }, strategy: "random" }, + ], + }) + expect(result.currency).toBe("USD") + expect(result.rules).toHaveLength(1) + }) + + it("accepts empty rules array", () => { + const result = RulesConfigSchema.parse({ + currency: "EUR", + default_strategy: "minimal", + rules: [], + }) + expect(result.rules).toHaveLength(0) + }) +}) + +describe("ConditionSchema", () => { + it("parses divisible_by condition", () => { + const result = ConditionSchema.parse({ type: "divisible_by", divisor: 3 }) + expect(result).toEqual({ type: "divisible_by", divisor: 3 }) + }) + + it("parses amount_range condition", () => { + const result = ConditionSchema.parse({ + type: "amount_range", + min_cents: 100, + max_cents: 500, + }) + expect(result.type).toBe("amount_range") + }) + + it("parses always condition", () => { + const result = ConditionSchema.parse({ type: "always" }) + expect(result.type).toBe("always") + }) + + it("rejects unknown type", () => { + expect(() => ConditionSchema.parse({ type: "bogus" })).toThrow() + }) + + it("rejects divisible_by with divisor < 1", () => { + expect(() => + ConditionSchema.parse({ type: "divisible_by", divisor: 0 }), + ).toThrow() + }) +}) diff --git a/app/src/lib/build-config.ts b/app/src/lib/build-config.ts new file mode 100644 index 00000000..ab8ce8fb --- /dev/null +++ b/app/src/lib/build-config.ts @@ -0,0 +1,5 @@ +import type { RulesConfig } from "./schemas" + +export function buildConfigJson(config: RulesConfig): string { + return JSON.stringify(config) +} diff --git a/app/src/lib/default-config.ts b/app/src/lib/default-config.ts new file mode 100644 index 00000000..e2b5ab1e --- /dev/null +++ b/app/src/lib/default-config.ts @@ -0,0 +1,12 @@ +import type { RulesConfig } from "./schemas" + +export const DEFAULT_RULES_CONFIG: RulesConfig = { + currency: "USD", + default_strategy: "minimal", + rules: [ + { + condition: { type: "divisible_by", divisor: 3 }, + strategy: "random", + }, + ], +} diff --git a/app/src/lib/schemas.ts b/app/src/lib/schemas.ts new file mode 100644 index 00000000..15264dce --- /dev/null +++ b/app/src/lib/schemas.ts @@ -0,0 +1,53 @@ +import { z } from "zod" + +export const ProcessRequestSchema = z.object({ + fileContent: z.string().min(1), + rulesConfigJson: z.string().min(1), +}) + +export type ProcessRequest = z.infer + +const LineResultSchema = z.object({ + line: z.number(), + input: z.string(), + output: z.string().nullable(), + strategy: z.string().nullable(), + error: z.string().nullable(), +}) + +export const ProcessResponseSchema = z.object({ + success: z.boolean(), + results: z.array(LineResultSchema), + total_lines: z.number(), + error_count: z.number(), +}) + +export type ProcessResponse = z.infer +export type LineResult = z.infer + +export const ConditionSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("divisible_by"), divisor: z.number().int().min(1) }), + z.object({ + type: z.literal("amount_range"), + min_cents: z.number().int().optional(), + max_cents: z.number().int().optional(), + }), + z.object({ type: z.literal("always") }), +]) + +export type Condition = z.infer + +export const RuleSchema = z.object({ + condition: ConditionSchema, + strategy: z.string(), +}) + +export type Rule = z.infer + +export const RulesConfigSchema = z.object({ + currency: z.string(), + default_strategy: z.string(), + rules: z.array(RuleSchema), +}) + +export type RulesConfig = z.infer diff --git a/app/src/router.tsx b/app/src/router.tsx new file mode 100644 index 00000000..a62aff81 --- /dev/null +++ b/app/src/router.tsx @@ -0,0 +1,19 @@ +import { createRouter as createTanStackRouter } from "@tanstack/react-router" +import { routeTree } from "./routeTree.gen" + +export function getRouter() { + const router = createTanStackRouter({ + routeTree, + scrollRestoration: true, + defaultPreload: "intent", + defaultPreloadStaleTime: 0, + }) + + return router +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType + } +} diff --git a/app/src/routes/__root.tsx b/app/src/routes/__root.tsx new file mode 100644 index 00000000..7d83cf3d --- /dev/null +++ b/app/src/routes/__root.tsx @@ -0,0 +1,31 @@ +import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router" + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { title: "Cash Register" }, + ], + }), + component: RootLayout, + shellComponent: RootDocument, +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + ) +} + +function RootLayout() { + return +} diff --git a/app/src/routes/index.tsx b/app/src/routes/index.tsx new file mode 100644 index 00000000..e89e3c83 --- /dev/null +++ b/app/src/routes/index.tsx @@ -0,0 +1,102 @@ +import { createFileRoute } from "@tanstack/react-router" +import { useState } from "react" +import { FileUpload } from "../components/FileUpload" +import { RulesBuilder } from "../components/RulesBuilder" +import { ResultsTable } from "../components/ResultsTable" +import { ResultsTableSkeleton } from "../components/ResultsTable.skeleton" +import { ErrorBanner } from "../components/ErrorBanner" +import { DEFAULT_RULES_CONFIG } from "../lib/default-config" +import { buildConfigJson } from "../lib/build-config" +import { processTransactions } from "../server/process-file" +import type { ProcessResponse, RulesConfig } from "../lib/schemas" +import "../styles/app.css" + +export const Route = createFileRoute("/")({ + component: IndexPage, +}) + +const SUPPORTED_CURRENCIES = ["USD", "EUR"] +const SUPPORTED_STRATEGIES = ["minimal", "random"] + +function IndexPage() { + const [fileContent, setFileContent] = useState(null) + const [config, setConfig] = useState(DEFAULT_RULES_CONFIG) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const [isProcessing, setIsProcessing] = useState(false) + + const handleProcess = async () => { + if (!fileContent) { + return + } + + setIsProcessing(true) + setError(null) + setResult(null) + + const response = await processTransactions({ + data: { + fileContent, + rulesConfigJson: buildConfigJson(config), + }, + }) + + setIsProcessing(false) + + if (!response.ok) { + setError(response.error) + return + } + + setResult(response.data) + } + + return ( +
+
+

Cash Register

+
+ + + +
+
Results
+ + {error ? setError(null)} /> : null} + + {isProcessing ? : null} + + {result && !isProcessing ? : null} + + {!result && !isProcessing && !error ? ( +
+ Upload a file and click Process to see results +
+ ) : null} +
+
+ ) +} diff --git a/app/src/server/__tests__/process-file.test.ts b/app/src/server/__tests__/process-file.test.ts new file mode 100644 index 00000000..6cc465ac --- /dev/null +++ b/app/src/server/__tests__/process-file.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from "vitest" + +vi.mock("@tanstack/react-start", () => ({ + createServerFn: () => ({ + inputValidator: () => ({ handler: () => () => null }), + }), +})) + +vi.mock("../wasm-loader", () => ({ + loadWasmModule: vi.fn(), +})) + +import { processWithWasm } from "../process-file" +import type { WasmModule } from "../wasm-types" + +function makeWasmMock(overrides: Partial = {}): WasmModule { + return { + process_transactions: vi.fn(() => + JSON.stringify({ + success: true, + results: [ + { + line: 1, + input: "2.12,3.00", + output: "3 quarters,1 dime,3 pennies", + strategy: "minimal", + error: null, + }, + ], + total_lines: 1, + error_count: 0, + }), + ), + validate_config: vi.fn(), + list_supported_currencies: vi.fn(() => JSON.stringify(["USD", "EUR"])), + list_supported_strategies: vi.fn(() => JSON.stringify(["minimal", "random"])), + ...overrides, + } +} + +describe("processWithWasm", () => { + it("returns ok with parsed response on success", async () => { + const wasm = makeWasmMock() + const result = await processWithWasm(wasm, { + fileContent: "2.12,3.00", + rulesConfigJson: '{"currency":"USD","default_strategy":"minimal","rules":[]}', + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.results).toHaveLength(1) + expect(result.data.results[0].output).toBe("3 quarters,1 dime,3 pennies") + expect(result.data.total_lines).toBe(1) + } + }) + + it("passes fileContent and rulesConfigJson to wasm.process_transactions", async () => { + const processSpy = vi.fn(() => + JSON.stringify({ + success: true, + results: [], + total_lines: 0, + error_count: 0, + }), + ) + const wasm = makeWasmMock({ process_transactions: processSpy }) + + await processWithWasm(wasm, { + fileContent: "file content here", + rulesConfigJson: '{"currency":"EUR"}', + }) + + expect(processSpy).toHaveBeenCalledWith( + "file content here", + '{"currency":"EUR"}', + ) + }) + + it("returns ok:false when wasm throws", async () => { + const wasm = makeWasmMock({ + process_transactions: vi.fn(() => { + throw new Error("WASM crashed") + }), + }) + + const result = await processWithWasm(wasm, { + fileContent: "x", + rulesConfigJson: "{}", + }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toBe("WASM crashed") + } + }) + + it("returns ok:false when wasm returns invalid JSON", async () => { + const wasm = makeWasmMock({ + process_transactions: vi.fn(() => "not json"), + }) + + const result = await processWithWasm(wasm, { + fileContent: "x", + rulesConfigJson: "{}", + }) + + expect(result.ok).toBe(false) + }) + + it("returns ok:false when wasm returns response missing required fields", async () => { + const wasm = makeWasmMock({ + process_transactions: vi.fn(() => JSON.stringify({ success: true })), + }) + + const result = await processWithWasm(wasm, { + fileContent: "x", + rulesConfigJson: "{}", + }) + + expect(result.ok).toBe(false) + }) + + it("handles response with errors", async () => { + const wasm = makeWasmMock({ + process_transactions: vi.fn(() => + JSON.stringify({ + success: false, + results: [ + { + line: 1, + input: "bad", + output: null, + strategy: null, + error: "Parse error", + }, + ], + total_lines: 1, + error_count: 1, + }), + ), + }) + + const result = await processWithWasm(wasm, { + fileContent: "bad", + rulesConfigJson: "{}", + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.error_count).toBe(1) + expect(result.data.results[0].error).toBe("Parse error") + } + }) +}) diff --git a/app/src/server/process-file.ts b/app/src/server/process-file.ts new file mode 100644 index 00000000..e9a2e0da --- /dev/null +++ b/app/src/server/process-file.ts @@ -0,0 +1,30 @@ +import { createServerFn } from "@tanstack/react-start" +import { ProcessRequestSchema, ProcessResponseSchema } from "../lib/schemas" +import type { ProcessRequest, ProcessResponse } from "../lib/schemas" +import { loadWasmModule } from "./wasm-loader" +import type { WasmModule } from "./wasm-types" + +export type ProcessResult = + | { ok: true; data: ProcessResponse } + | { ok: false; error: string } + +export async function processWithWasm( + wasm: WasmModule, + data: ProcessRequest, +): Promise { + try { + const raw = wasm.process_transactions(data.fileContent, data.rulesConfigJson) + const parsed = ProcessResponseSchema.parse(JSON.parse(raw)) + return { ok: true, data: parsed } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } +} + +export const processTransactions = createServerFn({ method: "POST" }) + .inputValidator((data: unknown) => ProcessRequestSchema.parse(data)) + .handler(async ({ data }): Promise => { + const wasm = loadWasmModule() + return processWithWasm(wasm, data) + }) diff --git a/app/src/server/wasm-loader.ts b/app/src/server/wasm-loader.ts new file mode 100644 index 00000000..ca1b3042 --- /dev/null +++ b/app/src/server/wasm-loader.ts @@ -0,0 +1,18 @@ +import { + process_transactions, + validate_config, + list_supported_currencies, + list_supported_strategies, +} from "../wasm-pkg/cash_register_wasm" +import type { WasmModule } from "./wasm-types" + +export type { WasmModule } + +export function loadWasmModule(): WasmModule { + return { + process_transactions, + validate_config, + list_supported_currencies, + list_supported_strategies, + } +} diff --git a/app/src/server/wasm-types.ts b/app/src/server/wasm-types.ts new file mode 100644 index 00000000..bdc44c93 --- /dev/null +++ b/app/src/server/wasm-types.ts @@ -0,0 +1,6 @@ +export type WasmModule = { + process_transactions: (fileContent: string, rulesConfigJson: string) => string + validate_config: (rulesConfigJson: string) => void + list_supported_currencies: () => string + list_supported_strategies: () => string +} diff --git a/app/src/styles/app.css b/app/src/styles/app.css new file mode 100644 index 00000000..9aae8fe0 --- /dev/null +++ b/app/src/styles/app.css @@ -0,0 +1,472 @@ +:root { + --bg: #0a0a0a; + --surface: #141414; + --surface-2: #1c1c1c; + --surface-3: #242424; + --border: #2a2a2a; + --border-hover: #3a3a3a; + --text: #e5e5e5; + --text-muted: #888; + --accent: #3b82f6; + --accent-hover: #2563eb; + --green: #22c55e; + --red: #ef4444; + --purple: #a855f7; + --radius: 12px; + --radius-sm: 8px; +} + +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +/* Split Panel Layout */ +.split-container { + height: 100vh; + display: grid; + grid-template-columns: 380px 1fr; + grid-template-rows: auto 1fr; +} + +.split-header { + grid-column: 1 / -1; + padding: 20px 28px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.split-header h2 { + font-size: 18px; + font-weight: 600; +} + +.split-left { + padding: 24px; + border-right: 1px solid var(--border); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 20px; +} + +.split-right { + padding: 24px; + overflow-y: auto; + background: var(--surface); +} + +/* Dropzone */ +.dropzone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 40px 24px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.dropzone:hover, +.dropzone-dragging { + border-color: var(--accent); + background: rgba(59, 130, 246, 0.04); +} + +.dropzone-loaded { + border-color: var(--green); + border-style: solid; + background: rgba(34, 197, 94, 0.04); +} + +.dropzone-icon { + font-size: 32px; + margin-bottom: 12px; + opacity: 0.6; +} + +.dropzone-loaded .dropzone-icon { + opacity: 1; +} + +.dropzone-text { + color: var(--text-muted); + font-size: 14px; +} + +.dropzone-text strong { + color: var(--accent); +} + +.dropzone-loaded .dropzone-text strong { + color: var(--green); +} + +/* Config controls */ +.config-controls { + display: flex; + flex-direction: column; + gap: 14px; +} + +.config-field { + display: flex; + flex-direction: column; + gap: 5px; +} + +.config-field-label { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); +} + +.section-label { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.select-control, +.select-control-full { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 32px 8px 14px; + color: var(--text); + font-size: 14px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%23888' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; +} + +.select-control-full { + width: 100%; +} + +.select-sm { + padding: 6px 28px 6px 10px; + font-size: 13px; +} + +.number-input { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 8px; + color: var(--text); + font-size: 13px; + width: 60px; + text-align: center; +} + +.rules-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rule-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.rule-card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.rule-card-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.rule-card-remove { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.2s; +} + +.rule-card-remove:hover { + color: var(--red); + background: rgba(239, 68, 68, 0.1); +} + +.rule-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.rule-text { + font-size: 13px; + color: var(--text-muted); + white-space: nowrap; +} + +.btn-add-rule { + background: transparent; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + padding: 8px 16px; + color: var(--text-muted); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + width: 100%; + text-align: center; +} + +.btn-add-rule:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* Results */ +.results-container { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.results-table-wrapper { + overflow-x: auto; +} + +.results-table { + width: 100%; + border-collapse: collapse; +} + +.results-table th { + text-align: left; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); +} + +.results-table td { + padding: 12px 14px; + font-size: 14px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.results-table tr:last-child td { + border-bottom: none; +} + +.results-table .line-num { + color: var(--text-muted); + font-weight: 500; + width: 40px; +} + +.results-table .input-col { + color: var(--text-muted); + font-family: monospace; + width: 120px; +} + +.results-table .output-col { + font-weight: 500; +} + +.results-table .strategy-col { + width: 80px; + text-align: center; +} + +.row-error .output-col { + color: var(--red); +} + +.error-text { + font-size: 13px; + font-style: italic; +} + +.badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 99px; + font-weight: 600; +} + +.badge-ok { + background: rgba(34, 197, 94, 0.15); + color: var(--green); +} + +.badge-random { + background: rgba(168, 85, 247, 0.15); + color: var(--purple); +} + +.results-summary { + padding: 14px; + font-size: 13px; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 16px; + border-top: 1px solid var(--border); +} + +.summary-stat { + display: flex; + align-items: center; + gap: 6px; +} + +.summary-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.dot-green { + background: var(--green); +} + +.dot-red { + background: var(--red); +} + +/* Buttons */ +.btn { + padding: 10px 24px; + border-radius: var(--radius-sm); + border: none; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-full { + width: 100%; +} + +.btn-ghost { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); +} + +.btn-ghost:hover { + border-color: var(--border-hover); + color: var(--text); +} + +/* Error Banner */ +.error-banner { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-sm); + padding: 12px 16px; + color: var(--red); + font-size: 14px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.error-banner-dismiss { + background: none; + border: none; + color: var(--red); + font-size: 18px; + cursor: pointer; + padding: 0 4px; +} + +/* Empty state */ +.results-empty { + color: var(--text-muted); + font-size: 14px; + text-align: center; + padding: 60px 24px; +} + +/* Skeletons */ +.skeleton { + background: var(--surface-2); + border-radius: var(--radius-sm); + animation: pulse 1.5s ease-in-out infinite; +} + +.skeleton-label { + width: 80px; + height: 14px; +} + +.skeleton-select { + width: 100%; + height: 38px; +} + +.skeleton-rule-card { + width: 100%; + height: 120px; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 0.8; + } +} diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts new file mode 100644 index 00000000..b9e76229 --- /dev/null +++ b/app/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest" diff --git a/app/tsconfig.json b/app/tsconfig.json new file mode 100644 index 00000000..70860c5e --- /dev/null +++ b/app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node", "vitest/globals", "@testing-library/jest-dom"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "vitest.config.ts"] +} diff --git a/app/vite.config.ts b/app/vite.config.ts new file mode 100644 index 00000000..a3a56020 --- /dev/null +++ b/app/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite" +import tsconfigPaths from "vite-tsconfig-paths" +import { tanstackStart } from "@tanstack/react-start/plugin/vite" +import viteReact from "@vitejs/plugin-react" +import wasm from "vite-plugin-wasm" +import topLevelAwait from "vite-plugin-top-level-await" + +const config = defineConfig({ + plugins: [ + wasm(), + topLevelAwait(), + tsconfigPaths({ projects: ["./tsconfig.json"] }), + tanstackStart(), + viteReact(), + ], +}) + +export default config diff --git a/app/vitest.config.ts b/app/vitest.config.ts new file mode 100644 index 00000000..5da5bfae --- /dev/null +++ b/app/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config" +import tsconfigPaths from "vite-tsconfig-paths" +import viteReact from "@vitejs/plugin-react" + +export default defineConfig({ + plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] }), viteReact()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + exclude: ["**/node_modules/**", "**/dist/**", "**/wasm-pkg/**"], + }, +}) diff --git a/cash-register-wasm/.gitignore b/cash-register-wasm/.gitignore new file mode 100644 index 00000000..34aa146b --- /dev/null +++ b/cash-register-wasm/.gitignore @@ -0,0 +1,2 @@ +target/ +pkg/ diff --git a/cash-register-wasm/Cargo.lock b/cash-register-wasm/Cargo.lock new file mode 100644 index 00000000..3bc38b54 --- /dev/null +++ b/cash-register-wasm/Cargo.lock @@ -0,0 +1,512 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cash-register-wasm" +version = "0.1.0" +dependencies = [ + "getrandom", + "js-sys", + "rand", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-test", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb55e2540ad1c56eec35fd63e2aea15f83b11ce487fd2de9ad11578dfc047ea" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf0ca1bd612b988616bac1ab34c4e4290ef18f7148a1d8b7f31c150080e9295" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/cash-register-wasm/Cargo.toml b/cash-register-wasm/Cargo.toml new file mode 100644 index 00000000..a1f573a4 --- /dev/null +++ b/cash-register-wasm/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "cash-register-wasm" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +wasm-bindgen = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rand = { version = "0.8", features = ["small_rng"] } +js-sys = "0.3" +getrandom = { version = "0.2", features = ["js"] } +thiserror = "1" + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[profile.release] +opt-level = "s" +lto = true diff --git a/cash-register-wasm/src/currency/eur.rs b/cash-register-wasm/src/currency/eur.rs new file mode 100644 index 00000000..81252646 --- /dev/null +++ b/cash-register-wasm/src/currency/eur.rs @@ -0,0 +1,68 @@ +use crate::error::CashError; +use crate::types::{Denomination, Money}; + +use super::Currency; + +pub struct EurCurrency; + +const EUR_DENOMINATIONS: &[Denomination] = &[ + Denomination { value: 200, singular: "2-euro coin", plural: "2-euro coins" }, + Denomination { value: 100, singular: "euro", plural: "euros" }, + Denomination { value: 50, singular: "50-cent coin", plural: "50-cent coins" }, + Denomination { value: 20, singular: "20-cent coin", plural: "20-cent coins" }, + Denomination { value: 10, singular: "10-cent coin", plural: "10-cent coins" }, + Denomination { value: 5, singular: "5-cent coin", plural: "5-cent coins" }, + Denomination { value: 2, singular: "2-cent coin", plural: "2-cent coins" }, + Denomination { value: 1, singular: "1-cent coin", plural: "1-cent coins" }, +]; + +impl Currency for EurCurrency { + fn code(&self) -> &'static str { + "EUR" + } + + fn denominations(&self) -> &[Denomination] { + EUR_DENOMINATIONS + } + + fn parse_amount(&self, s: &str) -> Result { + Money::from_str_comma_decimal(s) + .or_else(|| Money::from_str_decimal(s)) + .ok_or_else(|| CashError::ConfigParseError( + format!("Invalid EUR amount: '{}'", s), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn denominations_ordered_largest_first() { + let currency = EurCurrency; + let denoms = currency.denominations(); + for window in denoms.windows(2) { + assert!(window[0].value > window[1].value); + } + } + + #[test] + fn smallest_denomination_is_one() { + let currency = EurCurrency; + let denoms = currency.denominations(); + assert_eq!(denoms.last().unwrap().value, 1); + } + + #[test] + fn parse_comma_decimal() { + let currency = EurCurrency; + assert_eq!(currency.parse_amount("2,13").unwrap(), Money(213)); + } + + #[test] + fn parse_dot_decimal_also_works() { + let currency = EurCurrency; + assert_eq!(currency.parse_amount("2.13").unwrap(), Money(213)); + } +} diff --git a/cash-register-wasm/src/currency/mod.rs b/cash-register-wasm/src/currency/mod.rs new file mode 100644 index 00000000..bf14254b --- /dev/null +++ b/cash-register-wasm/src/currency/mod.rs @@ -0,0 +1,45 @@ +pub mod eur; +pub mod usd; + +use crate::error::CashError; +use crate::types::{Denomination, Money}; + +pub trait Currency: Send + Sync { + fn code(&self) -> &'static str; + fn denominations(&self) -> &[Denomination]; + fn parse_amount(&self, s: &str) -> Result; +} + +pub fn currency_for_code(code: &str) -> Result, CashError> { + match code { + "USD" => Ok(Box::new(usd::UsdCurrency)), + "EUR" => Ok(Box::new(eur::EurCurrency)), + other => Err(CashError::UnknownCurrency(other.to_string())), + } +} + +pub fn supported_currencies() -> &'static [&'static str] { + &["USD", "EUR"] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_usd() { + let currency = currency_for_code("USD").unwrap(); + assert_eq!(currency.code(), "USD"); + } + + #[test] + fn registry_eur() { + let currency = currency_for_code("EUR").unwrap(); + assert_eq!(currency.code(), "EUR"); + } + + #[test] + fn registry_unknown() { + assert!(currency_for_code("GBP").is_err()); + } +} diff --git a/cash-register-wasm/src/currency/usd.rs b/cash-register-wasm/src/currency/usd.rs new file mode 100644 index 00000000..de694c35 --- /dev/null +++ b/cash-register-wasm/src/currency/usd.rs @@ -0,0 +1,64 @@ +use crate::error::CashError; +use crate::types::{Denomination, Money}; + +use super::Currency; + +pub struct UsdCurrency; + +const USD_DENOMINATIONS: &[Denomination] = &[ + Denomination { value: 100, singular: "dollar", plural: "dollars" }, + Denomination { value: 25, singular: "quarter", plural: "quarters" }, + Denomination { value: 10, singular: "dime", plural: "dimes" }, + Denomination { value: 5, singular: "nickel", plural: "nickels" }, + Denomination { value: 1, singular: "penny", plural: "pennies" }, +]; + +impl Currency for UsdCurrency { + fn code(&self) -> &'static str { + "USD" + } + + fn denominations(&self) -> &[Denomination] { + USD_DENOMINATIONS + } + + fn parse_amount(&self, s: &str) -> Result { + Money::from_str_decimal(s).ok_or_else(|| CashError::ConfigParseError( + format!("Invalid USD amount: '{}'", s), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn denominations_ordered_largest_first() { + let currency = UsdCurrency; + let denoms = currency.denominations(); + for window in denoms.windows(2) { + assert!(window[0].value > window[1].value); + } + } + + #[test] + fn smallest_denomination_is_one() { + let currency = UsdCurrency; + let denoms = currency.denominations(); + assert_eq!(denoms.last().unwrap().value, 1); + } + + #[test] + fn parse_amount_valid() { + let currency = UsdCurrency; + assert_eq!(currency.parse_amount("2.13").unwrap(), Money(213)); + assert_eq!(currency.parse_amount("0.01").unwrap(), Money(1)); + } + + #[test] + fn parse_amount_invalid() { + let currency = UsdCurrency; + assert!(currency.parse_amount("abc").is_err()); + } +} diff --git a/cash-register-wasm/src/error.rs b/cash-register-wasm/src/error.rs new file mode 100644 index 00000000..e25c8360 --- /dev/null +++ b/cash-register-wasm/src/error.rs @@ -0,0 +1,20 @@ +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Error, Serialize)] +pub enum CashError { + #[error("Unknown currency: {0}")] + UnknownCurrency(String), + + #[error("Unknown strategy: {0}")] + UnknownStrategy(String), + + #[error("Invalid config: {0}")] + ConfigParseError(String), + + #[error("Line {line}: {message}")] + ParseError { line: usize, message: String }, + + #[error("Line {line}: paid amount less than owed")] + InsufficientPayment { line: usize }, +} diff --git a/cash-register-wasm/src/lib.rs b/cash-register-wasm/src/lib.rs new file mode 100644 index 00000000..13089dbd --- /dev/null +++ b/cash-register-wasm/src/lib.rs @@ -0,0 +1,40 @@ +pub mod currency; +pub mod error; +pub mod processor; +pub mod rules; +pub mod strategy; +pub mod types; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn process_transactions(file_content: &str, rules_config_json: &str) -> Result { + let engine = rules::RulesEngine::from_json(rules_config_json) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + let response = processor::process_file(file_content, &engine) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + serde_json::to_string(&response) + .map(|s| JsValue::from_str(&s)) + .map_err(|e| JsValue::from_str(&e.to_string())) +} + +#[wasm_bindgen] +pub fn validate_config(rules_config_json: &str) -> Result<(), JsValue> { + rules::RulesEngine::from_json(rules_config_json) + .map(|_| ()) + .map_err(|e| JsValue::from_str(&e.to_string())) +} + +#[wasm_bindgen] +pub fn list_supported_currencies() -> JsValue { + let currencies = currency::supported_currencies(); + JsValue::from_str(&serde_json::to_string(currencies).unwrap()) +} + +#[wasm_bindgen] +pub fn list_supported_strategies() -> JsValue { + let strategies = strategy::supported_strategies(); + JsValue::from_str(&serde_json::to_string(strategies).unwrap()) +} diff --git a/cash-register-wasm/src/processor/formatter.rs b/cash-register-wasm/src/processor/formatter.rs new file mode 100644 index 00000000..7039a160 --- /dev/null +++ b/cash-register-wasm/src/processor/formatter.rs @@ -0,0 +1,62 @@ +use crate::types::Denomination; + +pub fn format_denominations(result: &[(usize, u32)], denominations: &[Denomination]) -> String { + result + .iter() + .filter(|(_, count)| *count > 0) + .map(|(i, count)| { + let denom = &denominations[*i]; + let name = if *count == 1 { + denom.singular + } else { + denom.plural + }; + format!("{} {}", count, name) + }) + .collect::>() + .join(",") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Denomination; + + const TEST_DENOMS: &[Denomination] = &[ + Denomination { value: 100, singular: "dollar", plural: "dollars" }, + Denomination { value: 25, singular: "quarter", plural: "quarters" }, + Denomination { value: 10, singular: "dime", plural: "dimes" }, + Denomination { value: 5, singular: "nickel", plural: "nickels" }, + Denomination { value: 1, singular: "penny", plural: "pennies" }, + ]; + + #[test] + fn format_multiple_denominations() { + let result = vec![(1, 3), (2, 1), (4, 3)]; + assert_eq!(format_denominations(&result, TEST_DENOMS), "3 quarters,1 dime,3 pennies"); + } + + #[test] + fn format_single_denomination() { + let result = vec![(0, 1)]; + assert_eq!(format_denominations(&result, TEST_DENOMS), "1 dollar"); + } + + #[test] + fn format_plural_vs_singular() { + let result = vec![(0, 2)]; + assert_eq!(format_denominations(&result, TEST_DENOMS), "2 dollars"); + } + + #[test] + fn format_empty() { + let result: Vec<(usize, u32)> = vec![]; + assert_eq!(format_denominations(&result, TEST_DENOMS), ""); + } + + #[test] + fn format_skips_zero_count() { + let result = vec![(0, 0), (1, 3)]; + assert_eq!(format_denominations(&result, TEST_DENOMS), "3 quarters"); + } +} diff --git a/cash-register-wasm/src/processor/mod.rs b/cash-register-wasm/src/processor/mod.rs new file mode 100644 index 00000000..e10113a0 --- /dev/null +++ b/cash-register-wasm/src/processor/mod.rs @@ -0,0 +1,177 @@ +pub mod formatter; +pub mod parser; + +use serde::Serialize; + +use crate::currency; +use crate::error::CashError; +use crate::rules::RulesEngine; + +#[derive(Debug, Serialize)] +pub struct LineResult { + pub line: usize, + pub input: String, + pub output: Option, + pub strategy: Option, + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct ProcessResponse { + pub success: bool, + pub results: Vec, + pub total_lines: usize, + pub error_count: usize, +} + +pub fn process_file(file_content: &str, rules_engine: &RulesEngine) -> Result { + let currency = currency::currency_for_code(rules_engine.currency_code())?; + let denominations = currency.denominations(); + + let lines: Vec<&str> = file_content + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .collect(); + + let total_lines = lines.len(); + let mut error_count = 0; + + let results: Vec = lines + .iter() + .enumerate() + .map(|(i, line)| { + let line_number = i + 1; + + match parser::parse_line(line, line_number, currency.as_ref()) { + Err(e) => { + error_count += 1; + LineResult { + line: line_number, + input: line.to_string(), + output: None, + strategy: None, + error: Some(e.to_string()), + } + } + Ok(transaction) => { + if transaction.change.0 == 0 { + return LineResult { + line: line_number, + input: line.to_string(), + output: Some(String::new()), + strategy: Some("minimal".to_string()), + error: None, + }; + } + + match rules_engine.select_strategy(&transaction) { + Err(e) => { + error_count += 1; + LineResult { + line: line_number, + input: line.to_string(), + output: None, + strategy: None, + error: Some(e.to_string()), + } + } + Ok(strategy) => { + let change_result = strategy.make_change(transaction.change.0, denominations); + let formatted = formatter::format_denominations(&change_result, denominations); + LineResult { + line: line_number, + input: line.to_string(), + output: Some(formatted), + strategy: Some(strategy.name().to_string()), + error: None, + } + } + } + } + } + }) + .collect(); + + Ok(ProcessResponse { + success: error_count == 0, + results, + total_lines, + error_count, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEFAULT_CONFIG: &str = r#"{ + "currency": "USD", + "default_strategy": "minimal", + "rules": [ + { "condition": { "type": "divisible_by", "divisor": 3 }, "strategy": "random" } + ] + }"#; + + #[test] + fn process_single_line_minimal() { + let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap(); + let response = process_file("2.12,3.00", &engine).unwrap(); + + assert_eq!(response.total_lines, 1); + assert_eq!(response.error_count, 0); + assert_eq!(response.results[0].output.as_deref(), Some("3 quarters,1 dime,3 pennies")); + assert_eq!(response.results[0].strategy.as_deref(), Some("minimal")); + } + + #[test] + fn process_single_line_random_sums_correctly() { + let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap(); + let response = process_file("3.33,5.00", &engine).unwrap(); + + assert_eq!(response.results[0].strategy.as_deref(), Some("random")); + let output = response.results[0].output.as_ref().unwrap(); + assert!(!output.is_empty()); + } + + #[test] + fn process_multiple_lines() { + let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap(); + let input = "2.12,3.00\n1.97,2.00\n3.33,5.00"; + let response = process_file(input, &engine).unwrap(); + + assert_eq!(response.total_lines, 3); + assert_eq!(response.results[0].output.as_deref(), Some("3 quarters,1 dime,3 pennies")); + assert_eq!(response.results[1].output.as_deref(), Some("3 pennies")); + assert_eq!(response.results[2].strategy.as_deref(), Some("random")); + } + + #[test] + fn process_with_bad_line_continues() { + let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap(); + let input = "2.12,3.00\nbadline\n1.97,2.00"; + let response = process_file(input, &engine).unwrap(); + + assert_eq!(response.total_lines, 3); + assert_eq!(response.error_count, 1); + assert!(response.results[1].error.is_some()); + assert_eq!(response.results[2].output.as_deref(), Some("3 pennies")); + } + + #[test] + fn process_skips_empty_lines() { + let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap(); + let input = "2.12,3.00\n\n\n1.97,2.00"; + let response = process_file(input, &engine).unwrap(); + + assert_eq!(response.total_lines, 2); + } + + #[test] + fn process_exact_change() { + let engine = RulesEngine::from_json(DEFAULT_CONFIG).unwrap(); + let response = process_file("3.00,3.00", &engine).unwrap(); + + assert_eq!(response.results[0].output.as_deref(), Some("")); + } +} diff --git a/cash-register-wasm/src/processor/parser.rs b/cash-register-wasm/src/processor/parser.rs new file mode 100644 index 00000000..406cfabb --- /dev/null +++ b/cash-register-wasm/src/processor/parser.rs @@ -0,0 +1,84 @@ +use crate::currency::Currency; +use crate::error::CashError; +use crate::types::{Money, ParsedTransaction}; + +pub fn parse_line( + line: &str, + line_number: usize, + currency: &dyn Currency, +) -> Result { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() != 2 { + return Err(CashError::ParseError { + line: line_number, + message: format!("Expected 'owed,paid' but got '{}'", line), + }); + } + + let owed = currency.parse_amount(parts[0]).map_err(|_| CashError::ParseError { + line: line_number, + message: format!("Invalid owed amount: '{}'", parts[0].trim()), + })?; + + let paid = currency.parse_amount(parts[1]).map_err(|_| CashError::ParseError { + line: line_number, + message: format!("Invalid paid amount: '{}'", parts[1].trim()), + })?; + + if paid.0 < owed.0 { + return Err(CashError::InsufficientPayment { line: line_number }); + } + + Ok(ParsedTransaction { + owed, + paid, + change: Money(paid.0 - owed.0), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::currency::usd::UsdCurrency; + + #[test] + fn parse_valid_line() { + let currency = UsdCurrency; + let tx = parse_line("2.12,3.00", 1, ¤cy).unwrap(); + assert_eq!(tx.owed, Money(212)); + assert_eq!(tx.paid, Money(300)); + assert_eq!(tx.change, Money(88)); + } + + #[test] + fn parse_line_with_whitespace() { + let currency = UsdCurrency; + let tx = parse_line(" 2.12 , 3.00 ", 1, ¤cy).unwrap(); + assert_eq!(tx.change, Money(88)); + } + + #[test] + fn parse_line_missing_comma() { + let currency = UsdCurrency; + assert!(parse_line("2.12 3.00", 1, ¤cy).is_err()); + } + + #[test] + fn parse_line_insufficient_payment() { + let currency = UsdCurrency; + assert!(parse_line("5.00,3.00", 1, ¤cy).is_err()); + } + + #[test] + fn parse_line_exact_payment() { + let currency = UsdCurrency; + let tx = parse_line("3.00,3.00", 1, ¤cy).unwrap(); + assert_eq!(tx.change, Money(0)); + } + + #[test] + fn parse_line_invalid_amount() { + let currency = UsdCurrency; + assert!(parse_line("abc,3.00", 1, ¤cy).is_err()); + } +} diff --git a/cash-register-wasm/src/rules/mod.rs b/cash-register-wasm/src/rules/mod.rs new file mode 100644 index 00000000..cec8d6e1 --- /dev/null +++ b/cash-register-wasm/src/rules/mod.rs @@ -0,0 +1,190 @@ +pub mod schema; + +use crate::error::CashError; +use crate::strategy::{self, ChangeStrategy}; +use crate::types::ParsedTransaction; + +use schema::{Condition, RulesConfig}; + +pub struct RulesEngine { + config: RulesConfig, +} + +impl RulesEngine { + pub fn from_json(json: &str) -> Result { + let config: RulesConfig = serde_json::from_str(json) + .map_err(|e| CashError::ConfigParseError(e.to_string()))?; + + strategy::strategy_for_name(&config.default_strategy)?; + for rule in &config.rules { + strategy::strategy_for_name(&rule.strategy)?; + } + + Ok(RulesEngine { config }) + } + + pub fn currency_code(&self) -> &str { + &self.config.currency + } + + pub fn select_strategy( + &self, + transaction: &ParsedTransaction, + ) -> Result, CashError> { + for rule in &self.config.rules { + if Self::matches_condition(&rule.condition, transaction) { + return strategy::strategy_for_name(&rule.strategy); + } + } + strategy::strategy_for_name(&self.config.default_strategy) + } + + fn matches_condition(condition: &Condition, transaction: &ParsedTransaction) -> bool { + match condition { + Condition::DivisibleBy { divisor } => { + *divisor != 0 && transaction.owed.0 % divisor == 0 + } + Condition::AmountRange { min_cents, max_cents } => { + let change = transaction.change.0; + let above_min = min_cents.map_or(true, |min| change >= min); + let below_max = max_cents.map_or(true, |max| change <= max); + above_min && below_max + } + Condition::Always => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Money; + + fn make_transaction(owed_cents: i64, paid_cents: i64) -> ParsedTransaction { + ParsedTransaction { + owed: Money(owed_cents), + paid: Money(paid_cents), + change: Money(paid_cents - owed_cents), + } + } + + #[test] + fn default_strategy_when_no_rules_match() { + let engine = RulesEngine::from_json(r#"{ + "currency": "USD", + "default_strategy": "minimal", + "rules": [] + }"#).unwrap(); + + let tx = make_transaction(197, 200); + let strategy = engine.select_strategy(&tx).unwrap(); + assert_eq!(strategy.name(), "minimal"); + } + + #[test] + fn divisible_by_3_selects_random() { + let engine = RulesEngine::from_json(r#"{ + "currency": "USD", + "default_strategy": "minimal", + "rules": [ + { "condition": { "type": "divisible_by", "divisor": 3 }, "strategy": "random" } + ] + }"#).unwrap(); + + let tx = make_transaction(333, 500); + let strategy = engine.select_strategy(&tx).unwrap(); + assert_eq!(strategy.name(), "random"); + } + + #[test] + fn not_divisible_by_3_selects_minimal() { + let engine = RulesEngine::from_json(r#"{ + "currency": "USD", + "default_strategy": "minimal", + "rules": [ + { "condition": { "type": "divisible_by", "divisor": 3 }, "strategy": "random" } + ] + }"#).unwrap(); + + let tx = make_transaction(212, 300); + let strategy = engine.select_strategy(&tx).unwrap(); + assert_eq!(strategy.name(), "minimal"); + } + + #[test] + fn custom_divisor() { + let engine = RulesEngine::from_json(r#"{ + "currency": "USD", + "default_strategy": "minimal", + "rules": [ + { "condition": { "type": "divisible_by", "divisor": 5 }, "strategy": "random" } + ] + }"#).unwrap(); + + let tx = make_transaction(500, 1000); + let strategy = engine.select_strategy(&tx).unwrap(); + assert_eq!(strategy.name(), "random"); + } + + #[test] + fn amount_range_condition() { + let engine = RulesEngine::from_json(r#"{ + "currency": "USD", + "default_strategy": "minimal", + "rules": [ + { "condition": { "type": "amount_range", "min_cents": 100, "max_cents": 500 }, "strategy": "random" } + ] + }"#).unwrap(); + + let tx_in_range = make_transaction(100, 400); + assert_eq!(engine.select_strategy(&tx_in_range).unwrap().name(), "random"); + + let tx_out_of_range = make_transaction(100, 110); + assert_eq!(engine.select_strategy(&tx_out_of_range).unwrap().name(), "minimal"); + } + + #[test] + fn first_matching_rule_wins() { + let engine = RulesEngine::from_json(r#"{ + "currency": "USD", + "default_strategy": "minimal", + "rules": [ + { "condition": { "type": "divisible_by", "divisor": 3 }, "strategy": "random" }, + { "condition": { "type": "always" }, "strategy": "minimal" } + ] + }"#).unwrap(); + + let tx = make_transaction(333, 500); + assert_eq!(engine.select_strategy(&tx).unwrap().name(), "random"); + } + + #[test] + fn invalid_strategy_in_config_fails() { + let result = RulesEngine::from_json(r#"{ + "currency": "USD", + "default_strategy": "bogus", + "rules": [] + }"#); + assert!(result.is_err()); + } + + #[test] + fn invalid_json_fails() { + let result = RulesEngine::from_json("not json"); + assert!(result.is_err()); + } + + #[test] + fn divisor_zero_does_not_match() { + let engine = RulesEngine::from_json(r#"{ + "currency": "USD", + "default_strategy": "minimal", + "rules": [ + { "condition": { "type": "divisible_by", "divisor": 0 }, "strategy": "random" } + ] + }"#).unwrap(); + + let tx = make_transaction(333, 500); + assert_eq!(engine.select_strategy(&tx).unwrap().name(), "minimal"); + } +} diff --git a/cash-register-wasm/src/rules/schema.rs b/cash-register-wasm/src/rules/schema.rs new file mode 100644 index 00000000..34204c29 --- /dev/null +++ b/cash-register-wasm/src/rules/schema.rs @@ -0,0 +1,28 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct RulesConfig { + pub currency: String, + pub default_strategy: String, + #[serde(default)] + pub rules: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Rule { + pub condition: Condition, + pub strategy: String, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Condition { + DivisibleBy { divisor: i64 }, + AmountRange { + #[serde(default)] + min_cents: Option, + #[serde(default)] + max_cents: Option, + }, + Always, +} diff --git a/cash-register-wasm/src/strategy/minimal.rs b/cash-register-wasm/src/strategy/minimal.rs new file mode 100644 index 00000000..566839f2 --- /dev/null +++ b/cash-register-wasm/src/strategy/minimal.rs @@ -0,0 +1,78 @@ +use crate::types::Denomination; + +use super::ChangeStrategy; + +pub struct MinimalChange; + +impl ChangeStrategy for MinimalChange { + fn name(&self) -> &'static str { + "minimal" + } + + fn make_change(&self, change_cents: i64, denominations: &[Denomination]) -> Vec<(usize, u32)> { + let mut remaining = change_cents; + let mut result = Vec::new(); + + for (i, denom) in denominations.iter().enumerate() { + if remaining <= 0 { + break; + } + let count = remaining / denom.value; + if count > 0 { + result.push((i, count as u32)); + remaining -= count * denom.value; + } + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Denomination; + + const TEST_DENOMS: &[Denomination] = &[ + Denomination { value: 100, singular: "dollar", plural: "dollars" }, + Denomination { value: 25, singular: "quarter", plural: "quarters" }, + Denomination { value: 10, singular: "dime", plural: "dimes" }, + Denomination { value: 5, singular: "nickel", plural: "nickels" }, + Denomination { value: 1, singular: "penny", plural: "pennies" }, + ]; + + #[test] + fn change_88_cents() { + let strategy = MinimalChange; + let result = strategy.make_change(88, TEST_DENOMS); + assert_eq!(result, vec![(1, 3), (2, 1), (4, 3)]); + } + + #[test] + fn change_exact_dollar() { + let strategy = MinimalChange; + let result = strategy.make_change(100, TEST_DENOMS); + assert_eq!(result, vec![(0, 1)]); + } + + #[test] + fn change_zero() { + let strategy = MinimalChange; + let result = strategy.make_change(0, TEST_DENOMS); + assert!(result.is_empty()); + } + + #[test] + fn change_one_cent() { + let strategy = MinimalChange; + let result = strategy.make_change(1, TEST_DENOMS); + assert_eq!(result, vec![(4, 1)]); + } + + #[test] + fn change_167_cents() { + let strategy = MinimalChange; + let result = strategy.make_change(167, TEST_DENOMS); + assert_eq!(result, vec![(0, 1), (1, 2), (2, 1), (3, 1), (4, 2)]); + } +} diff --git a/cash-register-wasm/src/strategy/mod.rs b/cash-register-wasm/src/strategy/mod.rs new file mode 100644 index 00000000..7de8547f --- /dev/null +++ b/cash-register-wasm/src/strategy/mod.rs @@ -0,0 +1,44 @@ +pub mod minimal; +pub mod random; + +use crate::error::CashError; +use crate::types::Denomination; + +pub trait ChangeStrategy: Send + Sync { + fn name(&self) -> &'static str; + fn make_change(&self, change_cents: i64, denominations: &[Denomination]) -> Vec<(usize, u32)>; +} + +pub fn strategy_for_name(name: &str) -> Result, CashError> { + match name { + "minimal" => Ok(Box::new(minimal::MinimalChange)), + "random" => Ok(Box::new(random::RandomChange)), + other => Err(CashError::UnknownStrategy(other.to_string())), + } +} + +pub fn supported_strategies() -> &'static [&'static str] { + &["minimal", "random"] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_minimal() { + let strategy = strategy_for_name("minimal").unwrap(); + assert_eq!(strategy.name(), "minimal"); + } + + #[test] + fn registry_random() { + let strategy = strategy_for_name("random").unwrap(); + assert_eq!(strategy.name(), "random"); + } + + #[test] + fn registry_unknown() { + assert!(strategy_for_name("greedy").is_err()); + } +} diff --git a/cash-register-wasm/src/strategy/random.rs b/cash-register-wasm/src/strategy/random.rs new file mode 100644 index 00000000..43894a7e --- /dev/null +++ b/cash-register-wasm/src/strategy/random.rs @@ -0,0 +1,81 @@ +use rand::Rng; + +use crate::types::Denomination; + +use super::ChangeStrategy; + +pub struct RandomChange; + +impl ChangeStrategy for RandomChange { + fn name(&self) -> &'static str { + "random" + } + + fn make_change(&self, change_cents: i64, denominations: &[Denomination]) -> Vec<(usize, u32)> { + let mut remaining = change_cents; + let mut counts = vec![0u32; denominations.len()]; + let mut rng = rand::thread_rng(); + + while remaining > 0 { + let eligible: Vec = denominations + .iter() + .enumerate() + .filter(|(_, d)| d.value <= remaining) + .map(|(i, _)| i) + .collect(); + + if eligible.is_empty() { + break; + } + + let pick = eligible[rng.gen_range(0..eligible.len())]; + counts[pick] += 1; + remaining -= denominations[pick].value; + } + + counts + .into_iter() + .enumerate() + .filter(|(_, count)| *count > 0) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Denomination; + + const TEST_DENOMS: &[Denomination] = &[ + Denomination { value: 100, singular: "dollar", plural: "dollars" }, + Denomination { value: 25, singular: "quarter", plural: "quarters" }, + Denomination { value: 10, singular: "dime", plural: "dimes" }, + Denomination { value: 5, singular: "nickel", plural: "nickels" }, + Denomination { value: 1, singular: "penny", plural: "pennies" }, + ]; + + fn total_from_result(result: &[(usize, u32)], denoms: &[Denomination]) -> i64 { + result.iter().map(|(i, count)| denoms[*i].value * (*count as i64)).sum() + } + + #[test] + fn random_change_sums_correctly() { + let strategy = RandomChange; + for amount in [1, 5, 10, 25, 33, 88, 100, 167, 250, 999] { + let result = strategy.make_change(amount, TEST_DENOMS); + assert_eq!( + total_from_result(&result, TEST_DENOMS), + amount, + "Failed for amount {}", + amount + ); + } + } + + #[test] + fn random_change_zero() { + let strategy = RandomChange; + let result = strategy.make_change(0, TEST_DENOMS); + assert!(result.is_empty()); + } +} diff --git a/cash-register-wasm/src/types.rs b/cash-register-wasm/src/types.rs new file mode 100644 index 00000000..75280be7 --- /dev/null +++ b/cash-register-wasm/src/types.rs @@ -0,0 +1,93 @@ +use serde::Serialize; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct Money(pub i64); + +impl Money { + pub fn from_str_decimal(s: &str) -> Option { + let trimmed = s.trim(); + let parts: Vec<&str> = trimmed.split('.').collect(); + match parts.len() { + 1 => { + let dollars = parts[0].parse::().ok()?; + Some(Money(dollars * 100)) + } + 2 => { + let dollars = parts[0].parse::().ok()?; + let cent_str = parts[1]; + if cent_str.len() > 2 { + return None; + } + let cents = if cent_str.len() == 1 { + cent_str.parse::().ok()? * 10 + } else { + cent_str.parse::().ok()? + }; + Some(Money(dollars * 100 + cents)) + } + _ => None, + } + } + + pub fn from_str_comma_decimal(s: &str) -> Option { + let replaced = s.trim().replace(',', "."); + Money::from_str_decimal(&replaced) + } +} + +impl fmt::Display for Money { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{:02}", self.0 / 100, self.0 % 100) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct Denomination { + pub value: i64, + pub singular: &'static str, + pub plural: &'static str, +} + +#[derive(Debug, Clone)] +pub struct ParsedTransaction { + pub owed: Money, + pub paid: Money, + pub change: Money, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_decimal_standard() { + assert_eq!(Money::from_str_decimal("2.13"), Some(Money(213))); + assert_eq!(Money::from_str_decimal("3.00"), Some(Money(300))); + assert_eq!(Money::from_str_decimal("0.01"), Some(Money(1))); + assert_eq!(Money::from_str_decimal("100"), Some(Money(10000))); + } + + #[test] + fn parse_decimal_single_digit_cents() { + assert_eq!(Money::from_str_decimal("1.5"), Some(Money(150))); + } + + #[test] + fn parse_decimal_invalid() { + assert_eq!(Money::from_str_decimal("abc"), None); + assert_eq!(Money::from_str_decimal("1.234"), None); + assert_eq!(Money::from_str_decimal(""), None); + } + + #[test] + fn parse_decimal_whitespace() { + assert_eq!(Money::from_str_decimal(" 2.13 "), Some(Money(213))); + } + + #[test] + fn parse_comma_decimal() { + assert_eq!(Money::from_str_comma_decimal("2,13"), Some(Money(213))); + assert_eq!(Money::from_str_comma_decimal("3,00"), Some(Money(300))); + } +} diff --git a/cash-register-wasm/tests/integration_tests.rs b/cash-register-wasm/tests/integration_tests.rs new file mode 100644 index 00000000..0e946851 --- /dev/null +++ b/cash-register-wasm/tests/integration_tests.rs @@ -0,0 +1,86 @@ +use cash_register_wasm::processor; +use cash_register_wasm::rules::RulesEngine; + +const USD_CONFIG: &str = r#"{ + "currency": "USD", + "default_strategy": "minimal", + "rules": [ + { "condition": { "type": "divisible_by", "divisor": 3 }, "strategy": "random" } + ] +}"#; + +const EUR_CONFIG: &str = r#"{ + "currency": "EUR", + "default_strategy": "minimal", + "rules": [] +}"#; + +#[test] +fn readme_sample_line_1() { + let engine = RulesEngine::from_json(USD_CONFIG).unwrap(); + let response = processor::process_file("2.12,3.00", &engine).unwrap(); + + assert_eq!(response.results[0].output.as_deref(), Some("3 quarters,1 dime,3 pennies")); + assert_eq!(response.results[0].strategy.as_deref(), Some("minimal")); +} + +#[test] +fn readme_sample_line_2() { + let engine = RulesEngine::from_json(USD_CONFIG).unwrap(); + let response = processor::process_file("1.97,2.00", &engine).unwrap(); + + assert_eq!(response.results[0].output.as_deref(), Some("3 pennies")); + assert_eq!(response.results[0].strategy.as_deref(), Some("minimal")); +} + +#[test] +fn readme_sample_line_3_random_strategy_used() { + let engine = RulesEngine::from_json(USD_CONFIG).unwrap(); + let response = processor::process_file("3.33,5.00", &engine).unwrap(); + + assert_eq!(response.results[0].strategy.as_deref(), Some("random")); + assert!(!response.results[0].output.as_ref().unwrap().is_empty()); +} + +#[test] +fn readme_full_sample() { + let engine = RulesEngine::from_json(USD_CONFIG).unwrap(); + let input = "2.12,3.00\n1.97,2.00\n3.33,5.00"; + let response = processor::process_file(input, &engine).unwrap(); + + assert_eq!(response.total_lines, 3); + assert!(response.results[0].error.is_none()); + assert!(response.results[1].error.is_none()); + assert!(response.results[2].error.is_none()); +} + +#[test] +fn eur_currency_denominations() { + let engine = RulesEngine::from_json(EUR_CONFIG).unwrap(); + let response = processor::process_file("1.50,2.00", &engine).unwrap(); + + let output = response.results[0].output.as_ref().unwrap(); + assert_eq!(output, "1 50-cent coin"); +} + +#[test] +fn eur_multiple_denominations() { + let engine = RulesEngine::from_json(EUR_CONFIG).unwrap(); + let response = processor::process_file("0.27,1.00", &engine).unwrap(); + + let output = response.results[0].output.as_ref().unwrap(); + assert_eq!(output, "1 50-cent coin,1 20-cent coin,1 2-cent coin,1 1-cent coin"); +} + +#[test] +fn malformed_input_per_line_error() { + let engine = RulesEngine::from_json(USD_CONFIG).unwrap(); + let input = "2.12,3.00\nnot_valid\n1.97,2.00"; + let response = processor::process_file(input, &engine).unwrap(); + + assert_eq!(response.total_lines, 3); + assert_eq!(response.error_count, 1); + assert!(response.results[0].error.is_none()); + assert!(response.results[1].error.is_some()); + assert!(response.results[2].error.is_none()); +} diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md new file mode 100644 index 00000000..a38f2eaf --- /dev/null +++ b/docs/CODEBASE_MAP.md @@ -0,0 +1,328 @@ +# System Overview + +CashRegister is a two-crate project: a **Rust/WebAssembly core** that computes change denominations for transaction files, and a **React + TanStack Start frontend** that wraps the WASM module behind a file-upload UI and a rules-builder form. The WASM core is built with `wasm-pack --target bundler` and imported directly into the Vite bundle via server-only code, so all processing runs inside a TanStack Start server function. + +```mermaid +graph TB + subgraph Client["Browser (React 19 + TanStack Router)"] + FileUpload[FileUpload] + RulesBuilder[RulesBuilder] + ResultsTable[ResultsTable] + IndexPage[routes/index.tsx
state container] + end + subgraph Server["TanStack Start Server Function"] + ProcessFn[processTransactions
createServerFn POST] + WasmLoader[wasm-loader.ts] + Zod[Zod: ProcessRequest / ProcessResponse] + end + subgraph WASM["cash-register-wasm (Rust → WASM)"] + Lib[lib.rs
#wasm_bindgen API] + Rules[rules::RulesEngine] + Processor[processor::process_file] + Currency[Currency trait
USD · EUR] + Strategy[ChangeStrategy trait
minimal · random] + end + + FileUpload --> IndexPage + RulesBuilder --> IndexPage + IndexPage -->|buildConfigJson| ProcessFn + ProcessFn --> Zod + Zod --> WasmLoader + WasmLoader --> Lib + Lib --> Rules + Lib --> Processor + Processor --> Currency + Processor --> Strategy + Processor -->|ProcessResponse JSON| WasmLoader + WasmLoader --> ProcessFn + ProcessFn -->|ProcessResult| IndexPage + IndexPage --> ResultsTable +``` + +## Directory Structure + +``` +CashRegister/ +├── app/ # React + TanStack Start frontend +│ ├── src/ +│ │ ├── routes/ # File-based routing (__root, index) +│ │ ├── components/ # UI components + __tests__ +│ │ │ ├── FileUpload.tsx # Drag-drop file reader +│ │ │ ├── RulesBuilder.tsx # Config form (currency, strategies, rules) +│ │ │ ├── ResultsTable.tsx # Per-line output table + summary +│ │ │ └── ErrorBanner.tsx # Dismissible error display +│ │ ├── lib/ # Shared Zod schemas, defaults, serializer +│ │ ├── server/ # Server function + WASM loader (server-only) +│ │ ├── wasm-pkg/ # wasm-pack output (consumed by server/) +│ │ ├── styles/app.css # Hand-written dark theme (no Tailwind) +│ │ ├── test/setup.ts # jest-dom matcher extension +│ │ ├── router.tsx # getRouter() factory +│ │ └── routeTree.gen.ts # Auto-generated route tree +│ ├── vite.config.ts # wasm + topLevelAwait + tanstackStart + react +│ ├── vitest.config.ts # jsdom, excludes wasm-pkg +│ └── package.json # bun scripts: wasm:build, dev, build, test +├── cash-register-wasm/ # Rust WASM crate +│ ├── src/ +│ │ ├── lib.rs # #[wasm_bindgen] API surface +│ │ ├── types.rs # Money (i64 cents), Denomination, ParsedTransaction +│ │ ├── error.rs # CashError (thiserror) +│ │ ├── currency/ # Currency trait + USD + EUR +│ │ ├── processor/ # process_file pipeline + parser + formatter +│ │ ├── rules/ # RulesEngine + RulesConfig schema (serde) +│ │ └── strategy/ # ChangeStrategy trait + minimal + random +│ ├── tests/integration_tests.rs # Black-box processor tests +│ ├── pkg/ # wasm-pack build output +│ └── Cargo.toml # cdylib + rlib, wasm-bindgen, rand/js, serde +├── README.md # Product problem statement +└── sample-input.txt # 3-line USD example +``` + +## Module Guide + +### app/routes — Routing + +**Purpose**: Single-route SPA shell. +**Entry point**: [app/src/routes/__root.tsx](app/src/routes/__root.tsx) +**Key files**: +| File | Purpose | +|------|---------| +| [__root.tsx](app/src/routes/__root.tsx) | RootDocument shell (HTML + HeadContent + Scripts) and RootLayout outlet | +| [index.tsx](app/src/routes/index.tsx) | The only page — owns all app state and orchestrates file → WASM → results flow | +| [router.tsx](app/src/router.tsx) | `getRouter()` factory: `defaultPreload: "intent"`, scroll restoration | +| [routeTree.gen.ts](app/src/routeTree.gen.ts) | Auto-generated by TanStack Router Vite plugin — do not edit | + +**Patterns**: TanStack Start with SSR enabled. Root route declares `` metadata via `head()` loader. +**Gotchas**: `IndexPage` holds all state in local `useState` (no TanStack Query, no route loader) — data flow is explicit user-click → server fn call. + +--- + +### app/components — UI Components + +**Purpose**: Stateless, fully-controlled presentation layer. +**Key files**: +| File | Purpose | +|------|---------| +| [FileUpload.tsx](app/src/components/FileUpload.tsx) | Drag-drop / click file input; `FileReader.readAsText()`; `onFileLoaded(content, fileName)` | +| [RulesBuilder.tsx](app/src/components/RulesBuilder.tsx) | Controlled form for `RulesConfig` (currency select, default strategy, rules list) | +| [ResultsTable.tsx](app/src/components/ResultsTable.tsx) | Renders `ProcessResponse.results` + summary footer; strategy badges (`badge-ok`/`badge-random`) | +| [ErrorBanner.tsx](app/src/components/ErrorBanner.tsx) | Dismissible error message (dismiss button conditional on `onDismiss` prop) | +| [ResultsTable.skeleton.tsx](app/src/components/ResultsTable.skeleton.tsx) | Hardcoded 3-row loading skeleton | +| [RulesBuilder.skeleton.tsx](app/src/components/RulesBuilder.skeleton.tsx) | Skeleton (not currently rendered — RulesBuilder mounts synchronously) | + +**Patterns**: Fully controlled components — every edit fires `onChange` with a new config. No internal state in RulesBuilder. Skeletons mirror layout for `isProcessing` states. +**Gotchas**: +- `RulesBuilder` only exposes the `divisible_by` divisor input; `amount_range` has `min_cents`/`max_cents` in the schema but no UI fields yet. +- Divisor input silently ignores `NaN` / `<= 0` (no validation error shown). +- Condition-type switches reset condition fields to defaults. + +--- + +### app/lib — Shared Schemas & Helpers + +**Purpose**: Zod schemas, defaults, serialization — single source of truth for client/server data contract. +**Key files**: +| File | Purpose | +|------|---------| +| [schemas.ts](app/src/lib/schemas.ts) | All Zod schemas: `ProcessRequest`, `ProcessResponse`, `Condition` (discriminated union), `Rule`, `RulesConfig` | +| [default-config.ts](app/src/lib/default-config.ts) | `DEFAULT_RULES_CONFIG`: USD / minimal / one `divisible_by: 3 → random` rule | +| [build-config.ts](app/src/lib/build-config.ts) | `buildConfigJson(config)` — isolated `JSON.stringify` for testability | + +**Patterns**: `ConditionSchema = z.discriminatedUnion("type", [...])`. Schemas are imported from both client (form state typing) and server (input validation) — single source of truth for the data contract. + +--- + +### app/server — Server Functions & WASM Loader + +**Purpose**: Server-only code bridging TanStack Start RPC to the WASM module. +**Key files**: +| File | Purpose | +|------|---------| +| [process-file.ts](app/src/server/process-file.ts) | `processTransactions` server fn (`createServerFn({ method: "POST" })`) + `processWithWasm` pure helper | +| [wasm-loader.ts](app/src/server/wasm-loader.ts) | `loadWasmModule()` — imports named exports from `wasm-pkg/` into a typed `WasmModule` shape | +| [wasm-types.ts](app/src/server/wasm-types.ts) | `WasmModule` interface — tightens `any` returns from wasm-pack `.d.ts` to `string` | + +**Pipeline**: `ProcessRequestSchema.parse(input)` → `loadWasmModule()` → `wasm.process_transactions(...)` → `JSON.parse` → `ProcessResponseSchema.parse` → `ProcessResult = { ok: true, data } | { ok: false, error }`. +**Patterns**: The server fn returns a discriminated envelope, not thrown errors — the client branches on `response.ok`. `processWithWasm` is extracted as a pure function so tests can drive it with a mocked `WasmModule` (no TanStack Start runtime needed). +**Gotchas**: `wasm-loader.ts` must never run client-side. Tests mock it via `vi.mock("../wasm-loader", ...)` at the module level, and also mock `@tanstack/react-start`'s `createServerFn` chain. + +--- + +### app/wasm-pkg — WASM Package (in-tree build output) + +**Purpose**: Output of `wasm-pack build --target bundler` run against `../cash-register-wasm`, consumed by `wasm-loader.ts`. +**Notable**: `.d.ts` types all as `any` — the `WasmModule` interface in `wasm-types.ts` is the real typed contract. `package.json` declares `sideEffects: ["./cash_register_wasm.js", "./snippets/*"]` to preserve WASM init code under tree-shaking. +**Build**: `bun run wasm:build` rebuilds the package. `dev` and `build` scripts chain it automatically. `vitest` excludes `**/wasm-pkg/**` from test runs. + +--- + +### cash-register-wasm — Rust Core + +**Purpose**: Pure Rust implementation of the parsing, rules, and change-making logic, compiled to WASM and consumed by the frontend. + +#### lib.rs — Public API +**Entry point**: [cash-register-wasm/src/lib.rs](cash-register-wasm/src/lib.rs) +Four `#[wasm_bindgen]` exports: +| Function | Returns | +|---|---| +| `process_transactions(file_content, rules_config_json)` | JSON string of `ProcessResponse` | +| `validate_config(rules_config_json)` | void (throws on invalid config) | +| `list_supported_currencies()` | JSON string array | +| `list_supported_strategies()` | JSON string array | + +Errors are stringified into `JsValue` — JS receives plain strings, not structured errors. `process_transactions` returns a JSON **string** inside a `JsValue`, not a JS object; callers must `JSON.parse()`. + +#### types.rs — Value Types +| Type | Purpose | +|---|---| +| `Money(i64)` | Newtype — stores cents as `i64`. `Display` divides by 100. Parsers: `from_str_decimal`, `from_str_comma_decimal` | +| `Denomination { value, singular, plural }` | Coin/bill tier; value is in cents | +| `ParsedTransaction { owed, paid, change }` | Result of parsing one `"owed,paid"` line | + +All arithmetic is integer. `"1.5"` normalizes to 150 cents (tenths). + +#### error.rs — `CashError` +`thiserror`-derived unified error with variants: `UnknownCurrency`, `UnknownStrategy`, `ConfigParseError`, `ParseError { line, message }`, `InsufficientPayment { line }`. `Serialize` is derived but unused — errors bubble to `lib.rs` and are stringified into `JsValue`. + +#### currency/ — Currency Trait & Implementations +- `trait Currency: Send + Sync` — `code()`, `denominations()`, `parse_amount()` +- `currency_for_code(code) -> Result, CashError>` — factory +- `supported_currencies() -> &'static [&'static str]` → `["USD", "EUR"]` +- **USD** ([usd.rs](cash-register-wasm/src/currency/usd.rs)): 5 denominations: 100, 25, 10, 5, 1 cents; dot-decimal only. "Dollar" is the 100-cent tier — no $5/$10/$20. +- **EUR** ([eur.rs](cash-register-wasm/src/currency/eur.rs)): 8 denominations: 200, 100, 50, 20, 10, 5, 2, 1 cents; tries comma-decimal first, falls back to dot. + +#### processor/ — Pipeline Orchestrator +[processor/mod.rs](cash-register-wasm/src/processor/mod.rs) drives the full per-file pipeline. `process_file`: +1. Resolves currency from `engine.currency_code()`. +2. Splits input, trims, filters blanks. +3. Per line: `parse_line` → if `change == 0` short-circuits to `""` + minimal → `engine.select_strategy(&tx)` → `strategy.make_change(change_cents, denominations)` → `format_denominations`. +4. Line-level errors are caught per-line and recorded in `LineResult.error` (processing continues). Only currency resolution failure aborts the whole file. + +- [parser.rs](cash-register-wasm/src/processor/parser.rs): Splits on `,` (exactly two fields), parses each via `currency.parse_amount()`, enforces `paid >= owed`. +- [formatter.rs](cash-register-wasm/src/processor/formatter.rs): `Vec<(index, count)>` → `"3 quarters,1 dime,3 pennies"` (comma-joined, singular/plural from `Denomination`, zero-count omitted). + +#### rules/ — Rules Engine +- [schema.rs](cash-register-wasm/src/rules/schema.rs): Serde types. `Condition` uses `serde(tag = "type", rename_all = "snake_case")` → variants `divisible_by { divisor }`, `amount_range { min_cents?, max_cents? }`, `always`. +- [mod.rs](cash-register-wasm/src/rules/mod.rs): `RulesEngine::from_json` deserializes and **eagerly validates all strategy names**. `select_strategy(&tx)` walks rules in order (first match wins), falls back to `default_strategy`. +- **Asymmetric subjects**: `divisible_by` tests `owed` cents; `amount_range` tests `change` cents. + +#### strategy/ — Change Strategies +- `trait ChangeStrategy: Send + Sync` — `name()`, `make_change(change_cents, denominations) -> Vec<(usize, u32)>`. The `usize` is the index into the currency's `denominations` slice (the coupling point with the formatter). +- **minimal** ([minimal.rs](cash-register-wasm/src/strategy/minimal.rs)): Greedy largest-first. Deterministic. +- **random** ([random.rs](cash-register-wasm/src/strategy/random.rs)): Random walk — each step picks uniformly from denominations that fit in the remaining amount. Uses `rand::thread_rng()`; non-deterministic. + +#### tests/integration_tests.rs +Black-box tests covering the README sample lines (both minimal and random outcomes), EUR denominations, and malformed-input per-line error handling. + +## Data Flow + +### File Upload → WASM → Results + +```mermaid +sequenceDiagram + participant User + participant FileUpload + participant IndexPage + participant ProcessFn as processTransactions
(server fn) + participant WasmLoader + participant Wasm as cash_register_wasm + + User->>FileUpload: Drop/select .csv or .txt + FileUpload->>FileUpload: FileReader.readAsText + FileUpload->>IndexPage: onFileLoaded(content, fileName) + User->>IndexPage: Click "Process File" + IndexPage->>IndexPage: buildConfigJson(config) + IndexPage->>ProcessFn: { fileContent, rulesConfigJson } + ProcessFn->>ProcessFn: ProcessRequestSchema.parse (Zod) + ProcessFn->>WasmLoader: loadWasmModule() + WasmLoader-->>ProcessFn: WasmModule + ProcessFn->>Wasm: process_transactions(fileContent, rulesConfigJson) + Wasm->>Wasm: RulesEngine::from_json (eager strategy validation) + Wasm->>Wasm: processor::process_file (per-line pipeline) + Wasm-->>ProcessFn: JSON string + ProcessFn->>ProcessFn: JSON.parse + ProcessResponseSchema.parse + ProcessFn-->>IndexPage: ProcessResult { ok, data | error } + alt ok + IndexPage->>User: + else error + IndexPage->>User: + end +``` + +### Rules Config Flow + +``` +DEFAULT_RULES_CONFIG (lib/default-config.ts) + → IndexPage useState + → (controlled, fires onChange on every edit) + → buildConfigJson(config) [JSON.stringify] + → passed as rulesConfigJson to processTransactions + → Zod validates ProcessRequest + → wasm.process_transactions(fileContent, rulesConfigJson) + → Rust: RulesEngine::from_json → validates strategy names eagerly +``` + +The frontend only validates the **envelope** (non-empty strings); the WASM module owns all semantic validation of the rules config. + +## Conventions + +- **Frontend naming**: React components use **PascalCase** filenames (`FileUpload.tsx`, `RulesBuilder.tsx`). Skeletons use `.skeleton.tsx` suffix. General helpers are kebab-case (`build-config.ts`, `default-config.ts`). +- **Path alias**: `@/*` → `./src/*` (declared in [app/tsconfig.json](app/tsconfig.json), resolved by `vite-tsconfig-paths`). Use it instead of relative parents. +- **Zod as the contract**: All client/server data types are inferred from Zod schemas in [app/src/lib/schemas.ts](app/src/lib/schemas.ts). Server function input is validated via `.inputValidator(data => ProcessRequestSchema.parse(data))`. +- **Controlled forms**: `RulesBuilder` never holds its own state — it always calls `onChange` with a new config object. +- **Error envelopes, not throws (over RPC)**: `processTransactions` returns a `ProcessResult` discriminated union; the client checks `response.ok` rather than catching. +- **Trait + factory in Rust**: Both `Currency` and `ChangeStrategy` follow the same pattern — trait, string-keyed factory (`currency_for_code` / `strategy_for_name`), and a `supported_*()` accessor. + +## Gotchas + +1. **WASM must be built before Vite runs** — `wasm:build` is chained into `dev` and `build` in [app/package.json](app/package.json). A fresh clone with stale `wasm-pkg/` will fail. +2. **`vite-plugin-wasm` + `vite-plugin-top-level-await` must precede `tanstackStart()`** in [app/vite.config.ts](app/vite.config.ts) or WASM imports won't be processed. +3. **Vitest mocks WASM entirely** — [app/vitest.config.ts](app/vitest.config.ts) excludes `**/wasm-pkg/**`, and `process-file.test.ts` mocks both `@tanstack/react-start` and `../wasm-loader` at the module level. +4. **`process_transactions` returns a JSON string, not a JS object** — the typed `WasmModule` interface in [wasm-types.ts](app/src/server/wasm-types.ts) reflects this (`string` return), then `processWithWasm` parses and Zod-validates it. +5. **`wasm-loader.ts` is server-only** — importing it on the client would try to load the WASM binary in a browser context that's not wired for it. Keep it behind the server fn. +6. **`getrandom` needs the `js` feature** (set in [Cargo.toml](cash-register-wasm/Cargo.toml)) or `rand::thread_rng()` panics at runtime inside WASM. +7. **EUR line format is ambiguous** — the line parser splits on `,`, but EUR's `parse_amount` also accepts `,` as a decimal separator. A line like `"2,13,3,00"` would split into four fields and fail. EUR inputs must currently use dot-decimal at the line level. +8. **Rule condition subjects are asymmetric** — `divisible_by` checks `owed`, `amount_range` checks `change`. Not documented in the UI. +9. **`RulesBuilder` only exposes the `divisible_by` divisor input** — `amount_range`'s `min_cents`/`max_cents` and `always` have no UI fields yet, though the schema supports them. +10. **TypeScript `.d.ts` from wasm-pack uses `any`** — the `WasmModule` interface in `wasm-types.ts` is the real type contract; never import wasm-pkg functions directly. +11. **Strategy names in rules are validated eagerly** at `RulesEngine::from_json` time, even for rules that would never be reached. +12. **`IndexPage` discards the `fileName`** from `FileUpload.onFileLoaded(content, fileName)` — currently unused. +13. **No `else` blocks / no `let`** are repo conventions. Frontend code uses guard clauses; Rust code is unaffected. + +## Navigation Guide + +**To add a new frontend component**: +1. Create `app/src/components/NewThing.tsx` (PascalCase). +2. If loading state applies, create `NewThing.skeleton.tsx` next to it. +3. Add `app/src/components/__tests__/NewThing.test.tsx`. + +**To add a new route**: +1. Drop a file in [app/src/routes/](app/src/routes/). The TanStack Start plugin regenerates [routeTree.gen.ts](app/src/routeTree.gen.ts) automatically. +2. Use route loaders for data (not `useEffect`). + +**To add a new server function**: +1. Create `app/src/server/.ts` (or better: `.query.ts` / `.mutation.ts`). +2. Use `createServerFn({ method: "POST" }).inputValidator(zod).handler(async ({ data }) => ...)`. +3. Extract the pure logic into a helper (like `processWithWasm`) so tests don't need the TanStack Start runtime. + +**To add a new Zod schema**: +1. Add it to [app/src/lib/schemas.ts](app/src/lib/schemas.ts) with an inferred TS type. +2. Import the schema server-side for validation, the type client-side for form state. + +**To add a new currency**: +1. Create `cash-register-wasm/src/currency/.rs` implementing the `Currency` trait. +2. Wire it into [currency/mod.rs](cash-register-wasm/src/currency/mod.rs): add to `currency_for_code` match and `supported_currencies()`. +3. Add integration tests in [tests/integration_tests.rs](cash-register-wasm/tests/integration_tests.rs). +4. Rebuild WASM: `bun run wasm:build` in `app/`. + +**To add a new change strategy**: +1. Create `cash-register-wasm/src/strategy/.rs` implementing `ChangeStrategy`. +2. Wire into [strategy/mod.rs](cash-register-wasm/src/strategy/mod.rs): `strategy_for_name` match + `supported_strategies()`. +3. Update the frontend's `SUPPORTED_STRATEGIES` in [routes/index.tsx](app/src/routes/index.tsx) (or fetch dynamically via `list_supported_strategies`, currently unused). +4. Rebuild WASM. + +**To add a new rule condition type**: +1. Add the variant to `Condition` in [rules/schema.rs](cash-register-wasm/src/rules/schema.rs) with the correct `serde` tag. +2. Implement its match logic in `matches_condition` in [rules/mod.rs](cash-register-wasm/src/rules/mod.rs). +3. Mirror it in the `ConditionSchema` discriminated union in [app/src/lib/schemas.ts](app/src/lib/schemas.ts). +4. Add the UI in [RulesBuilder.tsx](app/src/components/RulesBuilder.tsx). +5. Rebuild WASM. diff --git a/sample-input.txt b/sample-input.txt new file mode 100644 index 00000000..63a50779 --- /dev/null +++ b/sample-input.txt @@ -0,0 +1,3 @@ +2.12,3.00 +1.97,2.00 +3.33,5.00