이전 글 : [Canvas-불꽃놀이-29] Canvas 클래스 단위 테스트 (텍스트 데이터 생성)
Tail, Text, Circle, Spark의 생성 테스트
Tail, Text, Circle, Spark 파티클 생성 테스트
Canvas 클래스에서 애니메이션을 진행할 파티클들의 생성이 잘 이루어지는지 검증하도록 한다.
Canvas 클래스 파티클들의 생성 코드
더보기
// 파티클 생성 관련 Canvas 클래스 코드
// TailParticle 생성
createTailParticle() {
let params = { y: this.canvasCssHeight };
if (this.tailCount === 0) {
params.x = this.mainX;
params.vy = this.mainTailVY;
} else {
const max = this.tailQty - 1;
params.x = (this.isLeft ? this.tailsLeftPosX : this.tailsRightPosX)[randomInt(0, max)];
params.vy = this.tailsVY[randomInt(0, max)];
}
this.tailParticles.push(this.pm.acquireParticle(TYPE_TAIL, params));
this.tailCount = (this.tailCount + 1) % this.tailQty;
this.isLeft = !this.isLeft;
}
/**
* @param {number} x
* @returns {boolean} x좌표가 메인 위치에 있으면 true 반환, 그 외의 영역은 false 반환
*/
isMain(x) {
return x > this.tailsLeftPosX.at(-1) && x < this.tailsRightPosX.at(0);
}
// TextParticle 생성
/**
* @param {object} params
* @param {number} params.stringCenterX 문자열 중심의 x좌표 (물리적 크기)
* @param {number} params.stringCenterY 문자열 중심의 y좌표 (물리적 크기)
* @param {number} params.w 픽셀 데이터의 현재 가로 위치 (물리적 크기)
* @param {number} params.h 필셀 데이터의 현재 세로 위치 (물리적 크기)
* @param {number} params.x 꼬리의 x좌표 (css 크기)
* @param {number} params.y 꼬리의 y좌표 (css 크기)
* @returns {object} TextParticle x,y 좌표의 초기 속도 반환
*/
calculateTextParticleVelocity({ stringCenterX, stringCenterY, w, h, x, y }) {
const targetX = (stringCenterX + w) / this.dpr;
const targetY = (stringCenterY + h) / this.dpr;
return { vx: (targetX - x) / this.interval, vy: (targetY - y) / this.interval };
}
/**
* @param {number} x
* @param {number} y
*/
createTextParticle(x, y) {
const { data, width, height, fontBoundingBoxAscent, fontBoundingBoxDescent } = this.isMain(x) ? this.mainTextData : this.subTextData;
const stringCenterX = x * this.dpr - width / 2;
const stringCenterY = y * this.dpr - (height + fontBoundingBoxAscent + fontBoundingBoxDescent) / 2;
const particleFrequency = this.isSmallScreen ? TEXT.SMALL_FREQUENCY : TEXT.GENERAL_FREQUENCY;
for (let h = 0; h < height; h += particleFrequency) {
for (let w = 0; w < width; w += particleFrequency) {
const index = (h * width + w) * 4;
const alpha = data[index + 3];
if (alpha > 0) {
const { vx, vy } = this.calculateTextParticleVelocity({ stringCenterX, stringCenterY, w, h, x, y });
const params = { x, y, vx, vy, color: setHslaColor({ hue: randomInt(TEXT.MIN_HUE, TEXT.MAX_HUE) }) };
this.textParticles.push(this.pm.acquireParticle(TYPE_TEXT, params));
}
}
}
}
// CircleParticle 생성
/**
* @param {number} textWidth
* @returns {number} 문자열 width의 절반 또는 글자당 width중 더 큰 값을 기준으로 계층별 간격 반환
*/
calculateLayerOffset(textWidth) {
const radiusFromCssWidth = textWidth / 2 / this.dpr;
const radiusFromCharWidth = textWidth / this.textLength;
return Math.max(radiusFromCssWidth, radiusFromCharWidth) / CIRCLE.LAYERS;
}
/**
* @param {number} circleIdx
* @returns {object} 삼각함수 계산에 필요한 코사인, 사인값 반환
*/
calculateCosSin(circleIdx) {
const angleDegree = CIRCLE.PER_ANGLE * circleIdx;
const radian = PARTICLE.DEGREE_TO_RADIAN * angleDegree;
return { xCos: Math.cos(radian), ySin: Math.sin(radian) };
}
/**
* @param {number} layerIdx
* @param {number} circleIdx
* @param {number} layerOffset
* @param {number} textLengthOffset
* @returns {number} 생성지점으로부터의 레이어별 CircleParticle의 거리 반환
*/
calculateDistFromCreationPoint(layerIdx, circleIdx, layerOffset, textLengthOffset) {
const dist = layerOffset + textLengthOffset;
const layerDist = (dist * layerIdx) / 2;
const additionalDist = isEven(circleIdx) ? 0 : dist / 2;
return layerDist + additionalDist;
}
/**
* @param {number} x
* @param {number} y
*/
createCircleParticle(x, y) {
const layerOffset = this.calculateLayerOffset(this.isMain(x) ? this.mainTextData.width : this.subTextData.width);
const textLengthOffset = CIRCLE.BASE_TEXT_OFFSET - this.textLength;
const baseSpeed = layerOffset / this.interval;
for (let layerIdx = 0; layerIdx < CIRCLE.LAYERS; layerIdx++) {
const speedFactor = baseSpeed + layerIdx;
const radius = CIRCLE.RADII[layerIdx];
const opacity = CIRCLE.OPACITY_BASE + layerIdx * CIRCLE.OPACITY_OFFSET;
const color = setHslaColor({ hue: CIRCLE.HUES[layerIdx], saturation: CIRCLE.SATURATION, lightness: CIRCLE.LIGHTNESS });
for (let circleIdx = 0; circleIdx < CIRCLE.PER_QTY; circleIdx++) {
const distFromCreationPoint = this.calculateDistFromCreationPoint(layerIdx, circleIdx, layerOffset, textLengthOffset);
const { xCos, ySin } = this.calculateCosSin(circleIdx);
const circleParams = {
x: x + distFromCreationPoint * xCos,
y: y + distFromCreationPoint * ySin,
vx: speedFactor * xCos,
vy: speedFactor * ySin,
radius,
opacity,
color,
};
this.circleParticles.push(this.pm.acquireParticle(TYPE_CIRCLE, circleParams));
}
}
}
// SparkParticle 생성 (updateTailParticle, updateCircleParticle 안에서 생성)
updateTailParticle() {
for (let i = this.tailParticles.length - 1; i >= 0; i--) {
const tail = this.tailParticles[i];
tail.update();
tail.draw();
const sparkQty = Math.round(Math.abs(tail.vy * SPARK.TAIL_CREATION_RATE));
for (let i = 0; i < sparkQty; i++) {
const sparkParams = {
x: tail.x,
y: tail.y,
vx: randomFloat(SPARK.TAIL_MIN_VX, SPARK.TAIL_MAX_VX),
vy: randomFloat(SPARK.TAIL_MIN_VY, SPARK.TAIL_MAX_VY),
opacity: randomFloat(SPARK.TAIL_MIN_OPACITY, SPARK.TAIL_MAX_OPACITY),
color: tail.fillColor,
};
this.sparkParticles.push(this.pm.acquireParticle(TYPE_SPARK, sparkParams));
}
if (tail.belowOpacityLimit(TAIL.OPACITY_LIMIT)) {
this.createTextParticle(tail.x, tail.y);
this.createCircleParticle(tail.x, tail.y);
this.tailParticles.splice(i, 1);
this.pm.returnToPool(TYPE_TAIL, tail);
}
}
}
updateCircleParticle() {
for (let i = this.circleParticles.length - 1; i >= 0; i--) {
const circle = this.circleParticles[i];
circle.update();
circle.draw();
if (Math.random() < SPARK.CIRCLE_CREATION_RATE * this.textLength) {
const sparkParams = {
x: circle.x,
y: circle.y,
vx: randomFloat(SPARK.CIRCLE_MIN_VX, SPARK.CIRCLE_MAX_VX),
vy: SPARK.CIRCLE_VY,
color: circle.fillColor,
radius: circle.radius,
opacity: circle.opacity + SPARK.CIRCLE_OPACITY_OFFSET,
};
this.sparkParticles.push(this.pm.acquireParticle(TYPE_SPARK, sparkParams));
}
if (circle.belowOpacityLimit() || this.isOutOfCanvasArea(circle.x, circle.y)) {
this.circleParticles.splice(i, 1);
this.pm.returnToPool(TYPE_CIRCLE, circle);
}
}
}
1. 테스트 환경 공동 설정
- 캔버스 인스턴스와 ParticleManager의 파티클 생성을 관리하는 메서드의 호출 여부를 감시하기 위한 스파이를 전역에서 선언해 사용하도록 한다.
// CanvasCreateParticle.test.js
describe("Canvas 클래스 파티클 생성 테스트", () => {
// 캔버스 인스턴스
let canvasInst;
const userInput = "텍스트 데이터 생성";
const { INNER_HEIGHT } = TEST_OPTION;
// 파티클 생성을 관리하는 메서드 호출 감시를 위해 스파이 등록
let spyAcquireParticle;
// 생략...
}
- 테스트 실행 전 테스트용 캔버스를 생성한다. 파티클의 생성&재사용을 관리하는 ParticleManager의 spy도 인스턴스 생성 전 정의한다. 그다음 Canvas 클래스의 인스턴스 생성과 속성을 설정한다.
- 테스트가 종료된 후 spy의 호출기록을 초기화하여 다음 테스트에 영향이 가지 않게 한다.
// CanvasCreateParticle.test.js
// 테스트 실행 전
beforeEach(() => {
// 테스트용 캔버스를 생성하고 크기를 설정한다.
setTestCanvas();
// 파티클 생성을 관리하는 메서드의 호출을 감시하기 위해 스파이로 등록한다.
spyAcquireParticle = jest.spyOn(ParticleManager.prototype, "acquireParticle");
// 캔버스 클래스의 인스턴스를 생성하고 테스트에 필요한 속성을 설정한다.
canvasInst = new Canvas();
canvasInst.text = userInput;
canvasInst.textLength = userInput.length;
canvasInst.isSmallScreen = false;
canvasInst.mainFontSize = 100;
});
// 테스트 종료 후
afterEach(() => {
// 호출기록을 초기화 한다.
spyAcquireParticle.mockClear();
});
- 파티클 생성 메서드를 실행 후 파티클 배열의 결과값이 예상값과 맞는지 검증해야 한다. 각 파티클 배열의 타입은 다르나 배열이라는 자료형태는 모두 동일하므로 파티클 배열의 길이, 원소의 타입, 파티클 생성&재사용 메서드가 몇 번 호출되었는지 검증하는 공통 함수를 만들어 사용하도록 한다.
// CanvasCreateParticle.test.js
/**
* @param {object} params
* @param {Array} params.particleArr 파클 생성 후 파티클 배열
* @param {number} params.expectedLen 파티클 배열 예상 길이
* @param {TailParticle | TextParticle | CircleParticle | SparkParticle} params.particle 파티클 인스턴스
* @param {number} [params.callTimeCnt] 파티클 매니저 생성 메서드 호출 횟수
*/
function expectedCreatedParticleArr({ particleArr, expectedLen, particle, callTimeCnt }) {
// 배열의 길이가 예상값과 일치한지
expect(particleArr).toHaveLength(expectedLen);
// 배열의 첫번째 인스턴스의 타입이 예상값과 일치하는지
expect(particleArr[0]).toBeInstanceOf(particle);
// 파티클 생성&재사용 메서드의 호출 횟수가 예상값과 일치하는지
// callTimeCnt 파라미터 값이 undefined면 expectedLen으로 대체한다
expect(spyAcquireParticle).toHaveBeenCalledTimes(callTimeCnt ?? expectedLen);
}
2. TailParticle 생성 테스트
- tailCount가 0이면 메인 위치에서 꼬리를 생성하고 vy는 메인 vy 값으로 설정된다.
생성된 이후 다음 tailCount의 값과 isLeft의 값이 예상값과 일치하는지 검증하도록 한다. - 다음 tailCount는 (tailCount + 1) % tailQty, isLeft는 반전된 값이다.
// CanvasCreateParticle.test.js
test("createTailParticle 테스트 | tailCount = 0일 때", () => {
// Tail 파티클 생성
canvasInst.createTailParticle();
// 생성 후 파티클 배열의 길이, 생성시 사용한 메서드 호출횟수, 원소의 타입이 예상값과 일치하는지
expectedCreatedParticleArr({ particleArr: canvasInst.tailParticles, expectedLen: 1, particle: TailParticle });
// 생성&재사용된 파티클의 속성이 예상값과 일치하는지
const createdTail = canvasInst.tailParticles[0];
expect(createdTail.x).toBe(canvasInst.mainX);
expect(createdTail.y).toBe(INNER_HEIGHT);
expect(createdTail.vy).toBe(canvasInst.mainTailVY);
expect(canvasInst.tailCount).toBe(1);
expect(canvasInst.isLeft).toBeTruthy();
});
- 다음 tailCount가 1 이상일 때 예상값과 결괏값이 일치하는지 검증해 보도록 한다.
tailCount는 (tailCount + 1) % tailQty로 결정되므로 0, 1, 2, 3, 4가 계속 반복되게 되어있으므로 생성 후 값을 검증할 때 유의해서 진행하도록 한다.
// CanvasCreateParticle.test.js
// tailCount가 1 이상일때 예상값과 결과값이 일치하는지 검증
test.each([
// 상황별 테스트 케이스
{ tailCount: 1, afterTailCount: 2, isLeft: true },
{ tailCount: 4, afterTailCount: 0, isLeft: false },
])("createTailParticle 테스트 | tailCount = $tailCount, isLeft = $isLeft일 때", ({ tailCount, afterTailCount, isLeft }) => {
// 테스트 환경 설정하고 파티클을 생성한다.
canvasInst.isLeft = isLeft;
canvasInst.tailCount = tailCount;
canvasInst.init();
canvasInst.createTailParticle();
// 생성 완료 후 tail파티클 배열의 길이, 원소의 인스턴스, 생성&재사용 메서드 호출횟수가 예상값과 일치하는지 검증
expectedCreatedParticleArr({ particleArr: canvasInst.tailParticles, expectedLen: 1, particle: TailParticle });
const reusedTail = canvasInst.tailParticles[0];
// isLeft가 true/false일 때 올바른 x 좌표 배열에서 값을 가져오는지 검증
expect(isLeft ? canvasInst.tailsLeftPosX : canvasInst.tailsRightPosX).toContain(reusedTail.x);
// y좌표가 캔버스의 height인지, 속도는 속도배열에 속해있는지 검증
expect(reusedTail.y).toBe(INNER_HEIGHT);
expect(canvasInst.tailsVY).toContain(reusedTail.vy);
// tailCount와 isLeft의 업데이트되는 로직이 의도한대로 동작하는지 검증
expect(canvasInst.tailCount).toBe(afterTailCount);
expect(canvasInst.isLeft).toBe(!isLeft);
});
3. TextParticle 생성 테스트
- 원활한 테스트를 위해 textData mock값을 반환하는 함수를 생성하여 테스트에 사용하도록 한다.
- ImageData는 width, height의 rgba값을 담고있다. 그래서 이미지 데이터의 배열의 길이는 width * 4(rgba) * height * 4(rgba)이다. 원활한 테스트를 위해 alpha 값은 통일하도록 한다.
// CanvasCreateParticle.test.js
/**
* @param {object} params
* @param {boolean} params.widthOrHeightZero (width === 0 || height === 0)이라면 true 반환, 아니면 false 반환
* @param {number} params.width 텍스트 이미지 데이터 width
* @param {number} params.height 텍스트 이미지 데이터 height
* @param {number} params.alphaValue 이미지 데이터 alpha 값
* @returns {object} 텍스트 픽셀 데이터 mock 값
*/
function getTextData({ widthOrHeightZero, width, height, alphaValue }) {
// widht, height 값이 0인지 확인하여 설정
width = widthOrHeightZero ? 0 : width;
height = widthOrHeightZero ? 0 : height;
// ImageData.data의 길이는 width * height 크기의 픽셀 데이터를 rgba(4) 단위로 저장
return { data: new Array(width * 4 * height * 4).fill(alphaValue), width, height };
}
- 파티클을 생성할 x, y 좌표의 위치가 여부에 따라 폰트 사이즈가 달라져 적용할 텍스트 데이터도 달라진다.
isMain이 true면 mainTextData를 반환하고, false면 subTextData를 반환한다. - 화면 사이즈(PC || 모바일)에 따라 같은 글자여도 텍스트 파티클의 생성 개수가 달라진다. 이미지 데이터의 데이터를 그대로 사용하면 너무 많은 파티클의 개수가 생성되므로 성능에 영향을 미친다. 이를 조정하기 위해 일정한 간격을 두고 픽셀 데이터를 가져온다.
PC화면에서는 모바일보다 많은 파티클이 필요하므로 isSmallScreen 여부에 따라 간격값을 조절하도록 한다. - 텍스트를 그리기위한 픽셀 데이터를 생성할 때 alpha값이 0이면 가져오지 않는다. alpha 값에 따라 파티클의 배열의 길이가 예상값과 일치하는지 확인한다.
// CanvasCreateParticle.test.js
test.each([
{ isMain: true, isSmallScreen: false, alphaValue: 255, widthOrHeightZero: false },
{ isMain: true, isSmallScreen: true, alphaValue: 255, widthOrHeightZero: false },
{ isMain: false, isSmallScreen: false, alphaValue: 0, widthOrHeightZero: false },
{ isMain: false, isSmallScreen: true, alphaValue: 0, widthOrHeightZero: false },
{ isMain: true, isSmallScreen: false, alphaValue: 255, widthOrHeightZero: true },
{ isMain: false, isSmallScreen: true, alphaValue: 255, widthOrHeightZero: true },
])(
"createTextParticles 테스트 | isMain = $isMain, isSmallScreen = $isSmallScreen, alpha = $alphaValue, (width || height) = $widthOrHeightZero일 때",
({ isMain, isSmallScreen, alphaValue, widthOrHeightZero }) => {
const [x, y, vx, vy, width, height, offset] = [100, 100, 10, 10, 8, 10, 4];
// 실제 사용하는 메서드를 mock 함수화 하여 지정된 값을 강제로 리턴하도록 한다.
// 특정 좌표가 메인 영역에 있는지 강제로 true/false를 반환
jest.spyOn(canvasInst, "isMain").mockReturnValue(isMain);
// 파티클 x, y의 속도를 지정한 값으로 강제로 반환
jest.spyOn(canvasInst, "calculateTextParticleVelocity").mockReturnValue({ vx, vy });
// 텍스트 데이터도 getTextData를 이용한 mock 데이터를 대입한다.
canvasInst.mainTextData = getTextData({ widthOrHeightZero, width, height, alphaValue });
canvasInst.subTextData = getTextData({ widthOrHeightZero, width: width - offset, height: height - offset, alphaValue });
canvasInst.isSmallScreen = isSmallScreen;
// TextParticle 생성
canvasInst.createTextParticle(x, y);
// alpha 또는 widht나 height 값이 0이면
if (alphaValue === 0 || widthOrHeightZero) {
// 텍스트 파티클은 생성되지 않아 배열의 길이는 0이되고
expect(canvasInst.textParticles).toHaveLength(0);
// 파티클 생성&재사용하는 메서드는 호출되지 않는다.
expect(spyAcquireParticle).not.toHaveBeenCalled();
} else {
// PC와 모바일 화면여부에 따라 이미지 데이터를 가져올 간격 설정
const expectedFrequency = canvasInst.isSmallScreen ? TEXT.SMALL_FREQUENCY : TEXT.GENERAL_FREQUENCY;
// 파티클 생성 위치에 따른 textData 설정
const textData = isMain ? canvasInst.mainTextData : canvasInst.subTextData;
// textParticles 배열의 예상 길이 계산
const expectedLen = Math.ceil(textData.width / expectedFrequency) * Math.ceil(textData.height / expectedFrequency);
expectedCreatedParticleArr({ particleArr: canvasInst.textParticles, expectedLen, particle: TextParticle });
}
},
);
4. CircleParticle 생성
- CircleParticle은 속성과 관련된 테스트는 앞에서 진행했으므로 정해진 개수에 맞춰 파티클이 생성되는지 확인한다.
// CanvasCreateParticle.test.js
test("createCircleParticle 테스트", () => {
canvasInst.createCircleParticle(100, 100);
expectedCreatedParticleArr({
particleArr: canvasInst.circleParticles,
expectedLen: CIRCLE.LAYERS * CIRCLE.PER_QTY, // 한번에 생성되는 파티클 갯수
particle: CircleParticle,
});
});
5. SparkParticle 생성
- SparkParticle은 자연스런 잔상 효과를 주기 위해 Tail, Circle 파티클이 업데이트를 진행할 때 파티클이 생성이 된다.
따로 생성 메서드는 없으므로 각 상황에 맞춰 테스트를 진행한다.
// CanvasCreateParticle.test.js
test("updateTailParticle에서 SparkParticle 생성", () => {
canvasInst.init();
canvasInst.createTailParticle();
canvasInst.updateTailParticle();
// Tail의 y축 속도에 맞춰 Spark가 생성되므로 예측값을 미리 계산한다.
const expectedLen = Math.round(Math.abs(canvasInst.tailParticles[0].vy * SPARK.TAIL_CREATION_RATE));
expectedCreatedParticleArr({
particleArr: canvasInst.sparkParticles,
expectedLen,
particle: SparkParticle,
callTimeCnt: expectedLen + 1, // 1은 TailParticle을 생성할때 호출횟수
});
});
// CanvasCreateParticle.test.js
test("updateCircleParticle에서 SparkParticle 생성", () => {
// 랜덤 값을 이용하여 Circle에 대한 Spark를 생성하므로 테스트를 위해
// 랜덤 함수의 반환값을 mocking해서 지정된 값으로 강제로 반환하게 한다.
const spyMathRandom = jest.spyOn(Math, "random").mockReturnValue(0.1);
canvasInst.init();
canvasInst.createCircleParticle();
canvasInst.updateCircleParticle();
// spakr 파티클 배열 예상 길이와 예상값 검증
const expectedLen = CIRCLE.LAYERS * CIRCLE.PER_QTY;
expectedCreatedParticleArr({
particleArr: canvasInst.sparkParticles,
expectedLen,
particle: SparkParticle,
callTimeCnt: expectedLen * 2, // Circle생성 횟수 + Spark 생성 횟수
});
// mock 함수화한 random 함수를 원래 함수로 복원한다.
spyMathRandom.mockRestore();
});
테스트 결과
npx jest .__test__/canvas/CanvasCreateParticle.test.js
Github Repo
'Canvas > 불꽃놀이 프로젝트' 카테고리의 다른 글
[Canvas-불꽃놀이-32] Canvas 클래스 단위 테스트 (애니메이션) (2) | 2024.12.19 |
---|---|
[Canvas-불꽃놀이-31] Canvas 클래스 단위 테스트 (파티클 업데이트) (1) | 2024.12.19 |
[Canvas-불꽃놀이-29] Canvas 클래스 단위 테스트 (텍스트 데이터 생성) (1) | 2024.12.17 |
[Canvas-불꽃놀이-28] Canvas 클래스 단위 테스트 (초기화) (1) | 2024.12.17 |
[Canvas-불꽃놀이-27] CanvasOption 클래스 단위 테스트 (1) | 2024.12.17 |