From 42e5b8b073c79af338fcbd0845cfc26df0a8b5c9 Mon Sep 17 00:00:00 2001 From: gyuhochoime Date: Sat, 29 Aug 2026 13:17:53 +0900 Subject: [PATCH 1/6] =?UTF-8?q?MSG-489=20feat:=20[=EC=9B=B9]=20AI=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EC=B6=94=EC=B2=9C=20=E2=80=94=20mentionedAre?= =?UTF-8?q?a=20=EC=9E=90=EB=8F=99=20=EC=9D=B4=EB=8F=99=C2=B71km=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=C2=B72=EC=B0=A8=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=9E=AC=EC=9A=94=EC=B2=AD=C2=B7=EC=B6=9C=EB=B0=9C=EC=A7=80=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai-route/api/use-route-recommend.ts | 33 +- .../ai-route/model/ai-route-store.test.ts | 46 + .../features/ai-route/model/ai-route-store.ts | 54 +- .../model/route-mentioned-area.test.ts | 74 ++ .../ai-route/model/route-mentioned-area.ts | 61 ++ .../ai-route/model/route-origin.test.ts | 52 ++ .../features/ai-route/model/route-origin.ts | 32 + .../ai-route/model/route-request.test.ts | 106 +++ .../features/ai-route/model/route-request.ts | 61 +- apps/web/src/pages/ai-route/AiRoutePage.tsx | 124 +-- .../ai-route/ai-route-panel.smoke.test.tsx | 63 ++ .../src/pages/ai-route/ui/RouteInputCard.tsx | 15 +- .../src/pages/ai-route/ui/RouteToastHost.tsx | 44 + .../ui/use-ai-route-auto-move.test.tsx | 234 +++++ .../ai-route/ui/use-ai-route-auto-move.ts | 145 +++ apps/web/src/shared/geolocation.test.ts | 56 +- apps/web/src/shared/geolocation.ts | 21 +- apps/web/src/test/geolocation.ts | 32 + docs/STATUS.md | 13 +- docs/decisions/DECISIONS.md | 4 + docs/spec/MSG-489.md | 270 ++++++ nose.baseline.json | 844 +++++++++++------- 22 files changed, 1958 insertions(+), 426 deletions(-) create mode 100644 apps/web/src/features/ai-route/model/route-mentioned-area.test.ts create mode 100644 apps/web/src/features/ai-route/model/route-mentioned-area.ts create mode 100644 apps/web/src/features/ai-route/model/route-origin.test.ts create mode 100644 apps/web/src/features/ai-route/model/route-origin.ts create mode 100644 apps/web/src/pages/ai-route/ui/RouteToastHost.tsx create mode 100644 apps/web/src/pages/ai-route/ui/use-ai-route-auto-move.test.tsx create mode 100644 apps/web/src/pages/ai-route/ui/use-ai-route-auto-move.ts create mode 100644 apps/web/src/test/geolocation.ts create mode 100644 docs/spec/MSG-489.md diff --git a/apps/web/src/features/ai-route/api/use-route-recommend.ts b/apps/web/src/features/ai-route/api/use-route-recommend.ts index 86c7a4e7..b575d4e1 100644 --- a/apps/web/src/features/ai-route/api/use-route-recommend.ts +++ b/apps/web/src/features/ai-route/api/use-route-recommend.ts @@ -5,6 +5,10 @@ import type { RouteRecommendRequestDto } from "@/shared/api/generated"; import { unwrapEnvelope } from "@/shared/api/envelope"; import { useAiRouteStore } from "../model/ai-route-store"; import { routeErrorNotice } from "../model/route-error"; +import { + type RouteAutoMove, + resolveAutoMove, +} from "../model/route-mentioned-area"; /** * AI 경로 추천 요청 (MSG-488 §4-3) — `POST /api/routes/recommend`를 1회 쏜다. @@ -14,7 +18,9 @@ import { routeErrorNotice } from "../model/route-error"; * mutation은 TanStack 기본 `retry: 0`이라 14429(10초 제한)가 자동 재시도로 악화되지 않는다. * 콜백은 훅 레벨 옵션으로 받는다 — mutate per-call 콜백은 관찰자 언마운트 시 유실된다(MSG-325 선례). * - * [MSG-489 확장점] 2차 자동 재요청 트리거가 이 훅에 얹힌다. + * MSG-489: 응답에 `mentionedArea`가 실리면 **결과를 게시하지 않고**(D5) 2차 요청을 예약한다. + * 2차 인스턴스(`secondary: true`)는 예약 플래그를 보존한 채 로딩만 이어 간다 — + * `startRequest`를 쓰면 `autoMoved`가 초기화돼 2차 응답에서 또 이동하는 무한 루프가 된다. */ // 생성 팩토리는 mutationFn을 항상 채운다 — UseMutationOptions 타입만 optional이라 !로 좁힌다 const recommendFn = recommendMutation().mutationFn!; @@ -22,8 +28,14 @@ const recommendFn = recommendMutation().mutationFn!; export const useRouteRecommend = (callbacks?: { /** 401(2403) — 패널은 입력을 유지한 채 입력 대기로 돌아가고 로그인 모달만 연다 (§1-3) */ onLoginRequired?: () => void; + /** 언급 지역 신호 도착 — 지도 이동·2차 발사는 뷰-레이어 오케스트레이터가 맡는다 (D2·D4) */ + onAutoMove?: (move: RouteAutoMove) => void; + /** 2차 자동 재요청 인스턴스인가 — 요청 시작 처리가 갈린다 */ + secondary?: boolean; }) => { const startRequest = useAiRouteStore((s) => s.startRequest); + const startSecondaryRequest = useAiRouteStore((s) => s.startSecondaryRequest); + const markSecondarySent = useAiRouteStore((s) => s.markSecondarySent); const succeed = useAiRouteStore((s) => s.succeed); const fail = useAiRouteStore((s) => s.fail); @@ -31,10 +43,25 @@ export const useRouteRecommend = (callbacks?: { mutationFn: (body: RouteRecommendRequestDto, context) => recommendFn({ body }, context), // 이전 결과·선택은 요청 시작 시점에 비운다 — 로딩 화면에 잔상이 남지 않는다 (L7) - onMutate: () => startRequest(), + onMutate: (body) => { + const originSent = body.origin !== undefined; + if (callbacks?.secondary) markSecondarySent(originSent); + else startRequest(originSent); + }, onSuccess: (response) => { const data = unwrapEnvelope(response); - succeed(data.points, data.notice); + // 이동 여부는 스토어 현재값으로 판정한다 — 2차 응답은 alreadyMoved라 항상 null (L9·L17) + const move = resolveAutoMove({ + mentionedArea: data.mentionedArea, + alreadyMoved: useAiRouteStore.getState().autoMoved, + }); + if (move === null) { + succeed(data.points, data.notice); + return; + } + // 1차 결과는 스토어에도 오버레이에도 게시하지 않고 로딩을 유지한다 (D5) + startSecondaryRequest(move.areaName); + callbacks?.onAutoMove?.(move); }, onError: (error) => { const notice = routeErrorNotice(error); diff --git a/apps/web/src/features/ai-route/model/ai-route-store.test.ts b/apps/web/src/features/ai-route/model/ai-route-store.test.ts index 011860ad..45015a4b 100644 --- a/apps/web/src/features/ai-route/model/ai-route-store.test.ts +++ b/apps/web/src/features/ai-route/model/ai-route-store.test.ts @@ -113,3 +113,49 @@ describe("useAiRouteStore — 요청·결과 상태 전이 (L7)", () => { expect(store().selectedOrder).toBeNull(); }); }); + +describe("2차 자동 재요청 사이클 (L14·L15)", () => { + beforeEach(() => { + useAiRouteStore.setState(useAiRouteStore.getInitialState(), true); + }); + + it("2차 요청을 시작하면 로딩을 유지한 채 1차 결과를 게시하지 않고 자동 이동을 기록한다 (L14)", () => { + store().startRequest(); + + store().startSecondaryRequest("부산 서면"); + + expect(store().status).toBe("loading"); + expect(store().points).toHaveLength(0); + expect(store().autoMoved).toBe(true); + expect(store().movedAreaName).toBe("부산 서면"); + expect(store().secondaryPending).toBe(true); + }); + + it("새 1차 요청은 자동 이동·출발지·이동 지역명을 초기화한다 (L15)", () => { + store().startRequest(true); + store().startSecondaryRequest("부산 서면"); + store().markSecondarySent(true); + + store().startRequest(); + + expect(store().autoMoved).toBe(false); + expect(store().originSent).toBe(false); + expect(store().movedAreaName).toBeNull(); + expect(store().secondaryPending).toBe(false); + }); + + it("요청 시작 시각을 기록한다 — 2차 발사가 서버 10초 창을 계산하는 기준 (Q2 안 B)", () => { + const before = Date.now(); + + store().startRequest(); + + expect(store().requestedAt).not.toBeNull(); + expect(store().requestedAt!).toBeGreaterThanOrEqual(before); + }); + + it("출발지를 실어 보낸 요청은 originSent를 켠다 — 결과 화면 버튼 문구의 근거 (L13 배선)", () => { + store().startRequest(true); + + expect(store().originSent).toBe(true); + }); +}); diff --git a/apps/web/src/features/ai-route/model/ai-route-store.ts b/apps/web/src/features/ai-route/model/ai-route-store.ts index 90a02e00..c4735a51 100644 --- a/apps/web/src/features/ai-route/model/ai-route-store.ts +++ b/apps/web/src/features/ai-route/model/ai-route-store.ts @@ -7,7 +7,8 @@ import type { RouteErrorNotice } from "./route-error"; * 플랫폼 중립 — 지도 SDK·라우터·웹 API를 import하지 않는다(RN 경계). * 스토어가 정본이라 다른 섹션에 갔다 돌아와도 입력·결과·지도 표시가 복원된다 (S11). * - * [MSG-489 확장점] 출발지(origin)·mentionedArea·2차 자동 재요청 플래그가 이 스토어에 얹힌다. + * MSG-489가 출발지·자동 이동·2차 자동 재요청 플래그를 얹었다 — 전부 **요청 사이클** 단위라 + * 새 1차 요청이 한꺼번에 초기화한다 (L15). */ export type AiRouteStatus = "idle" | "loading" | "result" | "error"; @@ -23,14 +24,40 @@ interface AiRouteState { errorNotice: RouteErrorNotice | null; /** 14503(기능 꺼짐) — 세션 동안 제출을 막는다 (§1-4) */ featureDisabled: boolean; + /** 이번 사이클에서 자동 이동을 이미 했는가 — 2차 응답의 mentionedArea를 무시한다 (L9·L14) */ + autoMoved: boolean; + /** 직전 요청이 origin을 실어 보냈는가 — 결과 화면 버튼 문구의 근거 (L13) */ + originSent: boolean; + /** 자동 이동한 지역명 — 안내 토스트가 읽는다. 이동이 없었으면 null (D3) */ + movedAreaName: string | null; + /** 2차 요청이 예약됐지만 아직 발사되지 않았는가 — 섹션 이탈·재진입에도 예약이 살아남는다 */ + secondaryPending: boolean; + /** 1차 요청을 쏜 시각(ms) — 2차 발사가 서버 10초 창을 계산하는 기준 (Q2 안 B) */ + requestedAt: number | null; setText: (text: string) => void; - startRequest: () => void; + /** 1차 요청 시작 — 사이클 플래그를 전부 초기화한다 (L15) */ + startRequest: (originSent?: boolean) => void; + /** 자동 이동 확정 + 2차 요청 예약 — 1차 결과는 게시하지 않고 로딩을 유지한다 (D5·L14) */ + startSecondaryRequest: (areaName: string) => void; + /** 예약된 2차를 실제로 발사한 시점 — 재발사를 막고 출발지 재판정 결과를 반영한다 */ + markSecondarySent: (originSent: boolean) => void; succeed: (points: RoutePointDto[], notice: string | null) => void; fail: (notice: RouteErrorNotice) => void; selectOrder: (order: number | null) => void; reset: () => void; } +/** 요청 사이클 플래그 초기값 — startRequest와 reset이 공유한다 (L15) */ +const clearedCycle = (): Pick< + AiRouteState, + "autoMoved" | "originSent" | "movedAreaName" | "secondaryPending" +> => ({ + autoMoved: false, + originSent: false, + movedAreaName: null, + secondaryPending: false, +}); + /** 결과·선택·에러가 비워진 상태 — 새 요청 시작과 reset이 공유한다 (매번 새 배열) */ const cleared = (): Pick< AiRouteState, @@ -46,10 +73,28 @@ const cleared = (): Pick< export const useAiRouteStore = create((set) => ({ text: "", featureDisabled: false, + requestedAt: null, ...cleared(), + ...clearedCycle(), setText: (text) => set({ text }), // 새 요청은 이전 결과를 **먼저** 비운다 — 잔상(이전 카드·지도 표시)이 로딩 중에 남지 않는다 - startRequest: () => set({ ...cleared(), status: "loading" }), + startRequest: (originSent = false) => + set({ + ...cleared(), + ...clearedCycle(), + status: "loading", + originSent, + requestedAt: Date.now(), + }), + startSecondaryRequest: (areaName) => + set({ + status: "loading", + autoMoved: true, + movedAreaName: areaName, + secondaryPending: true, + }), + markSecondarySent: (originSent) => + set({ secondaryPending: false, originSent }), succeed: (points, notice) => set({ status: "result", points, notice, errorNotice: null }), fail: (notice) => @@ -61,5 +106,6 @@ export const useAiRouteStore = create((set) => ({ })), selectOrder: (order) => set({ selectedOrder: order }), // 레일 재클릭 2단의 초기화 — 입력까지 비운다. featureDisabled는 세션 플래그라 남긴다 - reset: () => set({ ...cleared(), text: "" }), + reset: () => + set({ ...cleared(), ...clearedCycle(), text: "", requestedAt: null }), })); diff --git a/apps/web/src/features/ai-route/model/route-mentioned-area.test.ts b/apps/web/src/features/ai-route/model/route-mentioned-area.test.ts new file mode 100644 index 00000000..b1d78779 --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-mentioned-area.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { MAP_SCALE_1KM_ZOOM } from "@/features/map-home/model/map-scale"; +import type { MentionedAreaDto } from "@/shared/api/generated"; +import { + MOVED_TOAST_DESCRIPTION, + movedToastTitle, + resolveAutoMove, +} from "./route-mentioned-area"; + +const area = (kind: string): MentionedAreaDto => ({ + name: "부산 서면", + centerLat: 35.1579, + centerLng: 129.0594, + minLat: 35.1521, + minLng: 129.0537, + maxLat: 35.1662, + maxLng: 129.0712, + kind, +}); + +describe("resolveAutoMove — 언급 지역 자동 이동 판정 (L6~L9)", () => { + it("MOVE 신호는 지역 중심과 1km 축척 줌·지역명을 낸다 (L6)", () => { + expect( + resolveAutoMove({ mentionedArea: area("MOVE"), alreadyMoved: false }), + ).toEqual({ + center: { lat: 35.1579, lng: 129.0594 }, + zoom: MAP_SCALE_1KM_ZOOM, + areaName: "부산 서면", + kind: "MOVE", + }); + }); + + it("ZOOM_OUT 신호도 MOVE와 같은 이동으로 처리한다 (L7)", () => { + const moved = resolveAutoMove({ + mentionedArea: area("MOVE"), + alreadyMoved: false, + }); + const zoomedOut = resolveAutoMove({ + mentionedArea: area("ZOOM_OUT"), + alreadyMoved: false, + }); + + expect({ ...zoomedOut, kind: "MOVE" }).toEqual(moved); + expect(zoomedOut?.center).toEqual(moved?.center); + expect(zoomedOut?.zoom).toBe(moved?.zoom); + }); + + it("언급 지역 신호가 없으면 이동하지 않는다 (L8)", () => { + expect( + resolveAutoMove({ mentionedArea: null, alreadyMoved: false }), + ).toBeNull(); + }); + + it("이미 자동 이동한 사이클이면 신호가 또 와도 이동하지 않는다 — 무한 루프 차단 (L9)", () => { + expect( + resolveAutoMove({ mentionedArea: area("MOVE"), alreadyMoved: true }), + ).toBeNull(); + }); +}); + +describe("토스트 문구 파생 (L10)", () => { + it("제목은 지역명에 조사를 붙이고, 본문은 1km 기준을 알린다 — '2km'는 쓰지 않는다 (L10)", () => { + expect(movedToastTitle("부산 서면")).toBe("부산 서면으로 이동했어요"); + expect(MOVED_TOAST_DESCRIPTION).toBe( + "지도 범위 약 1km 기준으로 동선을 짜요", + ); + expect(MOVED_TOAST_DESCRIPTION).not.toContain("2km"); + }); + + it("받침 없는 지역명은 '로', ㄹ 받침도 '로'를 쓴다 (L10, 조사)", () => { + expect(movedToastTitle("해운대")).toBe("해운대로 이동했어요"); + expect(movedToastTitle("물만골")).toBe("물만골로 이동했어요"); + }); +}); diff --git a/apps/web/src/features/ai-route/model/route-mentioned-area.ts b/apps/web/src/features/ai-route/model/route-mentioned-area.ts new file mode 100644 index 00000000..5e20d944 --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-mentioned-area.ts @@ -0,0 +1,61 @@ +import type { LatLng } from "@/entities/cell"; +import { MAP_SCALE_1KM_ZOOM } from "@/features/map-home/model/map-scale"; +import type { MentionedAreaDto } from "@/shared/api/generated"; + +/** + * 언급 지역 자동 이동 판정 + 안내 토스트 문구 (MSG-489 L6~L10). + * 순수 함수 — 지도 SDK를 모른다(RN 재사용 대상). 명령 실행은 뷰-레이어 훅이 한다. + * + * `kind`는 생성 타입상 유니언이 아니라 raw `string`이라 분기하지 않는다(A3): + * MOVE·ZOOM_OUT을 같은 처리로 통일한 이상(D6), 서버가 신호를 보냈다는 사실 자체가 판정 근거다. + */ +export interface RouteAutoMove { + center: LatLng; + /** 축척 1km 단 고정 (D2) — 지역 외접 사각형 fitBounds는 채택하지 않는다 (D6) */ + zoom: number; + areaName: string; + /** 서버 신호 종류 — 기록만 하고 분기하지 않는다 (A3) */ + kind: string; +} + +export const resolveAutoMove = ({ + mentionedArea, + alreadyMoved, +}: { + mentionedArea: MentionedAreaDto | null; + /** 이번 요청 사이클에서 이미 자동 이동했는가 — 2차 응답 무시 = 무한 루프 차단 (D4) */ + alreadyMoved: boolean; +}): RouteAutoMove | null => { + if (mentionedArea === null || alreadyMoved) return null; + return { + center: { lat: mentionedArea.centerLat, lng: mentionedArea.centerLng }, + zoom: MAP_SCALE_1KM_ZOOM, + areaName: mentionedArea.name, + kind: mentionedArea.kind, + }; +}; + +/** Figma 15675:3267의 "약 2km"는 축척 고정 결정에 따라 1km로 정정해 구현한다 (D7) */ +export const MOVED_TOAST_DESCRIPTION = "지도 범위 약 1km 기준으로 동선을 짜요"; + +const HANGUL_BASE = 0xac00; +const HANGUL_LAST = 0xd7a3; +/** 종성 ㄹ의 인덱스 — 받침이 ㄹ이면 "으로"가 아니라 "로"를 쓴다 */ +const JONGSEONG_RIEUL = 8; + +/** + * 조사 "(으)로" 확정 — 받침 없음·ㄹ 받침은 "로", 나머지 받침은 "으로". + * 한글 음절이 아닌 끝 글자는 받침 없음으로 본다(지역명은 한글이 정본이라 폴백 경로다). + */ +const euroJosa = (name: string): string => { + const code = name.charCodeAt(name.length - 1); + if (Number.isNaN(code) || code < HANGUL_BASE || code > HANGUL_LAST) { + return "로"; + } + const jongseong = (code - HANGUL_BASE) % 28; + return jongseong === 0 || jongseong === JONGSEONG_RIEUL ? "로" : "으로"; +}; + +/** 이동 안내 토스트 제목 — "{지역명}(으)로 이동했어요" (D3) */ +export const movedToastTitle = (areaName: string): string => + `${areaName}${euroJosa(areaName)} 이동했어요`; diff --git a/apps/web/src/features/ai-route/model/route-origin.test.ts b/apps/web/src/features/ai-route/model/route-origin.test.ts new file mode 100644 index 00000000..8cf40594 --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-origin.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import type { Bounds } from "@/entities/cell"; +import { resolveRouteOrigin } from "./route-origin"; + +/** 부산 서면 일대 — MVP 지역 (route-points 픽스처와 같은 기준) */ +const BOUNDS: Bounds = { + sw: { lat: 35.1521, lng: 129.0537 }, + ne: { lat: 35.1662, lng: 129.0712 }, +}; + +const INSIDE = { lat: 35.1579, lng: 129.0594 }; + +describe("resolveRouteOrigin — 출발지 자동 판정 (L1~L5)", () => { + it("현위치가 뷰포트 안이면 그 좌표를 출발지로 싣는다 (L1)", () => { + expect(resolveRouteOrigin({ coords: INSIDE, bounds: BOUNDS })).toEqual({ + lat: 35.1579, + lng: 129.0594, + }); + }); + + it("현위치가 뷰포트 밖이면 출발지를 싣지 않는다 (L2, 경도·위도 각각)", () => { + expect( + resolveRouteOrigin({ + coords: { lat: 35.1579, lng: 129.1204 }, + bounds: BOUNDS, + }), + ).toBeNull(); + expect( + resolveRouteOrigin({ + coords: { lat: 35.2204, lng: 129.0594 }, + bounds: BOUNDS, + }), + ).toBeNull(); + }); + + it("현위치를 못 얻었으면(권한 거부·미확보) 출발지를 싣지 않는다 (L3)", () => { + expect(resolveRouteOrigin({ coords: null, bounds: BOUNDS })).toBeNull(); + }); + + it("지도가 준비되기 전(bounds null)이면 출발지를 싣지 않는다 (L4)", () => { + expect(resolveRouteOrigin({ coords: INSIDE, bounds: null })).toBeNull(); + }); + + it("경계선 위 좌표는 뷰포트 안으로 판정한다 (L5, 포함 경계)", () => { + expect(resolveRouteOrigin({ coords: BOUNDS.sw, bounds: BOUNDS })).toEqual( + BOUNDS.sw, + ); + expect(resolveRouteOrigin({ coords: BOUNDS.ne, bounds: BOUNDS })).toEqual( + BOUNDS.ne, + ); + }); +}); diff --git a/apps/web/src/features/ai-route/model/route-origin.ts b/apps/web/src/features/ai-route/model/route-origin.ts new file mode 100644 index 00000000..b3ea4b0b --- /dev/null +++ b/apps/web/src/features/ai-route/model/route-origin.ts @@ -0,0 +1,32 @@ +import type { Bounds, LatLng } from "@/entities/cell"; +import type { OriginDto } from "@/shared/api/generated"; + +/** + * 출발지 자동 판정 (MSG-489 L1~L5). + * 순수 함수 — 지도 SDK도 `navigator`도 모른다(RN 재사용 대상). 현위치 조회는 + * `shared/geolocation` 어댑터가, 뷰포트는 `viewport-store`가 공급한다. + * + * 토글이 아니다: 현위치가 지금 보이는 지도 범위 안에 있을 때만 출발지를 싣고, + * 밖이거나 권한 거부·미확보면 조용히 생략한다(기능은 그대로 동작한다 — D8). + */ + +/** 뷰포트 포함 판정 — 경계선 위는 안으로 본다 (L5). 2차 발사의 새 bounds 확인도 이 판정을 쓴다 */ +export const isWithinBounds = (coords: LatLng, { sw, ne }: Bounds): boolean => + coords.lat >= sw.lat && + coords.lat <= ne.lat && + coords.lng >= sw.lng && + coords.lng <= ne.lng; + +export const resolveRouteOrigin = ({ + coords, + bounds, +}: { + /** 현위치 — 권한 거부·미확보·조회 전은 null */ + coords: LatLng | null; + /** 지금 보이는 지도 범위 — 지도 준비 전은 null */ + bounds: Bounds | null; +}): OriginDto | null => { + if (coords === null || bounds === null) return null; + if (!isWithinBounds(coords, bounds)) return null; + return { lat: coords.lat, lng: coords.lng }; +}; diff --git a/apps/web/src/features/ai-route/model/route-request.test.ts b/apps/web/src/features/ai-route/model/route-request.test.ts index b35f0587..2321b354 100644 --- a/apps/web/src/features/ai-route/model/route-request.test.ts +++ b/apps/web/src/features/ai-route/model/route-request.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it } from "vitest"; import type { Bounds } from "@/entities/cell"; import { MAX_ROUTE_TEXT_LENGTH, + SECONDARY_MIN_INTERVAL_MS, buildRecommendBody, canSubmit, + needsSpanNormalize, + secondaryDelayMs, + submitLabel, toViewportDto, } from "./route-request"; @@ -92,3 +96,105 @@ describe("canSubmit — 제출 가능 판정 (L8)", () => { ).toBeNull(); }); }); + +describe("buildRecommendBody — 출발지 병합 (L11)", () => { + it("출발지를 주면 body에 origin이 실린다 (L11)", () => { + expect( + buildRecommendBody({ + text: "서면 동선", + bounds: BOUNDS, + origin: { lat: 35.1579, lng: 129.0594 }, + }), + ).toEqual({ + text: "서면 동선", + viewport: toViewportDto(BOUNDS), + origin: { lat: 35.1579, lng: 129.0594 }, + }); + }); + + it("출발지를 주지 않으면 origin 키 자체가 없다 (L11)", () => { + const body = buildRecommendBody({ text: "서면 동선", bounds: BOUNDS }); + + expect(body).not.toHaveProperty("origin"); + expect( + buildRecommendBody({ text: "서면 동선", bounds: BOUNDS, origin: null }), + ).not.toHaveProperty("origin"); + }); +}); + +describe("needsSpanNormalize — 0.5도 초과 뷰포트 예방 판정 (L12)", () => { + it("위·경도 어느 한 변이라도 0.5도를 넘으면 정규화가 필요하다 (L12)", () => { + expect( + needsSpanNormalize({ + sw: { lat: 35.0, lng: 129.0 }, + ne: { lat: 35.6, lng: 129.1 }, + }), + ).toBe(true); + expect( + needsSpanNormalize({ + sw: { lat: 35.0, lng: 129.0 }, + ne: { lat: 35.1, lng: 129.6 }, + }), + ).toBe(true); + }); + + it("두 변 모두 0.5도 이하면 정규화가 필요 없다 (L12, 경계 포함)", () => { + expect(needsSpanNormalize(BOUNDS)).toBe(false); + expect( + needsSpanNormalize({ + sw: { lat: 35.0, lng: 129.0 }, + ne: { lat: 35.5, lng: 129.5 }, + }), + ).toBe(false); + }); + + it("지도가 준비되기 전(bounds null)에는 정규화를 시도하지 않는다 (L12)", () => { + expect(needsSpanNormalize(null)).toBe(false); + }); +}); + +describe("submitLabel — 제출 버튼 문구 (L13)", () => { + it("최초 입력 대기는 '동선 짜기'다 (L13)", () => { + expect(submitLabel({ status: "idle", originSent: false })).toBe( + "동선 짜기", + ); + expect(submitLabel({ status: "idle", originSent: true })).toBe("동선 짜기"); + }); + + it("출발지를 실어 보낸 결과 화면은 '현재 위치에서 다시 짜기'다 (L13)", () => { + expect(submitLabel({ status: "result", originSent: true })).toBe( + "현재 위치에서 다시 짜기", + ); + }); + + it("출발지 없이 보낸 결과·실패 화면은 '다시 짜기'다 (L13)", () => { + expect(submitLabel({ status: "result", originSent: false })).toBe( + "다시 짜기", + ); + expect(submitLabel({ status: "error", originSent: false })).toBe( + "다시 짜기", + ); + }); +}); + +describe("secondaryDelayMs — 2차 자동 재요청 대기 (Q2 안 B)", () => { + it("1차 요청 시작으로부터 10초 창이 남아 있으면 남은 만큼 기다린다", () => { + expect(secondaryDelayMs({ requestedAt: 1_000, now: 4_000 })).toBe( + SECONDARY_MIN_INTERVAL_MS - 3_000, + ); + }); + + it("창이 이미 지났으면 기다리지 않는다 (경계 — 음수 바닥)", () => { + expect( + secondaryDelayMs({ + requestedAt: 1_000, + now: 1_000 + SECONDARY_MIN_INTERVAL_MS, + }), + ).toBe(0); + expect(secondaryDelayMs({ requestedAt: 1_000, now: 60_000 })).toBe(0); + }); + + it("1차 요청 시각을 모르면 기다리지 않는다", () => { + expect(secondaryDelayMs({ requestedAt: null, now: 4_000 })).toBe(0); + }); +}); diff --git a/apps/web/src/features/ai-route/model/route-request.ts b/apps/web/src/features/ai-route/model/route-request.ts index d5db21e1..ca952007 100644 --- a/apps/web/src/features/ai-route/model/route-request.ts +++ b/apps/web/src/features/ai-route/model/route-request.ts @@ -1,5 +1,6 @@ import type { Bounds } from "@/entities/cell"; import type { + OriginDto, RouteRecommendRequestDto, ViewportDto, } from "@/shared/api/generated"; @@ -9,7 +10,7 @@ import type { AiRouteStatus } from "./ai-route-store"; * 추천 요청 조립·제출 판정 (MSG-488 L8·L9). * 순수 함수 — 지도 SDK를 모르고 뷰포트를 플랫폼 중립 `Bounds`로 받는다(RN 경계). * - * [MSG-489 확장점] `origin` 병합과 2차 재요청 뷰포트 규칙이 여기에 얹힌다. + * MSG-489가 출발지 병합(L11)·0.5도 예방 판정(L12)·버튼 문구(L13)·2차 대기(Q2)를 얹었다. */ /** 서버 계약 상한 (RouteRecommendRequestDto.text: trim 후 1~500자) */ @@ -30,13 +31,69 @@ export const toViewportDto = ({ sw, ne }: Bounds): ViewportDto => ({ export const buildRecommendBody = ({ text, bounds, + origin, }: { text: string; bounds: Bounds | null; + /** 출발지 — 판정은 route-origin 소유. 없으면 키 자체를 싣지 않는다 (L11) */ + origin?: OriginDto | null; }): RouteRecommendRequestDto | null => { const trimmed = text.trim(); if (bounds === null || trimmed.length === 0) return null; - return { text: trimmed, viewport: toViewportDto(bounds) }; + const body: RouteRecommendRequestDto = { + text: trimmed, + viewport: toViewportDto(bounds), + }; + return origin ? { ...body, origin } : body; +}; + +/** 서버 뷰포트 상한 (14401) — 위·경도 한 변이 이 값을 넘으면 요청이 거절된다 */ +const MAX_VIEWPORT_SPAN_DEG = 0.5; + +/** + * 요청 전 축척 정규화가 필요한가 (L12, A2). + * 참이면 호출부가 1km 단으로 줌을 맞추고 새 뷰포트가 반영된 뒤 보낸다 — + * bounds를 잘라 보내지 않는다(사용자가 보는 화면과 요청 범위를 어긋나게 두지 않는다). + */ +export const needsSpanNormalize = (bounds: Bounds | null): boolean => { + if (bounds === null) return false; + return ( + bounds.ne.lat - bounds.sw.lat > MAX_VIEWPORT_SPAN_DEG || + bounds.ne.lng - bounds.sw.lng > MAX_VIEWPORT_SPAN_DEG + ); +}; + +/** 제출 버튼 문구 (L13·D9) — 출발지를 실어 보낸 결과 화면만 "현재 위치에서" 접두가 붙는다 */ +export const submitLabel = ({ + status, + originSent, +}: { + status: AiRouteStatus; + /** 직전 요청이 origin을 실어 보냈는가 */ + originSent: boolean; +}): string => { + if (status === "idle") return "동선 짜기"; + return originSent ? "현재 위치에서 다시 짜기" : "다시 짜기"; +}; + +/** + * 2차 자동 재요청 대기 시간 (Q2 안 B). + * 서버 재요청 제한(14429)은 **요청 시작 기준 10초 창**이고 자동 재요청도 예외가 아니다 + * (2026-08-28 실측: 1차 응답 직후 재호출 = 14429, 1차 시작 +11s = 200). + * 여유 500ms를 얹어 창을 확실히 넘긴 뒤 발사한다. + */ +export const SECONDARY_MIN_INTERVAL_MS = 10_500; + +export const secondaryDelayMs = ({ + requestedAt, + now, +}: { + /** 1차 요청을 쏜 시각 — 모르면 기다리지 않는다 */ + requestedAt: number | null; + now: number; +}): number => { + if (requestedAt === null) return 0; + return Math.max(0, requestedAt + SECONDARY_MIN_INTERVAL_MS - now); }; /** diff --git a/apps/web/src/pages/ai-route/AiRoutePage.tsx b/apps/web/src/pages/ai-route/AiRoutePage.tsx index 03fe4a09..d8871cd7 100644 --- a/apps/web/src/pages/ai-route/AiRoutePage.tsx +++ b/apps/web/src/pages/ai-route/AiRoutePage.tsx @@ -1,10 +1,9 @@ import { useCallback, useMemo } from "react"; -import { useRouteRecommend } from "@/features/ai-route/api/use-route-recommend"; import { useAiRouteStore } from "@/features/ai-route/model/ai-route-store"; import { partialBannerText } from "@/features/ai-route/model/route-point-view"; import { - buildRecommendBody, canSubmit, + submitLabel, } from "@/features/ai-route/model/route-request"; import { useLoginModalStore } from "@/features/auth/model/login-modal-store"; import { useOccupiedGridsQuery } from "@/features/map-home/model/use-occupied-grids-query"; @@ -21,6 +20,8 @@ import { RoutePartialBanner } from "./ui/RoutePartialBanner"; import { RouteResultHeader } from "./ui/RouteResultHeader"; import { RouteResultList } from "./ui/RouteResultList"; import { RouteSuggestionChips } from "./ui/RouteSuggestionChips"; +import { RouteToastHost } from "./ui/RouteToastHost"; +import { useAiRouteAutoMove } from "./ui/use-ai-route-auto-move"; import { useAiRouteOverlayPublish } from "./ui/use-ai-route-overlay-publish"; /** @@ -48,6 +49,7 @@ export const AiRoutePage = () => { const selectedOrder = useAiRouteStore((s) => s.selectedOrder); const errorNotice = useAiRouteStore((s) => s.errorNotice); const featureDisabled = useAiRouteStore((s) => s.featureDisabled); + const originSent = useAiRouteStore((s) => s.originSent); const setText = useAiRouteStore((s) => s.setText); const selectOrder = useAiRouteStore((s) => s.selectOrder); @@ -61,13 +63,10 @@ export const AiRoutePage = () => { [grids], ); - const { mutate } = useRouteRecommend({ onLoginRequired: openLoginModal }); - const submit = useCallback(() => { - // 지도 준비 전(bounds null)이거나 빈 문장이면 요청을 만들지 않는다 (L9) - const body = buildRecommendBody({ text, bounds }); - if (body === null) return; - mutate(body); - }, [text, bounds, mutate]); + // 제출·출발지 자동 판정·지역 자동 이동·2차 재요청은 자동 동작 훅이 소유한다 (MSG-489) + const { submit, originActive } = useAiRouteAutoMove({ + onLoginRequired: openLoginModal, + }); // 카드 클릭 — 선택 강조 + 그 지점으로 지도 이동(줌은 그대로). fitBounds·zoomTo는 489 몫 const selectFromCard = useCallback( @@ -91,62 +90,65 @@ export const AiRoutePage = () => { onWaypointSelect: selectFromMarker, }); - // [MSG-489 확장점] mentionedArea 자동 이동 훅을 여기서 마운트한다. - const loading = status === "loading"; const bannerText = partialBannerText(notice, points.length); return ( - + ); }; diff --git a/apps/web/src/pages/ai-route/ai-route-panel.smoke.test.tsx b/apps/web/src/pages/ai-route/ai-route-panel.smoke.test.tsx index 6e421135..46a5d95f 100644 --- a/apps/web/src/pages/ai-route/ai-route-panel.smoke.test.tsx +++ b/apps/web/src/pages/ai-route/ai-route-panel.smoke.test.tsx @@ -9,6 +9,11 @@ import { useViewportStore } from "@/features/map-home/model/viewport-store"; import { useMapOverlayStore } from "@/widgets/map-shell/map-overlay-store"; import type { MapShellContext } from "@/widgets/map-shell/use-map-shell"; import { envelopeResponse, errorEnvelope } from "@/test/envelope-response"; +import { + allowPositionAt, + denyPosition, + setGeolocation, +} from "@/test/geolocation"; import { signInForTest, signOutForTest } from "@/test/auth-session"; import { ROUTE_POINTS } from "@/test/route-points"; import { stubFetch } from "@/test/stub-fetch"; @@ -64,6 +69,11 @@ const submitButton = () => const textarea = () => screen.getByLabelText("하고 싶은 일 한 문장") as HTMLTextAreaElement; +/** 현위치 어댑터 스텁 (MSG-489) — jsdom 기본은 geolocation 부재라 "미확보"가 기본값이다 */ +const originalGeolocation = navigator.geolocation; + +const originRow = () => screen.queryByText("현재 위치에서 출발"); + /** 문장 입력 → 제출 — 결과·실패 케이스가 공유하는 진입 동작 */ const submitText = (value: string) => { fireEvent.change(textarea(), { target: { value } }); @@ -86,6 +96,7 @@ beforeEach(() => { }); afterEach(() => { + setGeolocation(originalGeolocation); vi.unstubAllGlobals(); }); @@ -267,3 +278,55 @@ describe("실패 경로 (S10)", () => { ).toBe(false); }); }); + +describe("출발지 자동 판정 (S1·S2·S7)", () => { + it("현위치가 뷰포트 안이면 입력 카드에 '현재 위치에서 출발' 표시가 뜬다 (S1)", async () => { + allowPositionAt({ lat: 35.1579, lng: 129.0594 }); + + renderPanel(); + + await waitFor(() => expect(originRow()).toBeTruthy()); + }); + + it("현위치가 뷰포트 밖이면 표시가 없고 제출은 그대로 동작한다 (S2)", async () => { + // 뷰포트(서면) 밖 — 같은 부산 안의 해운대 일대 + allowPositionAt({ lat: 35.1631, lng: 129.1635 }); + stubFetch(() => recommendResponse()); + renderPanel(); + + submitText("서면 동선"); + + await waitFor(() => expect(screen.getByText("· 3곳")).toBeTruthy()); + expect(originRow()).toBeNull(); + }); + + it("권한을 거부하면 표시가 없다 — 서면 폴백 좌표로 오판정하지 않는다 (S2·A1)", async () => { + denyPosition(); + renderPanel(); + + await waitFor(() => + expect(screen.getByRole("button", { name: "동선 짜기" })).toBeTruthy(), + ); + expect(originRow()).toBeNull(); + }); + + it("출발지를 실어 보낸 결과 화면의 버튼은 '현재 위치에서 다시 짜기'다 (S7)", async () => { + allowPositionAt({ lat: 35.1579, lng: 129.0594 }); + const received = stubFetch(() => recommendResponse()); + renderPanel(); + await waitFor(() => expect(originRow()).toBeTruthy()); + + submitText("서면 동선"); + + await waitFor(() => + expect( + screen.getByRole("button", { name: "현재 위치에서 다시 짜기" }), + ).toBeTruthy(), + ); + expect(received[0].body).toMatchObject({ + origin: { lat: 35.1579, lng: 129.0594 }, + }); + // 결과 화면에서는 표시 행이 사라지고 버튼 문구만 출발지를 알린다 (Figma 15666:13139) + expect(originRow()).toBeNull(); + }); +}); diff --git a/apps/web/src/pages/ai-route/ui/RouteInputCard.tsx b/apps/web/src/pages/ai-route/ui/RouteInputCard.tsx index 57ddde4e..724ce9ef 100644 --- a/apps/web/src/pages/ai-route/ui/RouteInputCard.tsx +++ b/apps/web/src/pages/ai-route/ui/RouteInputCard.tsx @@ -1,4 +1,5 @@ import { Button } from "@fillmap/ui-web"; +import { Navigation } from "lucide-react"; import { MAX_ROUTE_TEXT_LENGTH } from "@/features/ai-route/model/route-request"; /** @@ -14,6 +15,8 @@ interface RouteInputCardProps { canSubmit: boolean; /** 버튼 문구 — 입력 대기 "동선 짜기" / 결과·실패 "다시 짜기" (로딩은 아래에서 덮는다) */ submitLabel: string; + /** 현위치가 뷰포트 안이라 출발지가 실리는가 — 상태 표시일 뿐 누르는 컨트롤이 아니다 (D8) */ + originActive: boolean; loading: boolean; } @@ -23,6 +26,7 @@ export const RouteInputCard = ({ onSubmit, canSubmit, submitLabel, + originActive, loading, }: RouteInputCardProps) => (
@@ -42,8 +46,15 @@ export const RouteInputCard = ({ className="resize-none bg-transparent text-fm-base text-foreground outline-none placeholder:text-foreground-muted" />
- {/* [MSG-489 확장점] 출발지 상태 행이 이 슬롯에 들어온다 — 지금은 null. */} - + {/* 좌측 슬롯 — 출발지가 실릴 때만 상태 행이 뜬다 (Figma 15666:12571). 없으면 자리만 (S2) */} + {originActive ? ( + + + 현재 위치에서 출발 + + ) : ( + + )}