유틸리티 함수 단위 테스트 추가 (vitest)

- package.json: vitest 추가 + test/test:watch 스크립트
- utils/__tests__/formatting.test.js (7 tests)
- features/symbol/__tests__/utils.test.js (8 tests)
- features/liberation/__tests__/utils.test.js (18 tests)
- features/boss-crystal/pc/admin/__tests__/constants.test.js (6 tests)

총 39개 테스트 통과 (716ms)
- formatMeso / formatMesoKorean 경계 조건
- computeCompletion (심볼 완료 시뮬레이션)
- bossEarn, calcWeekPoints, calcDoneEarn, calcMonthlyEarn
- getSchedulerWeekRange (1주차/2주차, 목요일 시작 등)
- computeCompletionDate (단순 계산/주차별 계산 모드)
- 보스 결정 난이도 정의 및 스타일 헬퍼

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-19 12:41:48 +09:00
parent 1646617069
commit 0dd81b56e5
6 changed files with 1875 additions and 5 deletions

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,9 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -34,6 +36,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"vite": "^8.0.4"
"vite": "^8.0.4",
"vitest": "^3.2.4"
}
}

View file

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { DIFFICULTIES, getDifficultyBadgeStyle, formatMeso, getDifficultyImageUrl } from '../constants'
describe('DIFFICULTIES', () => {
it('5개 난이도 (easy, normal, hard, chaos, extreme)', () => {
expect(DIFFICULTIES.map((d) => d.key)).toEqual(['easy', 'normal', 'hard', 'chaos', 'extreme'])
})
it('모든 항목에 key/label/initial/colors 존재', () => {
DIFFICULTIES.forEach((d) => {
expect(d.key).toBeTruthy()
expect(d.label).toBeTruthy()
expect(d.initial).toBeTruthy()
expect(d.colors).toHaveProperty('bg')
expect(d.colors).toHaveProperty('border')
expect(d.colors).toHaveProperty('text')
})
})
})
describe('getDifficultyBadgeStyle', () => {
it('난이도 객체를 CSS 스타일로 변환', () => {
const s = getDifficultyBadgeStyle('easy')
expect(s.backgroundColor).toBe('#999999')
expect(s.borderColor).toBe('#999999')
expect(s.color).toBe('#ffffff')
})
it('없는 key는 빈 객체', () => {
expect(getDifficultyBadgeStyle('invalid')).toEqual({})
})
})
describe('formatMeso (re-export from utils)', () => {
it('utils/formatting의 formatMeso와 동일 동작', () => {
expect(formatMeso(0)).toBe('0')
expect(formatMeso(100_010_000)).toBe('1억 1만')
})
})
describe('getDifficultyImageUrl', () => {
it('S3 경로 규칙대로 URL 반환', () => {
expect(getDifficultyImageUrl('easy'))
.toBe('https://s3.caadiq.co.kr/maplestory/crystal/difficulty/easy.webp')
expect(getDifficultyImageUrl('chaos'))
.toBe('https://s3.caadiq.co.kr/maplestory/crystal/difficulty/chaos.webp')
})
})

View file

@ -0,0 +1,227 @@
import { describe, it, expect } from 'vitest'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import {
bossEarn,
calcWeekPoints,
calcDoneEarn,
calcMonthlyEarn,
getSchedulerWeekRange,
computeCompletionDate,
} from '../utils'
import { calcPoints, WEEKLY_BOSSES } from '../data'
import { makeEmptyWeekly } from '../store'
dayjs.extend(utc)
dayjs.extend(timezone)
describe('calcPoints', () => {
it('파티원 수로 나누고 버림', () => {
expect(calcPoints(100, 1)).toBe(100)
expect(calcPoints(100, 3)).toBe(33)
expect(calcPoints(50, 2)).toBe(25)
expect(calcPoints(7, 2)).toBe(3) // Math.floor(3.5) = 3
})
})
describe('bossEarn', () => {
const suu = WEEKLY_BOSSES.find((b) => b.key === 'lotus') // 스우
it('선택 없음이면 0', () => {
expect(bossEarn(suu, null)).toBe(0)
expect(bossEarn(suu, undefined)).toBe(0)
})
it('존재하지 않는 난이도면 0', () => {
expect(bossEarn(suu, { difficulty: 'invalid', party: 1 })).toBe(0)
})
it('난이도별 점수를 파티 수로 분배', () => {
// 스우 하드 = 50점
expect(bossEarn(suu, { difficulty: 'hard', party: 1 })).toBe(50)
expect(bossEarn(suu, { difficulty: 'hard', party: 2 })).toBe(25)
expect(bossEarn(suu, { difficulty: 'hard', party: 3 })).toBe(16) // floor(50/3)
})
})
describe('calcWeekPoints / calcDoneEarn', () => {
it('빈 주간 설정은 0', () => {
const empty = makeEmptyWeekly()
expect(calcWeekPoints(empty)).toBe(0)
expect(calcDoneEarn(empty)).toBe(0)
})
it('선택된 보스의 점수 합산', () => {
const cfg = makeEmptyWeekly()
cfg.bosses.lotus = { difficulty: 'hard', party: 1, done: false } // 50
cfg.bosses.damien = { difficulty: 'normal', party: 1, done: false } // 10
expect(calcWeekPoints(cfg)).toBe(60)
expect(calcDoneEarn(cfg)).toBe(0) // 완료 없음
})
it('done=true인 것만 calcDoneEarn 합산', () => {
const cfg = makeEmptyWeekly()
cfg.bosses.lotus = { difficulty: 'hard', party: 1, done: true }
cfg.bosses.damien = { difficulty: 'normal', party: 1, done: false }
expect(calcWeekPoints(cfg)).toBe(60)
expect(calcDoneEarn(cfg)).toBe(50)
})
})
describe('calcMonthlyEarn', () => {
it('검은 마법사 난이도별 점수', () => {
const cfg = makeEmptyWeekly()
expect(calcMonthlyEarn(cfg)).toBe(0) // 기본은 none
cfg.blackMage = { difficulty: 'hard', party: 1, done: false }
expect(calcMonthlyEarn(cfg)).toBe(600)
cfg.blackMage = { difficulty: 'hard', party: 2, done: false }
expect(calcMonthlyEarn(cfg)).toBe(300)
})
})
describe('getSchedulerWeekRange', () => {
it('1주차는 시작일부터 다음 목요일 전날까지', () => {
// 2026-04-19 일요일 시작 → 다음 목요일 2026-04-23
const r = getSchedulerWeekRange('2026-04-19T00:00:00+09:00', 1)
expect(r.start.format('YYYY-MM-DD')).toBe('2026-04-19')
expect(r.end.format('YYYY-MM-DD')).toBe('2026-04-22') // 목요일 전날 (수요일)
})
it('2주차는 다음 목요일부터 7일', () => {
const r = getSchedulerWeekRange('2026-04-19T00:00:00+09:00', 2)
expect(r.start.format('YYYY-MM-DD')).toBe('2026-04-23')
expect(r.end.format('YYYY-MM-DD')).toBe('2026-04-29')
})
it('목요일에 시작하면 그 목요일이 1주차', () => {
// 2026-04-23 목요일 시작
const r1 = getSchedulerWeekRange('2026-04-23T00:00:00+09:00', 1)
expect(r1.start.format('YYYY-MM-DD')).toBe('2026-04-23')
// 1주차 end는 다음 목요일(4/30) 전날 = 4/29
expect(r1.end.format('YYYY-MM-DD')).toBe('2026-04-29')
})
})
describe('computeCompletionDate (단순 계산)', () => {
const baseParams = {
calcMode: 'simple',
alreadyDone: false,
monthlyDoneThisMonth: false,
}
it('alreadyDone이면 오늘 반환', () => {
const r = computeCompletionDate({
...baseParams,
alreadyDone: true,
state: { startDate: '2026-04-19T00:00:00+09:00' },
remaining: 0,
weeklyEarn: 0,
doneEarn: 0,
monthlyEarn: 0,
})
expect(r).toBeInstanceOf(Date)
})
it('remaining=0이면 시작일 반환', () => {
const r = computeCompletionDate({
...baseParams,
state: { startDate: '2026-04-19T00:00:00+09:00' },
remaining: 0,
weeklyEarn: 100,
doneEarn: 0,
monthlyEarn: 0,
})
expect(r).toBeInstanceOf(Date)
expect(dayjs(r).tz('Asia/Seoul').format('YYYY-MM-DD')).toBe('2026-04-19')
})
it('weeklyEarn=0, monthlyEarn=0이면 null', () => {
const r = computeCompletionDate({
...baseParams,
state: { startDate: '2026-04-19T00:00:00+09:00' },
remaining: 1000,
weeklyEarn: 0,
doneEarn: 0,
monthlyEarn: 0,
})
expect(r).toBeNull()
})
it('주 100점 · 6500점 필요 → 약 65주 후 완료', () => {
const r = computeCompletionDate({
...baseParams,
state: { startDate: '2026-04-19T00:00:00+09:00' }, // 일요일
remaining: 6500,
weeklyEarn: 100,
doneEarn: 0,
monthlyEarn: 0,
})
expect(r).toBeInstanceOf(Date)
// 시작 2026-04-19 + 65주 = 2027-07-22 전후 (약 1년 3개월)
const days = dayjs(r).diff(dayjs('2026-04-19T00:00:00+09:00'), 'day')
expect(days).toBeGreaterThan(400)
expect(days).toBeLessThan(500)
})
it('월간 검은 마법사도 반영', () => {
// 주간 0, 월간 600, remaining 1200 → 2회 검마 필요 → 2개월 후
const r = computeCompletionDate({
...baseParams,
state: { startDate: '2026-04-19T00:00:00+09:00' },
remaining: 1200,
weeklyEarn: 0,
doneEarn: 0,
monthlyEarn: 600,
monthlyDoneThisMonth: false,
})
expect(r).toBeInstanceOf(Date)
// 시작 당일 1회 + 다음달 1회 = 2개월 후
expect(dayjs(r).tz('Asia/Seoul').format('YYYY-MM-DD')).toBe('2026-05-01')
})
})
describe('computeCompletionDate (주차별 계산)', () => {
it('schedulerWeeks가 비어있으면 null', () => {
const r = computeCompletionDate({
calcMode: 'weekly',
state: { startDate: '2026-04-19T00:00:00+09:00', schedulerWeeks: [] },
alreadyDone: false,
remaining: 1000,
weeklyEarn: 0,
doneEarn: 0,
monthlyEarn: 0,
monthlyDoneThisMonth: false,
})
expect(r).toBeNull()
})
it('1주차 + 2주차 설정이 서로 다르게 적립', () => {
const week1 = makeEmptyWeekly()
week1.bosses.lotus = { difficulty: 'hard', party: 1, done: false } // 50
const week2 = makeEmptyWeekly()
week2.bosses.lotus = { difficulty: 'hard', party: 1, done: false }
week2.bosses.damien = { difficulty: 'hard', party: 1, done: false } // +50 = 100
const r = computeCompletionDate({
calcMode: 'weekly',
state: {
startDate: '2026-04-19T00:00:00+09:00',
schedulerWeeks: [
{ id: 1, config: week1 },
{ id: 2, config: week2 },
],
},
alreadyDone: false,
remaining: 6500,
weeklyEarn: 0,
doneEarn: 0,
monthlyEarn: 0,
monthlyDoneThisMonth: false,
})
// 1주차 50 + 2주차+ 매주 100 → 6500 도달까지 대략 65주
expect(r).toBeInstanceOf(Date)
})
})

View file

@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import { formatKoreanDate, computeCompletion, TYPE_ORDER } from '../utils'
describe('TYPE_ORDER', () => {
it('아케인 → 어센틱 → 그랜드 어센틱 순서', () => {
expect(TYPE_ORDER).toEqual(['아케인', '어센틱', '그랜드 어센틱'])
})
})
describe('formatKoreanDate', () => {
it('YYYY년 MM월 DD일 (요일) 형식', () => {
const d = new Date('2026-04-19T00:00:00+09:00')
const s = formatKoreanDate(d)
expect(s).toMatch(/^2026년 04월 19일 \([일월화수목금토]\)$/)
})
it('월/일 2자리 zero-padding', () => {
expect(formatKoreanDate(new Date('2026-01-05T00:00:00+09:00')))
.toMatch(/^2026년 01월 05일 /)
})
})
describe('computeCompletion', () => {
it('need가 0 (extra로 커버)이면 days 0', () => {
const r = computeCompletion({
remainingSymbols: 100,
daily: 1,
weeklyPerWeek: 0,
extra: 200,
dailyDone: false,
})
expect(r.days).toBe(0)
expect(r.date).toBeInstanceOf(Date)
})
it('daily/weekly 모두 0이면 불가능', () => {
const r = computeCompletion({
remainingSymbols: 100,
daily: 0,
weeklyPerWeek: 0,
extra: 0,
dailyDone: false,
})
expect(r.days).toBeNull()
expect(r.date).toBeNull()
})
it('일퀘 하루 1개 · 100개 필요 → 100일 후 완료', () => {
const r = computeCompletion({
remainingSymbols: 100,
daily: 1,
weeklyPerWeek: 0,
extra: 0,
dailyDone: false,
})
// dailyDone=false라 오늘(day 0)도 적립 → 1/day 누적 100일
expect(r.days).toBe(99) // day 0: 1, day 1: 2, ..., day 99: 100 ≥ 100
})
it('dailyDone이면 오늘은 적립 안 됨 → 하루 더 걸림', () => {
const r1 = computeCompletion({
remainingSymbols: 10, daily: 1, weeklyPerWeek: 0, extra: 0, dailyDone: false,
})
const r2 = computeCompletion({
remainingSymbols: 10, daily: 1, weeklyPerWeek: 0, extra: 0, dailyDone: true,
})
expect(r2.days).toBe(r1.days + 1)
})
it('extra가 remaining을 전부 덮으면 즉시 완료', () => {
const r = computeCompletion({
remainingSymbols: 50,
daily: 5,
weeklyPerWeek: 10,
extra: 100,
dailyDone: false,
})
expect(r.days).toBe(0)
})
})

View file

@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { formatMeso, formatMesoKorean } from '../formatting'
describe('formatMeso', () => {
it('0 이하는 "0" 반환', () => {
expect(formatMeso(0)).toBe('0')
expect(formatMeso(-100)).toBe('0')
expect(formatMeso(null)).toBe('0')
expect(formatMeso(undefined)).toBe('0')
})
it('1만 미만은 그대로 locale 표기', () => {
expect(formatMeso(500)).toBe('500')
expect(formatMeso(9999)).toBe('9,999')
})
it('만 단위만', () => {
expect(formatMeso(10000)).toBe('1만')
expect(formatMeso(12345)).toBe('1만')
expect(formatMeso(99_990_000)).toBe('9,999만')
})
it('억 단위만', () => {
expect(formatMeso(100_000_000)).toBe('1억')
expect(formatMeso(500_000_000)).toBe('5억')
})
it('억 + 만 조합', () => {
expect(formatMeso(100_010_000)).toBe('1억 1만')
expect(formatMeso(123_456_789)).toBe('1억 2,345만')
expect(formatMeso(2_576_000_000)).toBe('25억 7,600만')
})
it('formatMesoKorean은 formatMeso와 동일', () => {
expect(formatMesoKorean(100_010_000)).toBe(formatMeso(100_010_000))
expect(formatMesoKorean(0)).toBe('0')
})
it('문자열 입력도 처리', () => {
expect(formatMeso('12345')).toBe('1만')
expect(formatMeso('100000000')).toBe('1억')
})
})