diff --git a/.claude/skills/page-verification/SKILL.md b/.claude/skills/page-verification/SKILL.md index fe88c0d..828466e 100644 --- a/.claude/skills/page-verification/SKILL.md +++ b/.claude/skills/page-verification/SKILL.md @@ -63,6 +63,10 @@ pnpm openapi-ts && git diff --exit-code apps/web/src/shared/api/generated # 생 #### 3-A. 웹 (apps/web · packages/ui-web) 1. `pnpm dev`로 dev 서버 실행 (백그라운드) +1-1. **시간을 재기 전에 탭이 살아 있는지부터 확인한다** — 브라우저 자동화 탭은 기본적으로 백그라운드일 수 있고, 그러면 `requestAnimationFrame`이 초당 0.5~1회로 죽어 **지도 애니메이션·전이·디바운스 계측이 10~40배로 부풀려진다**. 계측형 기준(대기 시간·타임아웃·연속 호출 간격)이 하나라도 있으면 다음을 먼저 실측해 리포트에 남긴다: + - `document.visibilityState === "visible"`, 아니면 탭을 전면으로 올린 뒤 재확인 (`osascript -e 'tell application "Google Chrome" to activate'` 등) + - rAF 프레임률 1초 표본 — 60fps 이상이어야 계측이 유효하다 + 숨은 탭에서 잰 숫자는 **버리고 다시 잰다**. 지도가 `idle`을 내지 않아 뷰포트 스토어가 옛 값에 머무는 등 **동작 자체가 달라지는** 경로도 있다 (MSG-489 — "정규화 대기 14.2초"가 이 아티팩트였고, 같은 전이가 전면 탭에서 316ms였다. 그 오염된 숫자 뒤에 실제 결함이 숨어 있었다) 2. 화면형 수용 기준을 하나씩 브라우저에서 재현하고 관찰한다. 브라우저 자동화 도구가 있으면 스크린샷을 `_workspace/MSG-{번호}/screenshots/`에 남기고, 자동화가 불가하면 리포트에 "스크린샷 미보존(사유)"를 명시한다 — 조용히 생략하지 않는다 3. **Figma 대조**: 스펙에 Figma 노드가 있으면 해당 노드 스크린샷(`get_screenshot`)과 렌더 결과를 나란히 놓고 구조·간격·색·타이포를 비교한다. 스펙의 "오탐 방지/의도된 편차" 목록에 있는 차이는 결함이 아니다 — 목록에 없는 차이만 실패 후보로 올린다 4. **a11y 점검** (변경된 UI 범위만): diff --git a/CLAUDE.md b/CLAUDE.md index 0e4864e..27261ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,8 +28,8 @@ pnpm 모노레포 — apps/web(React 19 + Vite) + apps/mobile(Expo, 대부분 **변경 이력 (최근 5행 이내 — 전체는 `docs/HARNESS_CHANGELOG.md`, 밀려난 행은 그리로 이관):** | 날짜 | 변경 내용 | 대상 | 사유 | |------|----------|------|------| +| 2026-08-29 | 웹 실동작 검증(3-A)에 **탭 가시성·rAF 프레임률 전제 조건** 신설 — 계측형 기준이 있으면 `document.visibilityState`와 rAF 표본을 먼저 실측하고, 숨은 탭에서 잰 숫자는 버리고 다시 잰다 | skills/page-verification | MSG-489 후속 — 브라우저 자동화 탭이 백그라운드였고 rAF가 **0.5~1fps로 정지**해 있었다. 같은 줌 전이가 숨은 탭 11,162ms · 전면 탭 316ms로 **35배** 부풀려졌고, 그 값이 "정규화 대기 14.2초 무피드백"이라는 리포트 문장과 후속 티켓 권장까지 만들어 냈다. 더 나쁜 것은 **동작 자체가 달라진다**는 점이다 — 숨은 탭에서는 네이버 지도가 `idle`을 내지 않아 뷰포트 스토어가 옛 값에 머물고, 그 상태에서 D13 타임아웃이 발화해 정규화되지 않은 뷰포트(1.9°×4.7°)로 요청이 나가 서버 400(14401)이 재현됐다. 즉 오염된 계측이 실제 결함을 가리고 있었다. 계측 신뢰성은 검증자 개인의 주의력이 아니라 절차의 전제 조건으로 내린다 | | 2026-08-28 | react-doctor `js-tosorted-immutable` 규칙 off 선언 + **규칙 설정이 v0.9.3 스캐너에 안 먹는다는 실측 기록** | doctor.config.json, CLAUDE.md | MSG-488 PR #104 리뷰 — **두 도구가 정반대를 지시하고 있었다**: react-doctor는 `[...arr].sort()`를 `toSorted()`로 바꾸라 하고, `.oxlintrc.json:84`는 `toSorted`를 error로 금지한다(Hermes 미구현 — MSG-427에서 지도 홈을 마운트 즉시 크래시시킨 그 API). oxlint 금지가 `apps/mobile`·`packages/ui-native` 한정이라 **웹 파일에서는 react-doctor만 말하고 게이트 6종이 아무것도 안 잡는다**. 비용은 실측된다: 웹 프로덕션 `toSorted` 6곳 중 모바일 이식본이 있는 **5곳 전부**가 손으로 `[...].sort()`로 되돌려져 Hermes 주석을 달고 있고(`hot-region-summary`·`course`·`use-multi-grid-videos-query`·`region-cluster-overlay`·`gallery-groups`), 뒤 둘은 MSG-427 사고로 지목된 바로 그 파일들이다(주석 2회로도 못 막혔던 자리). 지적 대상 `route-legs.ts:6`은 스스로 `(RN 재사용 대상)`이라 선언한다. 규칙의 근거도 사실과 다르다 — `toSorted()`도 새 배열을 할당하므로 "복사 없이 정렬"이 아니다. **단, 이 설정은 현재 무효다(실측)**: v0.9.3에서 `rules list`·`rules explain`은 `off`로 보고하고 공식 `react-doctor rules disable`이 쓴 것도 같은 내용인데, 스캐너는 규칙을 그대로 보고한다(레포 루트·`apps/web` cwd 양쪽, `ignore.tags`도 동일하게 무시). **기존 `js-combine-iterations: off`도 같은 이유로 무효였다.** 따라서 실효 방어선은 이 행의 기록이고, 설정은 도구가 고쳐지면 자동으로 듣도록 선언만 남긴다. **후속 후보**: oxlint 금지를 레포 전역으로 승격하면 이식 세금이 원천 제거되나 기존 웹 6곳 수정이 따르므로 별도 티켓 | | 2026-08-24 | claude-review 완주 검증 판정을 **PR 코멘트 마커 grep → 액션 execution output 3단 판정**(① 파일 존재 → ② 마지막 result `is_error=false && subtype=success` → ③ 게시 도구 호출 증거: `gh pr comment` Bash 또는 `update_claude_comment`)으로 교체, 마커는 warning 강등(프롬프트 지시 존치·1줄 보정), 실패 사유별 구분 `::error::`+실측값 출력 | .github/workflows/claude-review.yml | MSG-465 — PR #89 af67beb 오탐: 리뷰가 정상 완주했는데 봇이 진행 코멘트에 요약을 이어 붙이며 SHA 마커를 생략해 검증 스텝이 실패 표시. 판정이 봇의 그때그때 코멘트 작성 방식에 좌우되던 것을 액션 산출물(전사) 기준으로 분리 — MSG-403 원 사고(정상 종료·요약 미게시, `is_error=false`·`num_turns=17` 실측이라 result 판정만으론 통과)는 ③ 게시 증거가 계속 잡는다. 잔여 창(체크리스트만 갱신 후 침묵 종료)은 마커 warning으로 관찰, 재발 실측 시 후속 조임 | | 2026-08-21 | codex 리뷰 **실행 경로를 Bash companion 하나로 확정** + 시점을 커밋 직전 → **push 전**으로 이동 | skills/fillmap-page-dev | MSG-451 사고 — 종전 문구가 "`/codex:review` **또는** companion 서브커맨드"로 두 경로를 동등 나열해 세션마다 선택이 갈렸다. 8/17~19 세션은 companion으로 정상 실행했으나(워크트리 state 실측 15건), 이번 세션은 슬래시 커맨드를 시도했다가 `disable-model-invocation`에 막히자 **그대로 건너뛰어 커밋 4개가 리뷰 없이 진행**됐다. 선택지를 주면 막힌 쪽을 고른 세션이 스킵한다 — 명령줄을 스킬에 그대로 박았다. 시점은 커밋이 로컬이라 되돌리기 싸고 중간 커밋 리뷰가 곧 폐기될 코드를 반복해 보기 때문(MSG-451은 뒤 커밋이 앞 결정을 뒤집었다). **조사 과정 교훈**: 메인 레포 state만 보고 "8/13 이후 리뷰 0건"으로 단정했다가 워크트리별 state에서 15건을 발견해 정정 — 워크트리 병렬 작업에서는 저장소 단위 상태가 흩어진다 | | 2026-08-21 | 검증 스킬 실동작 경로를 **대상 앱**으로 분기 — 3-A(웹 브라우저) / 3-B(모바일 Android dev client) 신설, 모바일 확인불가 판정 기준·리포트 형식 명문화, 실기 런북(`docs/MOBILE_RUNBOOK.md`)과 원커맨드 스크립트(`pnpm --filter mobile android:dev`) 신설 | skills/page-verification, skills/fillmap-page-dev, docs/MOBILE_RUNBOOK.md, apps/mobile/scripts | MSG-449 — 절차 3이 `pnpm dev` + 브라우저로만 쓰여 있어 모바일 티켓에 대응 절차가 없었고, 웨이브 0~3(MSG-419~431)이 전부 정적 게이트(vitest·typecheck·lint)만으로 통과했다. 2026-08-20 실기 시도에서 함정 6가지(임베디드 번들 로더·스킴 충돌·8081 콜드 스타트 복귀·페어링 포트 만료·`--device`는 모델명·새 네이티브 의존성 prebuild)에 순차로 걸렸는데 전부 재발할 문제라 문서가 아니라 검증 경로 자체로 내린다. 후속 모바일 티켓이 이 절차로 검증한다 | -| 2026-08-19 | 모바일 런타임(Hermes) 미구현 API 차단 규칙 신설 — `apps/mobile`·`packages/ui-native` 한정 `no-restricted-properties`(`toSorted`·`toReversed`·`toSpliced`·`Object.groupBy`·`Map.groupBy`·`Promise.withResolvers`) + `no-restricted-globals`(`structuredClone`) | .oxlintrc.json | MSG-427 실기 사고 — 웹에서 포팅한 `toSorted` 3곳이 지도 홈을 마운트 즉시 크래시시켰는데 **게이트 6종이 전부 통과**했다(vitest는 Node에서 돌아 메서드가 존재하고, typecheck는 `lib`에 ES2023이 있다). 같은 함정이 `gallery-groups.ts`·`region-cluster-overlay.ts`에 두 번 주석으로 문서화돼 있었는데도 재발해, 주석으로는 못 막는 것이 실증됐다. 웹→모바일 포팅이 계속되는 한(MSG-428 등) 재발 창이 열려 있어 기계 게이트로 내린다 (MSG-298·386 좌시프트 원칙) | 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 86c7a4e..8e0274a 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,16 @@ import { routeErrorNotice } from "../model/route-error"; * mutation은 TanStack 기본 `retry: 0`이라 14429(10초 제한)가 자동 재시도로 악화되지 않는다. * 콜백은 훅 레벨 옵션으로 받는다 — mutate per-call 콜백은 관찰자 언마운트 시 유실된다(MSG-325 선례). * - * [MSG-489 확장점] 2차 자동 재요청 트리거가 이 훅에 얹힌다. + * **쿼리 무효화가 없는 것은 의도다** (react-doctor `query-mutation-missing-invalidation` 오탐, + * 2026-08-29 판정). 이 엔드포인트는 body가 필요해 POST일 뿐 **읽기형**이라(생성 스펙: "…보내면 + * 지점 목록을 돌려준다") 서버 리소스를 만들지도 고치지도 않고, 응답은 쿼리 캐시가 아니라 + * `ai-route-store`로 간다 — 낡아질 캐시가 없다. 되레 `onSuccess`에 무효화를 넣으면 1차 응답 + * 분기(결과 미게시 + 2차 예약)에서도 실행돼 무관한 격자 쿼리를 재요청하고 오버레이를 흔든다. + * 규칙 자체는 레포의 다른 mutation(video·profile·badge)에서 유효하므로 전역 off 하지 않는다. + * + * MSG-489: 응답에 `mentionedArea`가 실리면 **결과를 게시하지 않고**(D5) 2차 요청을 예약한다. + * 2차 인스턴스(`secondary: true`)는 예약 플래그를 보존한 채 로딩만 이어 간다 — + * `startRequest`를 쓰면 `autoMoved`가 초기화돼 2차 응답에서 또 이동하는 무한 루프가 된다. */ // 생성 팩토리는 mutationFn을 항상 채운다 — UseMutationOptions 타입만 optional이라 !로 좁힌다 const recommendFn = recommendMutation().mutationFn!; @@ -22,19 +35,43 @@ 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); + // 읽기형 POST라 무효화할 캐시가 없다 — 위 주석의 판정(2026-08-29). 규칙은 레포 다른 + // mutation에서 유효하므로 전역 off 대신 이 한 줄만 억제한다. + // react-doctor-disable-next-line react-doctor/query-mutation-missing-invalidation return useMutation({ 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 011860a..30973f8 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,117 @@ 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); + }); +}); + +describe("축척 정규화 대기 (L23·D11)", () => { + beforeEach(() => { + useAiRouteStore.setState(useAiRouteStore.getInitialState(), true); + }); + + it("정규화 대기를 시작하면 즉시 로딩이지만 요청 시각은 아직 기록하지 않는다 (L23)", () => { + store().startNormalize(); + + expect(store().status).toBe("loading"); + expect(store().normalizePending).toBe(true); + expect(store().requestedAt).toBeNull(); + }); + + it("실제 mutate 시점에 요청 시각이 기록되고 정규화 대기가 풀린다 (L23)", () => { + store().startNormalize(); + const before = Date.now(); + + store().startRequest(); + + expect(store().normalizePending).toBe(false); + expect(store().requestedAt!).toBeGreaterThanOrEqual(before); + }); + + it("정규화 대기는 이전 결과를 먼저 비운다 — 잔상이 로딩 중에 남지 않는다 (D11)", () => { + store().startRequest(); + store().succeed(ROUTE_POINTS, null); + + store().startNormalize(); + + expect(store().points).toHaveLength(0); + expect(store().selectedOrder).toBeNull(); + }); +}); + +describe("요청 없이 대기를 종결한다 (§12 — 영구 로딩·확정 400 동시 금지)", () => { + /** 뷰포트가 서버 상한을 넘어 보낼 수 없을 때의 안내 (route-error VIEWPORT_TOO_WIDE_NOTICE와 같은 모양) */ + const TOO_WIDE = { + message: "지도를 조금 더 확대하거나 다른 곳으로 옮긴 뒤 다시 시도해 주세요", + retryable: true, + disablesFeature: false, + requiresLogin: false, + } as const; + + beforeEach(() => { + useAiRouteStore.setState(useAiRouteStore.getInitialState(), true); + }); + + it("정규화 대기를 안내로 종결하면 에러 화면이 되고 대기 플래그가 풀린다 — 다시 누를 수 있다 (§12·D13)", () => { + store().startNormalize(); + + store().abortPending(TOO_WIDE); + + expect(store().status).toBe("error"); + expect(store().errorNotice).toEqual(TOO_WIDE); + expect(store().normalizePending).toBe(false); + }); + + it("2차 대기도 같은 종결을 쓴다 — 예약이 남아 재발사되지 않는다 (§12·D13)", () => { + store().startRequest(); + store().startSecondaryRequest("부산 해운대"); + + store().abortPending(TOO_WIDE); + + expect(store().status).toBe("error"); + expect(store().secondaryPending).toBe(false); + }); +}); 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 90a02e0..88548da 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,59 @@ 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; + /** 제출했지만 1km 축척 정규화를 기다리는 중인가 — 로딩 표시의 근거 (D11·L23) */ + normalizePending: boolean; + /** 1차 요청을 쏜 시각(ms) — 2차 발사가 서버 10초 창을 계산하는 기준 (Q2 안 B) */ + requestedAt: number | null; setText: (text: string) => void; - startRequest: () => void; + /** + * 제출 직후 축척 정규화 대기 시작 (D11) — 즉시 로딩 화면이지만 요청은 아직 안 나갔다. + * `requestedAt`을 여기서 기록하지 않는 이유: 그러면 정규화 대기가 서버 10초 창을 + * 잡아먹어 2차가 조기 발사되고 14429가 난다 (L23). + */ + startNormalize: () => void; + /** 1차 요청 시작 — 사이클 플래그를 전부 초기화한다 (L15) */ + startRequest: (originSent?: boolean) => void; + /** 자동 이동 확정 + 2차 요청 예약 — 1차 결과는 게시하지 않고 로딩을 유지한다 (D5·L14) */ + startSecondaryRequest: (areaName: string) => void; + /** 예약된 2차를 실제로 발사한 시점 — 재발사를 막고 출발지 재판정 결과를 반영한다 */ + markSecondarySent: (originSent: boolean) => void; + /** + * 요청을 보내지 못한 채 대기를 끝낸다 (§12) — 정착 상한이 만료됐는데 뷰포트가 + * 서버 상한을 넘어 확정 400이 되는 경로. 쏘면 400, 안 쏘면 영구 로딩이라 + * **명시적 안내로 종결**해 사용자가 다시 누를 수 있게 한다 (D13). + */ + abortPending: (notice: RouteErrorNotice) => 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" + | "normalizePending" +> => ({ + autoMoved: false, + originSent: false, + movedAreaName: null, + secondaryPending: false, + normalizePending: false, +}); + /** 결과·선택·에러가 비워진 상태 — 새 요청 시작과 reset이 공유한다 (매번 새 배열) */ const cleared = (): Pick< AiRouteState, @@ -46,10 +92,44 @@ const cleared = (): Pick< export const useAiRouteStore = create((set) => ({ text: "", featureDisabled: false, + requestedAt: null, ...cleared(), + ...clearedCycle(), setText: (text) => set({ text }), + // 정규화 대기도 새 사이클이라 이전 결과를 먼저 비운다 — 잔상이 로딩 중에 남지 않는다 + startNormalize: () => + set({ + ...cleared(), + ...clearedCycle(), + status: "loading", + normalizePending: true, + }), // 새 요청은 이전 결과를 **먼저** 비운다 — 잔상(이전 카드·지도 표시)이 로딩 중에 남지 않는다 - 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 }), + // 두 대기(정규화·2차)를 함께 푼다 — 예약이 남으면 같은 뷰포트로 재발사된다 + abortPending: (notice) => + set({ + status: "error", + errorNotice: notice, + normalizePending: false, + secondaryPending: false, + }), succeed: (points, notice) => set({ status: "result", points, notice, errorNotice: null }), fail: (notice) => @@ -61,5 +141,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-error.test.ts b/apps/web/src/features/ai-route/model/route-error.test.ts index 8205ec4..e81a700 100644 --- a/apps/web/src/features/ai-route/model/route-error.test.ts +++ b/apps/web/src/features/ai-route/model/route-error.test.ts @@ -18,11 +18,11 @@ describe("routeErrorNotice — developCode → UI 반응 매핑 (L5, §1-4)", () } }); - it("14429(요청 과다)는 잠시 후 재시도 안내다 — 클라이언트 쿨다운은 걸지 않는다 (L5, Q7)", () => { + it("14429(요청 과다)는 서버와 같은 문구로 사유까지 알린다 — 클라이언트 쿨다운은 걸지 않는다 (L5, Q7)", () => { expect( routeErrorNotice(apiError({ status: 429, developCode: 14429 })), ).toEqual({ - message: "잠시 후 다시 시도해 주세요", + message: "요청이 너무 잦습니다. 잠시 후 다시 시도해주세요", retryable: true, disablesFeature: false, requiresLogin: false, diff --git a/apps/web/src/features/ai-route/model/route-error.ts b/apps/web/src/features/ai-route/model/route-error.ts index 2e9eb0e..a2187a3 100644 --- a/apps/web/src/features/ai-route/model/route-error.ts +++ b/apps/web/src/features/ai-route/model/route-error.ts @@ -40,7 +40,10 @@ const BY_DEVELOP_CODE: Record = { 14401: notice( "지도를 조금 더 확대하거나 다른 곳으로 옮긴 뒤 다시 시도해 주세요", ), - 14429: notice("잠시 후 다시 시도해 주세요"), + // 서버 문구를 그대로 쓴다 (MSG-489, 2026-08-29 사용자 지시) — "다시 시도"를 연속으로 누르면 + // 이 코드만 반복해서 보게 되는데, 종전 문구는 왜 막혔는지를 알려주지 않았다. + // 서버 응답 문자열을 렌더하지는 않는다(FE 고정 문구 정책, MSG-488 §1-5) — 같은 문장을 상수로 둔다. + 14429: notice("요청이 너무 잦습니다. 잠시 후 다시 시도해주세요"), 14502: notice("AI가 문장을 이해하지 못했어요. 다시 시도해 주세요"), 14503: notice("지금은 경로 추천을 쓸 수 없어요", { retryable: false, @@ -49,6 +52,14 @@ const BY_DEVELOP_CODE: Record = { 2403: notice(null, { retryable: false, requiresLogin: true }), }; +/** + * 요청을 **보내지 못하고** 끝낸 경우의 안내 (MSG-489 §12). + * 지도가 정착하지 못해 뷰포트가 서버 상한을 넘을 때, 확정 400(14401)을 대신 맞아 주는 대신 + * 같은 안내로 종결한다 — 사용자가 할 일(확대 후 재시도)이 서버 응답 때와 동일하다. + */ +export const VIEWPORT_TOO_WIDE_NOTICE: RouteErrorNotice = + BY_DEVELOP_CODE[14401]; + export const routeErrorNotice = (error: unknown): RouteErrorNotice => { if (!(error instanceof ApiError)) return GENERIC; 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 0000000..b1d7877 --- /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 0000000..5e20d94 --- /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 0000000..8cf4059 --- /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 0000000..b3ea4b0 --- /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 b35f058..752dc83 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,16 @@ import { describe, expect, it } from "vitest"; import type { Bounds } from "@/entities/cell"; import { MAX_ROUTE_TEXT_LENGTH, + advanceSettleDeadline, + MAX_VIEWPORT_SPAN_DEG, + SECONDARY_MIN_INTERVAL_MS, buildRecommendBody, canSubmit, + exceedsViewportSpan, + needsZoomNormalize, + reachedTargetViewport, + secondaryDelayMs, + submitLabel, toViewportDto, } from "./route-request"; @@ -92,3 +100,314 @@ 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("needsZoomNormalize — 항상 1km 정규화 판정 (L20·L21)", () => { + it("현재 줌이 목표 축척 단이 아니면 정규화가 필요하다 (L20)", () => { + expect(needsZoomNormalize({ zoom: 16, targetZoom: 13 })).toBe(true); + expect(needsZoomNormalize({ zoom: 10, targetZoom: 13 })).toBe(true); + }); + + it("이미 목표 축척 단이면 정규화가 필요 없다 — 줌 명령을 내면 안 된다 (L21)", () => { + expect(needsZoomNormalize({ zoom: 13, targetZoom: 13 })).toBe(false); + }); + + it("소수 줌은 축척 라벨과 같은 내림 기준으로 같은 단으로 본다 (L21)", () => { + expect(needsZoomNormalize({ zoom: 13.4, targetZoom: 13 })).toBe(false); + }); +}); + +describe("reachedTargetViewport — 목표 뷰포트 도달 판정 (L24·D12)", () => { + const MOVED: Bounds = { + sw: { lat: 35.1523, lng: 129.1521 }, + ne: { lat: 35.1712, lng: 129.1712 }, + }; + + it("줌이 목표 단이고 이동이 지도에 반영되면 도달이다 (L24)", () => { + expect( + reachedTargetViewport({ + bounds: MOVED, + boundsAtCommand: BOUNDS, + zoom: 13, + targetZoom: 13, + }), + ).toBe(true); + }); + + it("이동이 아직 반영되지 않았으면(명령 시점 뷰포트 그대로) 도달이 아니다 (L24)", () => { + expect( + reachedTargetViewport({ + bounds: BOUNDS, + boundsAtCommand: BOUNDS, + zoom: 13, + targetZoom: 13, + }), + ).toBe(false); + }); + + it("대기 중 사용자가 줌을 바꿔 목표를 벗어나면 도달이 아니다 (D12)", () => { + expect( + reachedTargetViewport({ + bounds: MOVED, + boundsAtCommand: BOUNDS, + zoom: 16, + targetZoom: 13, + }), + ).toBe(false); + }); + + it("지도가 준비되기 전(bounds null)에는 도달이 아니다 (L24)", () => { + expect( + reachedTargetViewport({ + bounds: null, + boundsAtCommand: BOUNDS, + zoom: 13, + targetZoom: 13, + }), + ).toBe(false); + }); + + it("이 마운트에서 이동 명령을 내지 않았으면(재진입) 이미 정착한 뷰포트로 본다 (A7)", () => { + expect( + reachedTargetViewport({ + bounds: MOVED, + boundsAtCommand: null, + zoom: 16, + targetZoom: 13, + }), + ).toBe(true); + }); +}); + +describe("exceedsViewportSpan — 서버 뷰포트 상한 가드 (§12 · developCode 14401)", () => { + /** 줌 9(축척 16km)에서 아직 정착하지 않은 지도의 뷰포트 — 검증 실측 span 1.9379° × 4.7461° */ + const UNSETTLED_WIDE: Bounds = { + sw: { lat: 34.2, lng: 126.6 }, + ne: { lat: 36.1379, lng: 131.3461 }, + }; + + it("정착 전 저줌 뷰포트는 상한을 넘는다 — 이대로 나가면 확정 400(14401)이다 (§12)", () => { + expect( + exceedsViewportSpan({ + viewport: toViewportDto(UNSETTLED_WIDE), + maxSpanDeg: MAX_VIEWPORT_SPAN_DEG, + }), + ).toBe(true); + }); + + it("한 변만 넘어도 상한 초과다 — 위도·경도를 각각 본다 (§12)", () => { + const tallOnly: Bounds = { + sw: { lat: 35.0, lng: 129.0 }, + ne: { lat: 35.6, lng: 129.1 }, + }; + const wideOnly: Bounds = { + sw: { lat: 35.0, lng: 129.0 }, + ne: { lat: 35.1, lng: 129.6 }, + }; + + expect( + exceedsViewportSpan({ + viewport: toViewportDto(tallOnly), + maxSpanDeg: MAX_VIEWPORT_SPAN_DEG, + }), + ).toBe(true); + expect( + exceedsViewportSpan({ + viewport: toViewportDto(wideOnly), + maxSpanDeg: MAX_VIEWPORT_SPAN_DEG, + }), + ).toBe(true); + }); + + it("1km 단으로 정착한 뷰포트는 상한 안이다 — 정상 경로는 이 가드에 걸리지 않는다 (§12·D10)", () => { + expect( + exceedsViewportSpan({ + viewport: toViewportDto(BOUNDS), + maxSpanDeg: MAX_VIEWPORT_SPAN_DEG, + }), + ).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); + }); +}); + +describe("advanceSettleDeadline — 가시 시간만 소모하는 정착 마감 (§13 P2)", () => { + const TIMEOUT = 3_000; + + it("첫 호출이 마감을 확정한다 — 상한만큼 남은 채로 시작한다", () => { + const { deadline, remainingMs } = advanceSettleDeadline({ + deadline: null, + now: 1_000, + visible: true, + timeoutMs: TIMEOUT, + }); + + expect(deadline).toEqual({ deadlineAt: 4_000, hiddenSince: null }); + expect(remainingMs).toBe(TIMEOUT); + }); + + it("이미 선 마감은 다시 계산하지 않는다 — 재호출은 남은 시간만 돌려준다", () => { + const first = advanceSettleDeadline({ + deadline: null, + now: 1_000, + visible: true, + timeoutMs: TIMEOUT, + }); + // 대기 중 뷰포트가 갱신돼 이펙트가 여러 번 재실행되는 상황 + const second = advanceSettleDeadline({ + deadline: first.deadline, + now: 2_500, + visible: true, + timeoutMs: TIMEOUT, + }); + const third = advanceSettleDeadline({ + deadline: second.deadline, + now: 3_800, + visible: true, + timeoutMs: TIMEOUT, + }); + + expect(second.remainingMs).toBe(1_500); + expect(third.remainingMs).toBe(200); + expect(third.deadline.deadlineAt).toBe(4_000); + }); + + it("마감이 지나면 0이다 — 호출부가 즉시 종결 경로를 탄다 (D13)", () => { + const started = advanceSettleDeadline({ + deadline: null, + now: 1_000, + visible: true, + timeoutMs: TIMEOUT, + }); + + expect( + advanceSettleDeadline({ + deadline: started.deadline, + now: 9_000, + visible: true, + timeoutMs: TIMEOUT, + }).remainingMs, + ).toBe(0); + }); + + it("숨은 구간은 마감에서 제외된다 — 복귀 시 그만큼 마감이 밀린다 (§12)", () => { + const started = advanceSettleDeadline({ + deadline: null, + now: 1_000, + visible: true, + timeoutMs: TIMEOUT, + }); + // 1초 소모한 뒤 탭이 숨는다 + const hidden = advanceSettleDeadline({ + deadline: started.deadline, + now: 2_000, + visible: false, + timeoutMs: TIMEOUT, + }); + // 숨은 채 5초가 더 흘러도 마감은 그대로 밀려 있다 + const stillHidden = advanceSettleDeadline({ + deadline: hidden.deadline, + now: 7_000, + visible: false, + timeoutMs: TIMEOUT, + }); + const resumed = advanceSettleDeadline({ + deadline: stillHidden.deadline, + now: 7_000, + visible: true, + timeoutMs: TIMEOUT, + }); + + expect(hidden.deadline.hiddenSince).toBe(2_000); + // 숨김 시작 시각은 첫 숨김 관측으로 고정된다 — 재실행이 구간을 잘게 쪼개지 않는다 + expect(stillHidden.deadline.hiddenSince).toBe(2_000); + // 남은 상한은 숨기 직전 그대로(2초)이고, 마감은 숨은 5초만큼 뒤로 간다 + expect(resumed.remainingMs).toBe(2_000); + expect(resumed.deadline).toEqual({ deadlineAt: 9_000, hiddenSince: null }); + }); + + it("숨은 채로 시작하면 그 시점부터 숨김 구간이다 — 상한을 소모하지 않는다", () => { + const started = advanceSettleDeadline({ + deadline: null, + now: 1_000, + visible: false, + timeoutMs: TIMEOUT, + }); + const resumed = advanceSettleDeadline({ + deadline: started.deadline, + now: 11_000, + visible: true, + timeoutMs: TIMEOUT, + }); + + expect(resumed.remainingMs).toBe(TIMEOUT); + }); +}); 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 d5db21e..e483932 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)·축척 정규화 판정(L20·L24)·버튼 문구(L13)·2차 대기(Q2)를 얹었다. */ /** 서버 계약 상한 (RouteRecommendRequestDto.text: trim 후 1~500자) */ @@ -30,13 +31,193 @@ 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; +}; + +/** + * 요청 전 축척 정규화가 필요한가 (L20·L21, D10). + * 추천은 **항상 1km 단 뷰포트**에서만 나간다 — 참이면 호출부가 줌을 먼저 맞추고 + * 새 뷰포트가 반영된 뒤 보낸다(bounds를 잘라 보내지 않는다). 거짓이면 즉시 발사한다: + * 이미 목표 단인데 줌 명령을 내면 지도가 `idle`을 내지 않아 요청이 영영 안 나간다 (L21). + * + * 목표 줌 단은 파라미터로 받는다 — 이 모듈은 지도 축척 상수(features/map-home)를 + * import하지 않는다(RN 경계·feature 경계). 값 주입은 뷰-레이어 훅 몫이다. + * 내림 비교인 이유: 축척 라벨(map-scale)이 내림 기준이라 13.4도 사용자에게는 "1km"다. + * + * MSG-489 §11에서 0.5도 상한 판정(`needsSpanNormalize`, A2)을 대체했다. + * 단 §11이 함께 적은 "항상 1km면 14401이 구조적으로 불가능"은 §12에서 반증됐다 — + * 정착 대기 상한이 만료되는 출구(D13)는 이 판정을 거치지 않는다. + * 최종 방어선은 아래 `exceedsViewportSpan`이다. + */ +export const needsZoomNormalize = ({ + zoom, + targetZoom, +}: { + zoom: number; + targetZoom: number; +}): boolean => Math.floor(zoom) !== targetZoom; + +/** + * 서버 뷰포트 상한 (developCode 14401) — 요청 사각형의 한 변이 이 값을 넘으면 400이다. + * + * MSG-489 §11이 "항상 1km 정규화면 14401은 구조적으로 불가능"이라며 이 상수와 + * `needsSpanNormalize`를 폐기했으나, §12 검증에서 **반증**됐다: 숨은 탭에서는 지도가 + * `idle`을 내지 않아 뷰포트가 갱신되지 않는데, D13의 종결 출구(정착 상한 만료)가 + * 그 옛 bounds로 그대로 쏴 줌 9의 1.9379° × 4.7461° 요청이 나가 400을 맞았다(실측). + * + * 그래서 **정규화 트리거가 아니라 발사 직전 최종 가드**로만 되살렸다 — + * 정상 경로의 "항상 1km"(D10)는 그대로이고, 이 판정은 정착 실패 경로에서만 참이 된다. + */ +export const MAX_VIEWPORT_SPAN_DEG = 0.5; + +/** + * 보내려는 사각형이 서버 상한을 넘는가 (§12) — 참이면 호출부는 요청을 보내지 않고 + * 안내로 종결한다(확정 400을 대신 맞아 주지 않는다). 판정 대상은 **실제로 실릴 body의 + * viewport**라, 어떤 경로로 조립됐든 같은 가드를 통과한다. + * 상한 값은 파라미터로 받는다 — 테스트가 값을 고정하고, 서버 계약이 바뀌어도 호출부만 고친다. + */ +export const exceedsViewportSpan = ({ + viewport, + maxSpanDeg, +}: { + viewport: ViewportDto; + maxSpanDeg: number; +}): boolean => + viewport.maxLat - viewport.minLat > maxSpanDeg || + viewport.maxLng - viewport.minLng > maxSpanDeg; + +/** + * 목표 뷰포트 도달 판정 (L24·D12) — 2차 자동 재요청의 발사 조건. + * "이동 명령이 지도에 반영됐고(bounds 참조 교체) 줌이 목표 단"일 때만 참이라, + * 대기 중 사용자가 지도를 만져도 2차가 엉뚱한 뷰포트로 나가지 않는다(지도는 잠그지 않는다). + * + * `boundsAtCommand`가 null이면 이 마운트에서 이동 명령을 내지 않은 것이다(섹션 재진입, A7) — + * 이동은 이전 마운트에서 이미 정착했으므로 도달로 본다. + */ +export const reachedTargetViewport = ({ + bounds, + boundsAtCommand, + zoom, + targetZoom, +}: { + bounds: Bounds | null; + /** 이동 명령을 낸 시점의 bounds — 새 bounds가 들어오면 참조가 바뀐다 */ + boundsAtCommand: Bounds | null; + zoom: number; + targetZoom: number; +}): boolean => { + if (bounds === null) return false; + if (boundsAtCommand === null) return true; + return ( + bounds !== boundsAtCommand && !needsZoomNormalize({ zoom, targetZoom }) + ); +}; + +/** + * 지도 정착 대기 상한 (D13) — 목표 도달 판정이 성립하지 않아도 이 시간이 지나면 + * 현재 뷰포트로 보낸다. 사용자가 대기 중 줌을 되돌리면 목표에 영영 닿지 않는데, + * 그때 패널이 로딩에 갇히는 것을 막는 유일한 출구다. 어떤 경로로도 요청은 1회 종결한다. + */ +export const VIEWPORT_SETTLE_TIMEOUT_MS = 3_000; + +/** 대기 사이클 1회의 마감 — 호출부(뷰-레이어 훅)가 ref에 들고 다닌다 */ +export interface SettleDeadline { + /** 절대 마감 시각(ms) — 숨은 구간만큼 뒤로 밀린다 */ + deadlineAt: number; + /** 현재 숨김 구간이 시작된 시각. 보이는 중이면 null */ + hiddenSince: number | null; +} + +/** + * 정착 대기의 남은 상한 (codex 리뷰 P2 — §13). + * + * 대기 이펙트는 `bounds`·`zoom`이 갱신될 때마다 재실행되는데, 그때마다 상한을 처음부터 + * 다시 재면 사용자가 계속 패닝·줌하는 동안 종결이 **무한히 연기**돼 패널이 로딩에 갇힌다 + * (D13 "영구 로딩 금지" 위반). 그래서 마감은 사이클당 **한 번만** 정하고(`deadline === null`인 + * 첫 호출), 이후 재실행은 남은 시간만 돌려받아 그만큼만 다시 스케줄한다. 0이면 즉시 종결이다. + * + * 다만 마감은 **가시 상태에서 흐른 시간**만 소모한다 (§12) — 숨은 탭은 rAF가 멈춰 지도가 + * 정착할 수 없으므로 그 구간을 세면 갱신되지 않은 옛 뷰포트로 발사돼 14401을 맞는다. + * 숨은 동안 마감을 정지시키는 대신 **복귀 시 숨어 있던 만큼 마감을 뒤로 민다**(같은 결과이고 + * 상태가 두 값뿐이다). 시각(`now`)과 가시성은 파라미터로 받는다 — 이 모듈은 시계도 `document`도 + * 모른다(RN 경계). + */ +export const advanceSettleDeadline = ({ + deadline, + now, + visible, + timeoutMs, +}: { + /** 진행 중인 대기의 마감. null이면 이번 호출이 이 사이클의 첫 예약이다 */ + deadline: SettleDeadline | null; + now: number; + visible: boolean; + timeoutMs: number; +}): { deadline: SettleDeadline; remainingMs: number } => { + const next = ((): SettleDeadline => { + if (deadline === null) { + return { deadlineAt: now + timeoutMs, hiddenSince: visible ? null : now }; + } + if (!visible) { + // 숨김 시작 시각은 첫 관측으로 고정한다 — 재실행이 구간을 잘게 쪼개면 안 된다 + return deadline.hiddenSince === null + ? { ...deadline, hiddenSince: now } + : deadline; + } + if (deadline.hiddenSince === null) return deadline; + return { + deadlineAt: deadline.deadlineAt + (now - deadline.hiddenSince), + hiddenSince: null, + }; + })(); + + return { deadline: next, remainingMs: Math.max(0, next.deadlineAt - now) }; +}; + +/** 제출 버튼 문구 (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 03fe4a0..d8871cd 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 6e42113..a93599d 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 @@ -5,10 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RequireAuth } from "@/app/RequireAuth"; import { useAiRouteStore } from "@/features/ai-route/model/ai-route-store"; import { useLoginModalStore } from "@/features/auth/model/login-modal-store"; +import { MAP_SCALE_1KM_ZOOM } from "@/features/map-home/model/map-scale"; 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 +70,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 } }); @@ -75,17 +86,20 @@ beforeEach(() => { useLoginModalStore.setState({ open: false }); useAiRouteStore.setState(useAiRouteStore.getInitialState(), true); useMapOverlayStore.setState(useMapOverlayStore.getInitialState(), true); - // 지도 준비 완료 — 요청 뷰포트의 근원 (부산 서면) + // 지도 준비 완료 — 요청 뷰포트의 근원 (부산 서면). + // 줌은 1km 단으로 둔다: MSG-489 §11 이후 제출은 이 단에서만 즉시 발사된다 (D10) useViewportStore.setState({ bounds: { sw: { lat: 35.1521, lng: 129.0537 }, ne: { lat: 35.1662, lng: 129.0712 }, }, + zoom: MAP_SCALE_1KM_ZOOM, }); moveTo.mockClear(); }); afterEach(() => { + setGeolocation(originalGeolocation); vi.unstubAllGlobals(); }); @@ -267,3 +281,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 57ddde4..724ce9e 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 ? ( + + + 현재 위치에서 출발 + + ) : ( + + )}