diff --git a/src/search-builder.ts b/src/search-builder.ts index 4e82a99..4f6c235 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -21,7 +21,7 @@ import type { DateParam, } from "./params.js"; import { BooleanNumber, StopParam } from "./params.js"; -import type { Join } from "./util/type.js"; +import type { Join, RangeParam } from "./util/type.js"; export type DefaultSearchResultFields = keyof Omit< NarouSearchResult, @@ -54,6 +54,34 @@ export abstract class SearchBuilderBase< return Array.from(new Set(array)); } + /** + * 範囲指定や配列をハイフン区切りの文字列に変換する + * @protected + * @static + * @param n 範囲指定オブジェクト、数値の配列、あるいは単一の数値 + * @returns ハイフン区切りの文字列 + */ + protected static range2string( + n: T | readonly [T, T] | RangeParam + ): Join | undefined { + if (typeof n === "object" && n !== null && !Array.isArray(n)) { + if ("equal" in n && typeof n.equal === "number") { + return n.equal.toString() as Join; + } else if ("min" in n || "max" in n) { + const obj = n as Extract, { min?: T, max?: T }>; + if (obj.min === undefined && obj.max === undefined) { + return undefined; + } + return `${obj.min ?? ""}-${obj.max ?? ""}` as Join; + } + return undefined; + } + if (Array.isArray(n) && n.length > 2) { + throw new Error("範囲指定の配列は要素数を2つ以内にする必要があります"); + } + return SearchBuilderBase.array2string(n) as Join; + } + /** * 配列をハイフン区切りの文字列に変換する * @protected @@ -294,11 +322,15 @@ export abstract class NovelSearchBuilderBase< /** * 抽出する作品の文字数を指定します (length)。 * 範囲指定する場合は、最小文字数と最大文字数をハイフン(-)記号で区切ってください。 - * @param length 文字数、または[最小文字数, 最大文字数] + * オブジェクトによる指定 ({ min?: number, max?: number } または { equal: number }) も可能です。 + * @param length 文字数、または[最小文字数, 最大文字数]、またはオブジェクト指定 * @return {this} */ - length(length: number | readonly number[]): this { - this.set({ length: NovelSearchBuilderBase.array2string(length) }); + length(length: number | readonly [number, number] | RangeParam): this { + const val = NovelSearchBuilderBase.range2string(length); + if (val !== undefined) { + this.set({ length: val }); + } return this; } @@ -315,8 +347,23 @@ export abstract class NovelSearchBuilderBase< * @return {this} */ kaiwaritu(min: number, max: number): this; + /** + * 抽出する作品の会話率を%単位で範囲指定またはオブジェクトで指定します (kaiwaritu)。 + * @param range 範囲指定オブジェクト + * @return {this} + */ + kaiwaritu(range: RangeParam): this; - kaiwaritu(min: number, max?: number): this { + kaiwaritu(minOrRange: number | RangeParam, max?: number): this { + if (typeof minOrRange === "object" && minOrRange !== null) { + const val = NovelSearchBuilderBase.range2string(minOrRange); + if (val !== undefined) { + this.set({ kaiwaritu: val }); + } + return this; + } + + const min = minOrRange; let n: number | string; if (max != null) { n = `${min}-${max}`; @@ -329,21 +376,29 @@ export abstract class NovelSearchBuilderBase< /** * 抽出する作品の挿絵数を指定します (sasie)。 - * @param num 挿絵数、または[最小挿絵数, 最大挿絵数] + * オブジェクトによる指定 ({ min?: number, max?: number } または { equal: number }) も可能です。 + * @param num 挿絵数、または[最小挿絵数, 最大挿絵数]、またはオブジェクト指定 * @return {this} */ - sasie(num: number | readonly number[]): this { - this.set({ sasie: NovelSearchBuilderBase.array2string(num) }); + sasie(num: number | readonly [number, number] | RangeParam): this { + const val = NovelSearchBuilderBase.range2string(num); + if (val !== undefined) { + this.set({ sasie: val }); + } return this; } /** * 抽出する作品の予想読了時間を分単位で指定します (time)。 - * @param num 読了時間(分)、または[最小読了時間, 最大読了時間] + * オブジェクトによる指定 ({ min?: number, max?: number } または { equal: number }) も可能です。 + * @param num 読了時間(分)、または[最小読了時間, 最大読了時間]、またはオブジェクト指定 * @return {this} */ - time(num: number | readonly number[]): this { - this.set({ time: NovelSearchBuilderBase.array2string(num) }); + time(num: number | readonly [number, number] | RangeParam): this { + const val = NovelSearchBuilderBase.range2string(num); + if (val !== undefined) { + this.set({ time: val }); + } return this; } diff --git a/src/util/type.ts b/src/util/type.ts index 851a5fd..4ff3405 100644 --- a/src/util/type.ts +++ b/src/util/type.ts @@ -24,3 +24,12 @@ type Stringable = string | number | bigint | boolean | null | undefined; * type JoinedNumbers = Join; // '1' | '2' | '3' | '1-1' | '1-2' | '1-3' | '2-1' | '2-2' | '2-3' | '3-1' | '3-2' | '3-3' */ export type Join = `${T}-${T}` | `${T}`; + +/** + * 範囲指定のためのオブジェクトの型。 + * @template T - 範囲指定する値の型 + */ +export type RangeParam = + | { min: T; max?: T } + | { min?: T; max: T } + | { equal: T }; diff --git a/test/search-builder.test.ts b/test/search-builder.test.ts index 57a8deb..cd07d2b 100644 --- a/test/search-builder.test.ts +++ b/test/search-builder.test.ts @@ -787,6 +787,46 @@ describe("SearchBuilder", () => { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith(`0-${length}`, "5", "json", 3); }); + + test("if length = { min: 1000 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["length", "gzip", "out"]); + + const result = await NarouAPI.search().length({ min: 1000 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`1000-`, "5", "json", 3); + }); + + test("if length = { max: 1000 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["length", "gzip", "out"]); + + const result = await NarouAPI.search().length({ max: 1000 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`-1000`, "5", "json", 3); + }); + + test("if length = { min: 100, max: 1000 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["length", "gzip", "out"]); + + const result = await NarouAPI.search().length({ min: 100, max: 1000 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`100-1000`, "5", "json", 3); + }); + + test("if length = { equal: 1000 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["length", "gzip", "out"]); + + const result = await NarouAPI.search().length({ equal: 1000 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`1000`, "5", "json", 3); + }); }); describe("kaiwaritu", () => { @@ -823,6 +863,26 @@ describe("SearchBuilder", () => { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith(`${min}-${max}`, "5", "json", 3); }); + + test("if kaiwaritu = { min: 10 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["kaiwaritu", "gzip", "out"]); + + const result = await NarouAPI.search().kaiwaritu({ min: 10 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`10-`, "5", "json", 3); + }); + + test("if kaiwaritu = { max: 50 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["kaiwaritu", "gzip", "out"]); + + const result = await NarouAPI.search().kaiwaritu({ max: 50 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`-50`, "5", "json", 3); + }); }); describe("sasie", () => { @@ -859,6 +919,54 @@ describe("SearchBuilder", () => { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith(`${min}-${max}`, "5", "json", 3); }); + + test("if sasie = array > 2 length throws", async () => { + const builder = NarouAPI.search(); + expect(() => { + // @ts-expect-error Testing invalid runtime input + builder.sasie([1, 2, 3]); + }).toThrow("範囲指定の配列は要素数を2つ以内にする必要があります"); + }); + + test("if sasie = { min: 5 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["sasie", "gzip", "out"]); + + const result = await NarouAPI.search().sasie({ min: 5 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`5-`, "5", "json", 3); + }); + + test("if sasie = { max: 10 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["sasie", "gzip", "out"]); + + const result = await NarouAPI.search().sasie({ max: 10 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`-10`, "5", "json", 3); + }); + + test("if sasie = { min: 5, max: 10 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["sasie", "gzip", "out"]); + + const result = await NarouAPI.search().sasie({ min: 5, max: 10 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`5-10`, "5", "json", 3); + }); + + test("if sasie = { equal: 5 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["sasie", "gzip", "out"]); + + const result = await NarouAPI.search().sasie({ equal: 5 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`5`, "5", "json", 3); + }); }); describe("time", () => { @@ -895,6 +1003,46 @@ describe("SearchBuilder", () => { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith(`${min}-${max}`, "5", "json", 3); }); + + test("if time = { min: 10, max: 20 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["time", "gzip", "out"]); + + const result = await NarouAPI.search().time({ min: 10, max: 20 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`10-20`, "5", "json", 3); + }); + + test("if time = { min: 10 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["time", "gzip", "out"]); + + const result = await NarouAPI.search().time({ min: 10 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`10-`, "5", "json", 3); + }); + + test("if time = { max: 20 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["time", "gzip", "out"]); + + const result = await NarouAPI.search().time({ max: 20 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`-20`, "5", "json", 3); + }); + + test("if time = { equal: 10 }", async () => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["time", "gzip", "out"]); + + const result = await NarouAPI.search().time({ equal: 10 }).execute(); + expect(result.allcount).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(`10`, "5", "json", 3); + }); }); describe("ncode", () => {