diff --git a/public/experience/check-solid.svg b/public/experience/check-solid.svg deleted file mode 100644 index a35c042..0000000 --- a/public/experience/check-solid.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/app/experience/simulation/page.tsx b/src/app/experience/simulation/page.tsx deleted file mode 100644 index 32b1c2e..0000000 --- a/src/app/experience/simulation/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SimulationResultView } from '@/views/experience'; - -export default function ExperienceSimulationPage() { - return ; -} diff --git a/src/app/globals.css b/src/app/globals.css index 85d417c..b2781c5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -39,10 +39,6 @@ --color-primary: #ba7448; /* 채워진 강조 버튼 (활성 '시뮬레이션 하기' 등) */ --color-primary-hover: #a8663d; /* primary 버튼 hover */ --color-primary-active: #965a35; /* primary 버튼 눌림 */ - --color-danger: #d96a5c; /* 시뮬레이션 실패 배지·테두리·강조 텍스트 */ - --color-danger-bg: #ffefed; /* 시뮬레이션 실패 카드 배경 */ - --color-success: #9dc88b; /* 시뮬레이션 성공 배지·테두리·강조 텍스트 */ - --color-success-bg: rgba(233, 255, 238, 0.86); /* 시뮬레이션 성공 카드 배경 */ } /* 카드 배경의 점 격자 (Figma Rectangle 5·7, 약 18px 간격). bg-card 와 함께 쓴다. */ diff --git a/src/features/simulation/api/mocks.ts b/src/features/simulation/api/mocks.ts index 363e1db..ca1b67c 100644 --- a/src/features/simulation/api/mocks.ts +++ b/src/features/simulation/api/mocks.ts @@ -53,7 +53,6 @@ export const simulationHandlers = [ normalizedCommandCount: countCommands(program.chain), totalDistanceM: evaluation.totalDistanceM, violations, - failedAtIndex: evaluation.failedAtIndex, notes: passed ? ['실제 물리 결과와 차이가 있을 수 있어요.'] : [], }; diff --git a/src/features/simulation/api/simulateProgram.test.ts b/src/features/simulation/api/simulateProgram.test.ts index c4218de..021e7a1 100644 --- a/src/features/simulation/api/simulateProgram.test.ts +++ b/src/features/simulation/api/simulateProgram.test.ts @@ -31,7 +31,6 @@ describe('simulateProgram (mock API)', () => { expect(result.passed).toBe(true); expect(result.totalDistanceM).toBe(2); expect(result.violations).toEqual([]); - expect(result.failedAtIndex).toBeUndefined(); }); it("'종료' 로 끝나지 않으면 통과하지 못한다", async () => { @@ -64,9 +63,6 @@ describe('simulateProgram (mock API)', () => { expect(result.totalDistanceM).toBe(3); expect(result.violations[0]?.code).toBe('exceeds-safe-zone'); expect(result.violations[0]?.message).toMatch(/안전 구역/); - // repeat(3){move 1m} 를 실제 실행 순서로 펼치면 [start, move, move, move, greet, end] — - // 4번째(세 번째 move)에서 누적 3m 로 2m 안전 구역을 처음 벗어난다. - expect(result.failedAtIndex).toBe(4); }); it('반복 안 이동과 별도 이동 블록의 거리를 각각 더한다 (첫 값만 뽑지 않는다)', async () => { @@ -98,50 +94,19 @@ describe('simulateProgram (mock API)', () => { expect(result.passed).toBe(false); expect(result.totalDistanceM).toBe(0); expect(result.violations[0]?.code).toBe('invalid-values'); - // count 가 무효해 실행 순서를 펼칠 수 없다 — 실패 위치는 알 수 없음 - expect(result.failedAtIndex).toBeUndefined(); }); - it("'앞으로' 이동도 '뒤로' 이동과 같은 총 이동 거리 계산에 포함된다", async () => { + it('알 수 없는(지원 종료된) 블록 kind 는 값 검증을 우회하지 못하고 거부된다 (inryeok-bot 리뷰)', async () => { + // 예: 지원 종료된 구버전 moveForward 요청 — 알 수 없는 kind 를 값 검증 없이 통과시키면 + // 안전 구역 계산에서도 빠져 위반을 놓칠 수 있다. + const unknownNode = { + id: 'legacy-0', + kind: 'moveForward', + distanceM: 999, + } as unknown as SerializedBlockNode; const result = await simulateProgram({ program: program({ - chain: [ - { id: 'start-0', kind: 'start' }, - { id: 'move-1', kind: 'move', distanceM: 1 }, - { id: 'forward-0', kind: 'moveForward', distanceM: 1 }, - { id: 'end-0', kind: 'end' }, - ], - }), - }); - - expect(result.totalDistanceM).toBe(2); - }); - - it('회전(오른쪽·왼쪽)은 거리가 아니라 총 이동 거리 합산에 포함되지 않는다', async () => { - const result = await simulateProgram({ - program: program({ - chain: [ - { id: 'start-0', kind: 'start' }, - { id: 'move-1', kind: 'move', distanceM: 1 }, - { id: 'turn-0', kind: 'turnRight', degrees: 90 }, - { id: 'turn-1', kind: 'turnLeft', degrees: 90 }, - { id: 'end-0', kind: 'end' }, - ], - }), - }); - - expect(result.totalDistanceM).toBe(1); - expect(result.passed).toBe(true); - }); - - it('회전 각도가 허용 범위(1~90) 밖이면 서버가 거부한다', async () => { - const result = await simulateProgram({ - program: program({ - chain: [ - { id: 'start-0', kind: 'start' }, - { id: 'turn-0', kind: 'turnRight', degrees: 180 }, - { id: 'end-0', kind: 'end' }, - ], + chain: [{ id: 'start-0', kind: 'start' }, unknownNode, { id: 'end-0', kind: 'end' }], }), }); diff --git a/src/features/simulation/model/safety.ts b/src/features/simulation/model/safety.ts index 1f81fbe..ad1f0a3 100644 --- a/src/features/simulation/model/safety.ts +++ b/src/features/simulation/model/safety.ts @@ -12,7 +12,6 @@ export const LIMITS = { repeatCount: { min: 1, max: 20 }, moveDistance: { min: 1, max: 10 }, waitSeconds: { min: 1, max: 60 }, - turnDegrees: { min: 1, max: 90 }, } as const; const inRange = (raw: unknown, { min, max }: { min: number; max: number }) => { @@ -23,17 +22,18 @@ const inRange = (raw: unknown, { min, max }: { min: number; max: number }) => { function isValueValid(node: SerializedBlockNode): boolean { switch (node.kind) { case 'move': - case 'moveForward': return inRange(node.distanceM, LIMITS.moveDistance); - case 'turnRight': - case 'turnLeft': - return inRange(node.degrees, LIMITS.turnDegrees); case 'wait': return inRange(node.seconds, LIMITS.waitSeconds); case 'repeat': return inRange(node.count, LIMITS.repeatCount); + case 'start': + case 'greet': + case 'end': + return true; // 파라미터가 없는 블록 — 값 검증 대상이 아니다 default: - return true; + // 알 수 없는(지원 종료됐거나 조작된) kind — 값 검증을 우회하지 못하게 명시적으로 거부한다. + return false; } } @@ -49,40 +49,15 @@ function allValuesValid(nodes: SerializedBlockNode[]): boolean { return invalidCount === 0; } -/** 반복 중첩까지 포함한 총 이동 거리 (명세: "제한을 우회하는 중첩·합산 값도 계산"). - * 회전(turnRight/turnLeft)은 거리가 아니라 각도라 합산에 포함하지 않는다. */ +/** 반복 중첩까지 포함한 총 이동 거리 (명세: "제한을 우회하는 중첩·합산 값도 계산"). */ function totalDistance(nodes: SerializedBlockNode[]): number { return sumOverBlockTree(nodes, { - valueOf: (node) => (node.kind === 'move' || node.kind === 'moveForward' ? node.distanceM : 0), + valueOf: (node) => (node.kind === 'move' ? node.distanceM : 0), bodyOf, repeatCountOf: (node) => (node.kind === 'repeat' ? node.count : 1), }); } -/** repeat 를 실제 실행 횟수만큼 펼친다 (구조상 1개인 repeat 를 count 번 반복 실행한 순서). */ -function unroll(nodes: SerializedBlockNode[]): SerializedBlockNode[] { - return nodes.flatMap((node) => { - if (node.kind !== 'repeat') return [node]; - const body = unroll(node.body); - return Array.from({ length: node.count }, () => body).flat(); - }); -} - -/** - * 안전 구역을 처음 벗어나는 실행 순서상 위치 (1-based, "N번째 블록에서 멈춤" 명세용). - * 값이 유효할 때만 부른다 — count 가 무효하면 펼칠 수 없다. - */ -function safeZoneBreachIndex(nodes: SerializedBlockNode[]): number | undefined { - let cumulative = 0; - const steps = unroll(nodes); - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - if (step.kind === 'move' || step.kind === 'moveForward') cumulative += step.distanceM; - if (cumulative > SAFE_ZONE_M) return i + 1; - } - return undefined; -} - export type ProgramEvaluation = { structurallyValid: boolean; valuesValid: boolean; @@ -90,8 +65,6 @@ export type ProgramEvaluation = { withinSafeZone: boolean; /** 시뮬레이션·실제 실행을 모두 진행할 수 있는 상태인지 */ runnable: boolean; - /** 안전 구역을 벗어난 실행 순서상 위치 (1-based) — 벗어나지 않았거나 값이 무효하면 없음. */ - failedAtIndex?: number; }; export function evaluateProgram(program: SerializedBlockProgram): ProgramEvaluation { @@ -106,6 +79,5 @@ export function evaluateProgram(program: SerializedBlockProgram): ProgramEvaluat totalDistanceM, withinSafeZone, runnable: structurallyValid && valuesValid && withinSafeZone, - failedAtIndex: valuesValid && !withinSafeZone ? safeZoneBreachIndex(program.chain) : undefined, }; } diff --git a/src/features/simulation/model/types.ts b/src/features/simulation/model/types.ts index 72ad7bd..1e27edf 100644 --- a/src/features/simulation/model/types.ts +++ b/src/features/simulation/model/types.ts @@ -11,16 +11,6 @@ export type SerializedBlockNode = | { id: string; kind: 'greet' } | { id: string; kind: 'end' } | { id: string; kind: 'move'; distanceM: number } - | { id: string; kind: 'moveForward'; distanceM: number } - | { id: string; kind: 'turnRight'; degrees: number } - | { id: string; kind: 'turnLeft'; degrees: number } - | { id: string; kind: 'stop' } - | { id: string; kind: 'sit' } - | { id: string; kind: 'standUp' } - | { id: string; kind: 'heart' } - | { id: string; kind: 'dance' } - | { id: string; kind: 'rollOver' } - | { id: string; kind: 'attack' } | { id: string; kind: 'wait'; seconds: number } | { id: string; kind: 'repeat'; count: number; body: SerializedBlockNode[] }; @@ -53,9 +43,6 @@ export type SimulationResult = { totalDistanceM: number; /** 안전 제한 위반 목록 (명세 "안전 제한 검증") */ violations: SafetyViolation[]; - /** 안전 구역을 벗어난 실행 순서상 위치 (1-based) — 통과했거나 값 자체가 무효하면 없음. - * 결과 화면 "N번째 블록에서 멈춤"·실행순서 단계별 상태 표시에 쓴다. */ - failedAtIndex?: number; /** 실제 물리 결과와 차이가 있을 수 있음 등 안내 문구 */ notes: string[]; }; diff --git a/src/shared/testing/routerMock.ts b/src/shared/testing/routerMock.ts deleted file mode 100644 index 6fa1330..0000000 --- a/src/shared/testing/routerMock.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { vi } from 'vitest'; - -// next/navigation 은 실제 App Router 트리 없이 렌더하면 "invariant expected app router to be -// mounted" 로 죽는다. vitest.setup.ts 가 이 mockRouter 로 next/navigation 을 전역 대체하고, -// 네비게이션을 검증해야 하는 테스트는 여기서 mockRouter 를 그대로 가져다 assert 한다. -export const mockRouter = { - push: vi.fn(), - replace: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - refresh: vi.fn(), - prefetch: vi.fn(), -}; - -export function resetMockRouter(): void { - mockRouter.push.mockClear(); - mockRouter.replace.mockClear(); - mockRouter.back.mockClear(); - mockRouter.forward.mockClear(); - mockRouter.refresh.mockClear(); - mockRouter.prefetch.mockClear(); -} diff --git a/src/views/experience/index.ts b/src/views/experience/index.ts index 1cc5d2c..2c87862 100644 --- a/src/views/experience/index.ts +++ b/src/views/experience/index.ts @@ -1,2 +1 @@ export { ExperienceView } from './ui/ExperienceView'; -export { SimulationResultView } from './ui/SimulationResultView'; diff --git a/src/views/experience/lib/simulationResultStorage.test.ts b/src/views/experience/lib/simulationResultStorage.test.ts deleted file mode 100644 index 29818a3..0000000 --- a/src/views/experience/lib/simulationResultStorage.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { SimulationResult } from '@/features/simulation'; - -import { INITIAL_PROGRAM, type BlockProgram } from '../model/blockProgram'; -import { - clearSimulationResult, - isResultForProgram, - readSimulationResult, - writeSimulationResult, -} from './simulationResultStorage'; - -const CONNECTED_PROGRAM: BlockProgram = { - stack: [...INITIAL_PROGRAM.stack, { id: 'end-0', kind: 'end' }], - floating: [], -}; - -const PASSED_RESULT: SimulationResult = { - passed: true, - normalizedCommandCount: 4, - totalDistanceM: 2, - violations: [], - notes: [], -}; - -afterEach(() => { - vi.restoreAllMocks(); - clearSimulationResult(); -}); - -describe('writeSimulationResult / readSimulationResult — 왕복', () => { - it('쓴 그대로 읽힌다', () => { - expect(writeSimulationResult({ program: CONNECTED_PROGRAM, result: PASSED_RESULT })).toBe(true); - expect(readSimulationResult()).toEqual({ program: CONNECTED_PROGRAM, result: PASSED_RESULT }); - }); - - it('아무것도 저장하지 않았으면 null', () => { - expect(readSimulationResult()).toBeNull(); - }); -}); - -describe('writeSimulationResult — 저장 실패 (inryeok-bot 리뷰: 실패해도 결과 화면으로 이동하면 안 됨)', () => { - it('sessionStorage.setItem 이 던지면 false 를 반환한다 (용량 초과 등)', () => { - // Storage.prototype 은 localStorage 와 공유돼, sessionStorage 호출일 때만 던지고 - // localStorage 호출은 그대로 통과시켜야 다른 저장(자동 저장 등)에 영향이 없다. - const proto = Object.getPrototypeOf(window.sessionStorage) as Storage; - const original = proto.setItem; - vi.spyOn(proto, 'setItem').mockImplementation(function (this: Storage, key, value) { - if (this === window.sessionStorage) throw new DOMException('QuotaExceededError'); - return original.call(this, key, value); - }); - - expect(writeSimulationResult({ program: CONNECTED_PROGRAM, result: PASSED_RESULT })).toBe( - false, - ); - }); -}); - -describe('readSimulationResult — 손상된 저장값은 신뢰하지 않는다 (inryeok-bot 리뷰: 구조 검증 없이 단언)', () => { - it('JSON 구문 자체가 깨졌으면 null', () => { - window.sessionStorage.setItem('poppy.experience.simulationResult', '{ not json'); - expect(readSimulationResult()).toBeNull(); - }); - - it('program 이 유효한 BlockProgram 스냅샷이 아니면 null (필드 누락)', () => { - window.sessionStorage.setItem( - 'poppy.experience.simulationResult', - JSON.stringify({ program: { stack: [], floating: [] }, result: PASSED_RESULT }), - ); - expect(readSimulationResult()).toBeNull(); - }); - - it('result 에 필수 필드가 없으면 null', () => { - window.sessionStorage.setItem( - 'poppy.experience.simulationResult', - JSON.stringify({ program: CONNECTED_PROGRAM, result: { passed: true } }), - ); - expect(readSimulationResult()).toBeNull(); - }); - - it('result.passed 타입이 어긋나면 null', () => { - window.sessionStorage.setItem( - 'poppy.experience.simulationResult', - JSON.stringify({ - program: CONNECTED_PROGRAM, - result: { ...PASSED_RESULT, passed: 'yes' }, - }), - ); - expect(readSimulationResult()).toBeNull(); - }); -}); - -describe('isResultForProgram', () => { - it('저장된 program 과 같으면 true', () => { - expect( - isResultForProgram({ program: CONNECTED_PROGRAM, result: PASSED_RESULT }, CONNECTED_PROGRAM), - ).toBe(true); - }); - - it('저장된 게 없거나 program 이 다르면 false', () => { - expect(isResultForProgram(null, CONNECTED_PROGRAM)).toBe(false); - expect( - isResultForProgram({ program: CONNECTED_PROGRAM, result: PASSED_RESULT }, INITIAL_PROGRAM), - ).toBe(false); - }); -}); diff --git a/src/views/experience/lib/simulationResultStorage.ts b/src/views/experience/lib/simulationResultStorage.ts deleted file mode 100644 index 755f72e..0000000 --- a/src/views/experience/lib/simulationResultStorage.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { SimulationResult } from '@/features/simulation'; - -import { isBlockProgramSnapshot, type BlockProgram } from '../model/blockProgram'; - -// 시뮬레이션 결과 화면(신규 라우트 /experience/simulation)에 넘길 마지막 시뮬레이션 결과. -// ExperienceView 는 컴포넌트가 아니라 페이지 단위로 떠 있어, 라우트를 넘어가면 리액트 state 가 -// 사라진다 — 세션 탭 안에서만 유효하면 되는 값이라 sessionStorage 에 program·result 를 함께 -// 스냅샷으로 남긴다(program 은 이미 있는 localDraft 대신 직접 들고 다닌다 — 시뮬레이트 시점과 -// 결과 화면을 보는 시점 사이에 사용자가 블록을 더 편집했을 수 있어, 그 프로그램과 결과가 -// 어긋나면 안 된다). -const KEY = 'poppy.experience.simulationResult'; - -export type StoredSimulationResult = { - program: BlockProgram; - result: SimulationResult; -}; - -function safeSessionStorage(): Storage | null { - try { - return typeof window !== 'undefined' ? window.sessionStorage : null; - } catch { - return null; - } -} - -/** - * 성공 여부를 반환한다 — 저장이 실패했는데(용량 초과·접근 거부 등) 호출자가 모르고 결과 - * 화면으로 이동하면, 그 화면은 읽을 게 없어 곧장 /experience 로 되돌아가 방금 끝난 - * 시뮬레이션 결과가 통째로 사라진다. 실패하면 호출자가 지금 화면에서 계속 보여줘야 한다. - */ -export function writeSimulationResult(value: StoredSimulationResult): boolean { - try { - const store = safeSessionStorage(); - if (!store) return false; - store.setItem(KEY, JSON.stringify(value)); - return true; - } catch { - return false; - } -} - -function isSimulationResultShape(value: unknown): value is SimulationResult { - if (typeof value !== 'object' || value === null) return false; - const r = value as Record; - return ( - typeof r.passed === 'boolean' && - typeof r.normalizedCommandCount === 'number' && - typeof r.totalDistanceM === 'number' && - Array.isArray(r.violations) && - Array.isArray(r.notes) && - (r.failedAtIndex === undefined || typeof r.failedAtIndex === 'number') - ); -} - -/** 저장된 값이 실제로 program·result 모양을 갖췄는지 확인한다 — JSON 파싱만 되고 필드가 - * 빠지거나 어긋난 값을 그대로 믿으면 결과 화면이 program.stack 등에 접근하다 죽는다. */ -function isStoredSimulationResult(value: unknown): value is StoredSimulationResult { - if (typeof value !== 'object' || value === null) return false; - const v = value as Record; - return isBlockProgramSnapshot(v.program) && isSimulationResultShape(v.result); -} - -export function readSimulationResult(): StoredSimulationResult | null { - const store = safeSessionStorage(); - if (!store) return null; - try { - const raw = store.getItem(KEY); - if (!raw) return null; - const parsed: unknown = JSON.parse(raw); - return isStoredSimulationResult(parsed) ? parsed : null; - } catch { - return null; - } -} - -export function clearSimulationResult(): void { - try { - safeSessionStorage()?.removeItem(KEY); - } catch { - // 무시 - } -} - -/** 저장된 결과가 지금 이 program 을 대상으로 한 게 맞는지 (그 사이 편집됐으면 무효). */ -export function isResultForProgram( - stored: StoredSimulationResult | null, - program: BlockProgram, -): stored is StoredSimulationResult { - return stored != null && JSON.stringify(stored.program) === JSON.stringify(program); -} diff --git a/src/views/experience/model/blockProgram.test.ts b/src/views/experience/model/blockProgram.test.ts index a7db13d..8f2878c 100644 --- a/src/views/experience/model/blockProgram.test.ts +++ b/src/views/experience/model/blockProgram.test.ts @@ -7,13 +7,11 @@ import { draftToProgram, dropOnCanvas, dropOnSlot, - hasBlockKind, isBlockProgramSnapshot, newBlock, serializeProgram, setBlockParam, totalTravelDistance, - unrollExecutionSteps, validateBlockProgram, type BlockNode, type BlockProgram, @@ -247,93 +245,4 @@ describe('totalTravelDistance', () => { it('반복 횟수 × 중첩 이동 거리', () => { expect(totalTravelDistance(INITIAL_PROGRAM)).toBe(2); }); - - it("'앞으로' 이동도 '뒤로' 이동과 같이 더하고, 회전은 더하지 않는다", () => { - const program: BlockProgram = { - stack: [ - node('start', 'start-0'), - node('move', 'move-0', { distanceM: 1 }), - node('moveForward', 'forward-0', { distanceM: 2 }), - node('turnRight', 'turn-0', { degrees: 90 }), - node('end', 'end-0'), - ], - floating: [], - }; - expect(totalTravelDistance(program)).toBe(3); - }); -}); - -describe('newBlock — 새 이동·동작 블록 (기명서 Figma Slide 5 카탈로그)', () => { - it('moveForward 는 move 와 같은 거리 파라미터로 시작한다', () => { - expect(newBlock('moveForward')).toMatchObject({ kind: 'moveForward', distanceM: 1 }); - }); - - it('turnRight·turnLeft 는 허용 최소 각도로 시작한다', () => { - expect(newBlock('turnRight')).toMatchObject({ kind: 'turnRight', degrees: 1 }); - expect(newBlock('turnLeft')).toMatchObject({ kind: 'turnLeft', degrees: 1 }); - }); - - it('정지·동작류(앉기 등)는 파라미터 없이 id·kind만 가진다', () => { - for (const kind of [ - 'stop', - 'sit', - 'standUp', - 'heart', - 'dance', - 'rollOver', - 'attack', - ] as const) { - const block = newBlock(kind); - expect(block.kind).toBe(kind); - expect(Object.keys(block).sort()).toEqual(['id', 'kind']); - } - }); -}); - -describe('hasBlockKind — 시작·종료 중복 생성 방지 (inryeok-bot 리뷰: 팔레트에서 유일 블록을 또 만들면 안 됨)', () => { - it('스택에 있으면 찾는다', () => { - expect(hasBlockKind(connected, 'start')).toBe(true); - expect(hasBlockKind(connected, 'end')).toBe(true); - }); - - it('자유 블록(floating)에 있어도 찾는다', () => { - expect(hasBlockKind(INITIAL_PROGRAM, 'end')).toBe(true); // 종료가 떨어진 자유 블록으로만 있음 - }); - - it('어디에도 없으면 false', () => { - expect(hasBlockKind(INITIAL_PROGRAM, 'wait')).toBe(false); - }); -}); - -describe('unrollExecutionSteps — 시뮬레이션 결과 화면 "실행순서" (repeat 를 실제 실행 횟수만큼 펼침)', () => { - it('repeat 가 아닌 블록은 그대로 지나간다', () => { - const steps = unrollExecutionSteps([node('start', 'start-0'), node('end', 'end-0')]); - expect(steps.map((s) => s.kind)).toEqual(['start', 'end']); - }); - - it('repeat 의 body 를 count 번 펼친다', () => { - const steps = unrollExecutionSteps(INITIAL_PROGRAM.stack); - // start, repeat{move}×2, greet — repeat 자신은 사라지고 body(move)가 count(2)번 나온다 - expect(steps.map((s) => s.kind)).toEqual(['start', 'move', 'move', 'greet']); - }); - - it('펼친 각 단계는 원래 body 블록의 값(파라미터)을 그대로 들고 있다', () => { - const program: BlockProgram = { - stack: [ - node('start', 'start-0'), - node('repeat', 'repeat-0', { - count: 3, - body: [node('move', 'move-0', { distanceM: 2 })], - }), - ], - floating: [], - }; - const steps = unrollExecutionSteps(program.stack); - expect(steps).toHaveLength(4); // start + move×3 - expect(steps.slice(1)).toEqual([ - { id: 'move-0', kind: 'move', distanceM: 2 }, - { id: 'move-0', kind: 'move', distanceM: 2 }, - { id: 'move-0', kind: 'move', distanceM: 2 }, - ]); - }); }); diff --git a/src/views/experience/model/blockProgram.ts b/src/views/experience/model/blockProgram.ts index ea1b596..0a1e2c1 100644 --- a/src/views/experience/model/blockProgram.ts +++ b/src/views/experience/model/blockProgram.ts @@ -18,46 +18,19 @@ import { LIMITS } from '@/features/simulation'; import type { SerializedBlockNode, SerializedBlockProgram } from '@/features/simulation'; import { sumOverBlockTree } from '@/shared/lib/blockTree'; -export type BlockKind = - | 'start' - | 'repeat' - | 'move' - | 'moveForward' - | 'turnRight' - | 'turnLeft' - | 'stop' - | 'greet' - | 'sit' - | 'standUp' - | 'heart' - | 'dance' - | 'rollOver' - | 'attack' - | 'wait' - | 'end'; +export type BlockKind = 'start' | 'repeat' | 'move' | 'greet' | 'wait' | 'end'; // 서버(features/simulation LIMITS)와 같은 값을 그대로 가져다 쓴다 — 입력칸 min·max 가 // 서버 허용 범위와 따로 놀다 어긋나는 걸 막는다. export const REPEAT_RANGE = LIMITS.repeatCount; export const MOVE_RANGE = LIMITS.moveDistance; export const WAIT_RANGE = LIMITS.waitSeconds; -export const TURN_RANGE = LIMITS.turnDegrees; export type BlockNode = | { id: string; kind: 'start' } | { id: string; kind: 'greet' } | { id: string; kind: 'end' } | { id: string; kind: 'move'; distanceM: number } - | { id: string; kind: 'moveForward'; distanceM: number } - | { id: string; kind: 'turnRight'; degrees: number } - | { id: string; kind: 'turnLeft'; degrees: number } - | { id: string; kind: 'stop' } - | { id: string; kind: 'sit' } - | { id: string; kind: 'standUp' } - | { id: string; kind: 'heart' } - | { id: string; kind: 'dance' } - | { id: string; kind: 'rollOver' } - | { id: string; kind: 'attack' } | { id: string; kind: 'wait'; seconds: number } | { id: string; kind: 'repeat'; count: number; body: BlockNode[] }; @@ -90,11 +63,7 @@ function nextId(prefix: string): string { export function newBlock(kind: BlockKind): BlockNode { switch (kind) { case 'move': - case 'moveForward': - return { id: nextId(kind), kind, distanceM: MOVE_RANGE.min }; - case 'turnRight': - case 'turnLeft': - return { id: nextId(kind), kind, degrees: TURN_RANGE.min }; + return { id: nextId('move'), kind, distanceM: MOVE_RANGE.min }; case 'wait': return { id: nextId('wait'), kind, seconds: WAIT_RANGE.min }; case 'repeat': @@ -218,12 +187,7 @@ export function deleteCarried(program: BlockProgram, pick: DragPick): BlockProgr return detachCarried(program, pick); } -type BlockParamPatch = { - count?: number; - distanceM?: number; - seconds?: number; - degrees?: number; -}; +type BlockParamPatch = { count?: number; distanceM?: number; seconds?: number }; function containsId(nodes: BlockNode[], id: string): boolean { return nodes.some( @@ -280,24 +244,7 @@ export function serializeProgram(program: BlockProgram): SerializedBlockProgram // ── 초안(localStorage) 복원 ─────────────────────────────────────────────────── -const BLOCK_KINDS: readonly BlockKind[] = [ - 'start', - 'repeat', - 'move', - 'moveForward', - 'turnRight', - 'turnLeft', - 'stop', - 'greet', - 'sit', - 'standUp', - 'heart', - 'dance', - 'rollOver', - 'attack', - 'wait', - 'end', -]; +const BLOCK_KINDS: readonly BlockKind[] = ['start', 'repeat', 'move', 'greet', 'wait', 'end']; /** 블록 종류별 필수 필드까지 확인한다 — 손상된 스냅샷을 그대로 렌더하면 화면이 죽는다. */ function isBlockNode(value: unknown): value is BlockNode { @@ -308,11 +255,7 @@ function isBlockNode(value: unknown): value is BlockNode { switch (node.kind as BlockKind) { case 'move': - case 'moveForward': return typeof node.distanceM === 'number'; - case 'turnRight': - case 'turnLeft': - return typeof node.degrees === 'number'; case 'wait': return typeof node.seconds === 'number'; case 'repeat': @@ -349,7 +292,7 @@ function countKind(nodes: BlockNode[], kind: BlockKind): number { /** * program(스택 + 자유 블록) 어디든 해당 종류가 하나라도 있는지. - * 팔레트에서 시작·종료처럼 프로그램에 하나만 있어야 하는 블록을 중복 생성하지 못하게 + * 팔레트에서 종료처럼 프로그램에 많아야 하나 있어야 하는 블록을 중복 생성하지 못하게 * 막는 데 쓴다 — 중복되면 자동 저장 스냅샷 검증(isBlockProgramSnapshot)이 다음 복원 때 * 전체 초안을 거부해 작업이 통째로 사라진다. */ @@ -438,25 +381,11 @@ export function validateBlockProgram(program: BlockProgram): BlockError[] { return errors; } -/** 프로그램이 로봇을 움직이는 총 거리 (m). 명세: "제한을 우회하는 중첩·합산 값도 계산". - * 회전(turnRight/turnLeft)은 거리가 아니라 각도라 합산에 포함하지 않는다. */ +/** 프로그램이 로봇을 움직이는 총 거리 (m). 명세: "제한을 우회하는 중첩·합산 값도 계산". */ export function totalTravelDistance(program: BlockProgram): number { return sumOverBlockTree(program.stack, { - valueOf: (node) => (node.kind === 'move' || node.kind === 'moveForward' ? node.distanceM : 0), + valueOf: (node) => (node.kind === 'move' ? node.distanceM : 0), bodyOf, repeatCountOf: (node) => (node.kind === 'repeat' ? node.count : 1), }); } - -/** - * repeat 를 실제 실행 횟수만큼 펼친 실행 순서 (시뮬레이션 결과 화면 "실행순서"). - * 서버(features/simulation safety.ts)의 안전 구역 판정과 같은 펼치기 규칙을 쓴다 — - * 그래야 결과의 failedAtIndex 가 여기서 만든 목록의 몇 번째와 같은 블록을 가리킨다. - */ -export function unrollExecutionSteps(nodes: BlockNode[]): BlockNode[] { - return nodes.flatMap((node) => { - if (node.kind !== 'repeat') return [node]; - const body = unrollExecutionSteps(node.body); - return Array.from({ length: node.count }, () => body).flat(); - }); -} diff --git a/src/views/experience/ui/Block.tsx b/src/views/experience/ui/Block.tsx index bf38364..a3cebef 100644 --- a/src/views/experience/ui/Block.tsx +++ b/src/views/experience/ui/Block.tsx @@ -175,18 +175,8 @@ const OUTLINE_SHAPE: Record = start: { path: HAT_PATH, w: 212, h: 48 }, end: { path: CAP_PATH, w: 212, h: 42 }, move: { path: STATEMENT_PATH, w: 212, h: 48 }, - moveForward: { path: STATEMENT_PATH, w: 212, h: 48 }, - turnRight: { path: STATEMENT_PATH, w: 212, h: 48 }, - turnLeft: { path: STATEMENT_PATH, w: 212, h: 48 }, - stop: { path: STATEMENT_PATH, w: 212, h: 48 }, wait: { path: STATEMENT_PATH, w: 212, h: 48 }, greet: { path: STATEMENT_PATH, w: 212, h: 48 }, - sit: { path: STATEMENT_PATH, w: 212, h: 48 }, - standUp: { path: STATEMENT_PATH, w: 212, h: 48 }, - heart: { path: STATEMENT_PATH, w: 212, h: 48 }, - dance: { path: STATEMENT_PATH, w: 212, h: 48 }, - rollOver: { path: STATEMENT_PATH, w: 212, h: 48 }, - attack: { path: STATEMENT_PATH, w: 212, h: 48 }, repeat: { path: CBLOCK_PATH, w: 238, h: 106 }, }; diff --git a/src/views/experience/ui/BlockPalette.tsx b/src/views/experience/ui/BlockPalette.tsx index 8bbaad5..03682ab 100644 --- a/src/views/experience/ui/BlockPalette.tsx +++ b/src/views/experience/ui/BlockPalette.tsx @@ -1,15 +1,13 @@ 'use client'; -import { useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; - -import { hasBlockKind, newBlock, type BlockKind, type BlockProgram } from '../model/blockProgram'; +import { hasBlockKind, newBlock, type BlockProgram } from '../model/blockProgram'; import { useBlockDrag } from '../lib/useBlockDrag'; import { Block, BlockInput, CBlock } from './Block'; // Figma node 21:520 — Frame 6(31:292, 카테고리 레일 w54, gap 4), Rectangle 3/4(레일·팔레트 패널), // Frame 7(31:295, 블록 목록 x=109 y=245, gap 16). -// Figma Slide 5(34:951) — 카테고리 4개(시작·흐름·이동·동작)의 전체 블록 목록 레퍼런스. // 팔레트 블록을 눌러 캔버스로 끌어다 놓는다 (기명서 "블록 드래그 이동·스냅 연결"). +// 카테고리 레일은 디자인상 '흐름' 고정 (전환은 이 화면 범위 아님). // w-[85px] 같은 정확한 디자인 치수는 임의값으로 둔다. const CATEGORIES = [ @@ -19,47 +17,18 @@ const CATEGORIES = [ { label: '동작', color: 'bg-block-action' }, ] as const; -type CategoryLabel = (typeof CATEGORIES)[number]['label']; - -/** 팔레트에서 끌어다 놓는 블록 하나. 눌러 startDrag 시작 — 값 입력칸은 CBlock/wait 처럼 자리표시만. */ -function PaletteBlockItem({ - ariaLabel, - kind, - onGrab, - children, -}: { - ariaLabel: string; - kind: BlockKind; - onGrab: (node: ReturnType, event: ReactPointerEvent) => void; - children: ReactNode; -}) { - return ( -
  • -
    onGrab(newBlock(kind), event)} - className="inline-block cursor-grab touch-none active:cursor-grabbing" - > - {children} -
    -
  • - ); -} +const SELECTED_CATEGORY = '흐름'; type BlockPaletteProps = { - /** 시작·종료가 이미 있는지 확인용 — 둘 다 프로그램에 하나만 있어야 해서 중복 생성을 막는다. */ + /** 종료가 이미 있는지 확인용 — 쓰레기통으로 지운 뒤 다시 꺼낼 수 있어야 한다(기명서 + * "블록 삭제"). 카테고리 전환은 이 화면 범위 밖이라, 흐름 목록 안에 조건부로 끼워 넣는다. */ program: BlockProgram; }; export function BlockPalette({ program }: BlockPaletteProps) { const { startDrag } = useBlockDrag(); - const [selectedCategory, setSelectedCategory] = useState('흐름'); const endExists = hasBlockKind(program, 'end'); - const grab = (node: ReturnType, event: ReactPointerEvent) => - startDrag({ origin: 'palette', node }, event); - return ( diff --git a/src/views/experience/ui/BlockStack.tsx b/src/views/experience/ui/BlockStack.tsx index 2897542..ef8b4fb 100644 --- a/src/views/experience/ui/BlockStack.tsx +++ b/src/views/experience/ui/BlockStack.tsx @@ -1,12 +1,6 @@ import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'; -import { - MOVE_RANGE, - REPEAT_RANGE, - TURN_RANGE, - WAIT_RANGE, - type BlockNode, -} from '../model/blockProgram'; +import { MOVE_RANGE, REPEAT_RANGE, WAIT_RANGE, type BlockNode } from '../model/blockProgram'; import { Block, BlockInput, CBlock } from './Block'; // 블록 체인(program.stack / floating 그룹)을 그대로 렌더한다. @@ -14,12 +8,7 @@ import { Block, BlockInput, CBlock } from './Block'; // onBlockPointerDown 을 주면 start 를 뺀 블록이 드래그 핸들이 된다 (잡으면 아래 블록이 함께 딸려온다). // 슬롯 측정은 각 블록의 [data-block] 로 한다. -export type BlockParamPatch = { - count?: number; - distanceM?: number; - seconds?: number; - degrees?: number; -}; +export type BlockParamPatch = { count?: number; distanceM?: number; seconds?: number }; type RenderOptions = { onParamChange?: (id: string, patch: BlockParamPatch) => void; @@ -45,20 +34,6 @@ function renderNode(node: BlockNode, options: RenderOptions): ReactNode { ); case 'greet': return 인사하기; - case 'sit': - return 앉기; - case 'standUp': - return 일어서기; - case 'heart': - return 하트; - case 'dance': - return 춤추기; - case 'rollOver': - return 구르기; - case 'attack': - return 공격; - case 'stop': - return 정지; case 'wait': return ( @@ -86,48 +61,6 @@ function renderNode(node: BlockNode, options: RenderOptions): ReactNode { m 이동 ); - case 'moveForward': - return ( - - 앞으로{' '} - ({ distanceM }))} - min={MOVE_RANGE.min} - max={MOVE_RANGE.max} - aria-label="이동 거리 (미터)" - />{' '} - m 이동 - - ); - case 'turnRight': - return ( - - 오른쪽으로{' '} - ({ degrees }))} - min={TURN_RANGE.min} - max={TURN_RANGE.max} - aria-label="회전 각도 (도)" - /> - ° 이동 - - ); - case 'turnLeft': - return ( - - 왼쪽으로{' '} - ({ degrees }))} - min={TURN_RANGE.min} - max={TURN_RANGE.max} - aria-label="회전 각도 (도)" - /> - ° 이동 - - ); case 'repeat': return ( { expect(savedRepeatCounts).not.toContain(3); }); - it('시뮬레이션 하기를 누르면 결과 화면(/experience/simulation)으로 이동한다', async () => { - const user = userEvent.setup(); - renderView(); - - connectEndBlock(); - await user.click(screen.getByRole('button', { name: '시뮬레이션 하기' })); - - await waitFor(() => expect(mockRouter.push).toHaveBeenCalledWith('/experience/simulation'), { - timeout: 3000, - }); - }); - - it('시뮬레이션이 실패해도 결과 화면으로 이동한다 (실패 결과도 그 화면 몫)', async () => { - const user = userEvent.setup(); - renderView(); - - connectEndBlock(); - fireEvent.change(screen.getByRole('spinbutton', { name: '반복 횟수' }), { - target: { value: '4' }, - }); - await user.click(screen.getByRole('button', { name: '시뮬레이션 하기' })); - - await waitFor(() => expect(mockRouter.push).toHaveBeenCalledWith('/experience/simulation'), { - timeout: 3000, - }); - }); - - it('결과 저장이 실패하면(용량 초과 등) 결과 화면으로 이동하지 않고 이 화면에서 계속 보여준다', async () => { - // Storage.prototype 은 localStorage 와 공유돼, sessionStorage 호출일 때만 던지고 - // localStorage 호출(자동 저장)은 그대로 통과시킨다. - const proto = Object.getPrototypeOf(window.sessionStorage) as Storage; - const original = proto.setItem; - vi.spyOn(proto, 'setItem').mockImplementation(function (this: Storage, key, value) { - if (this === window.sessionStorage) throw new DOMException('QuotaExceededError'); - return original.call(this, key, value); - }); - - const user = userEvent.setup(); - renderView(); - - connectEndBlock(); - await user.click(screen.getByRole('button', { name: '시뮬레이션 하기' })); - - // 이동은 안 하지만, 이 화면 자체는 통과 결과를 그대로 보여준다(기존 인라인 흐름) - await waitForRunButton(); - expect(mockRouter.push).not.toHaveBeenCalledWith('/experience/simulation'); - expect(readSimulationResult()).toBeNull(); - }); - - it('결과 화면에서 돌아왔을 때 — 같은 program 의 통과 기록이 남아있으면 로봇 실행하기가 바로 활성화된다', () => { - writeSimulationResult({ - program: CONNECTED_PROGRAM, - result: { - passed: true, - normalizedCommandCount: 4, - totalDistanceM: 2, - violations: [], - notes: [], - }, - }); - seedDraft({ - sessionId: 'sess-restore-pass', - program: serializeProgram(CONNECTED_PROGRAM), - blocks: CONNECTED_PROGRAM, - projectVersion: 1, - dirty: false, - }); - renderView(); - - expect(screen.getByRole('button', { name: '로봇 실행하기' })).not.toHaveTextContent('잠김'); - expect(screen.getByText('2.0 m')).toBeInTheDocument(); - }); - - it('시뮬레이션 하기를 누르면 결과 화면이 읽을 program·result 스냅샷을 남긴다', async () => { - const user = userEvent.setup(); - renderView(); - - connectEndBlock(); - await user.click(screen.getByRole('button', { name: '시뮬레이션 하기' })); - await waitForRunButton(); - - const stored = readSimulationResult(); - expect(stored?.result.passed).toBe(true); - expect(stored?.program.stack.at(-1)?.kind).toBe('end'); - }); - - it('처음으로를 누르면 복원된 통과 기록·저장된 결과도 함께 지워진다', async () => { - const user = userEvent.setup(); - writeSimulationResult({ - program: CONNECTED_PROGRAM, - result: { - passed: true, - normalizedCommandCount: 4, - totalDistanceM: 2, - violations: [], - notes: [], - }, - }); - seedDraft({ - sessionId: 'sess-restore-edit', - program: serializeProgram(CONNECTED_PROGRAM), - blocks: CONNECTED_PROGRAM, - projectVersion: 1, - dirty: false, - }); - renderView(); - expect(screen.getByRole('button', { name: '로봇 실행하기' })).not.toHaveTextContent('잠김'); - - await user.click(screen.getByRole('button', { name: '처음으로' })); - - expect(screen.getByRole('button', { name: /로봇 실행하기/ })).toHaveTextContent('잠김'); - expect(readSimulationResult()).toBeNull(); - }); - - it('결과 화면에 남은 통과 기록이 지금 program 과 다르면(그 사이 편집됨) 무시한다', () => { - writeSimulationResult({ - program: CONNECTED_PROGRAM, - result: { - passed: true, - normalizedCommandCount: 4, - totalDistanceM: 2, - violations: [], - notes: [], - }, - }); - // 실제 복원되는 초안은 다른 프로그램(종료 미연결) — 저장된 결과 대상과 어긋난다 - seedDraft({ - sessionId: 'sess-restore-mismatch', - program: serializeProgram(INITIAL_PROGRAM), - blocks: INITIAL_PROGRAM, - projectVersion: 1, - dirty: false, - }); - renderView(); - - expect(screen.getByRole('button', { name: /로봇 실행하기/ })).toHaveTextContent('잠김'); - }); - it('처음으로를 누르면 블록·통과 기록이 초기화된다', async () => { const user = userEvent.setup(); renderView(); diff --git a/src/views/experience/ui/ExperienceView.tsx b/src/views/experience/ui/ExperienceView.tsx index 778ae46..2cee859 100644 --- a/src/views/experience/ui/ExperienceView.tsx +++ b/src/views/experience/ui/ExperienceView.tsx @@ -1,7 +1,6 @@ 'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { useRouter } from 'next/navigation'; import { cancelExecution as cancelExecutionApi, @@ -11,15 +10,9 @@ import { useRequestExecution, } from '@/features/execution'; import { readLocalDraft, useAutoSaveProject, useSession } from '@/features/session'; -import { useSimulateProgram, type SimulationResult } from '@/features/simulation'; +import { useSimulateProgram } from '@/features/simulation'; import { BlockDragProvider } from '../lib/useBlockDrag'; -import { - clearSimulationResult, - isResultForProgram, - readSimulationResult, - writeSimulationResult, -} from '../lib/simulationResultStorage'; import { INITIAL_PROGRAM, draftToProgram, @@ -61,22 +54,12 @@ function initialProgram(): { program: BlockProgram; restoredDirty: boolean } { const AUTOSAVE_DEBOUNCE_MS = 600; export function ExperienceView() { - const router = useRouter(); const [{ program: startProgram, restoredDirty }] = useState(initialProgram); const [program, setProgram] = useState(startProgram); const [executionId, setExecutionId] = useState(null); // 초기화 후 늦게 도착한 실행 요청 onSuccess 가 오래된 실행 ID 를 되살리지 않게 한다. const runGeneration = useRef(0); - // 시뮬레이션 결과 화면(/experience/simulation)에서 돌아왔을 때 — 그 화면에 남긴 결과가 - // 지금 이 program 을 대상으로 한 게 맞고(그 사이 편집 안 했으면) 통과였으면 이어 받는다. - // ExperienceView 는 라우트를 넘어갔다 오면 새로 마운트되어 simulation mutation 상태가 - // 초기화되므로, 이 값 없이는 성공 직후에도 항상 BlockWorkspace(편집 화면)로 보인다. - const [restoredResult, setRestoredResult] = useState(() => { - const stored = readSimulationResult(); - return isResultForProgram(stored, startProgram) && stored.result.passed ? stored.result : null; - }); - const session = useSession(); const autoSave = useAutoSaveProject(session.data?.sessionId ?? null); const simulation = useSimulateProgram(); @@ -110,7 +93,7 @@ export function ExperienceView() { }, [program, saveProgram]); // ──────────────────────────────────────────────────────────────────────── - const simulationResult = simulation.data ?? restoredResult ?? undefined; + const simulationResult = simulation.data; const simulationPassed = simulationResult?.passed === true; const simulationMessage = simulation.isError ? '시뮬레이션에 실패했어요. 잠시 후 다시 시도해 주세요.' @@ -137,27 +120,11 @@ export function ExperienceView() { const invalidate = () => { simulation.reset(); clearExecution(); - clearSimulationResult(); - setRestoredResult(null); }; const runSimulation = () => { if (simulation.isPending || !blockValid) return; - simulation.mutate( - { program: serializeProgram(program) }, - { - onSuccess: (result) => { - // 결과 화면(신규 라우트)이 이어받을 수 있게 program·result 스냅샷을 남긴다 — - // ExperienceView 는 라우트를 넘어가면 새로 마운트돼 이 mutation 상태를 잃는다. - // 저장이 실패하면(용량 초과 등) 이동하지 않는다 — 그 화면은 읽을 게 없어 곧장 - // 돌아오면서 방금 끝난 결과가 안내 없이 사라진다. 대신 이 화면의 기존 인라인 - // 안내(simulationMessage 등)로 계속 보여준다. - if (writeSimulationResult({ program, result })) { - router.push('/experience/simulation'); - } - }, - }, - ); + simulation.mutate({ program: serializeProgram(program) }); }; const requestRun = () => { diff --git a/src/views/experience/ui/SimulationResultView.test.tsx b/src/views/experience/ui/SimulationResultView.test.tsx deleted file mode 100644 index c784d77..0000000 --- a/src/views/experience/ui/SimulationResultView.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; - -import { mockRouter } from '@/shared/testing/routerMock'; - -import { writeSimulationResult } from '../lib/simulationResultStorage'; -import { INITIAL_PROGRAM, type BlockProgram } from '../model/blockProgram'; -import { SimulationResultView } from './SimulationResultView'; - -// INITIAL_PROGRAM(시작 → 반복(2){뒤로 1m} → 인사하기) + 종료 연결. -// 실행순서로 펼치면: 시작, 뒤로1m, 뒤로1m, 인사하기, 종료 (5단계). -const CONNECTED_PROGRAM: BlockProgram = { - stack: [...INITIAL_PROGRAM.stack, { id: 'end-0', kind: 'end' }], - floating: [], -}; - -describe('SimulationResultView', () => { - it('남겨진 결과가 없으면 /experience 로 돌려보낸다', () => { - render(); - - expect(mockRouter.replace).toHaveBeenCalledWith('/experience'); - }); - - it('실패 결과 — 실패 배지·안내·실행순서 상태·잠긴 실행 버튼을 보여준다', async () => { - const user = userEvent.setup(); - writeSimulationResult({ - program: CONNECTED_PROGRAM, - result: { - passed: false, - normalizedCommandCount: 5, - totalDistanceM: 2, - violations: [{ code: 'exceeds-safe-zone', message: '로봇이 2m 안전 구역을 벗어나요.' }], - failedAtIndex: 3, // 두 번째 '뒤로 1m 이동'(세 번째 단계)에서 멈춤 - notes: [], - }, - }); - - render(); - - expect(screen.getByText('실패')).toBeInTheDocument(); - expect(screen.getByText('조금만 고치면 돼요!')).toBeInTheDocument(); - expect(screen.getByText('로봇이 2m 안전 구역을 벗어나요.')).toBeInTheDocument(); - expect(screen.getByText('3번째 블록에서 멈춤')).toBeInTheDocument(); - - const lockedRun = screen.getByRole('button', { name: /로봇 실행하기/ }); - expect(lockedRun).toHaveTextContent('잠김'); - expect(lockedRun).toHaveAttribute('aria-disabled'); - - // 실행순서: 1·2단계 완료, 3단계 문제 발생, 4·5단계 대기 - const steps = within(screen.getByRole('list', { name: '실행순서' })).getAllByRole('listitem'); - expect(steps).toHaveLength(5); - expect(within(steps[0]).getByText('완료')).toBeInTheDocument(); - expect(within(steps[1]).getByText('완료')).toBeInTheDocument(); - expect(within(steps[2]).getByText('문제 발생')).toBeInTheDocument(); - expect(within(steps[3]).getByText('대기')).toBeInTheDocument(); - expect(within(steps[4]).getByText('대기')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: '블록 고치기' })); - expect(mockRouter.push).toHaveBeenCalledWith('/experience'); - }); - - it('성공 결과 — 성공 배지·안내·모든 단계 완료·활성화된 실행 버튼을 보여준다', async () => { - const user = userEvent.setup(); - writeSimulationResult({ - program: CONNECTED_PROGRAM, - result: { - passed: true, - normalizedCommandCount: 5, - totalDistanceM: 2, - violations: [], - notes: [], - }, - }); - - render(); - - expect(screen.getByText('성공')).toBeInTheDocument(); - expect(screen.getByText('시뮬레이션 통과!')).toBeInTheDocument(); - expect( - screen.getByText('안전 범위 안에서 잘 움직였어요. 이제 실제 로봇으로 실행할 수 있어요.'), - ).toBeInTheDocument(); - expect(screen.getByText('5/5 단계 성공')).toBeInTheDocument(); - - const steps = within(screen.getByRole('list', { name: '실행순서' })).getAllByRole('listitem'); - expect(steps).toHaveLength(5); - for (const step of steps) { - expect(within(step).getByText('완료')).toBeInTheDocument(); - } - - const runButton = screen.getByRole('button', { name: '로봇 실행하기' }); - expect(runButton).not.toHaveAttribute('aria-disabled'); - await user.click(runButton); - expect(mockRouter.push).toHaveBeenCalledWith('/experience'); - }); -}); diff --git a/src/views/experience/ui/SimulationResultView.tsx b/src/views/experience/ui/SimulationResultView.tsx deleted file mode 100644 index 6a3bb34..0000000 --- a/src/views/experience/ui/SimulationResultView.tsx +++ /dev/null @@ -1,257 +0,0 @@ -'use client'; - -import { useEffect, useMemo } from 'react'; -import { useRouter } from 'next/navigation'; - -import { PillButton, PoppyLogo } from '@/shared/ui'; - -import { readSimulationResult } from '../lib/simulationResultStorage'; -import { unrollExecutionSteps, type BlockNode } from '../model/blockProgram'; -import { BlockStack } from './BlockStack'; - -// Figma Slide 6(34:1350, 실패)·Slide 7(47:1733, 성공) — 시뮬레이션 결과 전용 화면. -// 명세 Simulation "시뮬레이션 실행"이 `페이지 필요: YES`로 바뀌어, 워크스페이스 안 텍스트 -// 힌트 대신 이 화면이 결과를 보여준다. '블록 고치기'·'로봇 실행하기' 는 실제 실행 요청을 -// 여기서 하지 않고 /experience 로 돌아간다 — ExperienceView 가 이 화면에서 남긴 결과를 -// 다시 읽어(같은 program 이면) RunReadyWorkspace/BlockWorkspace 중 맞는 쪽을 그대로 이어 보여준다. - -type StepStatus = '완료' | '문제 발생' | '대기'; - -function stepLabel(node: BlockNode): string { - switch (node.kind) { - case 'start': - return '시작'; - case 'end': - return '종료'; - case 'greet': - return '인사하기'; - case 'sit': - return '앉기'; - case 'standUp': - return '일어서기'; - case 'heart': - return '하트'; - case 'dance': - return '춤추기'; - case 'rollOver': - return '구르기'; - case 'attack': - return '공격'; - case 'stop': - return '정지'; - case 'wait': - return `${node.seconds}초 기다리기`; - case 'move': - return `뒤로 ${node.distanceM}m 이동`; - case 'moveForward': - return `앞으로 ${node.distanceM}m 이동`; - case 'turnRight': - return `오른쪽으로 ${node.degrees}° 이동`; - case 'turnLeft': - return `왼쪽으로 ${node.degrees}° 이동`; - case 'repeat': - return `${node.count}번 반복하기`; - } -} - -const STATUS_BADGE: Record = { - 완료: 'bg-success', - '문제 발생': 'bg-danger', - 대기: 'bg-muted', -}; - -const STATUS_TEXT: Record = { - 완료: 'text-muted', - '문제 발생': 'text-danger', - 대기: 'text-muted', -}; - -export function SimulationResultView() { - const router = useRouter(); - const stored = useMemo(() => readSimulationResult(), []); - - // 저장된 결과가 없으면(직접 주소 접근·새로고침 등) 볼 게 없다 — 조립 화면으로 돌려보낸다. - useEffect(() => { - if (!stored) router.replace('/experience'); - }, [stored, router]); - - if (!stored) return null; - const { program, result } = stored; - - const steps = unrollExecutionSteps(program.stack); - const total = steps.length; - const failedIndex = result.failedAtIndex; - - const stepStatus = (i: number): StepStatus => { - if (result.passed) return '완료'; - if (failedIndex == null) return '대기'; - if (i + 1 < failedIndex) return '완료'; - if (i + 1 === failedIndex) return '문제 발생'; - return '대기'; - }; - - const progressRatio = result.passed - ? 1 - : failedIndex != null && total > 0 - ? failedIndex / total - : 0; - const progressLabel = result.passed - ? `${total}/${total} 단계 성공` - : failedIndex != null - ? `${failedIndex}번째 블록에서 멈춤` - : '중단됨'; - - const backToExperience = () => router.push('/experience'); - - return ( -
    -
    - -
    - -
    - {/* 좌: 로봇 미리보기 (Figma 900×500 격자 박스 + 진행상태 + 스탯) */} -
    -
    -

    로봇 미리보기

    - {result.passed ? ( - 성공 - ) : ( - - - ! - - 실패 - - )} -
    - -
    - 2m x 2m 안전 구역 - -
    - -
    -
    -
    - 진행상태 - {progressLabel} -
    - {result.passed ? ( -
    - ) : ( -
    -
    -
    - )} -
    -
    - 자세 - 서있기 -
    -
    - 바라보는 방향 - 정면 -
    -
    - 예상 이동 거리 - {result.totalDistanceM.toFixed(1)} m -
    -
    -
    - - {/* 우: 결과 카드 + 실행순서 + 실행 블록 */} -
    - {result.passed ? ( -
    -
    - - {/* eslint-disable-next-line @next/next/no-img-element */} - - -

    시뮬레이션 통과!

    -
    -

    - 안전 범위 안에서 잘 움직였어요. 이제 실제 로봇으로 실행할 수 있어요. -

    -
    - 블록 고치기 - - 로봇 실행하기 - -
    -
    - ) : ( -
    -
    - - ! - -

    조금만 고치면 돼요!

    -
    -

    {result.violations[0]?.message}

    -
    - - 블록 고치기 - - - 로봇 실행하기• 잠김 - -
    -
    - )} - -
    -
    -

    실행순서

    -
      - {steps.map((step, i) => { - const status = stepStatus(i); - return ( -
    1. - - - {i + 1} - - - {stepLabel(step)} - - - {status} -
    2. - ); - })} -
    -
    -
    -

    실행 블록

    - -
    -
    -
    -
    -
    - ); -} diff --git a/src/views/experience/ui/blockDnd.test.tsx b/src/views/experience/ui/blockDnd.test.tsx index 22776c9..edb40b7 100644 --- a/src/views/experience/ui/blockDnd.test.tsx +++ b/src/views/experience/ui/blockDnd.test.tsx @@ -130,83 +130,21 @@ describe('블록 삭제 — 쓰레기통 드래그 (기명서)', () => { }); }); -describe('카테고리 전환 — 이동·동작 블록 팔레트 (Figma Slide 5 카탈로그)', () => { - it('기본은 흐름 카테고리만 보이고, 다른 카테고리 블록은 없다', () => { +describe('종료 블록 팔레트 복구 (inryeok-bot 리뷰: 쓰레기통으로 지우면 다시 꺼낼 방법이 있어야 함)', () => { + it('종료가 이미 있으면(기본 상태) 팔레트에서 그림만 보이고 꺼낼 수 없다', () => { renderView(); - expect( - screen.queryByRole('button', { name: '앞으로 이동 블록 꺼내기' }), - ).not.toBeInTheDocument(); - }); - - it('이동 카테고리를 누르면 새 이동 블록(뒤로·앞으로·회전·정지)을 꺼낼 수 있다', () => { - renderView(); - fireEvent.click(screen.getByRole('button', { name: '이동' })); - - const forward = screen.getByRole('button', { name: '앞으로 이동 블록 꺼내기' }); - drag(forward, STACK_X, slotCenterY(1)); - expect(stackOrder()).toEqual([ - 'start-0', - expect.stringContaining('moveForward'), - 'repeat-0', - 'greet-0', - ]); - // 카테고리를 전환했으니 흐름 블록은 더 이상 안 보인다 - expect( - screen.queryByRole('button', { name: '초 기다리기 블록 꺼내기' }), - ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '종료 블록 꺼내기' })).not.toBeInTheDocument(); + expect(screen.getAllByText('종료').length).toBeGreaterThan(0); }); - it('동작 카테고리를 누르면 새 동작 블록(춤추기 등)을 꺼낼 수 있다', () => { + it('종료를 쓰레기통으로 지우면 팔레트에서 다시 꺼낼 수 있다', () => { renderView(); - fireEvent.click(screen.getByRole('button', { name: '동작' })); - - const dance = screen.getByRole('button', { name: '춤추기 블록 꺼내기' }); - drag(dance, STACK_X, slotCenterY(1)); - expect(stackOrder()).toEqual([ - 'start-0', - expect.stringContaining('dance'), - 'repeat-0', - 'greet-0', - ]); - }); -}); - -describe('시작 카테고리 — 시작·종료 중복 생성 방지 (inryeok-bot 리뷰)', () => { - it('시작은 그림만 보여주고 팔레트에서 꺼낼 수 없다 (프로그램에 정확히 하나만 있어야 한다)', () => { - renderView(); - fireEvent.click(screen.getByRole('button', { name: '시작' })); - - const list = screen.getByRole('list', { name: '선택된 카테고리 블록' }); - expect( - within(list).queryByRole('button', { name: '시작 블록 꺼내기' }), - ).not.toBeInTheDocument(); - expect(within(list).getByText('시작')).toBeInTheDocument(); - }); - - it('종료가 이미 있으면(기본 상태) 종료도 그림만 보여주고 꺼낼 수 없다', () => { - renderView(); - fireEvent.click(screen.getByRole('button', { name: '시작' })); - - const list = screen.getByRole('list', { name: '선택된 카테고리 블록' }); - expect( - within(list).queryByRole('button', { name: '종료 블록 꺼내기' }), - ).not.toBeInTheDocument(); - expect(within(list).getByText('종료')).toBeInTheDocument(); - }); - - it('종료를 쓰레기통으로 지우면 그 뒤엔 팔레트에서 다시 꺼낼 수 있다', () => { - renderView(); - - // 기본 상태의 떨어진 종료를 쓰레기통에 드롭 — 프로그램에서 완전히 사라진다 drag(blockLi('종료'), TRASH_X, TRASH_Y); expect(within(canvas()).queryByText('종료')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '시작' })); - const list = screen.getByRole('list', { name: '선택된 카테고리 블록' }); - const endButton = within(list).getByRole('button', { name: '종료 블록 꺼내기' }); - + const endButton = screen.getByRole('button', { name: '종료 블록 꺼내기' }); drag(endButton, STACK_X, slotCenterY(3)); expect(stackOrder()).toEqual([ diff --git a/vitest.setup.ts b/vitest.setup.ts index 10a41d8..69ed294 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,16 +1,8 @@ import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; -import { afterAll, afterEach, beforeAll, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll } from 'vitest'; import { server } from '@/shared/api/msw/server'; -import { mockRouter, resetMockRouter } from '@/shared/testing/routerMock'; - -// next/navigation 의 useRouter 는 실제 App Router 트리 없이는 죽는다 — 전역으로 대체한다. -vi.mock('next/navigation', () => ({ - useRouter: () => mockRouter, - usePathname: () => '/experience', - useSearchParams: () => new URLSearchParams(), -})); // jsdom엔 matchMedia가 없다. matchMedia에 의존하는 라이브러리(토스트 등)를 위해 최소 구현을 채운다. if (!window.matchMedia) { @@ -34,10 +26,8 @@ beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { server.resetHandlers(); cleanup(); - resetMockRouter(); try { window.localStorage.clear(); - window.sessionStorage.clear(); } catch { // 무시 }