The POS shortlist ranked by shared words only, so "BOLT HEX M16X60 GR8.8 ZP" put the M22x65 above the M16x60 — every bolt shares "BOLT 8.8 ZP" and scores 0.75. The right size never reached the AI and the price check showed the wrong item. parseItemSize pulls the type, thread, length, grade, finish and other dimensions out of free text; compareSizes says whether a candidate is the same size and, when it is not, in plain words what differs. Pure and client-safe: no PocketBase, no AI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
/**
|
||
* 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("");
|
||
});
|
||
});
|