Skip to content
Open
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
77 changes: 66 additions & 11 deletions src/search-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -54,6 +54,34 @@ export abstract class SearchBuilderBase<
return Array.from(new Set(array));
}

/**
* 範囲指定や配列をハイフン区切りの文字列に変換する
* @protected
* @static
* @param n 範囲指定オブジェクト、数値の配列、あるいは単一の数値
* @returns ハイフン区切りの文字列
*/
protected static range2string<T extends number>(
n: T | readonly [T, T] | RangeParam<T>
): Join<T | ""> | undefined {
if (typeof n === "object" && n !== null && !Array.isArray(n)) {
if ("equal" in n && typeof n.equal === "number") {
return n.equal.toString() as Join<T>;
} else if ("min" in n || "max" in n) {
const obj = n as Extract<RangeParam<T>, { min?: T, max?: T }>;
if (obj.min === undefined && obj.max === undefined) {
return undefined;
}
return `${obj.min ?? ""}-${obj.max ?? ""}` as Join<T | "">;
}
return undefined;
}
if (Array.isArray(n) && n.length > 2) {
throw new Error("範囲指定の配列は要素数を2つ以内にする必要があります");
Comment on lines +79 to +80
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

range2string の配列チェックが n.length > 2 のみになっているため、ランタイムで [][1] を渡すと例外にならず、array2string 経由で ""(空文字)や単一値が生成されます。範囲指定用ユーティリティとしては配列は [min, max] の2要素のみを許可し、length !== 2 の場合は例外にする(または undefined を返して set しない)ようにして、無効なクエリの生成を防いでください。

Suggested change
if (Array.isArray(n) && n.length > 2) {
throw new Error("範囲指定の配列は要素数を2つ以内にする必要があります");
if (Array.isArray(n) && n.length !== 2) {
throw new Error("範囲指定の配列は要素数をちょうど2つにする必要があります");

Copilot uses AI. Check for mistakes.
}
return SearchBuilderBase.array2string(n) as Join<T>;
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

range2string は配列入力をそのまま array2string に委譲しているため、数値配列の要素数が 2 以外(例: [1,2,3])でも 1-2-3 のような文字列になり、range 系パラメータとしては不正なクエリを生成します。range 用ユーティリティとしては、配列はタプル [min, max] のみ受け付ける(型を readonly [T, T] にする)か、ランタイムで長さチェックしてエラーにするなどの防止策を入れた方が安全です。

Suggested change
return SearchBuilderBase.array2string(n) as Join<T>;
if (Array.isArray(n)) {
if (n.length !== 2) {
throw new TypeError(
"range2string only accepts arrays with exactly two elements: [min, max]"
);
}
return `${n[0]}-${n[1]}` as Join<T>;
}
return n.toString() as Join<T>;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

array2stringが意図しているタプルを返すかを(型かランタイムで)検証をしっかりすること

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

range2string は配列入力をそのまま array2string に委譲しているため、数値配列の要素数が 2 以外(例: [1,2,3])でも 1-2-3 のような文字列になり、range 系パラメータとしては不正なクエリを生成します。range 用ユーティリティとしては、配列はタプル [min, max] のみ受け付ける(型を readonly [T, T] にする)か、ランタイムで長さチェックしてエラーにするなどの防止策を入れた方が安全です。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ご指摘ありがとうございます!range2stringが不適切な要素数(3以上)の配列を受け付けないようにランタイムチェックを追加し、さらに安全性を高めるため、TypeScriptの型定義でも readonly [number, number] のタプル型を要求するように変更しました。

}

/**
* 配列をハイフン区切りの文字列に変換する
* @protected
Expand Down Expand Up @@ -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<number>): this {
const val = NovelSearchBuilderBase.range2string(length);
if (val !== undefined) {
this.set({ length: val });
}
return this;
}

Expand All @@ -315,8 +347,23 @@ export abstract class NovelSearchBuilderBase<
* @return {this}
*/
kaiwaritu(min: number, max: number): this;
/**
* 抽出する作品の会話率を%単位で範囲指定またはオブジェクトで指定します (kaiwaritu)。
* @param range 範囲指定オブジェクト
* @return {this}
*/
kaiwaritu(range: RangeParam<number>): this;

kaiwaritu(min: number, max?: number): this {
kaiwaritu(minOrRange: number | RangeParam<number>, max?: number): this {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

For consistency with length, sasie, and time, the kaiwaritu method should also support readonly number[] in its signature. The implementation already correctly handles arrays via range2string because typeof array === "object" is true.

  kaiwaritu(
    minOrRange: number | readonly number[] | RangeParam<number>,
    max?: number
  ): this {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

会話率も同じシグネチャのほうがいい

if (typeof minOrRange === "object" && minOrRange !== null) {
const val = NovelSearchBuilderBase.range2string(minOrRange);
if (val !== undefined) {
this.set({ kaiwaritu: val });
}
return this;
Comment on lines +357 to +363
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

kaiwaritu のオブジェクト分岐が typeof minOrRange === "object" だけなので、配列もここに入ってしまいます(例: JS利用者が誤って kaiwaritu([10, 20]) を渡す)。結果として range2string が配列を受け付けて文字列化し、意図しない入力をサイレントに受理する/空配列で "" を生成する可能性があります。RangeParam として扱う条件を !Array.isArray(minOrRange) まで含めるか、"min" in ... || "max" in ... || "equal" in ... のようにキーで判定して配列を除外してください。

Copilot uses AI. Check for mistakes.
}

const min = minOrRange;
let n: number | string;
if (max != null) {
n = `${min}-${max}`;
Expand All @@ -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<number>): 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<number>): this {
const val = NovelSearchBuilderBase.range2string(num);
if (val !== undefined) {
this.set({ time: val });
}
return this;
Comment on lines 377 to 402
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

sasie / timeRangeParam を受け付けるようになりましたが、テストが一部の形(例: sasie{ min } のみ、time{ min, max } のみ)しかカバーしていません。{ max }{ equal } など、サポートするとして公開したオブジェクト形状を最低限網羅するテストを追加してください。

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

テストが足りてない

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ご指摘ありがとうございます。sasie および time に対しても { min: x, max: y }{ equal: z } といったオブジェクト形式での網羅的なテストケースを追加し、不足を補いました。

}

Expand Down
9 changes: 9 additions & 0 deletions src/util/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ type Stringable = string | number | bigint | boolean | null | undefined;
* type JoinedNumbers = Join<Numbers>; // '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 extends Stringable> = `${T}-${T}` | `${T}`;

/**
* 範囲指定のためのオブジェクトの型。
* @template T - 範囲指定する値の型
*/
export type RangeParam<T extends number> =
| { min: T; max?: T }
| { min?: T; max: T }
Comment on lines +30 to +34
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

RangeParam の型定義が、JSDoc/PR説明にある { min?: T, max?: T } | { equal: T } と一致していません。現在の union だと {} は型エラーになりますが、range2string 側は {} を受けて undefined を返す実装になっています。{ min?: T; max?: T } を許可する形にするか、少なくとも JSDoc 側で「min/max のどちらか必須」と明記して型と挙動を揃えてください。

Suggested change
* @template T - 範囲指定する値の型
*/
export type RangeParam<T extends number> =
| { min: T; max?: T }
| { min?: T; max: T }
* `min` / `max` による範囲指定、または `equal` による完全一致を表します。
* `min` `max` はどちらも省略可能です。
* @template T - 範囲指定する値の型
*/
export type RangeParam<T extends number> =
| { min?: T; max?: T }

Copilot uses AI. Check for mistakes.
| { equal: T };
148 changes: 148 additions & 0 deletions test/search-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

kaiwaritu の RangeParam 対応のテストが { min }{ max } のみで、公開している形({ min, max }{ equal })が未カバーです。range2string の分岐(ハイフン区切り/単一値)を最低限網羅できるよう、kaiwaritu({ min: 10, max: 50 })kaiwaritu({ equal: 10 }) のケースも追加してください。

Suggested change
});
});
test("if kaiwaritu = { min: 10, max: 50 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["kaiwaritu", "gzip", "out"]);
const result = await NarouAPI.search()
.kaiwaritu({ min: 10, max: 50 })
.execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`10-50`, "5", "json", 3);
});
test("if kaiwaritu = { equal: 10 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["kaiwaritu", "gzip", "out"]);
const result = await NarouAPI.search().kaiwaritu({ equal: 10 }).execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`10`, "5", "json", 3);
});

Copilot uses AI. Check for mistakes.
});

describe("sasie", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading