Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ $ pnpm --filter admin dev

# 4. 빌드
$ pnpm build

# 5. 유닛 테스트 (네트워크/외부 OCR 호출 없이 순수 로직만 검증, 패키지별 병렬 실행)
$ pnpm test

# 특정 패키지만 실행할 경우
$ pnpm --filter @repo/constants test
$ pnpm --filter @repo/ui test
$ pnpm --filter @repo/ocr-lambda test

# 6. E2E 테스트 (client/admin 두 앱 모두 사전에 `pnpm build` 필요 — Playwright webServer가
# 두 앱을 `next start`로 자동 실행함)
$ pnpm test:e2e
```

### Tech Stack
Expand Down
5 changes: 4 additions & 1 deletion packages/constants/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
"dev": "tsc --watch",
"build": "tsc",
"lint": "eslint src",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"test": "tsx --test src/**/*.test.ts"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^20.19.33",
"eslint": "^9.39.4",
"tsx": "^4.23.15",
"typescript": "5.9.2"
}
}
106 changes: 106 additions & 0 deletions packages/constants/src/achievement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { getUsedAchievementFields } from './achievement';

test('GED는 학기 입력 자체가 없으므로 전형/학년제와 무관하게 항상 빈 배열이다', () => {
assert.deepEqual(
getUsedAchievementFields({
liberalSystem: '자유학년제',
graduationType: 'GED',
freeSemester: null,
}),
[],
);
});

test('자유학년제 졸업예정자는 1학년 전체(1-1·1-2)와 아직 없는 3-2가 빠진다', () => {
assert.deepEqual(
getUsedAchievementFields({
liberalSystem: '자유학년제',
graduationType: 'CANDIDATE',
freeSemester: null,
}),
['achievement2_1', 'achievement2_2', 'achievement3_1'],
);
});

test('자유학년제 졸업자는 1학년 전체(1-1·1-2)만 빠지고 3-2는 포함된다', () => {
assert.deepEqual(
getUsedAchievementFields({
liberalSystem: '자유학년제',
graduationType: 'GRADUATE',
freeSemester: null,
}),
['achievement2_1', 'achievement2_2', 'achievement3_1', 'achievement3_2'],
);
});

test('자유학기제 졸업예정자는 지정한 자유학기 한 학기와 아직 없는 3-2가 빠진다', () => {
assert.deepEqual(
getUsedAchievementFields({
liberalSystem: '자유학기제',
graduationType: 'CANDIDATE',
freeSemester: '2-1',
}),
['achievement1_1', 'achievement1_2', 'achievement2_2', 'achievement3_1'],
);
});

test('자유학기제 졸업자는 지정한 자유학기 한 학기만 빠진다', () => {
assert.deepEqual(
getUsedAchievementFields({
liberalSystem: '자유학기제',
graduationType: 'GRADUATE',
freeSemester: '3-1',
}),
['achievement1_1', 'achievement1_2', 'achievement2_1', 'achievement2_2', 'achievement3_2'],
);
});

test('자유학기제인데 아직 자유학기를 고르지 않았다면(freeSemester=null) 어떤 학기도 빠지지 않는다', () => {
assert.deepEqual(
getUsedAchievementFields({
liberalSystem: '자유학기제',
graduationType: 'GRADUATE',
freeSemester: null,
}),
[
'achievement1_1',
'achievement1_2',
'achievement2_1',
'achievement2_2',
'achievement3_1',
'achievement3_2',
],
);
});

test('liberalSystem이 아직 정해지지 않은 null은 자유학기제로 취급한다 — freeSemester가 없으면 전부 포함', () => {
assert.deepEqual(
getUsedAchievementFields({
liberalSystem: null,
graduationType: 'GRADUATE',
freeSemester: null,
}),
[
'achievement1_1',
'achievement1_2',
'achievement2_1',
'achievement2_2',
'achievement3_1',
'achievement3_2',
],
);
});

test('liberalSystem이 null이어도 freeSemester가 이미 정해졌다면 그 학기는 빠진다(자유학기제 취급 회귀 테스트)', () => {
assert.deepEqual(
getUsedAchievementFields({
liberalSystem: null,
graduationType: 'GRADUATE',
freeSemester: '1-2',
}),
['achievement1_1', 'achievement2_1', 'achievement2_2', 'achievement3_1', 'achievement3_2'],
);
});
4 changes: 3 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"check-types": "tsc --noEmit",
"dev:styles": "tailwindcss -i ./src/styles.css -o ./dist/index.css --watch",
"dev:components": "tsc --watch",
"lint": "eslint src"
"lint": "eslint src",
"test": "tsx --test src/**/*.test.ts"
},
"peerDependencies": {
"next": "^16.0.0",
Expand Down Expand Up @@ -59,6 +60,7 @@
"postcss": "^8.5.6",
"shadcn": "^3.8.5",
"tailwindcss": "^3.4.17",
"tsx": "^4.23.15",
"typescript": "5.9.2"
}
}
49 changes: 3 additions & 46 deletions packages/ui/src/components/StepWrapper/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,7 @@
usePutOneseoByMemberId,
} from '@repo/api/hooks';
import { oneseoQueryKeys } from '@repo/api/lib';
import {
AchievementFieldType,
ARTS_PHYSICAL_SUBJECTS,
GENERAL_SUBJECTS,
getUsedAchievementFields,
} from '@repo/constants';
import { ARTS_PHYSICAL_SUBJECTS, GENERAL_SUBJECTS } from '@repo/constants';
import { useModalStore } from '@repo/store';
import {
GEDAchievementType,
Expand Down Expand Up @@ -52,49 +47,11 @@
import { Step1Register, Step2Register, Step3Register, Step4Register } from '../register';
import StepBar from '../StepBar';

import { normalizeAchievements } from './normalizeAchievements';

/** step 4에서 마지막 입력 이후 이 시간만큼 입력이 없으면 자동으로 임시저장한다 */
const IDLE_AUTO_SAVE_DELAY = 2 * 60 * 1000;

/**
* 이번 전형에서 쓰지 않는 학기에 값이 남아 있으면 서버로 나가기 직전에 null로 지운다.
*
* 학기 열을 비우는 초기화는 입력 폼(FreeGradeForm/FreeSemesterForm)의 mount 시 한 번만
* 돌기 때문에, 그 뒤에 값을 집어넣는 경로(생기부 OCR 자동입력)를 막지 못한다. 실제로
* 자유학년제 졸업자가 OCR을 쓰면 1-1·1-2가 0('없음')으로 채워진 채 제출돼 성적 계산이
* 틀어졌다. 입력 경로마다 막는 것과 별개로, 페이로드를 만드는 이 마지막 지점에서 한 번
* 더 거른다.
*
* 아직 입력이 시작되지 않은 학기(undefined)는 undefined 그대로 둔다 — step 1~3 임시저장은
* step 4 값이 전부 비어 있는 채로 나가는데, 이걸 null로 바꾸면 그동안 서버가 받아 오던
* payload 모양이 달라진다.
*/
const normalizeAchievements = (
values: Step4FormType,
graduationType: GraduationTypeValueEnum,
): Record<AchievementFieldType, number[] | null | undefined> => {
const usedFields = getUsedAchievementFields({
liberalSystem: values.liberalSystem,
graduationType,
freeSemester: values.freeSemester,
});

const normalize = (field: AchievementFieldType) => {
// 스키마상 number[] | null이지만, 아직 기본값이 채워지지 않은 학기는 런타임에 undefined다
const value = values[field] as number[] | null | undefined;
if (value === undefined || usedFields.includes(field)) return value;
return null;
};

return {
achievement1_1: normalize('achievement1_1'),
achievement1_2: normalize('achievement1_2'),
achievement2_1: normalize('achievement2_1'),
achievement2_2: normalize('achievement2_2'),
achievement3_1: normalize('achievement3_1'),
achievement3_2: normalize('achievement3_2'),
};
};

interface StepWrapperProps {
data: GetMyOneseoType | undefined;
info?: MyMemberInfoType;
Expand Down Expand Up @@ -564,7 +521,7 @@
armIdleTimer();
// handleAutoTempSave는 렌더마다 새로 만들어지므로 의존성에 넣으면 타이머가
// 매 렌더 초기화돼 만료되지 않는다. 입력 변화에만 반응해야 하므로 의도적으로 제외한다.
}, [step4Values, isStep4, isClient]);

Check warning on line 524 in packages/ui/src/components/StepWrapper/index.tsx

View workflow job for this annotation

GitHub Actions / Lint and Build

React Hook useEffect has a missing dependency: 'armIdleTimer'. Either include it or remove the dependency array

// 타이머 정리는 언마운트 시에만 한다. 위 effect의 cleanup으로 두면 값이 바뀌지 않은
// 재실행에서도 대기 중인 타이머가 취소되고, hasEdited가 false라 재장전되지 않는다.
Expand Down Expand Up @@ -648,7 +605,7 @@
toast.error('인적사항 자동 저장에 실패하였습니다.');
});
}
}, [step]);

Check warning on line 608 in packages/ui/src/components/StepWrapper/index.tsx

View workflow job for this annotation

GitHub Actions / Lint and Build

React Hook useEffect has missing dependencies: 'getPersonalInfo', 'isClient', 'isStepSuccess', 'patchPersonalInfo', and 'step1UseForm.formState.isDirty'. Either include them or remove the dependency array

const handlePreviewPrint = async () => {
if (!isScoreComplete) {
Expand All @@ -674,7 +631,7 @@

if (step === StepEnum.FOUR && (!isStepSuccess[1] || !isStepSuccess[2] || !isStepSuccess[3]))
push(`${BASE_URL}?step=3`);
}, [step, isStepSuccess, push, BASE_URL]);

Check warning on line 634 in packages/ui/src/components/StepWrapper/index.tsx

View workflow job for this annotation

GitHub Actions / Lint and Build

React Hook useEffect has a missing dependency: 'errorStep'. Either include it or remove the dependency array

return (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
FreeSemesterValueEnum,
GraduationTypeValueEnum,
LiberalSystemValueEnum,
Step4FormType,
} from '@repo/types';

import { normalizeAchievements } from './normalizeAchievements';

/** 테스트에서 다루는 필드만 채우고 나머지는 비워 둔다 — normalizeAchievements는
* achievement1_1~3_2와 liberalSystem/freeSemester만 읽으므로 그 외 필드는 런타임에
* 실제로 undefined일 수 있는 상태를 그대로 흉내낸다. */
const values = (overrides: Partial<Step4FormType>): Step4FormType =>
({
liberalSystem: null,
freeSemester: null,
...overrides,
}) as Step4FormType;

test('자유학년제 졸업자가 OCR로 1-1·1-2에 값이 채워져도 null로 지운다(회귀 테스트)', () => {
Comment thread
junjuny0227 marked this conversation as resolved.
Resolved
// 실제 버그: OCR이 자유학년제 1학년 성취도를 0('없음')으로 채워 제출하면서 성적 계산이 틀어졌다.
const result = normalizeAchievements(
values({
liberalSystem: LiberalSystemValueEnum.FREE_GRADE,
achievement1_1: [0, 0, 0],
achievement1_2: [0, 0, 0],
achievement2_1: [90, 85],
}),
GraduationTypeValueEnum.GRADUATE,
);

assert.equal(result.achievement1_1, null);
assert.equal(result.achievement1_2, null);
assert.deepEqual(result.achievement2_1, [90, 85]);
});

test('자유학년제 졸업예정자는 1-1·1-2뿐 아니라 아직 없는 3-2도 null로 지운다', () => {
const result = normalizeAchievements(
values({
liberalSystem: LiberalSystemValueEnum.FREE_GRADE,
achievement1_1: [0],
achievement1_2: [0],
achievement3_2: [0],
}),
GraduationTypeValueEnum.CANDIDATE,
);

assert.equal(result.achievement1_1, null);
assert.equal(result.achievement1_2, null);
assert.equal(result.achievement3_2, null);
});

test('아직 입력이 시작되지 않은 학기(undefined)는 미사용 학기여도 undefined 그대로 둔다', () => {
// step 1~3 임시저장은 step 4 값이 전부 undefined인 채로 나가는데, 여기서 null로 바꾸면
// 서버가 받아 오던 payload 모양이 달라진다.
const result = normalizeAchievements(
values({
liberalSystem: LiberalSystemValueEnum.FREE_GRADE,
achievement1_1: undefined,
}),
GraduationTypeValueEnum.GRADUATE,
);

assert.equal(result.achievement1_1, undefined);
});

test('자유학기제는 지정한 자유학기 한 학기만 null로 지우고 나머지는 그대로 둔다', () => {
const result = normalizeAchievements(
values({
liberalSystem: LiberalSystemValueEnum.FREE_SEMESTER,
freeSemester: FreeSemesterValueEnum['1-1'],
achievement1_1: [0, 0],
achievement1_2: [88, 92],
}),
GraduationTypeValueEnum.GRADUATE,
);

assert.equal(result.achievement1_1, null);
assert.deepEqual(result.achievement1_2, [88, 92]);
});

test('사용 중인 학기의 값은 그대로 통과시킨다', () => {
const result = normalizeAchievements(
values({
liberalSystem: LiberalSystemValueEnum.FREE_SEMESTER,
freeSemester: null,
achievement1_1: [77, 80],
}),
GraduationTypeValueEnum.GRADUATE,
);

assert.deepEqual(result.achievement1_1, [77, 80]);
});
42 changes: 42 additions & 0 deletions packages/ui/src/components/StepWrapper/normalizeAchievements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { AchievementFieldType, getUsedAchievementFields } from '@repo/constants';
import { GraduationTypeValueEnum, Step4FormType } from '@repo/types';

/**
* 이번 전형에서 쓰지 않는 학기에 값이 남아 있으면 서버로 나가기 직전에 null로 지운다.
*
* 학기 열을 비우는 초기화는 입력 폼(FreeGradeForm/FreeSemesterForm)의 mount 시 한 번만
* 돌기 때문에, 그 뒤에 값을 집어넣는 경로(생기부 OCR 자동입력)를 막지 못한다. 실제로
* 자유학년제 졸업자가 OCR을 쓰면 1-1·1-2가 0('없음')으로 채워진 채 제출돼 성적 계산이
* 틀어졌다. 입력 경로마다 막는 것과 별개로, 페이로드를 만드는 이 마지막 지점에서 한 번
* 더 거른다.
*
* 아직 입력이 시작되지 않은 학기(undefined)는 undefined 그대로 둔다 — step 1~3 임시저장은
* step 4 값이 전부 비어 있는 채로 나가는데, 이걸 null로 바꾸면 그동안 서버가 받아 오던
* payload 모양이 달라진다.
*/
export const normalizeAchievements = (
values: Step4FormType,
graduationType: GraduationTypeValueEnum,
): Record<AchievementFieldType, number[] | null | undefined> => {
const usedFields = getUsedAchievementFields({
liberalSystem: values.liberalSystem,
graduationType,
freeSemester: values.freeSemester,
});

const normalize = (field: AchievementFieldType) => {
// 스키마상 number[] | null이지만, 아직 기본값이 채워지지 않은 학기는 런타임에 undefined다
const value = values[field] as number[] | null | undefined;
if (value === undefined || usedFields.includes(field)) return value;
return null;
};

return {
achievement1_1: normalize('achievement1_1'),
achievement1_2: normalize('achievement1_2'),
achievement2_1: normalize('achievement2_1'),
achievement2_2: normalize('achievement2_2'),
achievement3_1: normalize('achievement3_1'),
achievement3_2: normalize('achievement3_2'),
};
};
Loading
Loading