유틸리티 함수 단위 테스트 추가 (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:
parent
1646617069
commit
0dd81b56e5
6 changed files with 1875 additions and 5 deletions
1475
frontend/package-lock.json
generated
1475
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
227
frontend/src/features/liberation/__tests__/utils.test.js
Normal file
227
frontend/src/features/liberation/__tests__/utils.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
80
frontend/src/features/symbol/__tests__/utils.test.js
Normal file
80
frontend/src/features/symbol/__tests__/utils.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
43
frontend/src/utils/__tests__/formatting.test.js
Normal file
43
frontend/src/utils/__tests__/formatting.test.js
Normal 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억')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue