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:
Дмитрий Соловьев
2026-06-17 09:06:25 +03:00
parent 20d9eb9579
commit 1f44c1a5bb
15 changed files with 1473 additions and 65 deletions
@@ -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");
});
});