From de2470010c4fea1abad55c44fe6c70adfb290656 Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 21 Sep 2026 17:54:40 +0700 Subject: [PATCH] test: centralize evidence passport states --- apps/web/src/components/DataPassport.astro | 4 ++-- apps/web/src/lib/api.ts | 2 +- apps/web/src/lib/presentation.ts | 9 +++++++++ apps/web/tests/unit/presentation.test.ts | 9 ++++++++- docs/ROADMAP.md | 2 +- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/DataPassport.astro b/apps/web/src/components/DataPassport.astro index 95754de..65486e9 100644 --- a/apps/web/src/components/DataPassport.astro +++ b/apps/web/src/components/DataPassport.astro @@ -1,10 +1,10 @@ --- import SourceBadge from "./SourceBadge.astro"; -import { ago, freshnessStatus } from "../lib/api"; +import { ago, freshnessStatus, passportDisplayStatus } from "../lib/api"; type Props = { sources: string[]; sourceUrl?: string | null; observedAt?: string | null; periodLabel?: string | null; completeness?: number | null; confidence?: number | null; sampleSize?: number | null; independentPlayers?: number | null; coordinatePrecision?: "exact" | "approximate" | "area" | "missing" | null; sourceConflicts?: string[]; status?: "verified" | "unverified" | "incomplete" | "insufficient" | "blocked" }; const { sources, sourceUrl, observedAt, periodLabel = null, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, sourceConflicts = [], status = "verified" } = Astro.props as Props; const freshness = freshnessStatus(observedAt); -const displayStatus = sourceConflicts.length ? "conflict" : status === "verified" && freshness === "stale" ? "stale" : status; +const displayStatus = passportDisplayStatus(status, freshness, sourceConflicts.length > 0); const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", insufficient: "Недостаточно данных", blocked: "Источник ограничен", conflict: "Источники расходятся", stale: "Данные устарели" }; const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`; const precisionLabels = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" }; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 95964f4..8d6f5cc 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -37,7 +37,7 @@ export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tack export const spotPath = (item: Pick) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`; -export { activityLevel, ago, freshnessStatus, kg, plural, sourceStatusLabel } from "./presentation"; +export { activityLevel, ago, freshnessStatus, kg, passportDisplayStatus, plural, sourceStatusLabel } from "./presentation"; const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000"; diff --git a/apps/web/src/lib/presentation.ts b/apps/web/src/lib/presentation.ts index 67b3f34..7289a7c 100644 --- a/apps/web/src/lib/presentation.ts +++ b/apps/web/src/lib/presentation.ts @@ -28,6 +28,15 @@ export function freshnessStatus(value: string | null | undefined, now = Date.now return now - timestamp > 48 * 60 * 60 * 1000 ? "stale" : "fresh"; } +export type PassportStatus = "verified" | "unverified" | "incomplete" | "insufficient" | "blocked"; +export type PassportDisplayStatus = PassportStatus | "conflict" | "stale"; + +export function passportDisplayStatus(status: PassportStatus, freshness: "fresh" | "stale" | "unknown", hasConflicts: boolean): PassportDisplayStatus { + if (hasConflicts) return "conflict"; + if (status === "verified" && freshness === "stale") return "stale"; + return status; +} + export function sourceStatusLabel(status: string) { const labels: Record = { healthy: "Актуален", stale: "Данные устарели", temporarily_limited: "Источник временно ограничен", diff --git a/apps/web/tests/unit/presentation.test.ts b/apps/web/tests/unit/presentation.test.ts index 40f09ca..a05ab99 100644 --- a/apps/web/tests/unit/presentation.test.ts +++ b/apps/web/tests/unit/presentation.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { activityLevel, freshnessStatus, plural, sourceStatusLabel } from "../../src/lib/presentation.ts"; +import { activityLevel, freshnessStatus, passportDisplayStatus, plural, sourceStatusLabel } from "../../src/lib/presentation.ts"; import { fishVisualFamily } from "../../src/lib/fish-visuals.ts"; import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts"; import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts"; @@ -31,6 +31,13 @@ test("freshness status is explicit and deterministic around the 48-hour boundary assert.equal(freshnessStatus(null, now), "unknown"); }); +test("evidence passport prioritises conflict and stale states without hiding blocked data", () => { + assert.equal(passportDisplayStatus("verified", "fresh", false), "verified"); + assert.equal(passportDisplayStatus("verified", "stale", false), "stale"); + assert.equal(passportDisplayStatus("blocked", "fresh", false), "blocked"); + assert.equal(passportDisplayStatus("incomplete", "fresh", true), "conflict"); +}); + test("source status makes a blocked provider explicit", () => { assert.equal(sourceStatusLabel("temporarily_limited"), "Источник временно ограничен"); assert.equal(sourceStatusLabel("healthy"), "Актуален"); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c839adb..7989484 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -94,7 +94,7 @@ - [ ] **U01 · UX-контракт и scorecard.** Контракт маршрутов, смысловой порядок ответа, словарь статусов и целевые метрики зафиксированы в [ux-contract.md](ux-contract.md); для home, spot, waterbody, plan и tackle описаны первый ответ и обязательное объяснение. Сквозной Playwright task journey проверяет home → spot → «Что взять» → plan и возврат query-контекста, включая source/evidence-поля первой карточки; ручной сценарий и критерии зафиксированы в [ux-task-review-2026-09-21.md](ux-task-review-2026-09-21.md). Осталось провести review с участниками и собрать completion rate на пилоте. - [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная сохраняет рыбу, водоём, период и сортировку в shareable URL, явно показывает контекст запроса и даёт текстовый CTA «Открыть точку» на каждой карточке, включая mobile; первый экран ограничен пятью вариантами, а остальные доступны через сохраняющую query-контекст серверную пагинацию; empty-state предлагает вернуться к полному набору данных. Осталось добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры. -- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показано отдельное состояние `Недостаточно данных`, ограниченный источник не смешивается с неполными полями, а явно переданные provenance-конфликты видны текстом. Targeted E2E и API-тест проверяют публичные пути, период, 48-часовой порог и конфликт источников. +- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показано отдельное состояние `Недостаточно данных`, ограниченный источник не смешивается с неполными полями, а явно переданные provenance-конфликты видны текстом. Targeted E2E и API-тест проверяют публичные пути, период, 48-часовой порог и конфликт источников; единый `passportDisplayStatus` дополнительно закрепляет приоритет `conflict` → `stale` → исходный статус unit-регрессией. - [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Следующий шаг — единый list/map-контракт после подтверждения геометрии; на mobile карта должна открываться отдельным действием. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу. - [ ] **U05 · Mobile-first и сохранённый план рыбалки.** `/plan` поддерживает список до 5 локальных вариантов, удаление, очистку, переход к точке, print/PDF и восстановление из shareable URL; кнопка «Поделиться планом» использует native share или clipboard fallback. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Print/mobile-контракт проверяет 320/390 px, лимит импорта и отсутствие горизонтального overflow; карточка плана теперь сравнивает подтверждённые метод/проводку и явно показывает риск по выборке/уверенности. - [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, provenance и качество собраны в общем паспорте данных, а подтверждённые метод/проводка видны рядом с последними уловами; статусы дополнительно передаются текстом, малая выборка не маркируется как готовая рекомендация, а дублирующие catches/players/confidence убраны из вторичной колонки activity-карточки. Mobile route-contract проверяет первый ответ и действие на home, spot, waterbody, plan и tackle, а task regression требует source/evidence-поля; остаётся ручной review визуальных деталей по [ux-task-review-2026-09-21.md](ux-task-review-2026-09-21.md).