feat(gate-b): Gate B mask + BDFT + length axis (#21–#23) #31
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user