feat(tgu-ui): #50 слой поддержки решений по слотам + выбор полосами
Поверх ручного бронирования — информационный слой (достаточность судит оператор). M1 покрытие: coverage.ts (растровое Monte-Carlo % покрытия заявки, чистые функции + coverage.test.ts), CoveragePanel (живой % + бар), слой coverage на карте (drawCoverage, union контуров, клип по заявке). M2 эргономика: сортировка списка по вкладу в покрытие (жадный маргинальный прирост), по крену; колонка «Вклад» (+N%). M3 конфликты: бейдж держателя BUSY-слота (заявка + важность) из holders в SlotDTO. Выбор полосами (TASK_slot-strip-selection it.1): slotStrips.ts (groupStrips — same-roll цепочки по-КА, ε=0.01 с якорным креном, тотальный порядок) + slotStrips.test.ts; toggleStripSelection в slotSelection.ts; чип «полоса ×N» в строке, рельс по StripId в сортировке «время», подсветка полосы при наведении. Физику выполнимости не дублируем на фронте — это backend validate (it.2). Смежный баг: anchorIndexRef (якорь Shift-выбора) сбрасывается при смене видимого набора (фильтры/окно суток), не только сортировки.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ROLL_EPSILON, groupStrips } from "./slotStrips";
|
||||
|
||||
/** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */
|
||||
type S = {
|
||||
satelliteId: number;
|
||||
slotNumber: number;
|
||||
cycle: number;
|
||||
tn: string;
|
||||
tk: string;
|
||||
roll: number;
|
||||
};
|
||||
|
||||
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
|
||||
|
||||
/** Слот на минуту mm (от базовой даты) с длительностью 30 c. */
|
||||
function slot(satelliteId: number, slotNumber: number, roll: number, mm: number, cycle = 1): S {
|
||||
const tn = `2026-06-17T10:${String(mm).padStart(2, "0")}:00`;
|
||||
const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`;
|
||||
return { satelliteId, slotNumber, cycle, tn, tk, roll };
|
||||
}
|
||||
|
||||
/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён StripId. */
|
||||
function partition(map: Map<string, string>): string[][] {
|
||||
const byStrip = new Map<string, string[]>();
|
||||
for (const [key, strip] of map) {
|
||||
const arr = byStrip.get(strip) ?? [];
|
||||
arr.push(key);
|
||||
byStrip.set(strip, arr);
|
||||
}
|
||||
return [...byStrip.values()].map((keys) => keys.slice().sort()).sort((a, b) => (a[0]! < b[0]! ? -1 : 1));
|
||||
}
|
||||
|
||||
describe("groupStrips", () => {
|
||||
it("пустой вход → пустая Map", () => {
|
||||
expect(groupStrips([], keyOf).size).toBe(0);
|
||||
});
|
||||
|
||||
it("один слот → своя полоса", () => {
|
||||
const map = groupStrips([slot(1, 1, 5, 0)], keyOf);
|
||||
expect(map.get("1:1:1")).toBe("strip:1:1:1");
|
||||
});
|
||||
|
||||
it("мультиполоса в одном КА: смена крена начинает новую полосу", () => {
|
||||
const slots = [
|
||||
slot(1, 1, 5, 0),
|
||||
slot(1, 2, 5, 1),
|
||||
slot(1, 3, 20, 2), // крен изменился → новая полоса
|
||||
slot(1, 4, 20, 3),
|
||||
];
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||
["1:1:1", "1:2:1"],
|
||||
["1:3:1", "1:4:1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("два КА вперемешку по времени не склеиваются (даже при одном крене)", () => {
|
||||
const slots = [
|
||||
slot(1, 1, 5, 0),
|
||||
slot(2, 1, 5, 0),
|
||||
slot(1, 2, 5, 1),
|
||||
slot(2, 2, 5, 1),
|
||||
];
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([
|
||||
["1:1:1", "1:2:1"],
|
||||
["2:1:1", "2:2:1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("граница ε: diff == 0.01 склеивает, 0.0101 рвёт", () => {
|
||||
const same = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON, 1)], keyOf);
|
||||
expect(partition(same)).toEqual([["1:1:1", "1:2:1"]]);
|
||||
|
||||
const broken = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON + 0.0001, 1)], keyOf);
|
||||
expect(partition(broken)).toEqual([["1:1:1"], ["1:2:1"]]);
|
||||
});
|
||||
|
||||
it("дрейф крена 0.00/0.009/0.018 (сравнение с якорем) → две полосы, не одна", () => {
|
||||
const slots = [slot(1, 1, 0.0, 0), slot(1, 2, 0.009, 1), slot(1, 3, 0.018, 2)];
|
||||
// 0.009−0.0 ≤ ε → в полосе; 0.018−0.0 > ε → новая полоса (а не приклеивание к 0.009).
|
||||
expect(partition(groupStrips(slots, keyOf))).toEqual([["1:1:1", "1:2:1"], ["1:3:1"]]);
|
||||
});
|
||||
|
||||
it("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => {
|
||||
const slots = [
|
||||
slot(1, 1, 5, 0),
|
||||
slot(1, 2, 5, 1),
|
||||
slot(1, 3, 20, 2),
|
||||
slot(2, 1, 5, 0),
|
||||
];
|
||||
const a = partition(groupStrips(slots, keyOf));
|
||||
const b = partition(groupStrips([...slots].reverse(), keyOf));
|
||||
const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf));
|
||||
expect(b).toEqual(a);
|
||||
expect(c).toEqual(a);
|
||||
});
|
||||
|
||||
it("StripId стабилен = strip:${ключ первого по времени члена}", () => {
|
||||
const map = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf);
|
||||
// первый по времени — slotNumber 1 (mm=0); оба члена несут его StripId.
|
||||
expect(map.get("1:1:1")).toBe("strip:1:1:1");
|
||||
expect(map.get("1:2:1")).toBe("strip:1:1:1");
|
||||
});
|
||||
|
||||
it("tie-break при равном tn: устойчивая группировка по slotNumber/cycle/ключу", () => {
|
||||
const a = slot(1, 1, 5, 0);
|
||||
const b = slot(1, 2, 5, 0); // тот же tn/tk
|
||||
const map = groupStrips([b, a], keyOf);
|
||||
expect(partition(map)).toEqual([["1:1:1", "1:2:1"]]);
|
||||
expect(map.get("1:1:1")).toBe("strip:1:1:1"); // меньший ключ — якорь
|
||||
});
|
||||
|
||||
it("NaN tn → синглтон без падения, не клеит соседей", () => {
|
||||
const broken: S = { satelliteId: 1, slotNumber: 9, cycle: 1, tn: "битый", tk: "битый", roll: 5 };
|
||||
const slots = [slot(1, 1, 5, 0), broken, slot(1, 2, 5, 1)];
|
||||
const map = groupStrips(slots, keyOf);
|
||||
expect(map.get("1:9:1")).toBe("strip:1:9:1");
|
||||
// валидные соседи — своя полоса, битый слот их не склеивает и не рвёт между собой.
|
||||
expect(map.get("1:1:1")).toBe("strip:1:1:1");
|
||||
expect(map.get("1:2:1")).toBe("strip:1:1:1");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user