From af33ddb1ee8dc6c0ff3787ac3ff2202116ac4d25 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:27:26 +0000 Subject: [PATCH 1/4] feat: allow object format for range parameters Add support for specifying range parameters (`length`, `sasie`, `time`, `kaiwaritu`) using an object format (`{ min?: number, max?: number } | { equal: number }`). This provides a more intuitive and readable way to specify minimum/maximum constraints compared to relying strictly on arrays with `null`/`undefined`. - Created `RangeParam` type in `src/util/type.ts`. - Implemented `range2string` utility in `SearchBuilderBase`. - Updated `length`, `sasie`, `time`, and `kaiwaritu` API signatures. - Added test coverage in `test/search-builder.test.ts`. Co-authored-by: deflis <206113+deflis@users.noreply.github.com> --- src/search-builder.ts | 57 +++++++++++++++++++++----- src/util/type.ts | 6 +++ test/search-builder.test.ts | 80 +++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 11 deletions(-) diff --git a/src/search-builder.ts b/src/search-builder.ts index 4e82a99..bb33da1 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,26 @@ export abstract class SearchBuilderBase< return Array.from(new Set(array)); } + /** + * 範囲指定や配列をハイフン区切りの文字列に変換する + * @protected + * @static + * @param n 範囲指定オブジェクト、数値の配列、あるいは単一の数値 + * @returns ハイフン区切りの文字列 + */ + protected static range2string( + n: T | readonly T[] | RangeParam + ): Join { + if (typeof n === "object" && n !== null && !Array.isArray(n)) { + const obj = n as Extract, object>; + if ("equal" in obj) { + return obj.equal.toString() as Join; + } + return `${obj.min ?? ""}-${obj.max ?? ""}` as Join; + } + return SearchBuilderBase.array2string(n) as Join; + } + /** * 配列をハイフン区切りの文字列に変換する * @protected @@ -294,11 +314,12 @@ 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[] | RangeParam): this { + this.set({ length: NovelSearchBuilderBase.range2string(length) }); return this; } @@ -315,8 +336,20 @@ export abstract class NovelSearchBuilderBase< * @return {this} */ kaiwaritu(min: number, max: number): this; + /** + * 抽出する作品の会話率を%単位で範囲指定またはオブジェクトで指定します (kaiwaritu)。 + * @param range 範囲指定オブジェクト + * @return {this} + */ + kaiwaritu(range: RangeParam): this; + + kaiwaritu(minOrRange: number | RangeParam, max?: number): this { + if (typeof minOrRange === "object" && minOrRange !== null) { + this.set({ kaiwaritu: NovelSearchBuilderBase.range2string(minOrRange) }); + return this; + } - kaiwaritu(min: number, max?: number): this { + const min = minOrRange; let n: number | string; if (max != null) { n = `${min}-${max}`; @@ -329,21 +362,23 @@ 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[] | RangeParam): this { + this.set({ sasie: NovelSearchBuilderBase.range2string(num) }); 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[] | RangeParam): this { + this.set({ time: NovelSearchBuilderBase.range2string(num) }); return this; } diff --git a/src/util/type.ts b/src/util/type.ts index 851a5fd..7f80980 100644 --- a/src/util/type.ts +++ b/src/util/type.ts @@ -24,3 +24,9 @@ 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 } | { equal: T }; diff --git a/test/search-builder.test.ts b/test/search-builder.test.ts index 57a8deb..154b59c 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,16 @@ describe("SearchBuilder", () => { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith(`${min}-${max}`, "5", "json", 3); }); + + 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); + }); }); describe("time", () => { @@ -895,6 +965,16 @@ 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); + }); }); describe("ncode", () => { From bf7d4d1fa4262341eb0b8f6bc1ca4bdf02a9c45b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:51:25 +0000 Subject: [PATCH 2/4] fix: enforce min or max in RangeParam - Ensure type `RangeParam` strictly requires at least `min` or `max` when using range object notation. - Make `range2string` return `undefined` if neither bound is present (although type system restricts this). - Add undefined checks before setting the parameter value to avoid empty parameters in queries. Co-authored-by: deflis <206113+deflis@users.noreply.github.com> --- src/search-builder.ts | 25 ++++++++++++++++++++----- src/util/type.ts | 5 ++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/search-builder.ts b/src/search-builder.ts index bb33da1..3bac2d7 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -63,12 +63,15 @@ export abstract class SearchBuilderBase< */ protected static range2string( n: T | readonly T[] | RangeParam - ): Join { + ): Join | undefined { if (typeof n === "object" && n !== null && !Array.isArray(n)) { const obj = n as Extract, object>; if ("equal" in obj) { return obj.equal.toString() as Join; } + if (obj.min === undefined && obj.max === undefined) { + return undefined; + } return `${obj.min ?? ""}-${obj.max ?? ""}` as Join; } return SearchBuilderBase.array2string(n) as Join; @@ -319,7 +322,10 @@ export abstract class NovelSearchBuilderBase< * @return {this} */ length(length: number | readonly number[] | RangeParam): this { - this.set({ length: NovelSearchBuilderBase.range2string(length) }); + const val = NovelSearchBuilderBase.range2string(length); + if (val !== undefined) { + this.set({ length: val }); + } return this; } @@ -345,7 +351,10 @@ export abstract class NovelSearchBuilderBase< kaiwaritu(minOrRange: number | RangeParam, max?: number): this { if (typeof minOrRange === "object" && minOrRange !== null) { - this.set({ kaiwaritu: NovelSearchBuilderBase.range2string(minOrRange) }); + const val = NovelSearchBuilderBase.range2string(minOrRange); + if (val !== undefined) { + this.set({ kaiwaritu: val }); + } return this; } @@ -367,7 +376,10 @@ export abstract class NovelSearchBuilderBase< * @return {this} */ sasie(num: number | readonly number[] | RangeParam): this { - this.set({ sasie: NovelSearchBuilderBase.range2string(num) }); + const val = NovelSearchBuilderBase.range2string(num); + if (val !== undefined) { + this.set({ sasie: val }); + } return this; } @@ -378,7 +390,10 @@ export abstract class NovelSearchBuilderBase< * @return {this} */ time(num: number | readonly number[] | RangeParam): this { - this.set({ time: NovelSearchBuilderBase.range2string(num) }); + 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 7f80980..4ff3405 100644 --- a/src/util/type.ts +++ b/src/util/type.ts @@ -29,4 +29,7 @@ export type Join = `${T}-${T}` | `${T}`; * 範囲指定のためのオブジェクトの型。 * @template T - 範囲指定する値の型 */ -export type RangeParam = { min?: T; max?: T } | { equal: T }; +export type RangeParam = + | { min: T; max?: T } + | { min?: T; max: T } + | { equal: T }; From cd3a0a69789b46528c0db70c85fe45edb04fb934 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:42:45 +0000 Subject: [PATCH 3/4] fix: add runtime numeric validation and missing tests - Adds a runtime validation check `typeof n.equal === "number"` in `range2string` utility logic. - Adds comprehensive testing for `{ max: x }`, `{ min: x, max: y }`, and `{ equal: x }` cases in `sasie` and `time` parameters within `search-builder.test.ts`. Co-authored-by: deflis <206113+deflis@users.noreply.github.com> --- src/search-builder.ts | 16 +++++----- test/search-builder.test.ts | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/search-builder.ts b/src/search-builder.ts index 3bac2d7..28ff263 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -65,14 +65,16 @@ export abstract class SearchBuilderBase< n: T | readonly T[] | RangeParam ): Join | undefined { if (typeof n === "object" && n !== null && !Array.isArray(n)) { - const obj = n as Extract, object>; - if ("equal" in obj) { - return obj.equal.toString() as Join; + 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; } - if (obj.min === undefined && obj.max === undefined) { - return undefined; - } - return `${obj.min ?? ""}-${obj.max ?? ""}` as Join; + return undefined; } return SearchBuilderBase.array2string(n) as Join; } diff --git a/test/search-builder.test.ts b/test/search-builder.test.ts index 154b59c..43b004f 100644 --- a/test/search-builder.test.ts +++ b/test/search-builder.test.ts @@ -929,6 +929,36 @@ describe("SearchBuilder", () => { 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", () => { @@ -975,6 +1005,36 @@ describe("SearchBuilder", () => { 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", () => { From 0c85cd1bfd41d73587fccdc3f9f3e6a500fe8446 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:18:07 +0000 Subject: [PATCH 4/4] refactor: enforce tuple type and validate array length for range parameters - Modifies the parameter signature for range parameters (`length`, `sasie`, `time`) to accept `readonly [number, number]` instead of arbitrary `number[]` arrays to tighten type safety. - Adds a runtime length check inside `range2string` that throws an error if an array with more than 2 elements is passed, preventing incorrectly formatted query parameters (e.g. `1-2-3`). - Adds a unit test validating this specific error condition. Co-authored-by: deflis <206113+deflis@users.noreply.github.com> --- src/search-builder.ts | 11 +++++++---- test/search-builder.test.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/search-builder.ts b/src/search-builder.ts index 28ff263..4f6c235 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -62,7 +62,7 @@ export abstract class SearchBuilderBase< * @returns ハイフン区切りの文字列 */ protected static range2string( - n: T | readonly T[] | RangeParam + 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") { @@ -76,6 +76,9 @@ export abstract class SearchBuilderBase< } return undefined; } + if (Array.isArray(n) && n.length > 2) { + throw new Error("範囲指定の配列は要素数を2つ以内にする必要があります"); + } return SearchBuilderBase.array2string(n) as Join; } @@ -323,7 +326,7 @@ export abstract class NovelSearchBuilderBase< * @param length 文字数、または[最小文字数, 最大文字数]、またはオブジェクト指定 * @return {this} */ - length(length: number | readonly number[] | RangeParam): this { + length(length: number | readonly [number, number] | RangeParam): this { const val = NovelSearchBuilderBase.range2string(length); if (val !== undefined) { this.set({ length: val }); @@ -377,7 +380,7 @@ export abstract class NovelSearchBuilderBase< * @param num 挿絵数、または[最小挿絵数, 最大挿絵数]、またはオブジェクト指定 * @return {this} */ - sasie(num: number | readonly number[] | RangeParam): this { + sasie(num: number | readonly [number, number] | RangeParam): this { const val = NovelSearchBuilderBase.range2string(num); if (val !== undefined) { this.set({ sasie: val }); @@ -391,7 +394,7 @@ export abstract class NovelSearchBuilderBase< * @param num 読了時間(分)、または[最小読了時間, 最大読了時間]、またはオブジェクト指定 * @return {this} */ - time(num: number | readonly number[] | RangeParam): this { + time(num: number | readonly [number, number] | RangeParam): this { const val = NovelSearchBuilderBase.range2string(num); if (val !== undefined) { this.set({ time: val }); diff --git a/test/search-builder.test.ts b/test/search-builder.test.ts index 43b004f..cd07d2b 100644 --- a/test/search-builder.test.ts +++ b/test/search-builder.test.ts @@ -920,6 +920,14 @@ describe("SearchBuilder", () => { 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"]);