diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx index df710146..987bb353 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/deal-map.test.tsx @@ -13,9 +13,11 @@ import { render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { DealMapV3 } from "../_components/v3/DealMapV3"; -import { dealTitle } from "../_components/v3/deal-view"; +import { GuessGameV3 } from "../_components/v3/GuessGameV3"; +import { StreetMapV3 } from "../_components/v3/StreetMapV3"; import { EKB_MAP } from "../_components/v3/ekb-districts"; -import type { ShowcaseDeal } from "../public-api"; +import { dealTitle, toGameRound } from "../_components/v3/deal-view"; +import type { ShowcaseDeal, StreetScheme } from "../public-api"; /** Ширина кадра из атрибута viewBox отрисованной карты. */ function frameWidth(container: HTMLElement): number { @@ -108,6 +110,8 @@ describe("подпись сделки различает студию и ком note: "", lat: null, lon: null, + street_name: null, + street_scheme: null, }); it("rooms = 0 подписывается студией, а не «0-к»", () => { @@ -121,3 +125,104 @@ describe("подпись сделки различает студию и ком expect(dealTitle(deal(2, 52))).not.toContain("Студия"); }); }); + +// ── Схема улиц: что она обязана сказать и чего не должна заявлять ─────────── +// +// Схема приходит готовыми путями из ручки — рисовать тут нечего, и «рисуется +// ли svg» проверять бессмысленно. Ломаются другие вещи, и каждая ломается +// тихо: пропадает иерархия дорог (схема превращается в решётку), теряется +// подсветка целевой улицы (карта перестаёт быть про эту сделку), подпись +// начинает выдавать улицу за адрес объекта, а выбор между двумя картами +// съезжает — и 8% строк без схемы получают пустоту вместо района. + +const SCHEME: StreetScheme = { + street: "улица Краснолесья", + w: 1000, + h: 1000, + target: ["M431.2 88L500.1 210.4", "M500.1 210.4L512 640"], + roads: [ + { c: "trunk", d: "M0 0L100 100" }, + { c: "residential", d: "M0 900L100 950" }, + { c: "unknown_class", d: "M10 10L20 20" }, + ], + water: ["M0 500L1000 520"], + labels: [{ t: "улица Чкалова", x: 431.2, y: 88 }], +}; + +const widths = (container: HTMLElement, cls: string): number[] => + [...container.querySelectorAll(`path[class*="${cls}"]`)].map((n) => + Number(n.getAttribute("stroke-width")), + ); + +describe("схема улиц в карточке сделки", () => { + it("кадр берётся из данных, а не из константы фронта", () => { + const { container } = render(); + expect(container.querySelector("svg")!.getAttribute("viewBox")).toBe("0 0 800 800"); + // slice, а не meet: окно квадратное, карточка — нет. Целевая улица лежит в + // центре окна по построению, поэтому обрезка краёв её не трогает. + expect(container.querySelector("svg")!.getAttribute("preserveAspectRatio")).toContain("slice"); + }); + + it("целевая улица подсвечена и несёт ореол — по одному на каждый путь", () => { + const { container } = render(); + expect(widths(container, "streetTarget")).toHaveLength(SCHEME.target.length); + const halo = widths(container, "streetHalo"); + expect(halo).toHaveLength(SCHEME.target.length); + // Ореол обязан быть ШИРЕ линии — иначе он не ореол, а вторая линия. + expect(Math.min(...halo)).toBeGreaterThan(Math.max(...widths(container, "streetTarget"))); + }); + + it("иерархия дорог сохраняется, а незнакомый класс не даёт нулевой толщины", () => { + const { container } = render(); + const [trunk, residential, unknown] = widths(container, "streetRoad"); + expect(trunk).toBeGreaterThan(residential); + expect(unknown).toBeGreaterThan(0); + }); + + it("вода нарисована и отделена от дорог", () => { + const { container } = render(); + expect(widths(container, "streetWater")).toHaveLength(SCHEME.water.length); + }); + + it("подпись называет улицу и сразу говорит, что дом не известен", () => { + const { container, getByText } = render(); + expect(getByText(/УЛИЦА КРАСНОЛЕСЬЯ · ДОМ НЕ ИЗВЕСТЕН/u)).toBeTruthy(); + expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u); + }); + + it("схема ничего не сообщает скринридеру — всё, что она знает, стоит рядом текстом", () => { + const { container } = render(); + expect(container.querySelector("svg")!.getAttribute("aria-hidden")).toBe("true"); + }); +}); + +describe("карточка игры выбирает карту по наличию схемы", () => { + const deal = (scheme: StreetScheme | null): ShowcaseDeal => ({ + district: "Ленинский", + rooms: 2, + area_m2: 52, + floor: 3, + total_floors: 9, + deal_quarter: "II кв. 2026", + predicted_rub: 5_210_000, + fact_rub: 5_180_000, + err_pct: 0.58, + n_analogs: 12, + note: "", + lat: null, + lon: null, + street_name: scheme ? scheme.street : null, + street_scheme: scheme, + }); + + it("схема есть — показана улица", () => { + const { container } = render(); + expect(container.textContent).toContain("ДОМ НЕ ИЗВЕСТЕН"); + }); + + it("схемы нет — остаётся район, а не пустое место", () => { + const { container } = render(); + expect(container.textContent).toContain("ЛЕНИНСКИЙ"); + expect(container.textContent).not.toContain("ДОМ НЕ ИЗВЕСТЕН"); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx index ae83d8a5..2fdbbac7 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx @@ -56,6 +56,9 @@ const SHOWCASE: ShowcaseResponse = { note: "Прогноз посчитан по активным объявлениям на дату пересчёта", lat: null, lon: null, + // Схемы у этой строки нет — рендер идёт запасным путём, картой района. + street_name: null, + street_scheme: null, }, ], stats: { diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx index 562f05d3..e59f0ffc 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/GuessGameV3.tsx @@ -10,13 +10,18 @@ * реальных сделках» была неправдой. Сделок мало — игру не рендерит родитель: * играть на одной строке под заголовком «три квартиры» нельзя. * - * КАРТА (`DealMapV3`) — настоящая: границы районов ЕКБ статикой, кадр по - * району сделки, точка по координате, когда она есть. Раньше здесь стоял - * декоративный прямоугольник с подписью «ОБЪЕКТ · РАЙОН», а координат в - * витрине не было вовсе. Подпись объекта на карте сменилась: координата — это - * ЦЕНТРОИД УЛИЦЫ, а не дом, и карта произносит это сама (разбор — в шапке - * DealMapV3). Ниже карты объект по-прежнему подписан только тем, что известно: - * комнаты, площадь, этаж, район, квартал сделки. Адреса среди них нет. + * КАРТА — настоящая, и их две. Есть схема улиц (`street_scheme`, 92.1% строк + * витрины) — рисуется `StreetMapV3`: окрестности улицы сделки. Нет — остаётся + * `DealMapV3` с полигоном района, как было. Выбор делается ЗДЕСЬ, чтобы обе + * карты оставались тупыми: каждая рисует то, что ей дали, и ни одна не + * подставляет вместо отсутствующих данных правдоподобное. + * + * Обе подписывают себя сами и обе говорят про масштаб: схема улиц — «улица · + * ДОМ НЕ ИЗВЕСТЕН», район — «СЕРЕДИНА УЛИЦЫ, НЕ ДОМ». Раньше тут стоял + * декоративный прямоугольник с подписью «ОБЪЕКТ · РАЙОН», заявлявший + * местоположение объекта — то, чего в данных нет. Ниже карты объект + * по-прежнему подписан только тем, что известно: комнаты, площадь, этаж, + * район, квартал сделки. Адреса среди них нет. * * Ошибка игрока считается медианой |ответ − факт| / факт по сыгранным раундам * — той же формулой, что и ошибка МЕРЫ на этих же сделках (`err_pct` витрины). @@ -29,6 +34,7 @@ import { useState } from "react"; import styles from "../../landing-v3.module.css"; import type { GameRound } from "./deal-view"; import { DealMapV3 } from "./DealMapV3"; +import { StreetMapV3 } from "./StreetMapV3"; import { PublicLink } from "../PublicLink"; @@ -99,7 +105,11 @@ export function GuessGameV3({ rounds }: { rounds: readonly GameRound[] }) {