diff --git a/src/lib/item-size.test.ts b/src/lib/item-size.test.ts new file mode 100644 index 0000000..857375b --- /dev/null +++ b/src/lib/item-size.test.ts @@ -0,0 +1,332 @@ +/** + * item-size — the size parser and the line ↔ candidate comparison. + * + * The descriptions in the "real descriptions" block below are the ones + * that came off live data on 2026-10-24, where the POS shortlist + * ranked an M22 bolt above the M16 the customer asked for because + * they share the words "BOLT 8.8 ZP". + */ + +import { describe, expect, it } from "vitest"; + +import { + compareSizes, + describeSize, + parseItemSize, + SIZE_RANK_EXACT, + SIZE_RANK_KIND, + SIZE_RANK_NEAR_BASE, + SIZE_RANK_OTHER, + SIZE_RANK_THREAD, +} from "./item-size"; + +describe("parseItemSize — the real descriptions", () => { + it("reads a shorthand bolt", () => { + expect(parseItemSize("BOLT HEX M16X60 GR8.8 ZP")).toEqual({ + kind: "bolt", + variant: null, + thread: "M16", + length: 60, + grade: "8.8", + finish: "zp", + dims: [], + }); + }); + + it("reads a spelled-out bolt", () => { + expect(parseItemSize("Hex bolt M16 x 60 grade 8.8 zinc plated")).toEqual({ + kind: "bolt", + variant: null, + thread: "M16", + length: 60, + grade: "8.8", + finish: "zp", + dims: [], + }); + }); + + it("reads a bare grade and a different size", () => { + expect(parseItemSize("M22x65 BOLT 8.8 ZP")).toEqual({ + kind: "bolt", + variant: null, + thread: "M22", + length: 65, + grade: "8.8", + finish: "zp", + dims: [], + }); + }); + + it("reads a nyloc nut in both word orders", () => { + for (const text of ["NUT NYLOC M16 ZINC", "Nyloc nut M16 zinc plated"]) { + expect(parseItemSize(text)).toEqual({ + kind: "nut", + variant: "nyloc", + thread: "M16", + length: null, + grade: null, + finish: "zp", + dims: [], + }); + } + }); + + it("reads a flat washer", () => { + expect(parseItemSize("WASHER FLAT M16 ZINC PLATED")).toEqual({ + kind: "washer", + variant: "flat", + thread: "M16", + length: null, + grade: null, + finish: "zp", + dims: [], + }); + }); + + it("reads a spring washer", () => { + const s = parseItemSize("SPRING WASHER M16 ZP"); + expect(s.kind).toBe("washer"); + expect(s.variant).toBe("spring"); + }); + + it("reads a funnel with two dimensions and no kind", () => { + expect(parseItemSize('Funnel 150mm PVC round 2 piece Flexible ("6") Round')).toEqual({ + kind: null, + variant: null, + thread: null, + length: null, + grade: null, + finish: null, + dims: [ + { value: 150, unit: "mm" }, + { value: 6, unit: "in" }, + ], + }); + }); + + it("reads a welding rod", () => { + expect(parseItemSize("Welding rod 2.5mm")).toEqual({ + kind: null, + variant: null, + thread: null, + length: null, + grade: null, + finish: null, + dims: [{ value: 2.5, unit: "mm" }], + }); + }); + + it("reads a U bolt with a bracketed length", () => { + expect(parseItemSize("U BOLT M24(400) HDG")).toEqual({ + kind: "bolt", + variant: null, + thread: "M24", + length: 400, + grade: null, + finish: "hdg", + dims: [], + }); + }); +}); + +describe("parseItemSize — the attributes", () => { + it("keeps the type word in either order", () => { + expect(parseItemSize("hex bolt M16").kind).toBe("bolt"); + expect(parseItemSize("bolt hex M16").kind).toBe("bolt"); + }); + + it("knows the other fastener types", () => { + expect(parseItemSize("M6x30 SET SCR A2").kind).toBe("setscrew"); + expect(parseItemSize("M6x30 set screw A2").kind).toBe("setscrew"); + expect(parseItemSize("M8 cap screw 40").kind).toBe("cap_screw"); + expect(parseItemSize("M10 threaded stud 100").kind).toBe("stud"); + expect(parseItemSize("Chemical anchor 300").kind).toBe("anchor"); + expect(parseItemSize("4.8 rivet 16").kind).toBe("rivet"); + }); + + it("leaves an unknown type alone", () => { + expect(parseItemSize("Funnel 150mm PVC").kind).toBeNull(); + expect(parseItemSize("Welding rod 2.5mm").kind).toBeNull(); + }); + + it("reads the thread from every spelling", () => { + expect(parseItemSize("M16").thread).toBe("M16"); + expect(parseItemSize("M16X60").thread).toBe("M16"); + expect(parseItemSize("M16 x 60").thread).toBe("M16"); + expect(parseItemSize("m16x60mm").thread).toBe("M16"); + expect(parseItemSize("M 16").thread).toBe("M16"); + }); + + it("reads an imperial thread", () => { + expect(parseItemSize('1/2" bolt').thread).toBe('1/2"'); + expect(parseItemSize("1/2 UNC bolt").thread).toBe("1/2UNC"); + }); + + it("reads the length that follows the thread", () => { + expect(parseItemSize("M16x60").length).toBe(60); + expect(parseItemSize("M16 X 60MM").length).toBe(60); + expect(parseItemSize("M16 × 60").length).toBe(60); + expect(parseItemSize("M16x60mm").length).toBe(60); + expect(parseItemSize("M16").length).toBeNull(); + }); + + it("reads the grade in every spelling", () => { + expect(parseItemSize("bolt GR8.8").grade).toBe("8.8"); + expect(parseItemSize("bolt grade 8.8").grade).toBe("8.8"); + expect(parseItemSize("bolt GR 10.9").grade).toBe("10.9"); + expect(parseItemSize("bolt 12.9").grade).toBe("12.9"); + expect(parseItemSize("bolt 4.6").grade).toBe("4.6"); + }); + + it("does not read a plain number as a grade", () => { + expect(parseItemSize("bolt 60mm").grade).toBeNull(); + expect(parseItemSize("washer 150").grade).toBeNull(); + }); + + it("reads the finish", () => { + expect(parseItemSize("bolt zp").finish).toBe("zp"); + expect(parseItemSize("bolt zinc").finish).toBe("zp"); + expect(parseItemSize("bolt zinc plated").finish).toBe("zp"); + expect(parseItemSize("bolt hdg").finish).toBe("hdg"); + expect(parseItemSize("bolt galv").finish).toBe("hdg"); + expect(parseItemSize("bolt hot dip galvanised").finish).toBe("hdg"); + expect(parseItemSize("bolt ss").finish).toBe("ss"); + expect(parseItemSize("bolt stainless").finish).toBe("ss"); + expect(parseItemSize("bolt 304").finish).toBe("ss"); + expect(parseItemSize("bolt 316").finish).toBe("ss"); + expect(parseItemSize("bolt black").finish).toBe("black"); + expect(parseItemSize("bolt self colour").finish).toBe("black"); + expect(parseItemSize("bolt plain").finish).toBeNull(); + }); + + it("reads a dimension with its unit", () => { + expect(parseItemSize("pipe 150mm").dims).toEqual([{ value: 150, unit: "mm" }]); + expect(parseItemSize("drum 20L").dims).toEqual([{ value: 20, unit: "l" }]); + expect(parseItemSize("plate 2.5mm").dims).toEqual([{ value: 2.5, unit: "mm" }]); + expect(parseItemSize('plate 6"').dims).toEqual([{ value: 6, unit: "in" }]); + }); + + it("does not read the thread length as a dimension", () => { + expect(parseItemSize("M16x60mm").dims).toEqual([]); + expect(parseItemSize("M16 X 60MM").dims).toEqual([]); + expect(parseItemSize("U BOLT M24(400) HDG").dims).toEqual([]); + }); + + it("survives rubbish input", () => { + expect(parseItemSize("")).toEqual({ + kind: null, + variant: null, + thread: null, + length: null, + grade: null, + finish: null, + dims: [], + }); + expect(parseItemSize("the and of").kind).toBeNull(); + }); +}); + +describe("compareSizes", () => { + const line = parseItemSize("BOLT HEX M16X60 GR8.8 ZP"); + + it("is exact when every attribute the line gives matches", () => { + const cand = parseItemSize("HEX BOLT M16X60 8.8 ZP"); + const cmp = compareSizes(line, cand); + expect(cmp.exact).toBe(true); + expect(cmp.differs).toEqual([]); + expect(cmp.rank).toBe(SIZE_RANK_EXACT); + }); + + it("spells the differences out in plain words", () => { + const cmp = compareSizes(line, parseItemSize("M22x65 BOLT 8.8 ZP")); + expect(cmp.exact).toBe(false); + expect(cmp.differs).toContain("thread M22 vs M16"); + expect(cmp.differs).toContain("length 65 vs 60"); + }); + + it("spells a finish difference out", () => { + const cmp = compareSizes(line, parseItemSize("M16X60 BOLT 8.8 HDG")); + expect(cmp.differs).toContain("finish HDG vs ZP"); + }); + + it("never counts an attribute the line does not mention", () => { + // The line says nothing about the finish — a candidate with a + // different finish must not be marked down for it. + const bare = parseItemSize("BOLT M16X60"); + const cmp = compareSizes(bare, parseItemSize("BOLT M16X60 HDG")); + expect(cmp.differs).toEqual([]); + expect(cmp.exact).toBe(true); + }); + + it("counts an attribute the candidate does not mention", () => { + const cmp = compareSizes(line, parseItemSize("HEX BOLT M16")); + expect(cmp.exact).toBe(false); + expect(cmp.differs).toContain("length ? vs 60"); + expect(cmp.differs).toContain("grade ? vs 8.8"); + expect(cmp.differs).toContain("finish ? vs zp"); + }); + + it("is never exact when the line says nothing about size", () => { + const bare = parseItemSize("Widget"); + const cmp = compareSizes(bare, parseItemSize("Widget")); + expect(cmp.exact).toBe(false); + expect(cmp.rank).toBe(SIZE_RANK_OTHER); + }); + + it("is exact when the line only names a type and the candidate is the same type", () => { + const cmp = compareSizes(parseItemSize("Hex bolt"), parseItemSize("HEX BOLT M16")); + expect(cmp.exact).toBe(true); + }); + + it("ranks same kind + thread + length first", () => { + const cmp = compareSizes(line, parseItemSize("HEX BOLT M16X60 8.8 ZP")); + expect(cmp.rank).toBe(SIZE_RANK_EXACT); + }); + + it("ranks same kind + thread by how near the length is", () => { + const near = compareSizes(line, parseItemSize("HEX BOLT M16X65 8.8 ZP")); + const far = compareSizes(line, parseItemSize("HEX BOLT M16X170 8.8 ZP")); + expect(near.rank).toBe(SIZE_RANK_NEAR_BASE + 5); + expect(far.rank).toBe(SIZE_RANK_NEAR_BASE + 110); + expect(near.rank).toBeLessThan(far.rank); + }); + + it("ranks the same thread above the same kind with a different thread", () => { + const sameThread = compareSizes(line, parseItemSize("NUT M16 8.8 ZP")); + const sameKind = compareSizes(line, parseItemSize("M22X60 BOLT 8.8 ZP")); + expect(sameThread.rank).toBe(SIZE_RANK_THREAD); + expect(sameKind.rank).toBe(SIZE_RANK_KIND); + expect(sameThread.rank).toBeLessThan(sameKind.rank); + }); + + it("ranks an M16x60 bolt above an M22x65 bolt", () => { + const right = compareSizes(line, parseItemSize("HEX BOLT M16X60 8.8 ZP")); + const wrong = compareSizes(line, parseItemSize("HEX BOLT M22X65 8.8 ZP")); + expect(right.rank).toBeLessThan(wrong.rank); + }); + + it("ranks everything else last", () => { + const cmp = compareSizes(line, parseItemSize("Funnel 150mm PVC")); + expect(cmp.rank).toBe(SIZE_RANK_OTHER); + }); + + it("compares dimensions", () => { + const funnel = parseItemSize('Funnel 150mm PVC round 2 piece Flexible ("6") Round'); + const same = parseItemSize("Funnel 150mm PVC"); + const other = parseItemSize("Funnel 100mm PVC"); + expect(compareSizes(funnel, same).differs).toContain("size ? vs 6in"); + expect(compareSizes(funnel, other).differs).toContain("size 100mm vs 150mm"); + }); +}); + +describe("describeSize", () => { + it("writes one short line", () => { + expect(describeSize(parseItemSize("BOLT HEX M16X60 GR8.8 ZP"))).toBe( + "bolt, thread M16, length 60, grade 8.8, finish zp", + ); + }); + + it("says nothing when there is nothing to say", () => { + expect(describeSize(parseItemSize("Widget"))).toBe(""); + }); +}); diff --git a/src/lib/item-size.ts b/src/lib/item-size.ts new file mode 100644 index 0000000..fec8333 --- /dev/null +++ b/src/lib/item-size.ts @@ -0,0 +1,442 @@ +/** + * item-size — parse the size out of a free-text item description. + * + * Pure and client-safe: no PocketBase, no AI, no DOM. Both the server + * (POS matcher, quote assistant) and the client (price check popover) + * can use it. + * + * ## Why + * + * The POS shortlist used to rank candidates by *shared words* only. For + * "BOLT HEX M16X60 GR8.8 ZP" every bolt in the catalogue shares + * "BOLT 8.8 ZP" and scores 0.75 — the M16 that the customer actually + * asked for never reached the AI, and the price check showed the price + * of an M22. + * + * Size is the thing that tells two otherwise identical items apart. + * This module pulls it out of the text so the matcher can compare it. + * + * ## What comes out + * + * parseItemSize("BOLT HEX M16X60 GR8.8 ZP") + * → { kind: "bolt", variant: null, thread: "M16", length: 60, + * grade: "8.8", finish: "zp", dims: [] } + * + * parseItemSize("NUT NYLOC M16 ZINC") + * → { kind: "nut", variant: "nyloc", thread: "M16", …, finish: "zp" } + * + * parseItemSize('Funnel 150mm PVC round 2 piece Flexible ("6") Round') + * → { kind: null, …, dims: [{ value: 150, unit: "mm" }, + * { value: 6, unit: "in" }] } + * + * `compareSizes(line, candidate)` then says whether a candidate is the + * same size as the line, and in plain words what differs when it is + * not. + */ + +/** The normalised type of the item. `null` when the text names no type. */ +export type ItemSizeKind = + | "bolt" + | "nut" + | "washer" + | "setscrew" + | "cap_screw" + | "stud" + | "anchor" + | "rivet" + | null; + +/** A qualifier of the kind — "nyloc" nut, "flat"/"spring" washer. */ +export type ItemSizeVariant = "nyloc" | "flat" | "spring" | null; + +/** The surface finish. */ +export type ItemSizeFinish = "zp" | "hdg" | "ss" | "black" | null; + +/** A number with its unit — "150mm", "20L", 6". */ +export interface ItemSizeDim { + value: number; + /** Normalised unit: "mm", "cm", "m", "l", "kg", "g", "in", "ft". */ + unit: string; +} + +/** Everything the parser could tell about one description. */ +export interface ItemSize { + kind: ItemSizeKind; + variant: ItemSizeVariant; + /** Metric ("M16") or imperial ("1/2UNC", "1/2\"") thread. */ + thread: string | null; + /** The length that follows the thread — 60 in "M16x60". */ + length: number | null; + /** Property class / grade — "8.8", "10.9". */ + grade: string | null; + finish: ItemSizeFinish; + /** Other numbers with units, for non-fasteners. */ + dims: ItemSizeDim[]; +} + +/** The result of comparing a line's size with a candidate's size. */ +export interface SizeCompare { + /** True when every attribute the LINE states is matched by the candidate. */ + exact: boolean; + /** Plain words: "length 65 vs 60", "thread M22 vs M16". */ + differs: string[]; + /** Sort key — lower is a better match. */ + rank: number; +} + +// ─── Ranks ───────────────────────────────────────────────────────────── + +/** Same kind, same thread, same length. */ +export const SIZE_RANK_EXACT = 0; +/** Same kind + thread, different length — the bucket starts here and the + * length distance is added, so the nearest length sorts first. */ +export const SIZE_RANK_NEAR_BASE = 10; +/** Same thread, but a different kind (or no kind at all). */ +export const SIZE_RANK_THREAD = 1000; +/** Same kind, but a different thread. */ +export const SIZE_RANK_KIND = 2000; +/** Nothing comparable. */ +export const SIZE_RANK_OTHER = 3000; + +// ─── Word lists ──────────────────────────────────────────────────────── + +/** + * Type words, longest phrase first so "hex head bolt" wins over "bolt". + * The phrase only decides the *kind* — the variant ("flat", "spring", + * "nyloc") is worked out afterwards from the kind. + */ +const KIND_PHRASES: Array<{ re: RegExp; kind: ItemSizeKind }> = [ + { re: /\block\s*nuts?\b/, kind: "nut" }, + { re: /\bhex\s*nuts?\b/, kind: "nut" }, + { re: /\bnuts?\b/, kind: "nut" }, + { re: /\bhex\s*head\s*bolts?\b/, kind: "bolt" }, + { re: /\bhex\s*bolts?\b/, kind: "bolt" }, + { re: /\bbolts?\b/, kind: "bolt" }, + { re: /\bwashers?\b/, kind: "washer" }, + { re: /\bset\s*screws?\b/, kind: "setscrew" }, + { re: /\bset\s*scr\b/, kind: "setscrew" }, + { re: /\bgrub\s*screws?\b/, kind: "setscrew" }, + { re: /\bcap\s*screws?\b/, kind: "cap_screw" }, + { re: /\bsocket\s*head\s*cap\s*screws?\b/, kind: "cap_screw" }, + { re: /\bstuds?\b/, kind: "stud" }, + { re: /\bchemical\s*anchors?\b/, kind: "anchor" }, + { re: /\bwedge\s*anchors?\b/, kind: "anchor" }, + { re: /\banchors?\b/, kind: "anchor" }, + { re: /\brivets?\b/, kind: "rivet" }, +]; + +/** The word used to pull same-kind rows out of the POS catalogue. */ +export const KIND_WORDS: Record = { + bolt: "bolt", + nut: "nut", + washer: "washer", + setscrew: "screw", + cap_screw: "screw", + stud: "stud", + anchor: "anchor", + rivet: "rivet", +}; + +/** Property classes we recognise. A bare number only counts when it is + * one of these — "8.8" is a grade, "60" is a length, "150" is a size. */ +const KNOWN_GRADES = new Set(["4.6", "4.8", "5.6", "6.8", "8.8", "9.8", "10.9", "12.9"]); + +/** + * Units we read a dimension in. The word boundary after the unit is + * what keeps "8.8 GRADE" from being read as 8.8 grams — after the "g" + * of GRADE comes an "r", so there is no boundary and the clause fails. + */ +const DIM_RE = /(\d+(?:\.\d+)?)\s*(mm|cm|litre|liter|inch|in|ft|kg|lt|l|g|m)\b/gi; + +/** An inch mark: 6" */ +const INCH_RE = /(\d+(?:\.\d+)?)\s*"/g; + +/** Finish words, in the order we test them. */ +const FINISH_PATTERNS: Array<{ re: RegExp; finish: ItemSizeFinish }> = [ + { re: /\bzinc\s*plated\b/, finish: "zp" }, + { re: /\bhot\s*dip\s*galv(?:anised|anized)?\b/, finish: "hdg" }, + { re: /\bgalv(?:anised|anized)?\b/, finish: "hdg" }, + { re: /\bhdg\b/, finish: "hdg" }, + { re: /\bstainless\b/, finish: "ss" }, + { re: /\bss\b/, finish: "ss" }, + { re: /\b304\b/, finish: "ss" }, + { re: /\b316\b/, finish: "ss" }, + { re: /\bzinc\b/, finish: "zp" }, + { re: /\bzp\b/, finish: "zp" }, + { re: /\bself\s*colou?r\b/, finish: "black" }, + { re: /\bblack\b/, finish: "black" }, +]; + +// ─── Helpers ─────────────────────────────────────────────────────────── + +/** Replace a span of the text with spaces so later passes ignore it. */ +function mask(text: string, from: number, to: number): string { + if (from < 0 || to <= from) return text; + return text.slice(0, from) + " ".repeat(to - from) + text.slice(to); +} + +function parseKind(text: string): { kind: ItemSizeKind; variant: ItemSizeVariant } { + // "nyloc" names the type by itself — "NYLOC M16" is a nut. + if (/\bnyloc?k?\b/.test(text)) return { kind: "nut", variant: "nyloc" }; + + let kind: ItemSizeKind = null; + for (const entry of KIND_PHRASES) { + if (entry.re.test(text)) { + kind = entry.kind; + break; + } + } + if (kind === null) return { kind: null, variant: null }; + + // The variant of the kind — the word may come before or after the + // type word ("flat washer" and "washer flat" are the same thing). + if (kind === "washer") { + if (/\bspring\b/.test(text)) return { kind, variant: "spring" }; + if (/\bflat\b/.test(text)) return { kind, variant: "flat" }; + } + return { kind, variant: null }; +} + +/** + * The metric thread and the length that follows it. + * + * Handles "M16", "M16X60", "M16 x 60", "m16x60mm" and the bracketed + * form "M24(400)". + */ +function parseThread( + text: string, +): { thread: string | null; length: number | null; spanStart: number; spanEnd: number } { + const metric = /\bM\s*(\d{1,3}(?:\.\d)?)/i.exec(text); + if (metric) { + const thread = `M${metric[1]}`; + const rest = text.slice(metric.index + metric[0].length); + let length: number | null = null; + let spanEnd = metric.index + metric[0].length; + const byX = /^\s*[x×]\s*(\d+(?:\.\d+)?)/i.exec(rest); + if (byX) { + length = Number(byX[1]); + spanEnd += byX[0].length; + } else { + const byBracket = /^\s*\(\s*(\d+(?:\.\d+)?)/.exec(rest); + if (byBracket) { + length = Number(byBracket[1]); + spanEnd += byBracket[0].length; + } + } + return { thread, length, spanStart: metric.index, spanEnd }; + } + + // Imperial: 1/2", 1/2 UNC, 1/4-20 UNC + const imperial = /(\d{1,2})\s*\/\s*(\d{1,2})\s*(UNC|UNF|BSW|BSF)?/i.exec(text); + if (imperial) { + const suffix = imperial[3] ? imperial[3].toUpperCase() : ""; + const after = text.slice(imperial.index + imperial[0].length); + const inchMark = /^\s*"/.test(after) ? '"' : ""; + return { + thread: `${imperial[1]}/${imperial[2]}${suffix}${inchMark}`, + length: null, + spanStart: imperial.index, + spanEnd: imperial.index + imperial[0].length, + }; + } + + return { thread: null, length: null, spanStart: 0, spanEnd: 0 }; +} + +function parseGrade(text: string): string | null { + // "GR8.8", "grade 8.8", "class 10.9" + const labelled = /\b(?:gr|grade|class)\s*\.?\s*(\d{1,2}(?:\.\d)?)\b/i.exec(text); + if (labelled) { + const value = labelled[1]; + if (KNOWN_GRADES.has(value)) return value; + } + // A bare number only counts when it is a known property class. + for (const m of text.matchAll(/\b(\d{1,2}\.\d)\b/g)) { + if (KNOWN_GRADES.has(m[1])) return m[1]; + } + return null; +} + +function parseFinish(text: string): ItemSizeFinish { + for (const entry of FINISH_PATTERNS) { + if (entry.re.test(text)) return entry.finish; + } + return null; +} + +function parseDims(text: string): ItemSizeDim[] { + const found: Array<{ dim: ItemSizeDim; at: number }> = []; + for (const m of text.matchAll(DIM_RE)) { + const value = Number(m[1]); + if (!Number.isFinite(value)) continue; + found.push({ dim: { value, unit: m[2].toLowerCase() }, at: m.index ?? 0 }); + } + for (const m of text.matchAll(INCH_RE)) { + const value = Number(m[1]); + if (!Number.isFinite(value)) continue; + found.push({ dim: { value, unit: "in" }, at: m.index ?? 0 }); + } + // Keep the order the numbers appeared in. + return found.sort((a, b) => a.at - b.at).map((f) => f.dim); +} + +/** + * Parse the size out of a description. + * + * Never throws and never guesses: an attribute the text does not state + * stays `null` so it never counts against a candidate later. + */ +export function parseItemSize(text: string): ItemSize { + const raw = typeof text === "string" ? text : ""; + const lower = raw.toLowerCase(); + + const { kind, variant } = parseKind(lower); + const { thread, length, spanStart, spanEnd } = parseThread(raw); + + // The thread + length run is not a dimension — "M16x60mm" is one bolt, + // not a 60 mm pipe. Blank it out before the dimension pass. + const dimText = thread ? mask(raw, spanStart, spanEnd) : raw; + + return { + kind, + variant, + thread, + length, + grade: parseGrade(lower), + finish: parseFinish(lower), + dims: parseDims(dimText), + }; +} + +// ─── Comparison ──────────────────────────────────────────────────────── + +/** Does the line say anything about size at all? */ +export function hasSizeInfo(size: ItemSize): boolean { + return ( + size.kind !== null || + size.variant !== null || + size.thread !== null || + size.length !== null || + size.grade !== null || + size.finish !== null || + size.dims.length > 0 + ); +} + +/** "length ? vs 60" — the candidate simply does not say. */ +const UNKNOWN = "?"; + +/** + * Compare the line's size with a candidate's. + * + * - `exact` is true only when every attribute the LINE states is + * matched by the candidate. Attributes the line does not mention never + * count against the candidate. + * - `differs` spells the differences out in plain words, candidate + * first: "length 65 vs 60". + * - `rank` sorts the candidates: same kind + thread + length first, + * then same kind + thread with the nearest length, then the same + * thread, then the rest. + */ +export function compareSizes(line: ItemSize, cand: ItemSize): SizeCompare { + const differs: string[] = []; + + if (line.kind !== null) { + if (cand.kind === null) differs.push(`kind ${UNKNOWN} vs ${line.kind}`); + else if (cand.kind !== line.kind) differs.push(`kind ${cand.kind} vs ${line.kind}`); + } + + if (line.variant !== null) { + if (cand.variant === null) differs.push(`variant ${UNKNOWN} vs ${line.variant}`); + else if (cand.variant !== line.variant) { + differs.push(`variant ${cand.variant} vs ${line.variant}`); + } + } + + if (line.thread !== null) { + if (cand.thread === null) differs.push(`thread ${UNKNOWN} vs ${line.thread}`); + else if (cand.thread !== line.thread) differs.push(`thread ${cand.thread} vs ${line.thread}`); + } + + if (line.length !== null) { + if (cand.length === null) differs.push(`length ${UNKNOWN} vs ${line.length}`); + else if (cand.length !== line.length) { + differs.push(`length ${cand.length} vs ${line.length}`); + } + } + + if (line.grade !== null) { + if (cand.grade === null) differs.push(`grade ${UNKNOWN} vs ${line.grade}`); + else if (cand.grade !== line.grade) differs.push(`grade ${cand.grade} vs ${line.grade}`); + } + + if (line.finish !== null) { + if (cand.finish === null) differs.push(`finish ${UNKNOWN} vs ${line.finish}`); + else if (cand.finish !== line.finish) { + differs.push(`finish ${cand.finish.toUpperCase()} vs ${line.finish.toUpperCase()}`); + } + } + + for (const dim of line.dims) { + const sameUnit = cand.dims.find((d) => d.unit === dim.unit); + if (sameUnit) { + if (sameUnit.value !== dim.value) { + differs.push(`size ${sameUnit.value}${sameUnit.unit} vs ${dim.value}${dim.unit}`); + } + } else { + // The candidate does not state a dimension in this unit at all. + differs.push(`size ${UNKNOWN} vs ${dim.value}${dim.unit}`); + } + } + + const exact = hasSizeInfo(line) && differs.length === 0; + + return { exact, differs, rank: sizeRank(line, cand) }; +} + +/** + * The sort key. Lower is better: + * + * 0 same kind + thread + length + * 10 + Δlen same kind + thread, nearest length first + * 1000 same thread + * 2000 same kind, different thread + * 3000 nothing comparable + */ +export function sizeRank(line: ItemSize, cand: ItemSize): number { + if (!hasSizeInfo(line)) return SIZE_RANK_OTHER; + + const kindSame = line.kind !== null && cand.kind !== null && cand.kind === line.kind; + const threadSame = + line.thread !== null && cand.thread !== null && cand.thread === line.thread; + const lengthSame = + line.length !== null && cand.length !== null && cand.length === line.length; + + if (kindSame && threadSame && lengthSame) return SIZE_RANK_EXACT; + if (kindSame && threadSame) { + const gap = + line.length !== null && cand.length !== null + ? Math.min(999, Math.abs(line.length - cand.length)) + : 0; + return SIZE_RANK_NEAR_BASE + gap; + } + if (threadSame) return SIZE_RANK_THREAD; + if (kindSame) return SIZE_RANK_KIND; + return SIZE_RANK_OTHER; +} + +/** + * One short line describing a parsed size — for the AI prompt and the + * popover. Empty when the text said nothing about size. + */ +export function describeSize(size: ItemSize): string { + const bits: string[] = []; + if (size.kind) bits.push(size.kind); + if (size.variant) bits.push(size.variant); + if (size.thread) bits.push(`thread ${size.thread}`); + if (size.length !== null) bits.push(`length ${size.length}`); + if (size.grade) bits.push(`grade ${size.grade}`); + if (size.finish) bits.push(`finish ${size.finish}`); + for (const dim of size.dims) bits.push(`size ${dim.value}${dim.unit}`); + return bits.join(", "); +}