Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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-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 좌시프트 원칙) |
| 2026-08-19 | e2e apt 미러 고정(azure→archive) + Playwright 설치 스텝 timeout-minutes 8 | .github/workflows/ci.yml | PR #74 실측 — `azure.archive.ubuntu.com` 불통으로 apt가 저장소마다 재시도하다 21분·14분+ 멈춤(2회 재현). `playwright install --with-deps`로 바꾸라는 조언은 같은 apt 경로를 타므로 무효였고, 캐시 히트 경로에서 바이너리 재다운로드만 추가된다. 미러리스트를 정본으로 고정해 재시도 구간을 제거하고, 그래도 물리면 빨리 실패하도록 타임아웃을 건다 |
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ describe("mergeProfileImage — 확정 응답 병합 (포팅 parity)", () => {
profileImageUrl: null,
createdAt: "2026-01-12T00:00:00",
locationConsent: true,
role: "USER" as const,
};
const confirmed = {
...prev,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/shared/api/reset-session-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ describe("resetSessionCache — 로그인 직후 이전 세션 캐시 폐기 (AC
profileImageUrl: null,
createdAt: "2026-01-01T00:00:00Z",
locationConsent: true,
role: "USER",
},
});
// 비로그인 게이트는 enabled:false라 요청을 만들지 않는다 (AC 3)
Expand Down
2 changes: 1 addition & 1 deletion apps/web/openapi/api-docs.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion apps/web/public/robots.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# FillMap 웹 (MSG-478) — 색인 대상은 비로그인으로 열리는 홈뿐이다.
# /dex·/profile은 로그인 뒤 화면, /oauth/는 카카오 콜백, /upload는 스텁(실제 업로드는 모달).
# /dex·/profile·/ai-route는 로그인 뒤 화면, /oauth/는 카카오 콜백, /upload는 스텁(실제 업로드는 모달).
User-agent: *
Allow: /
Disallow: /dex
Disallow: /profile
Disallow: /oauth/
Disallow: /upload
Disallow: /ai-route

Sitemap: https://fillmap.kr/sitemap.xml
11 changes: 11 additions & 0 deletions apps/web/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AppLayout } from "@/app/layouts/AppLayout";
import { RequireAuth } from "@/app/RequireAuth";
import { RouteErrorBoundary } from "@/app/RouteErrorBoundary";
import { KAKAO_CALLBACK_PATH, ROUTES } from "@/app/routes";
import { AiRoutePage } from "@/pages/ai-route/AiRoutePage";
import { DexPanel } from "@/pages/dex/DexPanel";
import { MapHomePage } from "@/pages/map-home/MapHomePage";
import { KakaoCallbackPage } from "@/pages/oauth-callback/KakaoCallbackPage";
Expand All @@ -25,6 +26,16 @@ export const router = createBrowserRouter([
children: [
{ path: ROUTES.home, element: <MapHomePage /> },
// /explore는 MSG-328에서 제거 — 직접 진입은 무매칭 404로 errorElement에 수렴한다 (AC 2)
// AI 경로추천(MSG-488) — 서버가 익명 POST를 401(2403)로 막아 로그인 전용이다.
// 레일 클릭은 SideRailNav가 모달로 막고, 이 래핑은 직접 URL 진입 방어다
{
path: ROUTES.aiRoute,
element: (
<RequireAuth>
<AiRoutePage />
</RequireAuth>
),
},
{ path: ROUTES.upload, element: <SectionPanel title="업로드" /> },
// 도감(MSG-121·122) — 탭은 URL 정본(/dex·/dex/badges), 무효 탭("gallery" 포함)은 지도 폴백(AC 2·21).
// 비로그인 진입은 프로필과 동일하게 홈+로그인 모달 (MSG-328 사용자 피드백)
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/app/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe("getActiveNavKey", () => {
});

it("각 섹션 경로는 해당 네비 키를 반환한다", () => {
expect(getActiveNavKey(ROUTES.aiRoute)).toBe("aiRoute");
expect(getActiveNavKey(ROUTES.upload)).toBe("upload");
expect(getActiveNavKey(ROUTES.dex)).toBe("dex");
expect(getActiveNavKey(ROUTES.profile)).toBe("profile");
Expand All @@ -49,3 +50,29 @@ describe("getActiveNavKey", () => {
expect(getActiveNavKey("/explore")).toBeUndefined();
});
});

/**
* AI 경로추천 라우트 신설 (MSG-488 L11, 승인 Q1) — 티켓 가칭 `/route`가 아니라 `/ai-route`다.
* 기존 `route` 3중 동음이의(ROUTES · ThemeId "route" 코스 칩 · map-overlay-store.routes)를 피한다.
*/
describe("AI 경로추천 라우트 (MSG-488 L11)", () => {
it("네비 섹션은 홈·AI 경로추천·업로드·도감·프로필 5개다 (L11)", () => {
expect(Object.keys(ROUTES)).toEqual([
"home",
"aiRoute",
"upload",
"dex",
"profile",
]);
});

it('경로는 "/ai-route"이고 getActiveNavKey가 aiRoute를 반환한다 (L11)', () => {
expect(ROUTES.aiRoute).toBe("/ai-route");
expect(getActiveNavKey("/ai-route")).toBe("aiRoute");
});

it('티켓 가칭 "/route"는 미등록 경로다 — 라우터 404 폴백을 따른다 (L11, Q1)', () => {
expect("route" in ROUTES).toBe(false);
expect(getActiveNavKey("/route")).toBeUndefined();
});
});
4 changes: 4 additions & 0 deletions apps/web/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
*/
export const ROUTES = {
home: "/",
// AI 경로추천 (MSG-488) — 티켓 가칭 `/route`가 아니라 `/ai-route`다(승인 Q1):
// `route`는 코드베이스에서 이미 3중 의미(ROUTES · ThemeId "route" 코스 칩 ·
// map-overlay-store.routes)라 새 섹션까지 그 낱말을 쓰면 계속 섞인다
aiRoute: "/ai-route",
// explore는 MSG-328에서 제거 — 지역 탐색·검색이 홈 좌측 패널로 통합됐다. 재추가 금지.
upload: "/upload",
dex: "/dex",
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/app/seo-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,14 @@ describe("public/robots.txt · sitemap.xml (B1·B2)", () => {

expect(text).toMatch(/^User-agent: \*$/m);
expect(text).toMatch(/^Allow: \/$/m);
expect(disallows()).toEqual(["/dex", "/profile", "/oauth/", "/upload"]);
// MSG-488: /ai-route는 로그인 전용 화면이라 색인 대상이 아니다 (L13)
expect(disallows()).toEqual([
"/dex",
"/profile",
"/oauth/",
"/upload",
"/ai-route",
]);
expect(text).toMatch(/^Sitemap: https:\/\/fillmap\.kr\/sitemap\.xml$/m);
});

Expand Down
99 changes: 99 additions & 0 deletions apps/web/src/features/ai-route/api/use-route-recommend.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { envelopeResponse, errorEnvelope } from "@/test/envelope-response";
import { ROUTE_POINTS } from "@/test/route-points";
import { stubFetch } from "@/test/stub-fetch";
import { useAiRouteStore } from "../model/ai-route-store";
import { useRouteRecommend } from "./use-route-recommend";

const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
{children}
</QueryClientProvider>
);

const BODY = {
text: "서면에서 밥 먹고 저녁 경기까지 동선 짜 줘",
viewport: {
minLat: 35.1521,
minLng: 129.0537,
maxLat: 35.1662,
maxLng: 129.0712,
},
};

/** 훅 마운트 → 1회 요청 — 세 시나리오가 공유하는 진입 동작 */
const mutateRecommend = (
callbacks?: Parameters<typeof useRouteRecommend>[0],
) => {
const { result } = renderHook(() => useRouteRecommend(callbacks), {
wrapper,
});
result.current.mutate(BODY);
};

describe("useRouteRecommend — 추천 1회 요청 배선 (L5·L7 배선)", () => {
beforeEach(() => {
useAiRouteStore.setState(useAiRouteStore.getInitialState(), true);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("응답 봉투를 벗겨 지점과 부족 안내를 스토어에 싣는다 (§4-3)", async () => {
const received = stubFetch(() =>
envelopeResponse({
points: ROUTE_POINTS,
notice: null,
mentionedArea: null,
}),
);
mutateRecommend();

await waitFor(() =>
expect(useAiRouteStore.getState().status).toBe("result"),
);
expect(useAiRouteStore.getState().points.map((p) => p.order)).toEqual([
1, 2, 3,
]);
expect(received[0].body).toEqual(BODY);
});

it("실패는 developCode 매핑 안내로 스토어에 실린다 — 입력 문장은 유지된다 (§1-4)", async () => {
stubFetch(() => errorEnvelope(14400, "뷰포트가 너무 넓습니다", 400));
useAiRouteStore.getState().setText(BODY.text);
const { result } = renderHook(() => useRouteRecommend(), { wrapper });

result.current.mutate(BODY);

await waitFor(() =>
expect(useAiRouteStore.getState().status).toBe("error"),
);
expect(useAiRouteStore.getState().errorNotice?.message).toBe(
"지도를 조금 더 확대하거나 다른 곳으로 옮긴 뒤 다시 시도해 주세요",
);
expect(useAiRouteStore.getState().text).toBe(BODY.text);
});

it("401(2403)이면 입력 대기로 되돌아가고 로그인 콜백이 호출된다 (§1-3)", async () => {
stubFetch(() => errorEnvelope(2403, "인증이 필요합니다", 401));
const onLoginRequired = vi.fn();
const { result } = renderHook(
() => useRouteRecommend({ onLoginRequired }),
{
wrapper,
},
);

result.current.mutate(BODY);

await waitFor(() => expect(onLoginRequired).toHaveBeenCalledTimes(1));
expect(useAiRouteStore.getState().status).toBe("idle");
expect(useAiRouteStore.getState().errorNotice).toBeNull();
});
});
45 changes: 45 additions & 0 deletions apps/web/src/features/ai-route/api/use-route-recommend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useMutation } from "@tanstack/react-query";
// 생성 mutation 옵션은 barrel 미재수출 — 직접 경로 import (MSG-323 관례)
import { recommendMutation } from "@/shared/api/generated/@tanstack/react-query.gen";
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";

/**
* AI 경로 추천 요청 (MSG-488 §4-3) — `POST /api/routes/recommend`를 1회 쏜다.
* 생성 SDK mutation 옵션 기반(직접 fetch·URL 하드코딩 없음, use-video-mutations 패턴 미러).
* 봉투 언랩 → 스토어 게시까지가 이 훅의 계약이고, 화면 분기는 스토어를 구독한다.
*
* mutation은 TanStack 기본 `retry: 0`이라 14429(10초 제한)가 자동 재시도로 악화되지 않는다.
* 콜백은 훅 레벨 옵션으로 받는다 — mutate per-call 콜백은 관찰자 언마운트 시 유실된다(MSG-325 선례).
*
* [MSG-489 확장점] 2차 자동 재요청 트리거가 이 훅에 얹힌다.
*/
// 생성 팩토리는 mutationFn을 항상 채운다 — UseMutationOptions 타입만 optional이라 !로 좁힌다
const recommendFn = recommendMutation().mutationFn!;

export const useRouteRecommend = (callbacks?: {
/** 401(2403) — 패널은 입력을 유지한 채 입력 대기로 돌아가고 로그인 모달만 연다 (§1-3) */
onLoginRequired?: () => void;
}) => {
const startRequest = useAiRouteStore((s) => s.startRequest);
const succeed = useAiRouteStore((s) => s.succeed);
const fail = useAiRouteStore((s) => s.fail);

return useMutation({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/query-mutation-missing-invalidation (warning)

useMutation with no cache update here can leave your users looking at stale data after it runs.

Fix → Add onSuccess: () => queryClient.invalidateQueries({ queryKey: ['...'] }) so cached data stays in sync after the mutation

Docs

mutationFn: (body: RouteRecommendRequestDto, context) =>
recommendFn({ body }, context),
// 이전 결과·선택은 요청 시작 시점에 비운다 — 로딩 화면에 잔상이 남지 않는다 (L7)
onMutate: () => startRequest(),
onSuccess: (response) => {
const data = unwrapEnvelope(response);
succeed(data.points, data.notice);
},
onError: (error) => {
const notice = routeErrorNotice(error);
fail(notice);
if (notice.requiresLogin) callbacks?.onLoginRequired?.();
},
});
};
Loading
Loading