From 406e45bc85e3ba06a1673233cb286c95ff3a4206 Mon Sep 17 00:00:00 2001 From: mokjakA Date: Mon, 10 Aug 2026 08:39:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(S15P11A705-434):=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=AA=A8=EB=8B=AC=20=EB=B0=80=EB=8F=84=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/PlaceRecordSheet.test.tsx | 154 +++++++++++++ .../records/components/PlaceRecordSheet.tsx | 206 ++++++++++-------- 2 files changed, 269 insertions(+), 91 deletions(-) create mode 100644 src/features/records/components/PlaceRecordSheet.test.tsx diff --git a/src/features/records/components/PlaceRecordSheet.test.tsx b/src/features/records/components/PlaceRecordSheet.test.tsx new file mode 100644 index 0000000..542e5f8 --- /dev/null +++ b/src/features/records/components/PlaceRecordSheet.test.tsx @@ -0,0 +1,154 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PlaceRecordSheetProvider } from '@/contexts/PlaceRecordSheetProvider'; +import { usePlaceRecordSheet } from '@/contexts/usePlaceRecordSheet'; +import { PlaceRecordSheet } from './PlaceRecordSheet'; + +const { mutationMock, searchMutationMock } = vi.hoisted(() => { + const mutation = { + data: undefined, + error: null, + isError: false, + isPending: false, + isSuccess: false, + mutate: vi.fn(), + mutateAsync: vi.fn(), + reset: vi.fn(), + }; + return { + mutationMock: mutation, + searchMutationMock: { + ...mutation, + data: [ + { + kakaoPlaceId: 'place-1', + name: '첫 번째 장소', + categoryName: '카페', + address: '서울시 강남구', + roadAddress: '서울시 강남구 테헤란로', + phone: null, + placeUrl: null, + lat: 37.5, + lng: 127, + }, + { + kakaoPlaceId: 'place-2', + name: '두 번째 장소', + categoryName: '음식점', + address: '서울시 서초구', + roadAddress: null, + phone: null, + placeUrl: null, + lat: 37.4, + lng: 127.1, + }, + ], + isSuccess: true, + }, + }; +}); + +vi.mock('@/features/places/hooks/useKakaoPlaceSearch', () => ({ + useKakaoPlaceSearch: () => searchMutationMock, +})); +vi.mock('@/features/places/hooks/usePlaceSuggestionMutation', () => ({ + usePlaceSuggestionMutation: () => mutationMock, +})); +vi.mock('../hooks/useCreateRecordMutation', () => ({ + useCreateRecordMutation: () => mutationMock, +})); +vi.mock('@/features/collections/hooks/useMyCollectionsQuery', () => ({ + useMyCollectionsQuery: () => ({ + data: undefined, + isError: false, + isPending: false, + isSuccess: false, + }), +})); +vi.mock('@/features/collections/hooks/useAddRecordsToCollectionMutation', () => ({ + useAddRecordsToCollectionMutation: () => mutationMock, +})); +vi.mock('@/features/collections/hooks/useCreateCollectionMutation', () => ({ + useCreateCollectionMutation: () => mutationMock, +})); +vi.mock('@/features/collections/components/NewCollectionModal', () => ({ + NewCollectionModal: () => null, +})); +vi.mock('@/features/collections/components/CollectionCoverModal', () => ({ + CollectionCoverModal: () => null, +})); + +function SheetHarness() { + const sheet = usePlaceRecordSheet(); + return ( + <> + + + + ); +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +describe('PlaceRecordSheet 760px 노트 밀도', () => { + it('entry 고정 콘텐츠는 전체 스크롤을 만들지 않고 가변 검색 결과만 스크롤한다', () => { + act(() => { + root.render( + + + , + ); + }); + act(() => container.querySelector('button')?.click()); + + const viewport = container.querySelector('[data-place-record-viewport="entry"]'); + const searchResults = container.querySelector( + '[data-place-record-scroll-owner="search-results"]', + ); + + expect(viewport?.className).toContain('overflow-hidden'); + expect(viewport?.className.split(' ')).not.toContain('overflow-y-auto'); + expect(viewport?.className).toContain('max-sm:overflow-y-auto'); + expect(viewport?.className).toContain('[@media(max-height:680px)]:overflow-y-auto'); + expect(searchResults?.className).toContain('overflow-y-auto'); + expect(searchResults?.className).toContain('absolute'); + expect(searchResults?.className).toContain('max-h-40'); + }); + + it('업로드 영역과 CTA를 줄인 노트 비율로 렌더한다', () => { + act(() => { + root.render( + + + , + ); + }); + act(() => container.querySelector('button')?.click()); + + const dropzone = container.querySelector('[data-testid="place-capture-dropzone"]'); + const analyzeButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === '이 이미지로 분석 시작', + ); + + expect(dropzone?.className).toContain('min-h-[120px]'); + expect(analyzeButton?.className).toContain('h-12'); + expect(analyzeButton?.className).not.toContain('h-[60px]'); + }); +}); diff --git a/src/features/records/components/PlaceRecordSheet.tsx b/src/features/records/components/PlaceRecordSheet.tsx index 4d30181..f7a857e 100644 --- a/src/features/records/components/PlaceRecordSheet.tsx +++ b/src/features/records/components/PlaceRecordSheet.tsx @@ -107,10 +107,10 @@ function placeMeta(place: KakaoPlace): string { } // 노트북 카드 전역에서 반복되는 CTA 버튼 톤. 화면별로 활성색만 다르다(입력 단계 #4f9b78 · -// 저장 단계 #5faa84) — mockup(장소 기록 화면.dc.html 외 3종)의 원값을 그대로 옮겼다. +// 저장 단계 #5faa84). 434에서 760px 노트 비율에 맞춰 높이·글자·radius를 한 단계 줄였다. function ctaButtonClass(disabled: boolean, activeColor: string): string { return [ - 'mt-auto h-[60px] flex-none rounded-[14px] text-[19px] font-extrabold tracking-[-0.01em] text-white transition-colors', + 'mt-auto h-12 flex-none rounded-xl text-[17px] font-extrabold tracking-[-0.01em] text-white transition-colors', disabled ? 'cursor-default' : 'cursor-pointer', disabled ? 'bg-[#bcd8c7] shadow-none' @@ -518,8 +518,8 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe // 검색 입력 UI. entry 화면과, details 화면에서 "수정"으로 들어온 편집 모드가 함께 쓴다 // (mockup에는 편집 상태 화면이 따로 없어 entry의 검색 박스를 그대로 재사용했다). const searchBox = ( -
-
+
+
@@ -598,7 +605,7 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe {/* notebook page */}
✕ -
+

PLACE RECORD

장소 기록

-
+
{stage === 'entry' && (

장소 검색

-

+

등록하려는 장소의 이름이나 주소를 적어보세요.

{searchBox} -
+
{/* 375: 외부 CDN 장식 폰트를 로컬 번들 금은보화(font-hand)로 교체했다. */} OR @@ -660,14 +676,14 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe

대화 캡처 업로드

-

+

장소명이나 지역 정보가 보이는 대화 캡처 화면을 올려 주세요.

-

+

* 분석된 이미지는 저장되지 않으며, 장소 후보 확인에만 사용됩니다.

-
+
- {unresolvedCandidates.length > 0 && ( -
- {unresolvedCandidates.map((candidate) => ( -
- - {candidate.extractedName} - - - {unresolvedCandidateMessage(candidate.status)} - -
- ))} -
- )} - -
- @@ -960,7 +981,10 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe )} {showDetails && ( -
+
{isEditingPlace ? ( <>