From 0054bcb8f1cd80252cb09d7b13a4dc92a38bd216 Mon Sep 17 00:00:00 2001 From: mew-ton Date: Thu, 18 Jan 2024 17:25:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20performance=20regression=20test=20?= =?UTF-8?q?=E3=81=AE=E5=B0=8E=E5=85=A5=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add performance testing * fix: testing failed to create complex object for testing * refactor: remove describe block * fix: bench実施処理を最適化 * fix: bench の一部がコケる問題を暫定的に対処 * misc: ついでのテストケース追加 * feat: codspeed 追加 * fix: こまい action 修正 * Create ninety-pens-suffer.md * fix: 不要なコメントの削除 * fix: テスト縮小 * fix: テスト縮小 --- .changeset/ninety-pens-suffer.md | 5 + .github/renovate.json | 3 +- .github/workflows/codspeed.yml | 36 +++ .vscode/project.code-workspace | 3 + package.json | 3 + src/fold.ts | 8 +- src/type.ts | 2 +- test/fold.bench.ts | 36 +++ test/fold.spec.ts | 254 +++++++++-------- test/omit.bench.ts | 68 +++++ test/omit.spec.ts | 253 +++++++++-------- test/pick.bench.ts | 68 +++++ test/pick.spec.ts | 270 +++++++++---------- test/twist.bench.ts | 72 +++++ test/twist.spec.ts | 449 +++++++++++++++---------------- test/unfold.bench.ts | 31 +++ test/unfold.spec.ts | 350 ++++++++++++++---------- test/utils/constants.ts | 4 + test/utils/factory.spec.ts | 11 + test/utils/factory.ts | 95 +++++++ test/utils/index.ts | 3 + test/utils/random.ts | 24 ++ vitest.config.ts | 9 + yarn.lock | 16 ++ 24 files changed, 1322 insertions(+), 751 deletions(-) create mode 100644 .changeset/ninety-pens-suffer.md create mode 100644 .github/workflows/codspeed.yml create mode 100644 test/fold.bench.ts create mode 100644 test/omit.bench.ts create mode 100644 test/pick.bench.ts create mode 100644 test/twist.bench.ts create mode 100644 test/unfold.bench.ts create mode 100644 test/utils/constants.ts create mode 100644 test/utils/factory.spec.ts create mode 100644 test/utils/factory.ts create mode 100644 test/utils/index.ts create mode 100644 test/utils/random.ts create mode 100644 vitest.config.ts diff --git a/.changeset/ninety-pens-suffer.md b/.changeset/ninety-pens-suffer.md new file mode 100644 index 0000000..6d7cbe3 --- /dev/null +++ b/.changeset/ninety-pens-suffer.md @@ -0,0 +1,5 @@ +--- +"json-origami": patch +--- + +unfold 関数にて、値が空配列・空オブジェクトのときに値が結果に反映されない問題の修正 diff --git a/.github/renovate.json b/.github/renovate.json index 8451713..6cff233 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -6,5 +6,6 @@ "github>hacomono-lib/renovate-config:npm-lockfile", "github>hacomono-lib/renovate-config:github-actions", "github>hacomono-lib/renovate-config:pre-commit" - ] + ], + "platformAutomerge": true } diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000..77f56f8 --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,36 @@ +name: Codspeed Benchmarks + +permissions: + contents: read + packages: read + pull-requests: write + +on: + push: + branches: + - "main" + pull_request: + +jobs: + init__node: + if: | + !contains(github.event.pull_request.labels.*.name, 'renovate') + name: "Initialize: node" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/init-node + + benchmarks: + runs-on: ubuntu-latest + needs: + - init__node + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/init-node + + - name: Run benchmarks + uses: CodSpeedHQ/action@v2 + with: + run: yarn bench + token: ${{ secrets.CODSPEED_TOKEN }} diff --git a/.vscode/project.code-workspace b/.vscode/project.code-workspace index 1d7da00..2efc442 100644 --- a/.vscode/project.code-workspace +++ b/.vscode/project.code-workspace @@ -6,5 +6,8 @@ ], "settings": { "typescript.tsdk": "node_modules/typescript/lib" + }, + "extensions": { + "recommendations": ["zixuanchen.vitest-explorer"] } } diff --git a/package.json b/package.json index eab5043..b3883c3 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "scripts": { "dev": "vitest --ui", + "bench": "vitest bench", "build": "tsup", "test": "run-p test:*", "test:spec": "vitest --run --silent", @@ -51,11 +52,13 @@ "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", "@vitest/ui": "^1.2.0", + "defu": "^6.1.4", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", "prettier": "^3.2.2", "tsup": "^8.0.1", + "type-fest": "^4.9.0", "typescript": "^5.3.3", "vitest": "^1.2.0", "yarn-run-all": "latest" diff --git a/src/fold.ts b/src/fold.ts index 939b821..13415d2 100644 --- a/src/fold.ts +++ b/src/fold.ts @@ -33,6 +33,8 @@ import { * ``` */ export function fold(obj: D, option?: FoldOption): Folded { + if (Object.keys(obj).length <= 0) return {} + return Object.fromEntries( flatEntries(option?.keyPrefix ?? '', obj, { ...defaultCommonOption, @@ -52,13 +54,13 @@ function flatEntries(key: string, value: object, opt: FixedFoldOption): Array<[s const appendKey = (k: string | number) => typeof k === 'number' ? arrayKeyMap[opt.arrayIndex](key, k) : key === '' ? k : `${key}.${k}` - if (Array.isArray(value)) { + if (Array.isArray(value) && value.length > 0) { return value.flatMap((v, i) => flatEntries(appendKey(i), v, opt)) } - if (typeof value === 'object') { + if (typeof value === 'object' && Object.keys(value).length > 0) { return Object.entries(value as object).flatMap(([k, v]) => flatEntries(appendKey(k), v, opt)) } - return [[key, value as Primitive]] + return [[key, value]] } diff --git a/src/type.ts b/src/type.ts index ecd5639..b35f739 100644 --- a/src/type.ts +++ b/src/type.ts @@ -1,7 +1,7 @@ /** * */ -export type Primitive = string | number | boolean +export type Primitive = string | number | boolean | {} | [] type MaybeReadonly = T | (T extends Array ? readonly U[] : Readonly) diff --git a/test/fold.bench.ts b/test/fold.bench.ts new file mode 100644 index 0000000..2910f45 --- /dev/null +++ b/test/fold.bench.ts @@ -0,0 +1,36 @@ +import { bench, describe } from 'vitest' +import { + createRandomObject, + BENCHMARK_TARGET_OBJECT_VALUES, + BENCHMARK_TARGET_LIGHT_OBJECT_VALUES +} from './utils' +import { fold } from '../src/fold' + +const iterations = 10 + +interface TestCaseOption { + /** + * 生成するオブジェクトの値の数 + */ + objectValues: number +} + +function runBench({ objectValues }: TestCaseOption) { + const objects = Array.from({ length: iterations }, () => + createRandomObject({ leafs: objectValues }) + ) + let index = 0 + + bench(`fold (complex object including ${objectValues} values)`, () => { + const object = objects[index++ % iterations] + fold(object) + }) +} + +describe('fold with light object', () => { + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES }) +}) + +describe('fold with heavy object', () => { + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES }) +}) diff --git a/test/fold.spec.ts b/test/fold.spec.ts index 2f408e5..3c4c6cf 100644 --- a/test/fold.spec.ts +++ b/test/fold.spec.ts @@ -1,144 +1,178 @@ -/* eslint-disable max-lines-per-function */ -import { describe, it, expect } from 'vitest' +/* eslint-disable max-lines */ +import { it, expect } from 'vitest' import { fold } from '../src/fold' -describe('fold', () => { - it('should handle nested object', () => { - const target = { +it('should handle empty', () => { + const target = {} + expect(fold(target)).toEqual({}) +}) + +it('should handle nested object', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4] + } + } + expect(fold(target)).toEqual({ + a: 1, + 'b.c': 2, + 'b.d[0]': 3, + 'b.d[1]': 4 + }) +}) + +it('should handle nested object with dot array index', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4] + } + } + expect(fold(target, { arrayIndex: 'dot' })).toEqual({ + a: 1, + 'b.c': 2, + 'b.d.0': 3, + 'b.d.1': 4 + }) +}) + +it('should handle nested object with root array', () => { + const target = [ + { a: 1, b: { c: 2, d: [3, 4] } + }, + { + e: 5, + f: { + g: 6, + h: [7, 8] + } } - expect(fold(target)).toEqual({ - a: 1, - 'b.c': 2, - 'b.d[0]': 3, - 'b.d[1]': 4 - }) + ] as const + + expect(fold(target)).toEqual({ + '[0].a': 1, + '[0].b.c': 2, + '[0].b.d[0]': 3, + '[0].b.d[1]': 4, + '[1].e': 5, + '[1].f.g': 6, + '[1].f.h[0]': 7, + '[1].f.h[1]': 8 }) +}) - it('should handle nested object with dot array index', () => { - const target = { +it('should handle nested object with root array with dot array index', () => { + const target = [ + { a: 1, b: { c: 2, d: [3, 4] } - } - expect(fold(target, { arrayIndex: 'dot' })).toEqual({ - a: 1, - 'b.c': 2, - 'b.d.0': 3, - 'b.d.1': 4 - }) - }) - - it('should handle nested object with root array', () => { - const target = [ - { - a: 1, - b: { - c: 2, - d: [3, 4] - } - }, - { - e: 5, - f: { - g: 6, - h: [7, 8] - } + }, + { + e: 5, + f: { + g: 6, + h: [7, 8] } - ] as const + } + ] as const - expect(fold(target)).toEqual({ - '[0].a': 1, - '[0].b.c': 2, - '[0].b.d[0]': 3, - '[0].b.d[1]': 4, - '[1].e': 5, - '[1].f.g': 6, - '[1].f.h[0]': 7, - '[1].f.h[1]': 8 - }) + expect(fold(target, { arrayIndex: 'dot' })).toEqual({ + '0.a': 1, + '0.b.c': 2, + '0.b.d.0': 3, + '0.b.d.1': 4, + '1.e': 5, + '1.f.g': 6, + '1.f.h.0': 7, + '1.f.h.1': 8 }) +}) - it('should handle nested object with root array with dot array index', () => { - const target = [ - { - a: 1, - b: { - c: 2, - d: [3, 4] - } - }, - { - e: 5, - f: { - g: 6, - h: [7, 8] - } - } - ] as const +it('should handle nested object with key prefix', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4] + } + } - expect(fold(target, { arrayIndex: 'dot' })).toEqual({ - '0.a': 1, - '0.b.c': 2, - '0.b.d.0': 3, - '0.b.d.1': 4, - '1.e': 5, - '1.f.g': 6, - '1.f.h.0': 7, - '1.f.h.1': 8 - }) + expect(fold(target, { keyPrefix: 'root' })).toEqual({ + 'root.a': 1, + 'root.b.c': 2, + 'root.b.d[0]': 3, + 'root.b.d[1]': 4 }) +}) - it('should handle ested object with key prefix', () => { - const target = { +it('should handle nested object with root array with key prefix', () => { + const target = [ + { a: 1, b: { c: 2, d: [3, 4] } + }, + { + e: 5, + f: { + g: 6, + h: [7, 8] + } } + ] as const - expect(fold(target, { keyPrefix: 'root' })).toEqual({ - 'root.a': 1, - 'root.b.c': 2, - 'root.b.d[0]': 3, - 'root.b.d[1]': 4 - }) + expect(fold(target, { keyPrefix: 'root' })).toEqual({ + 'root[0].a': 1, + 'root[0].b.c': 2, + 'root[0].b.d[0]': 3, + 'root[0].b.d[1]': 4, + 'root[1].e': 5, + 'root[1].f.g': 6, + 'root[1].f.h[0]': 7, + 'root[1].f.h[1]': 8 }) +}) - it('should handle nested object with root array with key prefix', () => { - const target = [ - { - a: 1, - b: { - c: 2, - d: [3, 4] - } - }, - { - e: 5, - f: { - g: 6, - h: [7, 8] - } - } - ] as const +it('should handle nested object with empty object / empty array', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4], + e: {}, + f: { g: {} }, + h: [], + i: [ [] ], + j: [ {} ], + k: [ {}, {} ] + } + } - expect(fold(target, { keyPrefix: 'root' })).toEqual({ - 'root[0].a': 1, - 'root[0].b.c': 2, - 'root[0].b.d[0]': 3, - 'root[0].b.d[1]': 4, - 'root[1].e': 5, - 'root[1].f.g': 6, - 'root[1].f.h[0]': 7, - 'root[1].f.h[1]': 8 - }) + expect(fold(target)).toEqual({ + a: 1, + 'b.c': 2, + 'b.d[0]': 3, + 'b.d[1]': 4, + 'b.e': {}, + 'b.f.g': {}, + 'b.h': [], + 'b.i[0]': [], + 'b.j[0]': {}, + 'b.k[0]': {}, + 'b.k[1]': {} }) }) + diff --git a/test/omit.bench.ts b/test/omit.bench.ts new file mode 100644 index 0000000..209790c --- /dev/null +++ b/test/omit.bench.ts @@ -0,0 +1,68 @@ +import type { JsonObject } from 'type-fest' +import { bench, describe } from 'vitest' +import { + createRandomObject, + randomChoices, + BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, + BENCHMARK_TARGET_OBJECT_VALUES +} from './utils' +import { fold } from '../src/fold' +import { omit } from '../src/omit' + +const iterations = 10 + +interface TestCaseOption { + /** + * omit するキーの割合 + */ + percentOfOmitKeys: number + + /** + * 生成するオブジェクトの値の数 + */ + objectValues: number +} + +interface TestCase { + object: JsonObject + keys: string[] +} + +function createTestCase({ percentOfOmitKeys, objectValues }: TestCaseOption): TestCase { + const object = createRandomObject({ leafs: objectValues }) + const allKeys = Object.keys(fold(object)) + const keys = randomChoices(allKeys, Math.min(objectValues, allKeys.length) * percentOfOmitKeys) + return { object, keys } +} + +function runBench({ percentOfOmitKeys, objectValues }: TestCaseOption) { + const testCases = Array.from({ length: iterations }, () => + createTestCase({ + percentOfOmitKeys, + objectValues + }) + ) + + let index = 0 + + bench( + `omit (complex object including ${objectValues} values, omit ${percentOfOmitKeys * 100}% of keys)`, + () => { + const currentIndex = index++ % iterations + const { object, keys } = testCases[currentIndex] + omit(object, keys) + } + ) +} + +describe('omit with light object', () => { + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, percentOfOmitKeys: 0.1 }) + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, percentOfOmitKeys: 0.5 }) + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, percentOfOmitKeys: 0.9 }) +}) + +describe.skip('omit with heavy object', () => { + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES, percentOfOmitKeys: 0.1 }) + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES, percentOfOmitKeys: 0.5 }) + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES, percentOfOmitKeys: 0.9 }) +}) diff --git a/test/omit.spec.ts b/test/omit.spec.ts index a01af99..c1014dc 100644 --- a/test/omit.spec.ts +++ b/test/omit.spec.ts @@ -1,160 +1,157 @@ /* eslint-disable max-lines */ -/* eslint-disable max-lines-per-function */ -import { describe, it, expect } from 'vitest' +import { it, expect } from 'vitest' import { omit } from '../src/omit' -describe('omit', () => { - it('should omit specified keys from the object', () => { - const obj = { - a: 1, - b: { - c: 2, - d: [3, 4], - e: { - f: 5, - g: 6 - } +it('should omit specified keys from the object', () => { + const obj = { + a: 1, + b: { + c: 2, + d: [3, 4], + e: { + f: 5, + g: 6 } } - const result = omit(obj, ['a', 'b.c', 'b.e.f']) - expect(result).toEqual({ - b: { - d: [3, 4], - e: { - g: 6 - } + } + const result = omit(obj, ['a', 'b.c', 'b.e.f']) + expect(result).toEqual({ + b: { + d: [3, 4], + e: { + g: 6 } - }) + } }) +}) - it('should handle arrays correctly (bracket mode)', () => { - const obj = { - a: 1, - b: { - c: 2, - d: [3, 4], - e: { - f: 5, - g: 6 - } +it('should handle arrays correctly (bracket mode)', () => { + const obj = { + a: 1, + b: { + c: 2, + d: [3, 4], + e: { + f: 5, + g: 6 } } - const result = omit(obj, ['b.d[1]']) - expect(result).toEqual({ - a: 1, - b: { - c: 2, - d: [3], - e: { - f: 5, - g: 6 - } + } + const result = omit(obj, ['b.d[1]']) + expect(result).toEqual({ + a: 1, + b: { + c: 2, + d: [3], + e: { + f: 5, + g: 6 } - }) + } }) +}) - it('should handle arrays correctly (dot mode)', () => { - const obj = { - a: 1, - b: { - c: 2, - d: [3, 4], - e: { - f: 5, - g: 6 - } +it('should handle arrays correctly (dot mode)', () => { + const obj = { + a: 1, + b: { + c: 2, + d: [3, 4], + e: { + f: 5, + g: 6 } } - const result = omit(obj, ['b.d.1']) - expect(result).toEqual({ - a: 1, - b: { - c: 2, - d: [3], - e: { - f: 5, - g: 6 - } + } + const result = omit(obj, ['b.d.1']) + expect(result).toEqual({ + a: 1, + b: { + c: 2, + d: [3], + e: { + f: 5, + g: 6 } - }) + } }) +}) - it('should handle keys that are prefixes of other keys', () => { - const obj = { - a: 1, - ab: 2, - abc: 3 - } - const result = omit(obj, ['a', 'ab']) - expect(result).toEqual({ - abc: 3 - }) +it('should handle keys that are prefixes of other keys', () => { + const obj = { + a: 1, + ab: 2, + abc: 3 + } + const result = omit(obj, ['a', 'ab']) + expect(result).toEqual({ + abc: 3 }) +}) - it('should handle keys that are prefixes of nested other keys', () => { - const obj = { - a: { - b: 1 - }, - ab: { - b: 2 - }, - abc: { - b: 3 - } +it('should handle keys that are prefixes of nested other keys', () => { + const obj = { + a: { + b: 1 + }, + ab: { + b: 2 + }, + abc: { + b: 3 } - const result = omit(obj, ['a.b', 'ab']) - expect(result).toEqual({ - abc: { b: 3 } - }) + } + const result = omit(obj, ['a.b', 'ab']) + expect(result).toEqual({ + abc: { b: 3 } }) +}) - it('should handle keys that are suffixes of other keys', () => { - const obj = { - a: 1, - ba: 2, - cba: 3 - } - const result = omit(obj, ['a', 'ba']) - expect(result).toEqual({ - cba: 3 - }) +it('should handle keys that are suffixes of other keys', () => { + const obj = { + a: 1, + ba: 2, + cba: 3 + } + const result = omit(obj, ['a', 'ba']) + expect(result).toEqual({ + cba: 3 }) +}) - it('should handle RegExp keys', () => { - const obj = { - a: { - b: { - cde: 1 - }, - bc: { - de: 2 - }, - bcd: { - e: 3 - } +it('should handle RegExp keys', () => { + const obj = { + a: { + b: { + cde: 1 + }, + bc: { + de: 2 }, - ab: { - c: { - de: 4 - }, - cd: { - e: 5 - } + bcd: { + e: 3 + } + }, + ab: { + c: { + de: 4 }, - abc: { - d: { - e: 6 - } + cd: { + e: 5 + } + }, + abc: { + d: { + e: 6 } } - const result = omit(obj, [/^a\.b/, /\.e$/]) - expect(result).toEqual({ - ab: { - c: { - de: 4 - } + } + const result = omit(obj, [/^a\.b/, /\.e$/]) + expect(result).toEqual({ + ab: { + c: { + de: 4 } - }) + } }) }) diff --git a/test/pick.bench.ts b/test/pick.bench.ts new file mode 100644 index 0000000..0456406 --- /dev/null +++ b/test/pick.bench.ts @@ -0,0 +1,68 @@ +import { bench, describe } from 'vitest' +import { + createRandomObject, + randomChoices, + BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, + BENCHMARK_TARGET_OBJECT_VALUES +} from './utils' +import { fold } from '../src/fold' +import { pick } from '../src/pick' +import { JsonObject } from 'type-fest' + +const iterations = 10 + +interface TestCaseOption { + /** + * pick するキーの割合 + */ + percentOfPickKeys: number + + /** + * 生成するオブジェクトの値の数 + */ + objectValues: number +} + +interface TestCase { + object: JsonObject + keys: string[] +} + +function createTestCase({ percentOfPickKeys, objectValues }: TestCaseOption): TestCase { + const object = createRandomObject({ leafs: objectValues }) + const allKeys = Object.keys(fold(object)) + const keys = randomChoices(allKeys, Math.min(objectValues, allKeys.length) * percentOfPickKeys) + return { object, keys } +} + +function runBench({ percentOfPickKeys, objectValues }: TestCaseOption) { + const testCases = Array.from({ length: iterations }, () => + createTestCase({ + percentOfPickKeys, + objectValues + }) + ) + + let index = 0 + + bench( + `pick (complex object including ${objectValues} values, pick ${percentOfPickKeys * 100}% of keys)`, + () => { + const currentIndex = index++ % iterations + const { object, keys } = testCases[currentIndex] + pick(object, keys) + } + ) +} + +describe('pick with light object', () => { + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, percentOfPickKeys: 0.1 }) + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, percentOfPickKeys: 0.5 }) + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, percentOfPickKeys: 0.9 }) +}) + +describe.skip('pick with heavy object', () => { + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES, percentOfPickKeys: 0.1 }) + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES, percentOfPickKeys: 0.5 }) + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES, percentOfPickKeys: 0.9 }) +}) diff --git a/test/pick.spec.ts b/test/pick.spec.ts index 4e29c43..53c7f68 100644 --- a/test/pick.spec.ts +++ b/test/pick.spec.ts @@ -1,169 +1,167 @@ /* eslint-disable max-lines */ /* eslint-disable max-lines-per-function */ -import { describe, it, expect } from 'vitest' +import { it, expect } from 'vitest' import { pick } from '../src/pick' -describe('pick', () => { - it('should pick specified keys from the object', () => { - const obj = { - a: 1, - b: { - c: 2, - d: [3, 4], - e: { - f: 5, - g: 6 - } +it('should pick specified keys from the object', () => { + const obj = { + a: 1, + b: { + c: 2, + d: [3, 4], + e: { + f: 5, + g: 6 } } - const result = pick(obj, ['a', 'b.c', 'b.d', 'b.e.f']) - expect(result).toEqual({ - a: 1, - b: { - c: 2, - d: [3, 4], - e: { - f: 5 - } + } + const result = pick(obj, ['a', 'b.c', 'b.d', 'b.e.f']) + expect(result).toEqual({ + a: 1, + b: { + c: 2, + d: [3, 4], + e: { + f: 5 } - }) + } }) +}) - it('should handle arrays correctly (bracket mode)', () => { - const obj = { - a: 1, - b: { - c: 2, - d: [3, 4], - e: { - f: 5, - g: 6 - } +it('should handle arrays correctly (bracket mode)', () => { + const obj = { + a: 1, + b: { + c: 2, + d: [3, 4], + e: { + f: 5, + g: 6 } } - const result = pick(obj, ['b.d[0]']) - expect(result).toEqual({ - b: { - d: [3] - } - }) + } + const result = pick(obj, ['b.d[0]']) + expect(result).toEqual({ + b: { + d: [3] + } }) +}) - it('should handle arrays correctly (dot mode)', () => { - const obj = { - a: 1, - b: { - c: 2, - d: [3, 4], - e: { - f: 5, - g: 6 - } +it('should handle arrays correctly (dot mode)', () => { + const obj = { + a: 1, + b: { + c: 2, + d: [3, 4], + e: { + f: 5, + g: 6 } } - const result = pick(obj, ['b.d[0]']) - expect(result).toEqual({ - b: { - d: [3] - } - }) + } + const result = pick(obj, ['b.d[0]']) + expect(result).toEqual({ + b: { + d: [3] + } }) +}) - it('should handle keys that are prefixes of other keys', () => { - const obj = { - a: 1, - ab: 2, - abc: 3 - } - const result = pick(obj, ['a', 'ab']) - expect(result).toEqual({ - a: 1, - ab: 2 - }) +it('should handle keys that are prefixes of other keys', () => { + const obj = { + a: 1, + ab: 2, + abc: 3 + } + const result = pick(obj, ['a', 'ab']) + expect(result).toEqual({ + a: 1, + ab: 2 }) +}) - it('should handle keys that are prefixes of nested other keys', () => { - const obj = { - a: { - b: 1 - }, - ab: { - b: 2 - }, - abc: { - b: 3 - } +it('should handle keys that are prefixes of nested other keys', () => { + const obj = { + a: { + b: 1 + }, + ab: { + b: 2 + }, + abc: { + b: 3 } - const result = pick(obj, ['a', 'ab.b']) - expect(result).toEqual({ - a: { b: 1 }, - ab: { b: 2 } - }) + } + const result = pick(obj, ['a', 'ab.b']) + expect(result).toEqual({ + a: { b: 1 }, + ab: { b: 2 } }) +}) - it('should handle keys that are suffixes of other keys', () => { - const obj = { - a: 1, - ba: 2, - cba: 3 - } - const result = pick(obj, ['a', 'ba']) - expect(result).toEqual({ - a: 1, - ba: 2 - }) +it('should handle keys that are suffixes of other keys', () => { + const obj = { + a: 1, + ba: 2, + cba: 3 + } + const result = pick(obj, ['a', 'ba']) + expect(result).toEqual({ + a: 1, + ba: 2 }) +}) - it('should handle RegExp keys', () => { - const obj = { - a: { - b: { - cde: 1 - }, - bc: { - de: 2 - }, - bcd: { - e: 3 - } +it('should handle RegExp keys', () => { + const obj = { + a: { + b: { + cde: 1 }, - ab: { - c: { - de: 4 - }, - cd: { - e: 5 - } + bc: { + de: 2 }, - abc: { - d: { - e: 6 - } + bcd: { + e: 3 + } + }, + ab: { + c: { + de: 4 + }, + cd: { + e: 5 + } + }, + abc: { + d: { + e: 6 } } - const result = pick(obj, [/^a\.b/, /\.e$/]) - expect(result).toEqual({ - a: { - b: { - cde: 1 - }, - bc: { - de: 2 - }, - bcd: { - e: 3 - } + } + const result = pick(obj, [/^a\.b/, /\.e$/]) + expect(result).toEqual({ + a: { + b: { + cde: 1 }, - ab: { - cd: { - e: 5 - } + bc: { + de: 2 }, - abc: { - d: { - e: 6 - } + bcd: { + e: 3 } - }) + }, + ab: { + cd: { + e: 5 + } + }, + abc: { + d: { + e: 6 + } + } }) }) diff --git a/test/twist.bench.ts b/test/twist.bench.ts new file mode 100644 index 0000000..b951e33 --- /dev/null +++ b/test/twist.bench.ts @@ -0,0 +1,72 @@ +import { bench, describe } from 'vitest' +import { + createRandomObject, + randomChoices, + randomKeyName, + BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, + BENCHMARK_TARGET_OBJECT_VALUES +} from './utils' +import { fold } from '../src/fold' +import { twist } from '../src/twist' + +const iterations = 10 + +interface TestCaseOption { + /** + * twist するキーの割合 + */ + percentOfTwistKeys: number + + /** + * 生成するオブジェクトの値の数 + */ + objectValues: number +} + +interface TestCase { + object: Record + twistMap: Record +} + +function createTestCase({ percentOfTwistKeys, objectValues }: TestCaseOption): TestCase { + const object = createRandomObject({ leafs: objectValues }) + const allKeys = Object.keys(fold(object)) + const keys = randomChoices(allKeys, Math.min(objectValues, allKeys.length) * percentOfTwistKeys) + const twistMap = keys.reduce((acc, key) => { + acc[key] = randomKeyName() + return acc + }, {}) + return { object, twistMap } +} + +function runBench({ percentOfTwistKeys, objectValues }: TestCaseOption) { + const testCases = Array.from({ length: iterations }, () => + createTestCase({ + percentOfTwistKeys, + objectValues + }) + ) + + let index = 0 + + bench( + `twist (complex object including ${objectValues} values, twist ${percentOfTwistKeys * 100}% of keys)`, + () => { + const currentIndex = index++ % iterations + const { object, twistMap } = testCases[currentIndex] + twist(object, twistMap) + } + ) +} + +describe('twist with light object', () => { + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, percentOfTwistKeys: 0.1 }) + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, percentOfTwistKeys: 0.5 }) + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, percentOfTwistKeys: 0.9 }) +}) + +describe.skip('twist with heavy object', () => { + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES, percentOfTwistKeys: 0.1 }) + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES, percentOfTwistKeys: 0.5 }) + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES, percentOfTwistKeys: 0.9 }) +}) diff --git a/test/twist.spec.ts b/test/twist.spec.ts index 2291bd9..765dbe7 100644 --- a/test/twist.spec.ts +++ b/test/twist.spec.ts @@ -1,286 +1,283 @@ /* eslint-disable max-lines */ -/* eslint-disable max-lines-per-function */ -import { describe, it, expect } from 'vitest' +import { it, expect } from 'vitest' import { twist } from '../src/twist' -describe('twist', () => { - it('twist partial keys', () => { - const target = { - a: 1, - b: { - c: 2, - d: [3, 4] - } +it('twist partial keys', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4] } + } - expect(twist(target, { a: 'A', b: 'B', c: 'C', d: 'D' })).toEqual({ - A: 1, - B: { - c: 2, - d: [3, 4] - } - }) + expect(twist(target, { a: 'A', b: 'B', c: 'C', d: 'D' })).toEqual({ + A: 1, + B: { + c: 2, + d: [3, 4] + } }) +}) - it('twist nested keys', () => { - const target = { - a: 1, - b: { - c: 2, - d: [3, 4] - } +it('twist nested keys', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4] } + } - expect(twist(target, { 'b.c': 'C', 'b.d': 'D' })).toEqual({ - a: 1, - C: 2, - D: [3, 4] - }) + expect(twist(target, { 'b.c': 'C', 'b.d': 'D' })).toEqual({ + a: 1, + C: 2, + D: [3, 4] }) +}) + +it('merge existed key', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4] + } + } - it('merge existed key', () => { - const target = { + expect(twist(target, { a: 'b.a' })).toEqual({ + b: { a: 1, - b: { - c: 2, - d: [3, 4] - } + c: 2, + d: [3, 4] + } + }) +}) + +it('swap object keys', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4] } + } - expect(twist(target, { a: 'b.a' })).toEqual({ - b: { - a: 1, - c: 2, - d: [3, 4] - } - }) + expect(twist(target, { a: 'b', b: 'a' })).toEqual({ + a: { + c: 2, + d: [3, 4] + }, + b: 1 }) +}) - it('swap object keys', () => { - const target = { - a: 1, - b: { - c: 2, - d: [3, 4] - } +it('swap array index', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4] } + } - expect(twist(target, { a: 'b', b: 'a' })).toEqual({ - a: { - c: 2, - d: [3, 4] - }, - b: 1 - }) + expect(twist(target, { 'b.d[0]': 'b.d[1]', 'b.d[1]': 'b.d[0]' })).toEqual({ + a: 1, + b: { + c: 2, + d: [4, 3] + } + }) +}) + +it('swap array index with dot array index', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4] + } + } + + expect(twist(target, { 'b.d.0': 'b.d.1', 'b.d.1': 'b.d.0' }, { arrayIndex: 'dot' })).toEqual({ + a: 1, + b: { + c: 2, + d: [4, 3] + } }) +}) - it('swap array index', () => { - const target = { +it('should handle object with numeric and string keys in root', () => { + const target = [ + { a: 1, b: { c: 2, d: [3, 4] } + }, + { + e: 5, + f: { + g: 6, + h: [7, 8] + } } + ] as const - expect(twist(target, { 'b.d[0]': 'b.d[1]', 'b.d[1]': 'b.d[0]' })).toEqual({ - a: 1, + expect( + twist(target, { '[0].a': 'A', '[0].b.c': 'C', '[1].f.h[0]': 'D', '[1].f.h[1]': 'E' }) + ).toEqual({ + '0': { b: { - c: 2, - d: [4, 3] + d: [3, 4] } - }) + }, + '1': { + e: 5, + f: { + g: 6 + } + }, + A: 1, + C: 2, + D: 7, + E: 8 }) +}) - it('swap array index with dot array index', () => { - const target = { +it('should handle object with numeric and string keys in root with dot array index', () => { + const target = [ + { a: 1, b: { c: 2, d: [3, 4] } + }, + { + e: 5, + f: { + g: 6, + h: [7, 8] + } } + ] as const - expect(twist(target, { 'b.d.0': 'b.d.1', 'b.d.1': 'b.d.0' }, { arrayIndex: 'dot' })).toEqual({ - a: 1, + expect( + twist( + target, + { '0.a': 'A', '0.b.c': 'C', '1.f.h.0': 'D', '1.f.h.1': 'E' }, + { arrayIndex: 'dot' } + ) + ).toEqual({ + '0': { b: { - c: 2, - d: [4, 3] - } - }) - }) - - it('should handle object with numeric and string keys in root', () => { - const target = [ - { - a: 1, - b: { - c: 2, - d: [3, 4] - } - }, - { - e: 5, - f: { - g: 6, - h: [7, 8] - } + d: [3, 4] } - ] as const - - expect( - twist(target, { '[0].a': 'A', '[0].b.c': 'C', '[1].f.h[0]': 'D', '[1].f.h[1]': 'E' }) - ).toEqual({ - '0': { - b: { - d: [3, 4] - } - }, - '1': { - e: 5, - f: { - g: 6 - } - }, - A: 1, - C: 2, - D: 7, - E: 8 - }) - }) - - it('should handle object with numeric and string keys in root with dot array index', () => { - const target = [ - { - a: 1, - b: { - c: 2, - d: [3, 4] - } - }, - { - e: 5, - f: { - g: 6, - h: [7, 8] - } + }, + '1': { + e: 5, + f: { + g: 6 } - ] as const - - expect( - twist( - target, - { '0.a': 'A', '0.b.c': 'C', '1.f.h.0': 'D', '1.f.h.1': 'E' }, - { arrayIndex: 'dot' } - ) - ).toEqual({ - '0': { - b: { - d: [3, 4] - } - }, - '1': { - e: 5, - f: { - g: 6 - } - }, - A: 1, - C: 2, - D: 7, - E: 8 - }) + }, + A: 1, + C: 2, + D: 7, + E: 8 }) +}) - it('should prune array elements', () => { - const target = { - a: 1, - b: { - c: 2, - d: [3, 4, 5, 6] - } +it('should prune array elements', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4, 5, 6] } + } - expect(twist(target, { 'b.d[1]': 'D' }, { pruneArray: true })).toEqual({ - a: 1, - b: { - c: 2, - d: [3, 5, 6] - }, - D: 4 - }) + expect(twist(target, { 'b.d[1]': 'D' }, { pruneArray: true })).toEqual({ + a: 1, + b: { + c: 2, + d: [3, 5, 6] + }, + D: 4 }) +}) - it('should not prune array elements', () => { - const target = { - a: 1, - b: { - c: 2, - d: [3, 4, 5, 6] - } +it('should not prune array elements', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4, 5, 6] } + } - expect(twist(target, { 'b.d[1]': 'D' }, { pruneArray: false })).toEqual({ - a: 1, - b: { - c: 2, - d: [3, undefined, 5, 6] - }, - D: 4 - }) + expect(twist(target, { 'b.d[1]': 'D' }, { pruneArray: false })).toEqual({ + a: 1, + b: { + c: 2, + d: [3, undefined, 5, 6] + }, + D: 4 }) +}) - it('should return the original object when the second argument is an empty object', () => { - const target = { - a: 1, - b: { - c: 2, - d: [3, 4] - } +it('should return the original object when the second argument is an empty object', () => { + const target = { + a: 1, + b: { + c: 2, + d: [3, 4] } + } - expect(twist(target, {})).toEqual({ - a: 1, - b: { - c: 2, - d: [3, 4] - } - }) + expect(twist(target, {})).toEqual({ + a: 1, + b: { + c: 2, + d: [3, 4] + } }) +}) - // map データのキーが前方一致する組み合わせがある場合. e.g. foo.bar, foo.bar1 - it('should be twisted correctly when the prefix is the same string.', () => { - const target = { - foo: { - bar: 0, - bar1: 1, - bar2: 2, - barbar: 3, - }, - foo1: { - bar: 4, - bar1: 5, - bar2: 6 - } +// map データのキーが前方一致する組み合わせがある場合. e.g. foo.bar, foo.bar1 +it('should be twisted correctly when the prefix is the same string.', () => { + const target = { + foo: { + bar: 0, + bar1: 1, + bar2: 2, + barbar: 3 + }, + foo1: { + bar: 4, + bar1: 5, + bar2: 6 } + } - const map = { - 'foo.bar': 'baz', - 'foo.bar1': 'qux', - 'foo.bar2': 'quux', - 'foo.barbar': 'quux1' - } + const map = { + 'foo.bar': 'baz', + 'foo.bar1': 'qux', + 'foo.bar2': 'quux', + 'foo.barbar': 'quux1' + } - expect(twist(target, map)).toEqual({ - foo1: { - bar: 4, - bar1: 5, - bar2: 6 - }, - baz: 0, - qux: 1, - quux: 2, - quux1: 3 - }) + expect(twist(target, map)).toEqual({ + foo1: { + bar: 4, + bar1: 5, + bar2: 6 + }, + baz: 0, + qux: 1, + quux: 2, + quux1: 3 }) }) diff --git a/test/unfold.bench.ts b/test/unfold.bench.ts new file mode 100644 index 0000000..a747fb7 --- /dev/null +++ b/test/unfold.bench.ts @@ -0,0 +1,31 @@ +import { bench, describe } from 'vitest' +import { + createRandomObject, + BENCHMARK_TARGET_LIGHT_OBJECT_VALUES, + BENCHMARK_TARGET_OBJECT_VALUES +} from './utils' +import { unfold } from '../src/unfold' +import { fold } from '../src/fold' + +interface TestCaseOption { + /** + * 生成するオブジェクトの値の数 + */ + objectValues: number +} + +function runBench({ objectValues }: TestCaseOption) { + const object = fold(createRandomObject({ leafs: objectValues })) + + bench(`unfold (complex object including ${objectValues} values)`, () => { + unfold(object) + }) +} + +describe('unfold with light object', () => { + runBench({ objectValues: BENCHMARK_TARGET_LIGHT_OBJECT_VALUES }) +}) + +describe.skip('unfold with heavy object', () => { + runBench({ objectValues: BENCHMARK_TARGET_OBJECT_VALUES }) +}) diff --git a/test/unfold.spec.ts b/test/unfold.spec.ts index ecf48c3..ffa9a44 100644 --- a/test/unfold.spec.ts +++ b/test/unfold.spec.ts @@ -1,187 +1,245 @@ /* eslint-disable max-lines */ -/* eslint-disable max-lines-per-function */ -import { describe, it, expect } from 'vitest' +import { it, expect } from 'vitest' import { unfold } from '../src/unfold' -describe('unfold', () => { - it('should handle nested object', () => { - const kv = { - a: 1, - 'b.c': 2, - 'b.d[0]': 3, - 'b.d[1]': 4 +it('should handle nested object', () => { + const kv = { + a: 1, + 'b.c': 2, + 'b.d[0]': 3, + 'b.d[1]': 4 + } + + expect(unfold(kv)).toEqual({ + a: 1, + b: { + c: 2, + d: [3, 4] } + }) +}) + +it('should handle nested object with dot array indices', () => { + const kv = { + a: 1, + 'b.c': 2, + 'b.d.0': 3, + 'b.d.1': 4 + } - expect(unfold(kv)).toEqual({ + expect(unfold(kv, { arrayIndex: 'dot' })).toEqual({ + a: 1, + b: { + c: 2, + d: [3, 4] + } + }) +}) + +it('should handle nested object with root array', () => { + const kv = { + '[0].a': 1, + '[0].b.c': 2, + '[0].b.d[0]': 3, + '[0].b.d[1]': 4, + '[1].e': 5, + '[1].f.g': 6, + '[1].f.h[0]': 7, + '[1].f.h[1]': 8 + } + + expect(unfold(kv)).toEqual([ + { a: 1, b: { c: 2, d: [3, 4] } - }) - }) - - it('should handle nested object with dot array indices', () => { - const kv = { - a: 1, - 'b.c': 2, - 'b.d.0': 3, - 'b.d.1': 4 + }, + { + e: 5, + f: { + g: 6, + h: [7, 8] + } } + ]) +}) + +it('should handle nested object with root array with dot array indices', () => { + const kv = { + '0.a': 1, + '0.b.c': 2, + '0.b.d.0': 3, + '0.b.d.1': 4, + '1.e': 5, + '1.f.g': 6, + '1.f.h.0': 7, + '1.f.h.1': 8 + } - expect(unfold(kv, { arrayIndex: 'dot' })).toEqual({ + expect(unfold(kv, { arrayIndex: 'dot' })).toEqual([ + { a: 1, b: { c: 2, d: [3, 4] } - }) - }) - - it('should handle nested object with root array', () => { - const kv = { - '[0].a': 1, - '[0].b.c': 2, - '[0].b.d[0]': 3, - '[0].b.d[1]': 4, - '[1].e': 5, - '[1].f.g': 6, - '[1].f.h[0]': 7, - '[1].f.h[1]': 8 + }, + { + e: 5, + f: { + g: 6, + h: [7, 8] + } } + ]) +}) - expect(unfold(kv)).toEqual([ +it('should handle non-continuous array indices', () => { + const kv = { + 'a[1]': 1, + 'a[3]': 2, + 'a[5]': 3, + 'b[1].c': 4, + 'b[1].d': 5, + 'b[3].e': 6, + 'b[3].f': 7, + 'b[5].g': 8, + 'b[5].h': 9 + } + + expect(unfold(kv, { pruneArray: true })).toEqual({ + a: [1, 2, 3], + b: [ + { + c: 4, + d: 5 + }, { - a: 1, - b: { - c: 2, - d: [3, 4] - } + e: 6, + f: 7 }, { - e: 5, - f: { - g: 6, - h: [7, 8] - } + g: 8, + h: 9 } - ]) + ] }) +}) - it('should handle nested object with root array with dot array indices', () => { - const kv = { - '0.a': 1, - '0.b.c': 2, - '0.b.d.0': 3, - '0.b.d.1': 4, - '1.e': 5, - '1.f.g': 6, - '1.f.h.0': 7, - '1.f.h.1': 8 - } +it('should handle non-continuous array indices with undefined values', () => { + const kv = { + 'a[1]': 1, + 'a[3]': 2, + 'a[5]': 3, + 'b[1].c': 4, + 'b[1].d': 5, + 'b[3].e': 6, + 'b[3].f': 7, + 'b[5].g': 8, + 'b[5].h': 9 + } - expect(unfold(kv, { arrayIndex: 'dot' })).toEqual([ + expect(unfold(kv, { pruneArray: false })).toEqual({ + a: [undefined, 1, undefined, 2, undefined, 3], + b: [ + undefined, { - a: 1, - b: { - c: 2, - d: [3, 4] - } + c: 4, + d: 5 }, + undefined, { - e: 5, - f: { - g: 6, - h: [7, 8] - } + e: 6, + f: 7 + }, + undefined, + { + g: 8, + h: 9 } - ]) + ] }) +}) - it('should handle non-continuous array indices', () => { - const kv = { - 'a[1]': 1, - 'a[3]': 2, - 'a[5]': 3, - 'b[1].c': 4, - 'b[1].d': 5, - 'b[3].e': 6, - 'b[3].f': 7, - 'b[5].g': 8, - 'b[5].h': 9 - } +it('should handle include special characters', () => { + const kv = { + 'theme.$color_primary': '#25adc9', + 'theme.$color_secondary': '#333333', + 'theme.$color_black': '#191919', + 'feature.@mention': 'true' + } - expect(unfold(kv, { pruneArray: true })).toEqual({ - a: [1, 2, 3], - b: [ - { - c: 4, - d: 5 - }, - { - e: 6, - f: 7 - }, - { - g: 8, - h: 9 - } - ] - }) + expect(unfold(kv)).toEqual({ + feature: { + '@mention': 'true' + }, + theme: { + $color_primary: '#25adc9', + $color_secondary: '#333333', + $color_black: '#191919' + } }) +}) - it('should handle non-continuous array indices with undefined values', () => { - const kv = { - 'a[1]': 1, - 'a[3]': 2, - 'a[5]': 3, - 'b[1].c': 4, - 'b[1].d': 5, - 'b[3].e': 6, - 'b[3].f': 7, - 'b[5].g': 8, - 'b[5].h': 9 - } +it('should handle include empty object or empty array', () => { + const kv = { + 'a.b': {}, + 'a.c': [], + } - expect(unfold(kv, { pruneArray: false })).toEqual({ - a: [undefined, 1, undefined, 2, undefined, 3], - b: [ - undefined, - { - c: 4, - d: 5 - }, - undefined, - { - e: 6, - f: 7 - }, - undefined, - { - g: 8, - h: 9 - } - ] - }) + expect(unfold(kv)).toEqual({ + a: { + b: {}, + c: [] + } }) +}) - it('should handle include special characters', () => { - const kv = { - 'theme.$color_primary': '#25adc9', - 'theme.$color_secondary': '#333333', - 'theme.$color_black': '#191919', - 'feature.@mention': 'true' - } +// FIXME: should not throw error +it.skip('should handle include array key and object key in the same hierarchy.', () => { + const kv1 = { + 'a.x': 1, + 'a[0]': 2, + 'b[0]': 3, + 'b.y': 4, + } - expect(unfold(kv)).toEqual({ - feature: { - '@mention': 'true' - }, - theme: { - $color_primary: '#25adc9', - $color_secondary: '#333333', - $color_black: '#191919' - } - }) + expect(unfold(kv1)).toEqual({ + a: { + x: 1, + 0: 2 + }, + b: { + 0: 3, + y: 4 + } }) }) + +// FIXME: should not throw error +it.skip('should handle include array key and object key in root', () => { + const kv1 = { + 'a.b': 1, + '[0]': 2, + } + const kv2 = { + '[0]': 1, + 'a.b': 2, + } + expect(unfold(kv1)).toEqual({ 0: 2, a: { b: 1 } }) + expect(unfold(kv2)).toEqual({ 0: 1, a: { b: 2 } }) +}) + +// FIXME: should throw error +it.skip('should throw error when empty key exists', () => { + const kv = { + '': 1, + 'a': 2, + } + console.log(unfold(kv)) + + expect(() => unfold(kv)).toThrow() +}) + diff --git a/test/utils/constants.ts b/test/utils/constants.ts new file mode 100644 index 0000000..3d99273 --- /dev/null +++ b/test/utils/constants.ts @@ -0,0 +1,4 @@ + +export const BENCHMARK_TARGET_OBJECT_VALUES = 1_000 + +export const BENCHMARK_TARGET_LIGHT_OBJECT_VALUES = 100 diff --git a/test/utils/factory.spec.ts b/test/utils/factory.spec.ts new file mode 100644 index 0000000..fdd642a --- /dev/null +++ b/test/utils/factory.spec.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest' +import { fold } from '../../src/fold' +import { createRandomObject } from './factory' + +describe('createRandomObject', () => { + it.each([0, 30, 100, 500, 1_000, 5_000, 10_000])(`should created object has the same number of LEAFs as the specified number(%s)`, (leafs) => { + const target = createRandomObject({ leafs }) + const result = fold(target) + expect(Object.keys(result).length).toBe(leafs) + }) +}) diff --git a/test/utils/factory.ts b/test/utils/factory.ts new file mode 100644 index 0000000..9c8447f --- /dev/null +++ b/test/utils/factory.ts @@ -0,0 +1,95 @@ +/* eslint-disable max-lines-per-function */ +import type { JsonArray, JsonObject, JsonValue, Writable } from 'type-fest' +import { createRandomWord, dice, randomChoice } from './random' + +const DEFAULT_LEAFS = 1000 + +interface RandomObjectOption { + leafs: number +} + +export function createRandomObject({ leafs }: RandomObjectOption): JsonObject { + let leafCount = 0 + + if (leafs % 1 !== 0) throw new Error(`leafs must be an integer (input: ${leafs})`) + + const maxLeafs = leafs ?? DEFAULT_LEAFS + + function createNumberLeaf(): number { + leafCount++ + return leafCount + } + + function createStringLeaf(): string { + leafCount++ + return String(leafCount) + } + + function createBooleanLeaf(): boolean { + leafCount++ + return leafCount % 2 === 0 + } + + function createObject({ root }: { root?: boolean } = {}): JsonObject { + const obj: JsonObject = {} + + while ((root || dice(0.6)) && leafCount < maxLeafs) { + const key = createRandomWord() + + if (!key || key in obj) continue + + const value = createValue() + obj[key] = value + } + + if (Object.keys(obj).length <= 0) { + leafCount++ + } + + return obj + } + + function createArray(): JsonArray { + const arr: Writable = [] + + while (dice(0.3) && leafCount < maxLeafs) { + const value = createValue() + arr.push(value) + } + + if (arr.length <= 0) { + leafCount++ + } + + return arr + } + + function createValue(): JsonValue { + type ValueType = 'number' | 'string' | 'boolean' | 'object' | 'array' + + const map = { + number: createNumberLeaf, + string: createStringLeaf, + boolean: createBooleanLeaf, + object: createObject, + array: createArray + } satisfies Record JsonValue> + + return map[randomChoice([ + 'number', + 'string', + 'boolean', + 'object', + // FIXME: array だと twist できないエッジケースがあるため、一時的に無効化する + // 'array' + ])]() + } + + return createObject({ root: true }) +} + +export function randomKeyName(): string { + const length = Math.floor(Math.random() * 10) + 1 + const words = Array.from({ length }, createRandomWord) + return words.join('.') +} diff --git a/test/utils/index.ts b/test/utils/index.ts new file mode 100644 index 0000000..003beea --- /dev/null +++ b/test/utils/index.ts @@ -0,0 +1,3 @@ +export * from './factory' +export * from './random' +export * from './constants' diff --git a/test/utils/random.ts b/test/utils/random.ts new file mode 100644 index 0000000..038cabf --- /dev/null +++ b/test/utils/random.ts @@ -0,0 +1,24 @@ +export function dice(probability: number): boolean { + return Math.random() < probability +} + +export function randomChoice(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)] +} + +export function randomChoices(arr: T[], count: number): T[] { + const result = new Set() + while (result.size < count) { + result.add(randomChoice(arr)) + } + return [...result] +} + +export function createRandomWord(): string { + const length = Math.floor(Math.random() * 10) + 1 + let word = '' + for (let i = 0; i < length; i++) { + word += String.fromCharCode(Math.floor(Math.random() * 26) + 97) + } + return word +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6ed2b49 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + benchmark: { + include: ['test/**/*.bench.(js|ts)'] + } + }, +}) diff --git a/yarn.lock b/yarn.lock index 78b074c..4bb65f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2042,6 +2042,13 @@ __metadata: languageName: node linkType: hard +"defu@npm:^6.1.4": + version: 6.1.4 + resolution: "defu@npm:6.1.4" + checksum: aeffdb47300f45b4fdef1c5bd3880ac18ea7a1fd5b8a8faf8df29350ff03bf16dd34f9800205cab513d476e4c0a3783aa0cff0a433aff0ac84a67ddc4c8a2d64 + languageName: node + linkType: hard + "delegates@npm:^1.0.0": version: 1.0.0 resolution: "delegates@npm:1.0.0" @@ -3707,11 +3714,13 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^6.19.0" "@typescript-eslint/parser": "npm:^6.19.0" "@vitest/ui": "npm:^1.2.0" + defu: "npm:^6.1.4" eslint: "npm:^8.56.0" eslint-config-prettier: "npm:^9.1.0" eslint-plugin-import: "npm:^2.29.1" prettier: "npm:^3.2.2" tsup: "npm:^8.0.1" + type-fest: "npm:^4.9.0" typescript: "npm:^5.3.3" vitest: "npm:^1.2.0" yarn-run-all: "npm:latest" @@ -6000,6 +6009,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^4.9.0": + version: 4.9.0 + resolution: "type-fest@npm:4.9.0" + checksum: 49acfb67999566a24d5604435c8cff786dfc26ebea5a2a343e14d437d34f30a55248f8e597b8f64446c344bb68ce14af68899f562cf66ca66c1e1a856b393259 + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.0": version: 1.0.0 resolution: "typed-array-buffer@npm:1.0.0"