[Vitest] Fix: ArrayBuffer와 비트 연산자를 이용한 NaN 부호판단 및 올바른 파싱 지원

pengooseDev·2024년 5월 26일
0
post-thumbnail

[#5744] test.each does not print -0 and -NaN correctly


1. 문제 상황

각각 [-0], [-NaN]가 주어졌을 때, 예상과 다르게 포매팅이 진행된다는 것이다.

예상

 ✓ test/basic.test.ts (2)
   ✓ -0 // ⛳
   ✓ -NaN // ⛳

실제 결과

 ✓ test/basic.test.ts (2)0 // ⛳
   ✓ NaN // ⛳

2. 원인 파악

다음은 vitest에 추상화되어있는 test suite의 description(describe, test, it)의 문자열 포매팅 함수(format)의 일부이다.

case '%f': return Number.parseFloat(String(args[i++])).toString()

위 코드는 아래의 경우를 제대로 처리하지 못한다.

String(-0)
// '0'

String(-NaN)
// 'NaN'

3. 트러블슈팅(-NaN)

분기처리를 통해 edgeCase를 처리하였다.
이 과정에서 -0을 처리하는 것은 문제가 없었지만 -NaN을 처리하는 것은 조금 난이도가 있었다.
이유는 아래와 같다.

NaN;
// > NaN

-NaN;
// > NaN // ⛳ -NaN은 초기화 시점에서 -NaN이 아니라 NaN으로 평가된다.

NaN === NaN;
// false

Object.is(NaN, -NaN); // ⛳
// true

JS가 -NaN을 추상화 하는 과정에서 위 사이드 이펙트를 고려하지 못한 것 같았다.
따라서, 정상적인 negative 값인지 판단하는 것이 불가능하다.

String(-NaN);
// 'NaN'

4. 해결 아이디어 발견: (sign bit와 IEEE 표준)

검색하며 문제 해결에 대한 아이디어를 찾아보았다.
IEEE와 관련된 웹사이트를 돌아다니다 NaN에 대한 규약에서 아이디어를 얻었고, JS에서 sign bit에 접근하여 이에 대한 값(부호)를 확인할 수 있다면 해결할 수 있는 문제였다.

> IEEE
> FLODOC


5. 해결

값을 64비트 부동 소수점 배열로 변경한 뒤, 값 할당 이후 buffer값을 32비트 정수 배열로 바꿔 signIndex(최상위 인덱스. 즉, 부호)에 접근하여 1(음수)인지 확인하면 -NaN의 부호를 확인할 수 있는지 확인할 수 있었다.

/**
 * Checks if a given number is NaN and if it is a negative NaN.
 *
 * @param {number} val - The number to check.
 * @returns {boolean} - True if the number is a negative NaN, false otherwise.
 */
export function isNegativeNaN(val: number): boolean {
  // NaN이 아니면 ealry return.
  if (!Number.isNaN(val)) return false;

  // 64비트배열 생성
  const f64 = new Float64Array(1);
  // Float64Array [0, buffer: ArrayBuffer(8), byteLength: 8, byteOffset: 0, length: 1, Symbol(Symbol.toStringTag): 'Float64Array']

  // 값 할당
  f64[0] = val;
  // Float64Array [NaN, buffer: ArrayBuffer(8), byteLength: 8, byteOffset: 0, length: 1, Symbol(Symbol.toStringTag): 'Float64Array']

  // buffer값으로 32비트 정수 배열 생성
  const u32 = new Uint32Array(f64.buffer);
  // Uint32Array(2) [0, 4294443008, buffer: ArrayBuffer(8), byteLength: 8, byteOffset: 0, length: 2, Symbol(Symbol.toStringTag): 'Uint32Array']

  // 상위 32비트(index 1)의 마지막 비트가 음수(1)인지 확인
  const isNegative = u32[1] >>> 31 === 1;

  return isNegative;
}

잘 작동한다. 👍

isNegativeNaN(-NaN);
// true

isNegativeNaN(NaN);
// false

isNegativeNaN(-0);
// false

// ...

6. PR 및 모듈 배포

애초에 -NaN을 쓴다는 것 자체가 잘못된 개념이긴 하지만, 이에 관한 엣지케이스가 대부분의 라이브러리에 되어있지 않다는 것을 확인하였다.
조만간 vitest 말고도 다른 오픈소스에 작업을 진행할 예정이다.

PR 후, 다른사람들 편하게 쓰라고 배포까지 완료하였다.

> PR

> npm


7. Maintainer님의 빠꾸

PR(Closed)

sheremet 형님 : 거기 바꾸면 breakChange 되니까 다른 방식으로 해결하세요. 저도 고민중임.

잠시 슬픔과 죄송함의 복합적인 감정을 음미하였다.
뭔가 내 부족한 지식으로 메인테이너 형님의 시간을 빼았아, 폐가 되었기 때문이었을까.
이번 피드백으로 또 새로운 것을 배웠지만, 그래도 죄송한 마음은 어쩔 수 없을 따름이다.


8. 새 PR

format 자체에서 판단하는 것이 아닌, runner의 formatTitle에서 정규표현식으로 %f를 추출하는 과정에서 해당 값이 -0이나 -NaN일 경우 %f를 -%f로 바꾸는 것. 이 방식이 모든 코드를 살펴보았을 때, 셰르멧 형님이 원하는대로 변경점은 최소화가 된다.

금요일 퇴근 후, 시간이 조금 있어 자기 전 PR을 완성하였다.


9. Merge

  1. 기존 formatTitle에 대한 테스트가 존재하지 않았고
  2. 해당 함수는 외부로 export되지 않으며
  3. format이 완성된 title을 suite 함수 내부에서 this로 접근하는 것이 불가능했다.

그렇기에 회귀 테스트를 작성할 수 없어서, 방향성에 대한 피드백을 구했지만 스크린샷으로 올렸던 자체 테스트 + sheremet 형님이 직접 테스트로 확인하셨는지 그냥 merge 때리셨다.

> PR(merged)


Release : Vitest v.2.0.0-beta.7

0개의 댓글