__tests__/gigwatch-pricing.test.js
Rama main · réplica de lectura del repositorio original.
/*
* THE QUOTE MAY NEVER EXCEED THE PROMISE.
*
* The bug this covers: `hoursBySize` was decoupled from MAX_WORK_DAYS. An `xl` gig billed its full
* band of 110 hours (about 14 working days at 8h/day) while the form committed to 7 days, so the
* offer sold a fortnight of labour with a one-week delivery date on it. Separately, the prompt told
* the model to price "the whole project" while the delivery rule told it to promise only a working
* first part, so a CORRECT model produced a commercially incoherent offer: stage one in 7 days,
* all three stages at one number. Caught on a live useme gig before it was sent.
*
* llm is stubbed through require.cache rather than vi.mock: gigWatch is CommonJS and pulls llm with
* a lazy require() inside offerFor, so seeding the cache is both simpler and closer to reality.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
let reply = "";
let seenUser = [];
/* The system message carries every rule we teach the model. Untestable while the stub dropped it. */
let seenSystem = [];
const llmPath = require.resolve("../src/llm");
require.cache[llmPath] = {
id: llmPath, filename: llmPath, loaded: true, children: [], paths: [],
exports: {
chat: async (a) => {
seenSystem.push(a.messages[0].content);
seenUser.push(a.messages[1].content);
return { content: reply };
},
},
};
const gigWatch = require("../src/gigWatch");
const gig = (desc) => ({ title: "t", fields: { desc, offers: 19, budget: "do negocjacji" } });
const model = (size, days) =>
JSON.stringify({ size, work_days: days, message: "tresc {PRICE} zl", message_en: "text {PRICE} PLN" });
describe("offerFor: the quote may not exceed the promise", () => {
beforeEach(() => { reply = ""; });
it("caps an xl band at what the committed days can hold", async () => {
reply = model("xl", 7);
const o = await gigWatch.offerFor(gig("big multi system integration"), { pricing: {} });
expect(o.workDays).toBe(7);
expect(o.bandHours).toBe(110);
expect(o.hours).toBe(56); // 7 days x 8h, NOT the 110h band
expect(o.scoped).toBe(true);
expect(o.payment).toBe(6400); // 56 x 130 x 0.88 = 6406.4 -> nearest 50
});
it("leaves a small gig inside the day budget untouched", async () => {
reply = model("small", 2);
const o = await gigWatch.offerFor(gig("upload one csv"), { pricing: {} });
expect(o.hours).toBe(8);
expect(o.scoped).toBe(false);
});
it("never returns more hours than the committed days allow, for any band", async () => {
for (const size of ["small", "medium", "large", "xl"]) {
for (const days of [1, 3, 7, 21]) {
reply = model(size, days);
const o = await gigWatch.offerFor(gig("x"), { pricing: {} });
expect(o.workDays).toBeLessThanOrEqual(7);
expect(o.hours).toBeLessThanOrEqual(o.workDays * 8);
}
}
});
it("honours a pinned price so redrafting the words cannot move the quote", async () => {
reply = model("xl", 7);
const o = await gigWatch.offerFor(gig("big"), { pricing: {}, pinnedPayment: 6850 });
expect(o.payment).toBe(6850);
});
it("takes hoursPerDay from the owners config, not a hardcoded 8", async () => {
reply = model("xl", 5);
const o = await gigWatch.offerFor(gig("big"), { pricing: { hoursPerDay: 6 } });
/*
* 7 days, not the 5 the model asked for: useme refuses work_days under 7 (measured live - 1, 2,
* 3 and 5 were each rejected with "Podaj poprawna liczbe dni" while 7 submitted at once), so the
* floor applies before hours are derived. 7 days x 6h = 42, still under the 110h xl band.
*/
expect(o.workDays).toBe(7);
expect(o.hours).toBe(42);
});
});
/*
* THE TERM IS PINNED LIKE THE PRICE.
*
* The price pin alone was not stability. The same gig came back `xl` at 7 days on one run and
* `large` at 5 days on the next, and because the price was held at 6850 while the scope shrank
* from 56 to 40 hours, the offer silently drifted to 171 zl/h against a 130 rate. Pinning the
* days closes that. The prompt must also LEARN the pinned term, otherwise clamping it after the
* model has written its message reproduces the original defect: prose describing one scope while
* the form commits to another.
*/
describe("offerFor: a pinned term holds, and the model is told about it", () => {
beforeEach(() => { reply = ""; seenUser = []; });
it("keeps the agreed term instead of the model's answer", async () => {
reply = model("large", 5);
const o = await gigWatch.offerFor(gig("x"), { pricing: {}, pinnedWorkDays: 7 });
expect(o.workDays).toBe(7);
expect(o.daysPinned).toBe(true);
expect(o.hours).toBe(56); // 7 x 8, not the 5-day 40
});
it("tells the model the term is fixed, so the words match the form", async () => {
reply = model("large", 5);
await gigWatch.offerFor(gig("x"), { pricing: {}, pinnedWorkDays: 7 });
const sent = seenUser[seenUser.length - 1];
expect(sent).toContain("TERMIN JEST JUZ USTALONY");
expect(sent).toContain("work_days = 7");
});
it("says nothing about a term when none is pinned", async () => {
reply = model("large", 5);
const o = await gigWatch.offerFor(gig("x"), { pricing: {} });
/* Unpinned means the FLOOR, not the model's number: 5 is not a window useme will accept. */
expect(o.workDays).toBe(7);
expect(o.daysPinned).toBe(false);
expect(seenUser[seenUser.length - 1]).not.toContain("TERMIN JEST JUZ USTALONY");
});
it("will not let a pinned term break the one week rule", async () => {
reply = model("xl", 3);
const o = await gigWatch.offerFor(gig("x"), { pricing: {}, pinnedWorkDays: 30 });
expect(o.workDays).toBe(7);
});
});
/*
* -- USEME WILL NOT TAKE A WINDOW UNDER SEVEN DAYS, AND A QUICK JOB IS NOT PRICED AS A DAY --------
*
* Measured by sending real offers. work_days of 1, 2, 3 and 5 were each refused with "Podaj poprawna
* liczbe dni" - the value WAS in the field and the field still errored - and 7 submitted at once.
* The first offer only went through because the ceiling happened to sit exactly on that floor, so
* nothing in the code knew the floor existed.
*
* And a client who wrote "praca przewidziana na okolo 30-45 minut" was quoted 900 zl, because the
* smallest band was a full day. A quote ten times the work loses the gigs that are easiest to win
* and reads as not having read the job.
*/
describe('the window useme accepts, and the price a quick job deserves', () => {
beforeEach(() => { reply = ""; seenUser = []; seenSystem = []; });
it('never files a window under seven days, whatever the model answers', async () => {
for (const d of [1, 2, 3, 5]) {
reply = model("tiny", d);
const o = await gigWatch.offerFor(gig("quick"), { pricing: {} });
expect(o.workDays, `model asked for ${d}`).toBe(7);
}
});
/* The whole point of the tiny band. */
it('a sub-hour job is a minimum engagement, not a day of work', async () => {
reply = model("tiny", 1);
const o = await gigWatch.offerFor(gig("dwie konwersje, 30-45 minut przez AnyDesk"), { pricing: {} });
expect(o.size).toBe("tiny");
expect(o.payment).toBe(200);
});
it('while a real day of work still prices as one', async () => {
reply = model("small", 7);
const o = await gigWatch.offerFor(gig("wgranie pliku csv"), { pricing: {} });
expect(o.payment).toBe(900);
});
/* The minimum engagement is data: a board with a different economy is a config line, not a deploy. */
it('and the minimum engagement is the owners to set', async () => {
reply = model("tiny", 1);
const o = await gigWatch.offerFor(gig("quick"), { pricing: { minPricePln: 350 } });
expect(o.payment).toBe(350);
});
/*
* THE FORM WINDOW MUST NEVER BE READ AS THE DELIVERY PROMISE. A 45-minute job that files 7 days and
* says nothing about timing reads as a week's wait, which loses it to whoever said "today".
*/
it('tells the model the seven days are a ceiling, not when the work lands', async () => {
reply = model("tiny", 1);
await gigWatch.offerFor(gig("quick"), { pricing: {} });
const sys = seenSystem[seenSystem.length - 1] || "";
expect(sys).toContain("Formularz useme przyjmuje tylko 7 dni");
expect(sys).toContain("TYLKO gorna");
});
it('and offers the tiny band as a choice at all', async () => {
reply = model("tiny", 1);
await gigWatch.offerFor(gig("quick"), { pricing: {} });
expect(seenSystem[seenSystem.length - 1]).toContain('"tiny"');
});
});