폼 제출 시 FormData 테스트

test('폼 제출 시 formData 확인', () => {
  const handleSubmit = jest.fn((e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.target as HTMLFormElement)
    const data = Object.fromEntries(formData.entries())
    expect(data).toEqual({ username: 'testuser', password: 'password' })
  })

  render(<LoginForm onSubmit={handleSubmit} />)

  fireEvent.change(screen.getByPlaceholderText('아이디'), {
    target: { value: 'testuser' },
  })
  fireEvent.change(screen.getByPlaceholderText('비밀번호'), {
    target: { value: 'password' },
  })
  fireEvent.click(screen.getByRole('button', { name: '로그인' }))

  expect(handleSubmit).toHaveBeenCalled()
})
#326

거리 만km 포맷팅 — toFixed(1) 은 반올림하고 0 까지 채운 문자열을 준다(100000 → 10.0만km)1

function formatToManKm(distance: number): string {
  const manKm = (distance / 10000).toFixed(1)

  return `${manKm}만km`
}

formatToManKm(104335) // "10.4만km"

Footnotes

  1. Number.prototype.toFixed() - MDN: “The number is rounded if necessary, and the fractional part is padded with zeros if necessary so that it has the specified length.” ↩

#325

ARIA 속성 적용 패턴 — aria-labelledby 는 공백으로 구분한 id 목록을 받아 그 순서대로 텍스트를 이어 붙여 이름을 만든다. aria-label 보다 우선한다1

// region + labelledby
<div role="region" aria-labelledby={titleId}>
  <h2 id={titleId}>제목</h2>
</div>

// list + listitem
<div role="list" aria-labelledby="list-title">
  <div role="listitem" aria-labelledby={`${label}-label ${label}-value`}>
    <span id={`${label}-label`}>{label}</span>
    <span id={`${label}-value`}>{value}</span>
  </div>
</div>
  • aria-labelledby - 연결된 요소의 텍스트를 레이블로
  • aria-describedby - 추가 설명이 필요할 때
  • 중복 제거: aria-label과 화면 텍스트 중복 시 aria-labelledby 사용

Footnotes

  1. aria-labelledby - MDN: “the content from each referenced element is combined in the order that they are referenced in the aria-labelledby value.” · “If an element has both attributes set, aria-labelledby will be used.” ↩

#324

전화번호 포맷팅 — replace 는 매치된 부분만 바꾸고 나머지는 뒤에 그대로 남긴다. 그래서 그룹 자릿수 합이 길이와 같아야 한다1

function formatPhoneNumber(phoneNumber) {
  const cleaned = phoneNumber.replace(/\D/g, '')

  if (cleaned.length === 11) {
    return cleaned.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3')
  } else if (cleaned.length === 10 && cleaned.startsWith('02')) {
    return cleaned.replace(/(\d{2})(\d{4})(\d{4})/, '$1-$2-$3')
  } else if (cleaned.length === 10) {
    return cleaned.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3')
  } else if (cleaned.length === 9 && cleaned.startsWith('02')) {
    return cleaned.replace(/(\d{2})(\d{3})(\d{4})/, '$1-$2-$3')
  }
  return 'Invalid phone number'
}

formatPhoneNumber('01012341234') // 010-1234-1234
formatPhoneNumber('021234567') // 02-123-4567
formatPhoneNumber('0212345678') // 02-1234-5678

Footnotes

  1. String.prototype.replace() - MDN: “returns a new string with one, some, or all matches of a pattern replaced by a replacement.” ↩

#323

Map으로 localStorage 래핑 — JSON.stringify(map) 은 {} 라서 [...map](엔트리 배열)으로 저장하고 new Map(entries) 로 되살린다1

class LocalStorageMap {
  constructor(storageKey) {
    this.storageKey = storageKey
    this.map = this.loadFromStorage()
  }

  loadFromStorage() {
    const data = localStorage.getItem(this.storageKey)
    return data ? new Map(JSON.parse(data)) : new Map()
  }

  saveToStorage() {
    localStorage.setItem(this.storageKey, JSON.stringify([...this.map]))
  }

  set(key, value) {
    this.map.set(key, value)
    this.saveToStorage()
  }

  get(key) {
    return this.map.get(key)
  }

  delete(key) {
    const result = this.map.delete(key)
    this.saveToStorage()
    return result
  }

  clear() {
    this.map.clear()
    localStorage.removeItem(this.storageKey)
  }
}
  • localStorage는 문자열만 저장 → JSON 직렬화 필수
  • 출처당 5MiB — 넘으면 setItem 이 QuotaExceededError 를 던진다2

Footnotes

  1. JSON.stringify() - MDN: “Only enumerable own properties are visited. This means Map, Set, etc. will become “{}”.“ ↩

  2. Storage quotas and eviction criteria - MDN: “Browsers can store up to 5 MiB of local storage, and 5 MiB of session storage per origin.” · “Once this limit is reached, browsers throw a QuotaExceededError exception” ↩

#322
const color =
  (isUnchecked && 'palette.label.alternative') ||
  (isChecked && 'palette.primary.normal') ||
  'palette.label.normal'

중첩 삼항/논리 연산자는 간결하지만 상태가 늘어나면 유지보수 어려움.

핵심: status 모델을 어떻게 정의하고 참조할 것인가?

class StatusManager {
  static Status = {
    UNCHECKED: 'UNCHECKED',
    CHECKED: 'CHECKED',
    DEFAULT: 'DEFAULT',
  } as const

  static getColor(status: keyof typeof this.Status) {
    switch (status) {
      case this.Status.UNCHECKED:
        return 'palette.label.alternative'
      case this.Status.CHECKED:
        return 'palette.primary.normal'
      default:
        return 'palette.label.normal'
    }
  }
}

const color = StatusManager.getColor(StatusManager.Status.CHECKED)

#321

변수의 prefix는 역할과 의미를 명확히 하기 위해 사용된다.

/**
 * 초기값을 나타내며, 상태나 값이 처음 설정될 때 사용된다.
 * 적합한 상황: 프로그램이 시작되거나 객체가 처음 생성될 때의 값을 정의한다.
 */
const initialCount = 0 // 카운터의 초기값
const initialState = { loggedIn: false, user: null } // 초기 상태
const initialPosition = { x: 0, y: 0 } // 초기 좌표

/**
 * 기본값으로 널리 사용되는 표준 값이다.
 * 적합한 상황: 값이 없을 경우 사용할 기본값을 정의한다.
 */
const defaultTheme = 'light' // 기본 테마
const defaultUser = { name: 'Guest', role: 'viewer' } // 기본 사용자
const defaultPageSize = 20 // 페이지당 기본 항목 수

/**
 * 기준값 또는 다른 값의 참조점이 되는 값이다.
 * 적합한 상황: 값을 계산하거나 파생할 때 기준이 되는 값을 정의한다.
 */
const baseSalary = 3000 // 기준 급여
const baseUrl = 'https://api.example.com' // API의 기준 URL
const baseColor = '#FFFFFF' // 기준 색상

/**
 * 변경되기 전 원래 상태 또는 초기 상태를 강조한다.
 * 적합한 상황: 데이터를 변경하기 전에 원래 값을 유지해야 할 때 사용한다.
 */
const originalText = 'Hello World' // 변경 전 텍스트
const originalSettings = { theme: 'dark', notifications: true } // 원래 설정값
const originalImage = image.clone() // 원본 이미지 복사
  • initial* vs default* - initial은 초기 상태, 재설정 가능성 적음. default는 기본값, 교체 가능.
  • base* vs original* - base는 기준값(비교/계산용). original은 원래 상태 보존/복구용.
#320
💡

이건 그냥 상태 머신 아닌가? 그렇다! … “불리언 수프”에 기대는 컴포넌트도 마찬가지다. 선택은 상태 머신을 만드느냐 마느냐가 아니라, 암시적으로 만드느냐 명시적으로 만드느냐다.

stateDiagram-v2
  [*] --> Mounting

  Mounting --> AwaitingEmailInput
  Mounting --> AwaitingCodeInput

  AwaitingEmailInput --> SubmittingEmail

  SubmittingEmail --> AwaitingCodeInput
  SubmittingEmail --> AwaitingEmailInput

  AwaitingCodeInput --> SubmittingCode

  SubmittingCode --> Success
  SubmittingCode --> AwaitingCodeInput

  Success --> [*]

#319
💡

Blob URL 방식은 Firefox 58+에서 CSP 제한이 있는 페이지에서도 스크립트를 주입할 수 있는 방법입니다. 이 방식을 사용할 때, 비동기성 때문에 발생할 수 있는 문제를 방지하려면 초기화 코드가 준비된 후 스크립트를 전달해야 합니다.

src 로 넣은 스크립트는 내용을 가져온 뒤에야 돈다(비동기).1

const b = new Blob([script], { type: 'text/javascript' })
const u = URL.createObjectURL(b)
const s = document.createElement('script')
s.src = u
document.body.appendChild(s)
document.body.removeChild(s)
URL.revokeObjectURL(u)

Footnotes

  1. Inject scripts with Blob URLs: “Because the textContent way is synchronous while the src way is asynchronous.” ↩

#318

vitest에서 사용자 지정 assertion을 추가하여 Zod 스키마와 Response 객체를 비교하기

// vitest 5.0 기준
import { expect } from 'vitest'
import type { MatcherResult } from 'vitest'
import type { ZodTypeAny } from 'zod'

expect.extend({
  /**
   * @param received 테스트할 Response 객체
   * @param schema 검증할 Zod 스키마
   */
  async toMatchSchema(received: Response, schema: ZodTypeAny) {
    const response = await received.json()
    const result = await schema.safeParseAsync(response)

    return {
      message: () => '',
      pass: result.success,
    } satisfies MatcherResult
  },
})

vitest.d.ts 에서 vitest 모듈의 Matchers 인터페이스를 확장한다. 비동기 매처라 반환 타입은 R 이 아니라 Promise<void>1.

import 'vitest'
import type { ZodTypeAny } from 'zod'

declare module 'vitest' {
  interface Matchers<R, T> {
    toMatchSchema: (schema: ZodTypeAny) => Promise<void>
  }
}

todoResponse의 응답 데이터가 todoSchema에 정의된 Zod 스키마와 일치하는지 확인한다. 매처가 async라 테스트에서 await 한다1.

test('todo', async () => {
  expect(todoResponse.ok).toBeTruthy()
  await expect(todoResponse).toMatchSchema(todoSchema)
})

Footnotes

  1. Extending Matchers | Guide | Vitest: “If a matcher implementation is asynchronous, declare its return type as Promise<void> instead of R and don’t forget to await it in the test” ↩ ↩2

#317
33 중 15페이지