fix: unify coordinate precision labels
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import type { Activity } from "../lib/api";
|
import type { Activity } from "../lib/api";
|
||||||
import { activityLevel, ago, kg, spotPath } from "../lib/api";
|
import { activityLevel, ago, coordinatePrecisionLabel, kg, spotPath } from "../lib/api";
|
||||||
import FishingIcon from "./FishingIcon.astro";
|
import FishingIcon from "./FishingIcon.astro";
|
||||||
import DataPassport from "./DataPassport.astro";
|
import DataPassport from "./DataPassport.astro";
|
||||||
import FishSilhouette from "./FishSilhouette.astro";
|
import FishSilhouette from "./FishSilhouette.astro";
|
||||||
@@ -8,7 +8,7 @@ import TackleGlyph from "./TackleGlyph.astro";
|
|||||||
const { item, periodLabel = null } = Astro.props as { item: Activity; periodLabel?: string | null };
|
const { item, periodLabel = null } = Astro.props as { item: Activity; periodLabel?: string | null };
|
||||||
const level = activityLevel(item.activity_score);
|
const level = activityLevel(item.activity_score);
|
||||||
const limited = item.catches < 3;
|
const limited = item.catches < 3;
|
||||||
const precision = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" }[item.coordinate_precision];
|
const precision = coordinatePrecisionLabel(item.coordinate_precision);
|
||||||
---
|
---
|
||||||
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={spotPath(item)}>
|
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={spotPath(item)}>
|
||||||
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
---
|
---
|
||||||
import SourceBadge from "./SourceBadge.astro";
|
import SourceBadge from "./SourceBadge.astro";
|
||||||
import { ago, freshnessStatus, passportDisplayStatus } from "../lib/api";
|
import { ago, coordinatePrecisionLabel, 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" };
|
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 { 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 freshness = freshnessStatus(observedAt);
|
||||||
const displayStatus = passportDisplayStatus(status, freshness, sourceConflicts.length > 0);
|
const displayStatus = passportDisplayStatus(status, freshness, sourceConflicts.length > 0);
|
||||||
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", insufficient: "Недостаточно данных", blocked: "Источник ограничен", conflict: "Источники расходятся", stale: "Данные устарели" };
|
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", insufficient: "Недостаточно данных", blocked: "Источник ограничен", conflict: "Источники расходятся", stale: "Данные устарели" };
|
||||||
const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`;
|
const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`;
|
||||||
const precisionLabels = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" };
|
|
||||||
---
|
---
|
||||||
<section class="data-passport" aria-label="Паспорт данных">
|
<section class="data-passport" aria-label="Паспорт данных">
|
||||||
<header><span>Паспорт данных</span><strong data-passport-status={displayStatus}>{statusLabels[displayStatus]}</strong></header>
|
<header><span>Паспорт данных</span><strong data-passport-status={displayStatus}>{statusLabels[displayStatus]}</strong></header>
|
||||||
<div class="data-passport__sources">{sources.map(source => <SourceBadge source={source} href={sources.length === 1 ? sourceUrl : null}/>)}</div>
|
<div class="data-passport__sources">{sources.map(source => <SourceBadge source={source} href={sources.length === 1 ? sourceUrl : null}/>)}</div>
|
||||||
{sourceConflicts.length > 0 && <p class="data-passport__conflict"><strong>Источники расходятся:</strong> {sourceConflicts.join(" · ")}</p>}
|
{sourceConflicts.length > 0 && <p class="data-passport__conflict"><strong>Источники расходятся:</strong> {sourceConflicts.join(" · ")}</p>}
|
||||||
<dl><div><dt>Свежесть</dt><dd data-freshness={freshness}>{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}</dd></div>{periodLabel && <div><dt>Период</dt><dd>{periodLabel}</dd></div>}<div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div>{sampleSize != null && <div><dt>Наблюдения</dt><dd>{sampleSize}</dd></div>}{independentPlayers != null && <div><dt>Игроки</dt><dd>{independentPlayers}</dd></div>}{coordinatePrecision && <div><dt>Координаты</dt><dd>{precisionLabels[coordinatePrecision]}</dd></div>}</dl>
|
<dl><div><dt>Свежесть</dt><dd data-freshness={freshness}>{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}</dd></div>{periodLabel && <div><dt>Период</dt><dd>{periodLabel}</dd></div>}<div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div>{sampleSize != null && <div><dt>Наблюдения</dt><dd>{sampleSize}</dd></div>}{independentPlayers != null && <div><dt>Игроки</dt><dd>{independentPlayers}</dd></div>}{coordinatePrecision && <div><dt>Координаты</dt><dd>{coordinatePrecisionLabel(coordinatePrecision)}</dd></div>}</dl>
|
||||||
{sampleSize != null && sampleSize < 3 && <p class="data-passport__minimum">Минимум для рекомендации: 3 наблюдения.</p>}
|
{sampleSize != null && sampleSize < 3 && <p class="data-passport__minimum">Минимум для рекомендации: 3 наблюдения.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tack
|
|||||||
|
|
||||||
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
|
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
|
||||||
|
|
||||||
export { activityLevel, ago, freshnessStatus, kg, passportDisplayStatus, plural, sourceStatusLabel } from "./presentation";
|
export { activityLevel, ago, coordinatePrecisionLabel, freshnessStatus, kg, passportDisplayStatus, plural, sourceStatusLabel } from "./presentation";
|
||||||
|
|
||||||
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export function freshnessStatus(value: string | null | undefined, now = Date.now
|
|||||||
return now - timestamp > 48 * 60 * 60 * 1000 ? "stale" : "fresh";
|
return now - timestamp > 48 * 60 * 60 * 1000 ? "stale" : "fresh";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CoordinatePrecision = "exact" | "approximate" | "area" | "missing";
|
||||||
|
|
||||||
|
export function coordinatePrecisionLabel(value: CoordinatePrecision | string | null | undefined): string {
|
||||||
|
return ({ exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" } as Record<string, string>)[value ?? ""] ?? "не указаны";
|
||||||
|
}
|
||||||
|
|
||||||
export type PassportStatus = "verified" | "unverified" | "incomplete" | "insufficient" | "blocked";
|
export type PassportStatus = "verified" | "unverified" | "incomplete" | "insufficient" | "blocked";
|
||||||
export type PassportDisplayStatus = PassportStatus | "conflict" | "stale";
|
export type PassportDisplayStatus = PassportStatus | "conflict" | "stale";
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import TackleGlyph from "../../components/TackleGlyph.astro";
|
|||||||
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
||||||
import CatchList from "../../components/CatchList.astro";
|
import CatchList from "../../components/CatchList.astro";
|
||||||
import DataPassport from "../../components/DataPassport.astro";
|
import DataPassport from "../../components/DataPassport.astro";
|
||||||
import { activityLevel, api, ApiError, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
|
import { activityLevel, api, ApiError, coordinatePrecisionLabel, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
|
||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||||
let timeline: { start: string; end: string; count: number }[] = [];
|
let timeline: { start: string; end: string; count: number }[] = [];
|
||||||
@@ -25,7 +25,6 @@ try {
|
|||||||
if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60");
|
if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60");
|
||||||
}
|
}
|
||||||
const level = activity ? activityLevel(activity.activity_score) : null;
|
const level = activity ? activityLevel(activity.activity_score) : null;
|
||||||
const coordinatePrecision = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" } as const;
|
|
||||||
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
|
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
|
||||||
const method = catches.map(item => item.fishing_method).find(Boolean) ?? "";
|
const method = catches.map(item => item.fishing_method).find(Boolean) ?? "";
|
||||||
const retrieve = catches.map(item => item.retrieve_method).find(Boolean) ?? "";
|
const retrieve = catches.map(item => item.retrieve_method).find(Boolean) ?? "";
|
||||||
@@ -39,7 +38,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
|||||||
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
||||||
<AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} />
|
<AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} />
|
||||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><p class="coordinate-precision">Точность координат: <strong>{coordinatePrecision[spot.coordinate_precision as keyof typeof coordinatePrecision] ?? "не указаны"}</strong></p><div class="spot-hero__actions"><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><button class="plan-save" data-action="inverse" type="button" data-plan-save data-plan-key={Astro.url.pathname} data-plan-waterbody={spot.waterbody} data-plan-coordinates={`${spot.x}:${spot.y}`} data-plan-baits={spot.top_baits.join("|")} data-plan-method={methodLabels[method] ?? method} data-plan-retrieve={retrieve} data-plan-risk={risk} data-plan-freshness={activity?.last_confirmed_at ?? ""} data-plan-confidence={activity?.confidence_score ?? ""} aria-pressed="false">Сохранить в план</button><small class="copy-status" aria-live="polite"></small><small class="plan-status" aria-live="polite"></small></div></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><p class="coordinate-precision">Точность координат: <strong>{coordinatePrecisionLabel(spot.coordinate_precision)}</strong></p><div class="spot-hero__actions"><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><button class="plan-save" data-action="inverse" type="button" data-plan-save data-plan-key={Astro.url.pathname} data-plan-waterbody={spot.waterbody} data-plan-coordinates={`${spot.x}:${spot.y}`} data-plan-baits={spot.top_baits.join("|")} data-plan-method={methodLabels[method] ?? method} data-plan-retrieve={retrieve} data-plan-risk={risk} data-plan-freshness={activity?.last_confirmed_at ?? ""} data-plan-confidence={activity?.confidence_score ?? ""} aria-pressed="false">Сохранить в план</button><small class="copy-status" aria-live="polite"></small><small class="plan-status" aria-live="polite"></small></div></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||||
<ActivityTimeline buckets={timeline}/>
|
<ActivityTimeline buckets={timeline}/>
|
||||||
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
|
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { activityLevel, freshnessStatus, passportDisplayStatus, plural, sourceStatusLabel } from "../../src/lib/presentation.ts";
|
import { activityLevel, coordinatePrecisionLabel, freshnessStatus, passportDisplayStatus, plural, sourceStatusLabel } from "../../src/lib/presentation.ts";
|
||||||
import { fishVisualFamily } from "../../src/lib/fish-visuals.ts";
|
import { fishVisualFamily } from "../../src/lib/fish-visuals.ts";
|
||||||
import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts";
|
import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts";
|
||||||
import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts";
|
import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts";
|
||||||
@@ -38,6 +38,14 @@ test("evidence passport prioritises conflict and stale states without hiding blo
|
|||||||
assert.equal(passportDisplayStatus("incomplete", "fresh", true), "conflict");
|
assert.equal(passportDisplayStatus("incomplete", "fresh", true), "conflict");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coordinate precision keeps all four public states explicit", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
["exact", "approximate", "area", "missing"].map(coordinatePrecisionLabel),
|
||||||
|
["точные", "приблизительные", "район", "не указаны"],
|
||||||
|
);
|
||||||
|
assert.equal(coordinatePrecisionLabel("future"), "не указаны");
|
||||||
|
});
|
||||||
|
|
||||||
test("source status makes a blocked provider explicit", () => {
|
test("source status makes a blocked provider explicit", () => {
|
||||||
assert.equal(sourceStatusLabel("temporarily_limited"), "Источник временно ограничен");
|
assert.equal(sourceStatusLabel("temporarily_limited"), "Источник временно ограничен");
|
||||||
assert.equal(sourceStatusLabel("healthy"), "Актуален");
|
assert.equal(sourceStatusLabel("healthy"), "Актуален");
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@
|
|||||||
- [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентный upsert применён к canonical snapshot: `19/19`, без missing/duplicate/provenance issues. Осталось применить подтверждённые detail snapshots и отдельно разделить игровые и редакционные тексты при подключении detail-данных.
|
- [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентный upsert применён к canonical snapshot: `19/19`, без missing/duplicate/provenance issues. Осталось применить подтверждённые detail snapshots и отдельно разделить игровые и редакционные тексты при подключении detail-данных.
|
||||||
- [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника.
|
- [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника.
|
||||||
- [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. RF4MAP/RF4-STAT directory parsers теперь преобразуются в crosswalk identities без переноса метрик или автоматической привязки. Осталось подать полный набор реальных RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта.
|
- [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. RF4MAP/RF4-STAT directory parsers теперь преобразуются в crosswalk identities без переноса метрик или автоматической привязки. Осталось подать полный набор реальных RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта.
|
||||||
- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Исправлена spot detail: точность (`точные`/`приблизительные`/`район`/`не указаны`) теперь видна рядом с координатами и покрыта smoke regression. In-app Chromium и focused smoke подтвердили `/spots/vyunok-321x654` на desktop/mobile без overflow; browser regression для отсутствующей точки проверяет недоступное состояние без ложных координат, а API regression параметризован по всем четырём precision-состояниям. Осталась полная browser QA матрица для всех precision/error-состояний.
|
- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Исправлена spot detail: точность (`точные`/`приблизительные`/`район`/`не указаны`) теперь видна рядом с координатами и покрыта smoke regression. Все public-компоненты используют единый `coordinatePrecisionLabel` с безопасным fallback и unit-регрессией четырёх состояний. In-app Chromium и focused smoke подтвердили `/spots/vyunok-321x654` на desktop/mobile без overflow; browser regression для отсутствующей точки проверяет недоступное состояние без ложных координат, а API regression параметризован по всем четырём precision-состояниям. Осталась полная browser QA матрица для всех precision/error-состояний.
|
||||||
- [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Public media API теперь передаёт reviewed `media_role` и дополнительно отбрасывает unknown/cross-entity роли на границе публикации, waterbody detail принимает только `waterbody_cover`/`waterbody_map`/`waterbody_depth_map`, а UI подписывает карту/заставку/скриншот текстом. Осталось завершить browser QA для реальных role-вариантов и не показывать abstract fingerprint как внешнее изображение.
|
- [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Public media API теперь передаёт reviewed `media_role` и дополнительно отбрасывает unknown/cross-entity роли на границе публикации, waterbody detail принимает только `waterbody_cover`/`waterbody_map`/`waterbody_depth_map`, а UI подписывает карту/заставку/скриншот текстом. Осталось завершить browser QA для реальных role-вариантов и не показывать abstract fingerprint как внешнее изображение.
|
||||||
- [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; 20.09 targeted waterbody E2E подтвердил detail с пустой активностью, not-found waterbody и not-found waterbody/fish pair, а spot regression — unavailable state без ложной координаты; каталоги, media, analytics и tackle detail теперь имеют opt-in regression (`EXPECT_UNAVAILABLE=1`) для `503` + `Retry-After` + `no-store` без утечки внутренних ошибок. Полный web E2E и visual-matrix остаются обязательным финальным прогоном; внешние detail-данные закрываются отдельно. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff.
|
- [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; 20.09 targeted waterbody E2E подтвердил detail с пустой активностью, not-found waterbody и not-found waterbody/fish pair, а spot regression — unavailable state без ложной координаты; каталоги, media, analytics и tackle detail теперь имеют opt-in regression (`EXPECT_UNAVAILABLE=1`) для `503` + `Retry-After` + `no-store` без утечки внутренних ошибок. Полный web E2E и visual-matrix остаются обязательным финальным прогоном; внешние detail-данные закрываются отдельно. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user