Ctrl K
ComunidadGuías…

__tests__/gigwatch-pricing.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
 * 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"');
  });
});