Skip to content
Merged
4 changes: 4 additions & 0 deletions .claude/skills/page-verification/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 범위만):
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 좌시프트 원칙) |
43 changes: 40 additions & 3 deletions apps/web/src/features/ai-route/api/use-route-recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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회 쏜다.
Expand All @@ -14,27 +18,60 @@ 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!;

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);
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/features/ai-route/model/ai-route-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading