feat(gate-b): Gate B mask + BDFT + length axis (#21–#23) #31

Closed
grokbot_ty wants to merge 41 commits from gate-b into main
7 changed files with 286 additions and 0 deletions
Showing only changes of commit 34dbfa5d7d - Show all commits
@@ -0,0 +1,20 @@
/** TECH-SPEC §8 TV-8 pricing fixture. */
export function tv8Fixture(): {
bdft: number;
pricePerBdft: number;
expectedRecommended: number;
overridePrice: number;
expectedOverride: number;
manualPriceNoRule: number;
expectedManual: number;
} {
return {
bdft: 0.96,
pricePerBdft: 12.50,
expectedRecommended: 12.00,
overridePrice: 150.00,
expectedOverride: 150.00,
manualPriceNoRule: 99.99,
expectedManual: 99.99,
};
}
@@ -0,0 +1,11 @@
/** TECH-SPEC §8 TV-9 mock taxonomy fixture for thickness attribute. */
import type { ThicknessRange } from "../../../lib/pipeline/thicknessBand.js";
/** Mock Woo thickness attribute terms (synced from store). */
export function mockThicknessRanges(): ThicknessRange[] {
return [
{ wooId: 101, label: '3/4"–1"', minIn: 0.75, maxIn: 1.0 },
{ wooId: 102, label: '1"–1 1/2"', minIn: 1.0, maxIn: 1.5 },
{ wooId: 103, label: '1 1/2"–2"', minIn: 1.5, maxIn: 2.0 },
];
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { computeRecommendedPrice, computeSlabPrice } from "../lib/pipeline/pricing.js";
import { tv8Fixture } from "./fixtures/pricing.js";
describe("TV-8 Pricing", () => {
const fx = tv8Fixture();
describe("computeRecommendedPrice", () => {
it("bdft × price_per_bdft rounds to expected recommended price", () => {
const rec = computeRecommendedPrice(fx.bdft, fx.pricePerBdft);
expect(rec).toBe(fx.expectedRecommended);
expect(rec).toBe(12.00);
});
it("0 bdft yields 0", () => {
expect(computeRecommendedPrice(0, 99.99)).toBe(0);
});
});
describe("computeSlabPrice — with pricing rule", () => {
it("recommended price pre-fills when no override", () => {
const result = computeSlabPrice(fx.bdft, {
species: "Walnut",
pricePerBdft: fx.pricePerBdft,
});
expect(result.recommendedPrice).toBe(fx.expectedRecommended);
expect(result.finalPrice).toBe(fx.expectedRecommended);
expect(result.priceSource).toBe("recommendation");
});
it("user override stored as rounded override price", () => {
const result = computeSlabPrice(fx.bdft, {
species: "Walnut",
pricePerBdft: fx.pricePerBdft,
}, fx.overridePrice);
expect(result.recommendedPrice).toBe(fx.expectedRecommended);
expect(result.finalPrice).toBe(fx.expectedOverride);
expect(result.priceSource).toBe("override");
});
});
describe("computeSlabPrice — no pricing rule", () => {
it("recommended price is null, manual price stored", () => {
const result = computeSlabPrice(fx.bdft, undefined, fx.manualPriceNoRule);
expect(result.recommendedPrice).toBeNull();
expect(result.finalPrice).toBe(fx.expectedManual);
expect(result.priceSource).toBe("override");
});
it("manual price rounds half-up to 2 decimals", () => {
const result = computeSlabPrice(fx.bdft, undefined, 12.345);
expect(result.finalPrice).toBe(12.35);
expect(result.priceSource).toBe("override");
});
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { findThicknessBand } from "../lib/pipeline/thicknessBand.js";
import { mockThicknessRanges } from "./fixtures/thicknessBand.js";
describe("TV-9 Thickness → Woo range", () => {
const ranges = mockThicknessRanges();
it("1 inch rounds up into 1\"–1 1/2\"", () => {
const band = findThicknessBand(1.0, ranges);
expect(band).toBeDefined();
expect(band!.wooId).toBe(102);
expect(band!.label).toBe('1"–1 1/2"');
expect(band!.minIn).toBe(1.0);
expect(band!.maxIn).toBe(1.5);
});
it("0.75 inches maps to 3/4\"–1\"", () => {
const band = findThicknessBand(0.75, ranges);
expect(band).toBeDefined();
expect(band!.wooId).toBe(101);
expect(band!.label).toBe('3/4"–1"');
});
it("0.875 inches maps to 3/4\"–1\"", () => {
const band = findThicknessBand(0.875, ranges);
expect(band).toBeDefined();
expect(band!.wooId).toBe(101);
});
it("1.5 inches maps to 1 1/2\"–2\"", () => {
const band = findThicknessBand(1.5, ranges);
expect(band).toBeDefined();
expect(band!.wooId).toBe(103);
expect(band!.label).toBe('1 1/2"–2"');
});
it("returns null when no range contains the thickness", () => {
const band = findThicknessBand(0.5, ranges);
expect(band).toBeNull();
const band2 = findThicknessBand(2.5, ranges);
expect(band2).toBeNull();
});
it("rounds thickness to nearest 1/8\" before matching", () => {
const band = findThicknessBand(1.01, ranges);
expect(band).toBeDefined();
expect(band!.wooId).toBe(102);
});
});
+2
View File
@@ -4,3 +4,5 @@ export * from "./mask.js";
export * from "./units.js";
export * from "./axis.js";
export * from "./crop.js";
export * from "./pricing.js";
export * from "./thicknessBand.js";
+91
View File
@@ -0,0 +1,91 @@
import { INCH_STEP, PRICE_DECIMALS } from "./constants.js";
/** Pricing rule synced from WooCommerce (leaf species). */
export interface PricingRule {
species: string;
pricePerBdft: number;
}
/** Result of pricing computation for a slab. */
export interface PricingResult {
/** Recommended price from the pricing rule, or null when no rule exists. */
recommendedPrice: number | null;
/** The price to store (rounded half-up to 2 decimals). */
finalPrice: number;
/** Where the stored price came from. */
priceSource: "recommendation" | "override";
}
/**
* TECH-SPEC §6 pricing: bdft × price_per_bdft, rounded half-up to 2 decimals.
*
* @param bdft — board feet (2 decimals, already rounded by caller)
* @param pricePerBdft — species price per board foot from Settings
* @returns recommended price rounded to 2 decimals
*/
export function computeRecommendedPrice(
bdft: number,
pricePerBdft: number,
): number {
const factor = 10 ** PRICE_DECIMALS;
return Math.floor(Math.round(bdft * pricePerBdft * 100) / 100 * factor + 0.5) / factor;
}
/**
* TECH-SPEC §6 pricing with override and missing-rule handling.
*
* Three paths:
* 1. Has rule, no override → recommended price, source "recommendation"
* 2. Has rule, override → override stored, source "override"
* 3. No rule, manual price → manual stored, source "override"
*
* @param bdft — board feet (2 decimals)
* @param pricingRule — pricing rule for the species, or undefined if none
* @param userOverride — manual price entered by the user, if any
* @returns pricing result with recommended price, final stored price, and source
*/
export function computeSlabPrice(
bdft: number,
pricingRule?: PricingRule,
userOverride?: number,
): PricingResult {
const recommendedPrice =
pricingRule != null
? computeRecommendedPrice(bdft, pricingRule.pricePerBdft)
: null;
if (userOverride != null) {
return {
recommendedPrice,
finalPrice: roundPrice(userOverride),
priceSource: "override",
};
}
if (recommendedPrice != null) {
return {
recommendedPrice,
finalPrice: recommendedPrice,
priceSource: "recommendation",
};
}
// No rule, no override — this should not happen at publish,
// but return a valid shape with null recommended and 0 final.
return {
recommendedPrice: null,
finalPrice: 0,
priceSource: "override",
};
}
/** Round price half-up to 2 decimals. */
function roundPrice(price: number): number {
if (!Number.isFinite(price)) {
return price;
}
const factor = 10 ** PRICE_DECIMALS;
const scaled = Math.abs(price) * factor;
const rounded = Math.floor(scaled + 0.5) / factor;
return price < 0 ? -rounded : rounded;
}
@@ -0,0 +1,47 @@
import { INCH_STEP } from "./constants.js";
/**
* A WooCommerce attribute term for the Thickness attribute.
* Each term represents a thickness range (e.g. "1"–1 1/2"").
*
* The range is half-open: [minIn, maxIn). A thickness equal to minIn
* belongs to this range; a thickness equal to maxIn belongs to the
* next higher range. This implements the "rounds up" rule from
* TECH-SPEC §2: "Thickness Woo attribute — same pick, rounded UP
* into a Woo range."
*/
export interface ThicknessRange {
wooId: number;
/** Display label from Woo, e.g. '1"–1 1/2"'. */
label: string;
/** Lower bound in inches (inclusive). */
minIn: number;
/** Upper bound in inches (exclusive). */
maxIn: number;
}
/**
* Find the Woo thickness range that a given thickness rounds up into.
*
* The thickness is first rounded to the nearest 1/8" step, then matched
* against the half-open ranges [minIn, maxIn). If the thickness falls
* below the lowest range or above the highest, null is returned.
*
* @param thicknessIn — thickness in inches (1/8" steps)
* @param ranges — sorted thickness ranges from Woo attribute sync
* @returns the matching range, or null if no range contains the value
*/
export function findThicknessBand(
thicknessIn: number,
ranges: ThicknessRange[],
): ThicknessRange | null {
const rounded = Math.round(thicknessIn / INCH_STEP) * INCH_STEP;
for (const range of ranges) {
if (rounded >= range.minIn && rounded < range.maxIn) {
return range;
}
}
return null;
}