pcp: единообразие времени (UTC) по группе сервисов
Фронт (pcp-tgu-ops-ui): - тест-страж utcTime.guard.test.ts: запрещает Date.parse / new Date(string) / toLocale* без timeZone вне utils/utcTime; тестовые фикстуры переведены на parseUtc. Бэкенд: - UTC-now: LocalDateTime.now() -> LocalDateTime.now(ZoneOffset.UTC) по всем сервисам (entity-дефолты, воркеры, use case'ы) и в тестовых фикстурах. - Конвертации к UTC (баги на не-UTC JVM, каждая с регресс-тестом в не-UTC поясе): - tgu: VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase (Instant -> UTC LocalDateTime); - mission-planing: MissionPlaningService (OffsetDateTime -> UTC через toUtcLocalDateTime); - dynamic-plan: RequestServiceClient (OffsetDateTime -> UTC); - slots: SlotRepositoryImpl (getObject(LocalDateTime) вместо getTimestamp().toLocalDateTime()). - Контрактные сериализационные тесты формата на проводе (naive без зоны / Z): PlanResponse (tgu), RequestResponseDto (request, эталон), TguPlanning DTO (ui). - KDoc-инвариант «=UTC» на UI-facing DTO (tgu PlanResponse, ui-service DTO вкладки ТГУ). Документация: docs/standards/DATETIME_UTC.md (соглашение, таблица сервисов, под-задачи на миграцию к Z), docs/standards/CHECKLIST.md (пункт про UTC на API).
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
} from "./editorDraft";
|
||||
import type { PlanMode } from "./editorApi";
|
||||
import type { DraftHistory } from "./model/editorTypes";
|
||||
import { parseUtc } from "../../utils/utcTime";
|
||||
|
||||
const plan: TguPlanUi = {
|
||||
planId: "plan-1",
|
||||
@@ -22,8 +23,8 @@ const plan: TguPlanUi = {
|
||||
endTime: "2026-05-29T02:00:00Z",
|
||||
kppId: "KPP-1",
|
||||
status: "PLANNED",
|
||||
startMs: Date.parse("2026-05-29T01:00:00Z"),
|
||||
endMs: Date.parse("2026-05-29T02:00:00Z")
|
||||
startMs: parseUtc("2026-05-29T01:00:00Z"),
|
||||
endMs: parseUtc("2026-05-29T02:00:00Z")
|
||||
};
|
||||
|
||||
describe("editorDraft", () => {
|
||||
@@ -84,8 +85,8 @@ describe("editorDraft", () => {
|
||||
id: "survey-11",
|
||||
kind: "shoot",
|
||||
label: "Съёмка · виток 4321",
|
||||
startMs: Date.parse("2026-05-29T01:10:00Z"),
|
||||
endMs: Date.parse("2026-05-29T01:10:00Z") + 180_000,
|
||||
startMs: parseUtc("2026-05-29T01:10:00Z"),
|
||||
endMs: parseUtc("2026-05-29T01:10:00Z") + 180_000,
|
||||
origin: "plan",
|
||||
targetLat: 55.7,
|
||||
targetLon: 37.6
|
||||
@@ -94,8 +95,8 @@ describe("editorDraft", () => {
|
||||
id: "drop-22",
|
||||
kind: "downlink",
|
||||
label: "Сброс · 102",
|
||||
startMs: Date.parse("2026-05-29T01:40:00Z"),
|
||||
endMs: Date.parse("2026-05-29T01:40:00Z") + 120_000,
|
||||
startMs: parseUtc("2026-05-29T01:40:00Z"),
|
||||
endMs: parseUtc("2026-05-29T01:40:00Z") + 120_000,
|
||||
origin: "plan",
|
||||
stationId: "102"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { TguPlanUi } from "../../model/timelineTypes";
|
||||
import { DEFAULT_TGU_2D_MAP_LAYERS } from "./model/mapLayerTypes";
|
||||
import { getTgu2DMapIntervalState } from "./tgu2DMapInterval";
|
||||
import { parseUtc } from "../../utils/utcTime";
|
||||
|
||||
function plan(startTime: string, endTime: string): TguPlanUi {
|
||||
return {
|
||||
@@ -11,16 +12,16 @@ function plan(startTime: string, endTime: string): TguPlanUi {
|
||||
endTime,
|
||||
kppId: "KPP-1",
|
||||
status: "PLANNED",
|
||||
startMs: Date.parse(startTime),
|
||||
endMs: Date.parse(endTime)
|
||||
startMs: parseUtc(startTime),
|
||||
endMs: parseUtc(endTime)
|
||||
};
|
||||
}
|
||||
|
||||
describe("tgu2DMapInterval", () => {
|
||||
it("marks common map interval longer than 7 days as too large", () => {
|
||||
const state = getTgu2DMapIntervalState({
|
||||
fromMs: Date.parse("2026-05-29T00:00:00"),
|
||||
toMs: Date.parse("2026-06-05T00:00:01")
|
||||
fromMs: parseUtc("2026-05-29T00:00:00"),
|
||||
toMs: parseUtc("2026-06-05T00:00:01")
|
||||
});
|
||||
|
||||
expect(state.tooLarge).toBe(true);
|
||||
@@ -30,8 +31,8 @@ describe("tgu2DMapInterval", () => {
|
||||
const selectedPlan = plan("2026-05-29T01:00:00", "2026-06-07T02:00:00");
|
||||
const state = getTgu2DMapIntervalState(
|
||||
{
|
||||
fromMs: Date.parse("2026-05-29T00:00:00"),
|
||||
toMs: Date.parse("2026-06-10T00:00:00")
|
||||
fromMs: parseUtc("2026-05-29T00:00:00"),
|
||||
toMs: parseUtc("2026-06-10T00:00:00")
|
||||
},
|
||||
selectedPlan
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
|
||||
import { buildSequentialLinks, buildTimelineSegments, clipPlanToRange, timeToX } from "./tguTimelineLayout";
|
||||
import { parseUtc } from "../../utils/utcTime";
|
||||
|
||||
const range = {
|
||||
fromMs: Date.parse("2026-05-29T00:00:00"),
|
||||
toMs: Date.parse("2026-05-30T00:00:00")
|
||||
fromMs: parseUtc("2026-05-29T00:00:00"),
|
||||
toMs: parseUtc("2026-05-30T00:00:00")
|
||||
};
|
||||
|
||||
function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi {
|
||||
@@ -15,14 +16,14 @@ function plan(id: string, startTime: string, endTime: string, spacecraftId = "56
|
||||
endTime,
|
||||
kppId: "KPP-1",
|
||||
status: "PLANNED",
|
||||
startMs: Date.parse(startTime),
|
||||
endMs: Date.parse(endTime)
|
||||
startMs: parseUtc(startTime),
|
||||
endMs: parseUtc(endTime)
|
||||
};
|
||||
}
|
||||
|
||||
describe("tguTimelineLayout", () => {
|
||||
it("maps time to x inside the selected interval", () => {
|
||||
expect(timeToX(Date.parse("2026-05-29T12:00:00"), range, 1200)).toBe(600);
|
||||
expect(timeToX(parseUtc("2026-05-29T12:00:00"), range, 1200)).toBe(600);
|
||||
});
|
||||
|
||||
it("clips partially visible plans to the selected interval", () => {
|
||||
@@ -30,7 +31,7 @@ describe("tguTimelineLayout", () => {
|
||||
|
||||
expect(clipped).toEqual({
|
||||
startMs: range.fromMs,
|
||||
endMs: Date.parse("2026-05-29T02:00:00")
|
||||
endMs: parseUtc("2026-05-29T02:00:00")
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Страж инварианта «всё время — UTC» (см. docs/standards/DATETIME_UTC.md).
|
||||
*
|
||||
* Сканирует исходники и падает на паттернах, которые трактуют время в поясе
|
||||
* браузера: «голый» разбор строки (`new Date("...")`, `Date.parse`) и показ через
|
||||
* `toLocale*` без `timeZone`. Парсинг и форматирование времени бэка должны идти
|
||||
* только через `utils/utcTime` (`parseUtc`, `formatUtc*`). Этот файл и сам
|
||||
* `utcTime.ts` — единственные исключения: в них паттерны присутствуют легитимно
|
||||
* (как реализация утилиты и как строки-описания запретов).
|
||||
*/
|
||||
|
||||
const SRC_DIR = join(process.cwd(), "src");
|
||||
// Исключения: сам страж (содержит паттерны как строки-описания) и каноническая
|
||||
// утилита utcTime.ts (легитимная реализация parseUtc/formatUtc*).
|
||||
const EXCLUDED = ["utcTime.ts", "utcTime.guard.test.ts"];
|
||||
|
||||
interface Rule {
|
||||
readonly name: string;
|
||||
readonly pattern: RegExp;
|
||||
}
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{ name: "Date.parse(...) — парсит строку в поясе браузера; используйте parseUtc", pattern: /\bDate\.parse\s*\(/ },
|
||||
{ name: "new Date(\"...\") со строкой — трактует naive-строку как локальное время; используйте parseUtc", pattern: /new\s+Date\(\s*['"`]/ },
|
||||
{ name: "formatDateTimeLocal — локальный хелпер пикеров; используйте toUtcInputValue/parseUtc", pattern: /\bformatDateTimeLocal\b/ },
|
||||
// toLocale* без опций или только с локалью → пояс браузера.
|
||||
{ name: "toLocale*() без timeZone — показывает в поясе браузера; используйте formatUtc* или { timeZone: \"UTC\" }", pattern: /\.toLocale(String|DateString|TimeString)\(\s*(['"`][^'"`]*['"`]\s*)?\)/ },
|
||||
// toLocale* с inline-объектом опций, в котором нет timeZone.
|
||||
{ name: "toLocale*({...}) без timeZone в опциях — показывает в поясе браузера; добавьте timeZone: \"UTC\"", pattern: /\.toLocale(String|DateString|TimeString)\(\s*['"`][^'"`]*['"`]\s*,\s*\{(?![\s\S]*?timeZone)[\s\S]*?\}/ }
|
||||
];
|
||||
|
||||
function collectSourceFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...collectSourceFiles(full));
|
||||
} else if (/\.tsx?$/.test(entry.name) && !EXCLUDED.includes(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("UTC time invariant", () => {
|
||||
const files = collectSourceFiles(SRC_DIR);
|
||||
|
||||
it("scans the source tree", () => {
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const rule of RULES) {
|
||||
it(`forbids: ${rule.name}`, () => {
|
||||
const offenders = files.filter((file) => rule.pattern.test(readFileSync(file, "utf8")));
|
||||
expect(offenders.map((file) => relative(SRC_DIR, file))).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user