fix(inventory): sluta gissa utgangsdatum + las 2-siffriga bast-fore-stamplar
CI / Typecheck, test & build (push) Failing after 46s
CI / Typecheck, test & build (push) Failing after 46s
BUG: forseglad vara utan tryckt datum fick fabricerat utgang (purchasedAt + shelfLifeGuidance, som ar hallbarhet EFTER oppning) -> falska 'gar ut snart'-varningar OCH gav sken av att datum inte lastes. Nu: ingen data -> unknown (hedrar appens egen princip 'aldrig gissa'). parseDate laser aven YY-MM-DD/YYMMDD/YYYYMMDD (svenska stamplar) som forr slangdes. Regressionstester tillagda. 36/36 gront.
This commit is contained in:
@@ -182,13 +182,30 @@ const geminiFridgeResponseSchema = z.object({
|
|||||||
imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]),
|
imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseDate(value: string | null | undefined): string | null {
|
export function parseDate(value: string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const normalized = value.trim();
|
const normalized = value.trim();
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
// Accept ISO dates (YYYY-MM-DD) only.
|
// Accept ISO dates (YYYY-MM-DD).
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized;
|
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized;
|
||||||
// Try common Swedish/EU formats.
|
// Kompakta stämplar utan separator (vanligt på svensk förpackning): YYYYMMDD / YYMMDD.
|
||||||
|
if (/^\d{8}$/.test(normalized)) {
|
||||||
|
const y = +normalized.slice(0, 4),
|
||||||
|
m = +normalized.slice(4, 6),
|
||||||
|
d = +normalized.slice(6, 8);
|
||||||
|
if (y > 2000 && m >= 1 && m <= 12 && d >= 1 && d <= 31)
|
||||||
|
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (/^\d{6}$/.test(normalized)) {
|
||||||
|
const y = 2000 + +normalized.slice(0, 2),
|
||||||
|
m = +normalized.slice(2, 4),
|
||||||
|
d = +normalized.slice(4, 6);
|
||||||
|
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
|
||||||
|
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Separatorformat (- / .).
|
||||||
const parts = normalized.split(/[-/.]/);
|
const parts = normalized.split(/[-/.]/);
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
const [aStr, bStr, cStr] = parts;
|
const [aStr, bStr, cStr] = parts;
|
||||||
@@ -203,6 +220,9 @@ function parseDate(value: string | null | undefined): string | null {
|
|||||||
// DD-MM-YYYY
|
// DD-MM-YYYY
|
||||||
if (c > 2000 && b <= 12 && a <= 31)
|
if (c > 2000 && b <= 12 && a <= 31)
|
||||||
return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`;
|
return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`;
|
||||||
|
// YY-MM-DD (2-siffrigt år först — vanligast på svensk bäst-före-stämpel).
|
||||||
|
if (a <= 99 && b >= 1 && b <= 12 && c >= 1 && c <= 31)
|
||||||
|
return `${2000 + a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { GeminiAamosClient, MemoryBudgetStore } from "../src/gemini.js";
|
import { GeminiAamosClient, MemoryBudgetStore, parseDate } from "../src/gemini.js";
|
||||||
import type { AamosRequestEnvelope } from "../src/tasks.js";
|
import type { AamosRequestEnvelope } from "../src/tasks.js";
|
||||||
|
|
||||||
const FIXTURE_RESPONSE = {
|
const FIXTURE_RESPONSE = {
|
||||||
@@ -159,3 +159,14 @@ describe("GeminiAamosClient", () => {
|
|||||||
expect(result.error).toContain("Kontraktsfel");
|
expect(result.error).toContain("Kontraktsfel");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseDate (svenska bäst-före-stämplar)", () => {
|
||||||
|
it("läser 2-siffrigt år och kompakta format", () => {
|
||||||
|
expect(parseDate("25-08-30")).toBe("2025-08-30"); // YY-MM-DD
|
||||||
|
expect(parseDate("250830")).toBe("2025-08-30"); // YYMMDD
|
||||||
|
expect(parseDate("20250830")).toBe("2025-08-30"); // YYYYMMDD
|
||||||
|
expect(parseDate("2025-08-30")).toBe("2025-08-30"); // ISO
|
||||||
|
expect(parseDate("30-08-2025")).toBe("2025-08-30"); // DD-MM-YYYY
|
||||||
|
expect(parseDate("skräp")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -79,15 +79,11 @@ export function classifyExpiry(input: ExpiryInput, today: Date = new Date()): Ex
|
|||||||
factor: "opened_guidance",
|
factor: "opened_guidance",
|
||||||
date: new Date(opened.getTime() + guidanceDays * DAY_MS),
|
date: new Date(opened.getTime() + guidanceDays * DAY_MS),
|
||||||
});
|
});
|
||||||
} else if (!useBy && !bestBefore && guidanceDays != null) {
|
|
||||||
const purchased = parse(input.purchasedAt);
|
|
||||||
if (purchased) {
|
|
||||||
candidates.push({
|
|
||||||
factor: "purchased_guidance",
|
|
||||||
date: new Date(purchased.getTime() + guidanceDays * DAY_MS),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// shelfLifeGuidance = hållbarhet EFTER öppning (entities.ts). Vi gissar ALDRIG
|
||||||
|
// utgång från inköpsdatum på en oöppnad vara — utan riktigt tryckt datum blir
|
||||||
|
// status "unknown" (spec: "ingen data → unknown, aldrig gissning"). Den gamla
|
||||||
|
// purchased_guidance-grenen gav falska "går ut snart"-varningar på förseglade varor.
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return { status: "unknown", daysLeft: null, limitingFactor: "none", pastBestBefore: false };
|
return { status: "unknown", daysLeft: null, limitingFactor: "none", pastBestBefore: false };
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ describe("bäst före-klassning (spec §13)", () => {
|
|||||||
it("ingen data → unknown, aldrig gissning", () => {
|
it("ingen data → unknown, aldrig gissning", () => {
|
||||||
expect(classifyExpiry({}, TODAY).status).toBe("unknown");
|
expect(classifyExpiry({}, TODAY).status).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
it("förseglad vara: inköpsdatum + riktvärde men INGET tryckt datum → unknown", () => {
|
||||||
|
// Regressionsvakt: gamla purchased_guidance-grenen gissade utgång från inköp
|
||||||
|
// på oöppnade varor → falska "går ut snart"-varningar (Bug B).
|
||||||
|
const result = classifyExpiry(
|
||||||
|
{ purchasedAt: "2026-08-01", storageLocationType: "fridge", shelfLifeGuidance: { fridge: 7 } },
|
||||||
|
TODAY,
|
||||||
|
);
|
||||||
|
expect(result.status).toBe("unknown");
|
||||||
|
expect(result.limitingFactor).toBe("none");
|
||||||
|
});
|
||||||
it("mjölkprincipen: gick ut igår → använd sinnena, inte soptunnan", () => {
|
it("mjölkprincipen: gick ut igår → använd sinnena, inte soptunnan", () => {
|
||||||
// Bäst före är en KVALITETSgräns: mjölken som gick ut igår är inte
|
// Bäst före är en KVALITETSgräns: mjölken som gick ut igår är inte
|
||||||
// automatiskt dålig – användaren ska lukta/smaka. Appen får aldrig
|
// automatiskt dålig – användaren ska lukta/smaka. Appen får aldrig
|
||||||
|
|||||||
Reference in New Issue
Block a user