Files
AgendaPro/server/lib/scheduling.test.ts
T
AgendaPro Dev 811d2a24b2 feat(scheduling): specialist scoring + availability module with unit tests
Pure helpers (parseWorkingHours, getWorkingHoursForDate, specialtyMatch, overlaps, scoreCandidate) + DB-backed functions (getCandidates, isAvailable, pickBestSlotEmployee, autoAssign, runInTransaction). Hard constraint: no overlap with existing (scheduled,completed) appts + within working-hours window. Score = 50*specialty + 30*efficiency + 20*(1-load). 15 node:test unit tests.
2026-07-26 15:59:16 -06:00

92 lines
3.8 KiB
TypeScript

// server/lib/scheduling.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import {
parseWorkingHours, isoDayOfWeek, getWorkingHoursForDate,
normalizeText, tokens, specialtyMatch, overlaps, hasConflict, scoreCandidate,
} from "./scheduling.ts";
test("parseWorkingHours: json válido → mapa 1..7", () => {
const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null }));
assert.equal(m?.[1]?.start, "09:00");
assert.equal(m?.[6], null);
assert.equal(m?.[7], null);
assert.equal(m?.[2], null); // ausente → null
});
test("parseWorkingHours: null/invalid → null", () => {
assert.equal(parseWorkingHours(null), null);
assert.equal(parseWorkingHours("no-json"), null);
});
test("isoDayOfWeek: lunes=1, domingo=7", () => {
assert.equal(isoDayOfWeek(new Date("2026-07-27T12:00:00")), 1); // lunes
assert.equal(isoDayOfWeek(new Date("2026-07-26T12:00:00")), 7); // domingo
});
test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio", () => {
const emp = parseWorkingHours(JSON.stringify({ 1: { start: "10:00", end: "14:00" } }));
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
const r = getWorkingHoursForDate(emp, biz, new Date("2026-07-27T12:00:00")); // lunes
assert.equal(r?.startMs, new Date(2026, 6, 27, 10, 0).getTime());
});
test("getWorkingHoursForDate: día cerrado → null", () => {
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null })); // fin de semana cerrado
assert.equal(getWorkingHoursForDate(null, biz, new Date("2026-07-25T12:00:00")), null); // sábado
});
test("normalizeText/tokens: quita acentos y lowercase", () => {
assert.equal(normalizeText("Coloración"), "coloracion");
assert.deepEqual(tokens("Corte de cabello"), ["corte", "de", "cabello"]);
});
test("specialtyMatch: coincidencia exacta de etiqueta → 1", () => {
assert.equal(specialtyMatch(["corte"], { name: "Corte de cabello", category: "Cabello" }), 1);
});
test("specialtyMatch: etiqueta con acento/case → 1", () => {
assert.equal(specialtyMatch(["Coloración"], { name: "Tinte", category: "Coloración" }), 1);
});
test("specialtyMatch: sin etiquetas → 0.5", () => {
assert.equal(specialtyMatch([], { name: "Corte", category: "Cabello" }), 0.5);
});
test("specialtyMatch: etiqueta no relacionada → 0.5", () => {
assert.equal(specialtyMatch(["barba"], { name: "Manicura", category: "Uñas" }), 0.5);
});
test("overlaps: bordes inclusivos de no-traslape", () => {
// una cita termina exactamente cuando empieza otra → NO traslape
assert.equal(overlaps(100, 200, 200, 300), false);
// traslape real
assert.equal(overlaps(100, 250, 200, 300), true);
assert.equal(overlaps(200, 300, 100, 250), true);
});
test("hasConflict: lista vacía → false", () => {
assert.equal(hasConflict([], 100, 200), false);
});
test("scoreCandidate: pesos 50/30/20", () => {
// todo al máximo → 100
assert.equal(scoreCandidate({ specialtyMatch: 1, efficiency: 1, loadBalance: 0 }), 100);
// todo al mínimo → 0
assert.equal(scoreCandidate({ specialtyMatch: 0, efficiency: 0, loadBalance: 1 }), 0);
// sólo specialty (0.5) → 25
assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 25);
});
test("scoreCandidate: mayor efficiency → mayor score", () => {
const low = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.5 });
const high = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.5 });
assert.ok(high > low);
});
test("scoreCandidate: menor load (más disponible) → mayor score", () => {
const busy = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.9 });
const free = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.1 });
assert.ok(free > busy);
});