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.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user