From 1a7dfe73e4ad0eee4d19a4ad8ed93993244e9691 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 24 May 2026 00:33:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(tarot):=20useTarotShuffle=20hook=20(Fisher?= =?UTF-8?q?-Yates=20+=20reversed=20=ED=94=8C=EB=9E=98=EA=B7=B8)=20(T9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pages/tarot/hooks/useTarotShuffle.js | 25 ++++++++++ src/pages/tarot/hooks/useTarotShuffle.test.js | 47 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/pages/tarot/hooks/useTarotShuffle.js create mode 100644 src/pages/tarot/hooks/useTarotShuffle.test.js diff --git a/src/pages/tarot/hooks/useTarotShuffle.js b/src/pages/tarot/hooks/useTarotShuffle.js new file mode 100644 index 0000000..ff126a6 --- /dev/null +++ b/src/pages/tarot/hooks/useTarotShuffle.js @@ -0,0 +1,25 @@ +import { useCallback, useState } from 'react'; + +export function fisherYates(input) { + const a = [...input]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function buildShuffle(deck, size) { + return fisherYates(deck).slice(0, size).map((c) => ({ + ...c, + reversed: Math.random() < 0.5, + })); +} + +export function useTarotShuffle(deck, size = 16) { + const [slice, setSlice] = useState(() => buildShuffle(deck, size)); + const reshuffle = useCallback(() => { + setSlice(buildShuffle(deck, size)); + }, [deck, size]); + return { slice, reshuffle }; +} diff --git a/src/pages/tarot/hooks/useTarotShuffle.test.js b/src/pages/tarot/hooks/useTarotShuffle.test.js new file mode 100644 index 0000000..8fcbc75 --- /dev/null +++ b/src/pages/tarot/hooks/useTarotShuffle.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTarotShuffle, fisherYates } from './useTarotShuffle'; + +describe('fisherYates', () => { + it('원본 길이를 유지하고 모든 원소를 포함한다', () => { + const src = [1, 2, 3, 4, 5, 6, 7, 8]; + const out = fisherYates(src); + expect(out).toHaveLength(8); + expect([...out].sort()).toEqual(src); + }); + + it('새 배열을 반환한다 (mutation 없음)', () => { + const src = [1, 2, 3]; + const out = fisherYates(src); + expect(src).toEqual([1, 2, 3]); + expect(out).not.toBe(src); + }); +}); + +describe('useTarotShuffle', () => { + it('16장 슬라이스를 반환한다', () => { + const deck = Array.from({ length: 78 }, (_, i) => ({ id: i, slug: `c${i}` })); + const { result } = renderHook(() => useTarotShuffle(deck, 16)); + expect(result.current.slice).toHaveLength(16); + }); + + it('reshuffle 시 새 슬라이스를 만든다 (대부분 다름)', () => { + const deck = Array.from({ length: 78 }, (_, i) => ({ id: i, slug: `c${i}` })); + const { result } = renderHook(() => useTarotShuffle(deck, 16)); + const first = result.current.slice.map((c) => c.id); + act(() => result.current.reshuffle()); + const second = result.current.slice.map((c) => c.id); + // 무작위지만 16장 모두 같을 확률은 사실상 0 + const overlap = first.filter((id) => second.includes(id)).length; + expect(overlap).toBeLessThan(16); + }); + + it('reversed 플래그를 카드에 부여한다', () => { + const deck = Array.from({ length: 78 }, (_, i) => ({ id: i, slug: `c${i}` })); + const { result } = renderHook(() => useTarotShuffle(deck, 16)); + for (const card of result.current.slice) { + expect(card).toHaveProperty('reversed'); + expect(typeof card.reversed).toBe('boolean'); + } + }); +});