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
5 changes: 5 additions & 0 deletions .changeset/metal-buses-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@malib/gear': minor
---

feat: update GearData interface and add migrate function
26 changes: 20 additions & 6 deletions packages/gear/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,12 @@ pnpm add @malib/gear

`GearData` 객체를 생성하는 별도의 기능은 없으며 직접 생성해야 합니다.

라이브러리 업데이트로 인해 `GearData`의 형태가 변경될 경우 `meta.version` 속성에 반영되며, 기존의 장비를 새로운 버전으로 변환하는 함수가 제공됩니다.

```ts
import { type GearData, type GearType } from '@malib/gear';

const data: GearData = {
meta: {
id: 1009876,
version: 1,
},
version: 2,
id: 1009876,
name: 'Example cap',
icon: '1000000',
type: GearType.cap,
Expand All @@ -58,8 +54,26 @@ const data: GearData = {
};
```

### Migrating GearData

라이브러리 업데이트로 인해 `GearData`의 형태가 변경될 경우 `version` 속성에 반영되며, `migrate` 함수를 사용해 기존의 데이터를 새로운 버전으로 변환할 수 있습니다. 별도의 데이터 검증은 수행하지 않습니다.

```ts
import { migrate } from '@malib/gear';

const dataV2 = migrate(data, 2);

try {
const invalid = migrate({}, 2);
} catch (e) {
console.log(e); // 입력 데이터가 유효하지 않습니다.
}
```

### Creating ReadonlyGear

`ReadonlyGear` 및 `Gear`를 생성 시 오직 데이터의 `version` 속성만을 검사합니다.

```ts
import { ReadonlyGear } from '@malib/gear';

Expand Down
2 changes: 1 addition & 1 deletion packages/gear/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export {
/* GearData */
GearGender,
type GearData,
type GearMetadata,
type GearShapeData,
type GearReqData,
type GearBaseOption,
Expand Down Expand Up @@ -104,6 +103,7 @@ export {
canApplyScroll,
applyScroll,
} from './lib/enhance/upgrade';
export { migrate } from './lib/manage/migrate';
export { Gear } from './lib/Gear';
export { ReadonlyGear } from './lib/ReadonlyGear';
export { GearAttribute } from './lib/GearAttribute';
Expand Down
20 changes: 14 additions & 6 deletions packages/gear/src/lib/ReadonlyGear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ import {
} from './test';

describe('ReadonlyGear', () => {
describe('meta', () => {
describe('version', () => {
const gear = createReadonlyGear();
it('2이다.', () => {
expect(gear.version).toBe(2);
});

it('id 속성을 포함한다.', () => {
expect(gear.meta.id).toBe(1000000);
it('직접 설정할 수 없다.', () => {
// @ts-expect-error: Cannot assign to 'version' because it is a read-only property.
expect(() => (gear.version = 1)).toThrow();
});
});

describe('id', () => {
const gear = createReadonlyGear();

it('version이 1이다.', () => {
expect(gear.meta.version).toBe(1);
it('1000000이다.', () => {
expect(gear.id).toBe(1000000);
});

it('직접 설정할 수 없다.', () => {
// @ts-expect-error: Cannot assign to 'id' because it is a read-only property.
expect(() => (gear.meta = { id: 0, version: 1 })).toThrow();
expect(() => (gear.id = 0)).toThrow();
});
});

Expand Down
24 changes: 19 additions & 5 deletions packages/gear/src/lib/ReadonlyGear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import {
GearBaseOption,
GearData,
GearExceptionalOption,
GearMetadata,
GearShapeData,
GearStarforceOption,
GearType,
GearUpgradeOption,
PotentialGrade,
ReadonlySoulData,
SoulChargeOption,
VERSION,
} from './data';
import { ReadonlyPotential } from './enhance/potential';
import { getMaxStar } from './enhance/starforce';
import { ErrorMessage, GearError } from './errors';
import { GearAttribute } from './GearAttribute';
import { sumOptions, toGearOption } from './gearOption';
import { GearReq } from './GearReq';
Expand Down Expand Up @@ -43,8 +44,14 @@ export class ReadonlyGear implements _Gear {
/**
* 장비 정보를 참조하는 장비 인스턴스를 생성합니다.
* @param data 장비 정보.
*
* @throws {@link GearError}
* 지원하지 않는 장비 정보 버전일 경우.
*/
constructor(data: GearData) {
if (data.version as unknown !== VERSION) {
throw new GearError(ErrorMessage.Constructor_InvalidVersion, { id: data.id, name: data.name }, { expected: VERSION, actual: data.version });
}
this._data = data;
}

Expand All @@ -56,10 +63,17 @@ export class ReadonlyGear implements _Gear {
}

/**
* 장비 메타데이터
* 장비 정보 버전
*/
get version(): typeof VERSION {
return this.data.version;
}

/**
* 장비 ID
*/
get meta(): GearMetadata {
return this.data.meta;
get id(): number {
return this.data.id;
}

/**
Expand All @@ -73,7 +87,7 @@ export class ReadonlyGear implements _Gear {
* 장비 아이콘
*/
get icon(): string {
return this.data.icon ?? '';
return this.data.icon;
}

/**
Expand Down
26 changes: 8 additions & 18 deletions packages/gear/src/lib/data/GearData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@ import { PotentialData } from './PotentialData';
import { PotentialGrade } from './PotentialGrade';
import { SoulSlotData } from './SoulSlotData';

export const VERSION = 2;

/**
* 장비 정보
*/
export interface GearData {
/** 장비 메타데이터 */
meta: GearMetadata;
/** 장비 정보 버전 */
version: typeof VERSION;
/** 아이템 ID */
id: number;
/** 장비명 */
name: string;
/** 장비 아이콘 */
icon?: string;
/** 장비 설명 */
desc?: string;
/** 장비 아이콘 */
icon: string;
/** 장비 외형 */
shape?: GearShapeData;
/** 장비 분류 */
Expand Down Expand Up @@ -71,20 +75,6 @@ export interface GearData {
exceptionalUpgradeableCount?: number;
}

/**
* 장비 메타데이터
*
* 사용자는 메타데이터에 커스텀 속성을 추가할 수 있습니다.
* 커스텀 속성은 해당 라이브러리가 임의로 변경하지 않습니다.
* 충돌을 방지하기 위해 커스텀 속성명은 하나의 `_`으로 시작해야 합니다.
*/
export interface GearMetadata {
/** 아이템 ID */
id: number;
/** 장비 정보 버전 */
version: 1;
}

/**
* 장비 외형 정보
*/
Expand Down
13 changes: 9 additions & 4 deletions packages/gear/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { ReadonlyGear } from './ReadonlyGear';

export class GearError extends Error {
readonly gearId: number;
readonly gearName: string;
readonly status: Record<string, unknown>;

constructor(
message: string,
gear: ReadonlyGear,
gear: { id: number, name: string },
status: Record<string, unknown>,
) {
super(message);
this.name = 'GearError';
this.gearId = gear.meta.id;
this.gearId = gear.id;
this.gearName = gear.name;
this.status = status;
}
Expand Down Expand Up @@ -60,4 +58,11 @@ export const enum ErrorMessage {

Exceptional_InvalidEnhanceGear = '익셉셔널 강화를 적용할 수 없는 상태의 장비입니다.',
Exceptional_InvalidResetGear = '익셉셔널 강화를 초기화할 수 없는 장비입니다.',

Constructor_InvalidVersion = '지원하지 않는 장비 정보 버전입니다. 업그레이드 후 생성해야 합니다.',

Migrate_InvalidGearData = '입력 데이터가 유효하지 않습니다.',
Migrate_UnknownDataVersion = '입력 데이터의 버전이 잘못되었습니다.',
Migrate_DataVersionTooNew = '입력 데이터의 버전이 지원하는 버전보다 최신입니다.',
Migrate_DataPropertyWillBeOverwritten = '입력 데이터의 속성이 덮어쓰여집니다. 제거하고 다시 실행해 주세요.',
}
101 changes: 101 additions & 0 deletions packages/gear/src/lib/manage/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { GearType } from "../data";
import { migrate } from "./migrate";

describe('migrate', () => {
it('GearDataV1을 GearDataV2로 마이그레이션한다.', () => {
const data = {
meta: {
id: 1000000,
version: 1,
},
name: '테스트용 장비',
type: GearType.cap,
req: {},
attributes: {},
};
expect(migrate(data)).toEqual({
id: 1000000,
version: 2,
name: '테스트용 장비',
type: GearType.cap,
req: {},
attributes: {},
});
});

it('GearDataV2를 GearDataV2로 마이그레이션한다.', () => {
const data = {
id: 1000000,
version: 2,
name: '테스트용 장비',
type: GearType.cap,
req: {},
attributes: {},
};
expect(migrate(data)).toEqual(data);
});

it('GearDataV3을 전달하면 TypeError가 발생한다.', () => {
const data = {
id: 1000000,
version: 3,
name: '테스트용 장비',
type: GearType.cap,
};
expect(() => migrate(data)).toThrow(TypeError);
});

it('빈 객체를 전달하면 TypeError가 발생한다.', () => {
expect(() => migrate({})).toThrow(TypeError);
});

it('GearDataV1에서 id가 숫자가 아닌 경우 TypeError가 발생한다.', () => {
const data = {
meta: {
id: '1000000',
version: 1,
},
name: '테스트용 장비',
type: GearType.cap,
req: {},
attributes: {},
};
expect(() => migrate(data)).toThrow(TypeError);
});

it('GearDataV1에서 id가 없는 경우 TypeError가 발생한다.', () => {
const data = {
meta: {
version: 1,
},
name: '테스트용 장비',
type: GearType.cap,
};
expect(() => migrate(data)).toThrow(TypeError);
});

it('GearData에 포함되지 않는 속성도 함께 마이그레이션한다.', () => {
const data = {
id: 1000000,
version: 2,
name: '테스트용 장비',
type: GearType.cap,
req: {
unknown: 123,
},
attributes: {},
unknown: 'unknown',
};
expect(migrate(data)).toEqual({
id: 1000000,
version: 2,
name: '테스트용 장비',
type: GearType.cap,
req: {
unknown: 123,
},
attributes: {},
unknown: 'unknown',
});
});
});
48 changes: 48 additions & 0 deletions packages/gear/src/lib/manage/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { VERSION } from '../data';
import { ErrorMessage } from '../errors';
import { getVersion } from './version';

export function migrate(data: object, version: number = VERSION): object {
const currentVersion = getVersion(data);
if (currentVersion === undefined) {
throw new TypeError(ErrorMessage.Migrate_InvalidGearData);
}
if (currentVersion > version) {
throw new TypeError(ErrorMessage.Migrate_DataVersionTooNew);
}
if (currentVersion === version) {
return data;
}
switch (currentVersion) {
case 1:
return migrateV1ToV2(data);
default:
throw new TypeError(ErrorMessage.Migrate_UnknownDataVersion);
}
}

function migrateV1ToV2(data: object): object {
if (
('id' in data && data.id !== undefined) ||
('version' in data && data.version !== undefined)
) {
throw new TypeError(ErrorMessage.Migrate_DataPropertyWillBeOverwritten);
}
if (
!(
'meta' in data &&
typeof data.meta === 'object' &&
data.meta !== null &&
'id' in data.meta &&
typeof data.meta.id === 'number'
)
) {
throw new TypeError('Assert: id should exist');
}
const { meta, ...rest } = data;
return {
...rest,
id: meta.id,
version: 2,
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Migration silently discards custom properties in meta object

The migrateV1ToV2 function extracts only id from the meta object and discards everything else. The old GearMetadata interface documentation stated that users could add custom properties prefixed with _ to meta. During migration, these custom properties are silently lost since only meta.id is preserved in the output.

Fix in Cursor Fix in Web

}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Migration doesn't provide default value for required icon field

The migrateV1ToV2 function doesn't add an icon field when migrating from V1 to V2. The icon property changed from optional (in V1) to required (in V2), and the ReadonlyGear.icon getter removed its fallback value (?? ''). When V1 data without an icon is migrated, the resulting V2 data will lack the required icon field, causing ReadonlyGear.icon to return undefined instead of a string.

Additional Locations (1)

Fix in Cursor Fix in Web

Loading