From 170c8d2c6ae18852fd16bbbc91af1fb38c421a82 Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 00:53:47 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E6=A4=9C=E7=B4=A2=E3=83=93=E3=83=AB?= =?UTF-8?q?=E3=83=80=E3=83=BC=E3=81=AB=E6=95=B0=E5=80=A4=E3=83=BB=E6=97=A5?= =?UTF-8?q?=E4=BB=98=E3=81=AE=E3=82=AA=E3=83=96=E3=82=B8=E3=82=A7=E3=82=AF?= =?UTF-8?q?=E3=83=88=E7=AF=84=E5=9B=B2=E6=8C=87=E5=AE=9A=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/search-builder.ts | 281 ++++++++++++++++++++++++++++++---- src/util/search-condition.ts | 80 ++++++++++ test/search-condition.test.ts | 177 +++++++++++++++++++++ 3 files changed, 509 insertions(+), 29 deletions(-) create mode 100644 src/util/search-condition.ts create mode 100644 test/search-condition.test.ts diff --git a/src/search-builder.ts b/src/search-builder.ts index b4b6a4d..5510004 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -22,6 +22,20 @@ import type { } from "./params.js"; import { BooleanNumber, StopParam } from "./params.js"; import type { Join } from "./util/type.js"; +import type { + DateSearchCondition, + NumberSearchCondition, +} from "./util/search-condition.js"; +import { + formatNumberCondition, + formatDateCondition, + formatExactDate, +} from "./util/search-condition.js"; + +export type { + DateSearchCondition, + NumberSearchCondition, +} from "./util/search-condition.js"; export type DefaultSearchResultFields = keyof Omit< NarouSearchResult, @@ -293,12 +307,32 @@ export abstract class NovelSearchBuilderBase< /** * 抽出する作品の文字数を指定します (length)。 - * 範囲指定する場合は、最小文字数と最大文字数をハイフン(-)記号で区切ってください。 - * @param length 文字数、または[最小文字数, 最大文字数] - * @return {this} + * + * @remarks + * 単一の数値は完全一致、配列は `[最小値, 最大値]` の範囲指定です。 + * オブジェクトの `min`・`max` は境界値を含み、片側だけでも指定できます。 + * オブジェクトの値には0以上の整数を指定してください。 + * + * @param length - 文字数(文字)、範囲の配列、または {@link NumberSearchCondition} + * @returns このビルダー + * @throws TypeError — オブジェクトの上下限が両方とも未指定の場合。 + * @throws RangeError — オブジェクトの値が無効、または下限が上限を超える場合。 + * + * @example + * ```ts + * search().length(10); + * search().length([10, 20]); + * search().length({ min: 10, max: 20 }); + * search().length({ min: 10 }); + * search().length({ max: 20 }); + * ``` */ - length(length: number | readonly number[]): this { - this.set({ length: NovelSearchBuilderBase.array2string(length) }); + length(length: number | readonly number[] | NumberSearchCondition): this { + const value = + typeof length === "number" || Array.isArray(length) + ? NovelSearchBuilderBase.array2string(length) + : formatNumberCondition(length as NumberSearchCondition); + this.set({ length: value }); return this; } @@ -316,7 +350,33 @@ export abstract class NovelSearchBuilderBase< */ kaiwaritu(min: number, max: number): this; - kaiwaritu(min: number, max?: number): this { + /** + * 抽出する作品の会話率をオブジェクトで範囲指定します (kaiwaritu)。 + * + * @remarks + * `min`・`max` は境界値を含み、片側だけでも指定できます。 + * 値は0以上の整数で、単位はパーセントです。 + * 完全一致には単一の数値を渡すオーバーロードを使用してください。 + * + * @param condition - 会話率の下限・上限 + * @returns このビルダー + * @throws TypeError — 上下限が両方とも未指定の場合。 + * @throws RangeError — 値が無効、または下限が上限を超える場合。 + * + * @example + * ```ts + * search().kaiwaritu({ min: 20, max: 50 }); + * search().kaiwaritu({ min: 20 }); + * search().kaiwaritu({ max: 50 }); + * ``` + */ + kaiwaritu(condition: NumberSearchCondition): this; + + kaiwaritu(min: number | NumberSearchCondition, max?: number): this { + if (typeof min === "object") { + this.set({ kaiwaritu: formatNumberCondition(min) }); + return this; + } let n: number | string; if (max != null) { n = `${min}-${max}`; @@ -329,21 +389,64 @@ export abstract class NovelSearchBuilderBase< /** * 抽出する作品の挿絵数を指定します (sasie)。 - * @param num 挿絵数、または[最小挿絵数, 最大挿絵数] - * @return {this} + * + * @remarks + * 単一の数値は完全一致、配列は `[最小値, 最大値]` の範囲指定です。 + * オブジェクトの `min`・`max` は境界値を含み、片側だけでも指定できます。 + * オブジェクトの値には0以上の整数を指定してください。 + * + * @param num - 挿絵数(枚)、範囲の配列、または {@link NumberSearchCondition} + * @returns このビルダー + * @throws TypeError — オブジェクトの上下限が両方とも未指定の場合。 + * @throws RangeError — オブジェクトの値が無効、または下限が上限を超える場合。 + * + * @example + * ```ts + * search().sasie(10); + * search().sasie([10, 20]); + * search().sasie({ min: 10, max: 20 }); + * search().sasie({ min: 10 }); + * search().sasie({ max: 20 }); + * ``` */ - sasie(num: number | readonly number[]): this { - this.set({ sasie: NovelSearchBuilderBase.array2string(num) }); + sasie(num: number | readonly number[] | NumberSearchCondition): this { + const value = + typeof num === "number" || Array.isArray(num) + ? NovelSearchBuilderBase.array2string(num) + : formatNumberCondition(num as NumberSearchCondition); + this.set({ sasie: value }); return this; } /** - * 抽出する作品の予想読了時間を分単位で指定します (time)。 - * @param num 読了時間(分)、または[最小読了時間, 最大読了時間] - * @return {this} + * 抽出する作品の予想読了時間を指定します (time)。 + * + * @remarks + * 単一の数値は完全一致、配列は `[最小値, 最大値]` の範囲指定です。 + * オブジェクトの `min`・`max` は境界値を含み、片側だけでも指定できます。 + * オブジェクトの値には0以上の整数を指定してください。 + * 読了時間は文字数条件と併用できません。 + * + * @param num - 予想読了時間(分)、範囲の配列、または {@link NumberSearchCondition} + * @returns このビルダー + * @throws TypeError — オブジェクトの上下限が両方とも未指定の場合。 + * @throws RangeError — オブジェクトの値が無効、または下限が上限を超える場合。 + * + * @example + * ```ts + * search().time(10); + * search().time([10, 20]); + * search().time({ min: 10, max: 20 }); + * search().time({ min: 10 }); + * search().time({ max: 20 }); + * ``` */ - time(num: number | readonly number[]): this { - this.set({ time: NovelSearchBuilderBase.array2string(num) }); + time(num: number | readonly number[] | NumberSearchCondition): this { + const value = + typeof num === "number" || Array.isArray(num) + ? NovelSearchBuilderBase.array2string(num) + : formatNumberCondition(num as NumberSearchCondition); + this.set({ time: value }); return this; } @@ -398,27 +501,87 @@ export abstract class NovelSearchBuilderBase< } /** - * 最終更新日時を指定します (lastup)。 - * @param date 最終更新日時 (YYYYMMDDhhmmss形式またはUNIXタイムスタンプ) - * @return {this} + * 最終掲載日時を期間キーワードまたはAPI形式の文字列で指定します (lastup)。 + * + * @remarks + * 対象は `general_lastup` です。文字列は変換せずAPIへ渡します。 + * 期間キーワードには {@link DateParam} を使用できます。 + * + * @param date - 期間キーワード、または `開始UNIX秒-終了UNIX秒` 形式の文字列 + * @returns このビルダー */ lastUpdate(date: DateParam): this; /** - * 最終更新日時の範囲を指定します (lastup)。 - * @param from 開始日時 (UNIXタイムスタンプ) - * @param to 終了日時 (UNIXタイムスタンプ) + * 最終掲載日時の範囲を指定します (lastup)。 + * 範囲は両端を含みます。 + * @param from 開始日時 (UNIXタイムスタンプ、秒) + * @param to 終了日時 (UNIXタイムスタンプ、秒) * @return {this} */ lastUpdate(from: number, to: number): this; /** - * 最終更新日時の範囲を指定します (lastup)。 + * 最終掲載日時の範囲を指定します (lastup)。 + * 範囲は両端を含み、ミリ秒部分は切り捨てます。 * @param from 開始日時 (Dateオブジェクト) * @param to 終了日時 (Dateオブジェクト) * @return {this} */ lastUpdate(from: Date, to: Date): this; - lastUpdate(x: string | number | Date, y?: number | Date): this { + /** + * 最終掲載日時に秒単位で一致する条件を指定します (lastup)。 + * + * @remarks + * `Date` のミリ秒部分は切り捨てます。日付全体ではなく、その1秒に一致します。 + * + * @param date - Dateオブジェクト、または0以上の整数のUNIXタイムスタンプ(秒) + * @returns このビルダー + * @throws RangeError — 無効な日付、負数、非整数、または非有限の数値の場合。 + * + * @example + * ```ts + * search().lastUpdate(new Date("2026-01-01T00:00:00+09:00")); + * search().lastUpdate(1767193200); + * ``` + */ + lastUpdate(date: number | Date): this; + /** + * 最終掲載日時の範囲をオブジェクトで指定します (lastup)。 + * + * @remarks + * `from`・`to` は境界値を含み、片側だけでも指定できます。 + * 値には `Date` または0以上の整数のUNIXタイムスタンプ(秒)を使用できます。 + * 両者の混在も可能です。`Date` のミリ秒部分は切り捨てます。 + * + * @param condition - 開始日時・終了日時 + * @returns このビルダー + * @throws TypeError — 開始日時・終了日時が両方とも未指定の場合。 + * @throws RangeError — 日時が無効、または開始日時が終了日時を超える場合。 + * + * @example + * ```ts + * search().lastUpdate({ + * from: new Date("2026-01-01T00:00:00+09:00"), + * to: new Date("2026-01-31T23:59:59+09:00"), + * }); + * search().lastUpdate({ from: 1767193200 }); + * search().lastUpdate({ to: 1769871599 }); + * ``` + */ + lastUpdate(condition: DateSearchCondition): this; + + lastUpdate( + x: string | number | Date | DateSearchCondition, + y?: number | Date + ): this { + if (typeof x === "object" && !(x instanceof Date)) { + this.set({ lastup: formatDateCondition(x) }); + return this; + } + if (typeof x !== "string" && y === undefined) { + this.set({ lastup: formatExactDate(x) }); + return this; + } let date: string; if (typeof x == "string") { date = x; @@ -435,27 +598,87 @@ export abstract class NovelSearchBuilderBase< } /** - * 作品の更新日時を指定します (lastupdate)。 - * @param date 作品の更新日時 (YYYYMMDDhhmmss形式またはUNIXタイムスタンプ) - * @return {this} + * 作品の最終更新日時を期間キーワードまたはAPI形式の文字列で指定します (lastupdate)。 + * + * @remarks + * 対象は `novelupdated_at` です。文字列は変換せずAPIへ渡します。 + * 期間キーワードには {@link DateParam} を使用できます。 + * + * @param date - 期間キーワード、または `開始UNIX秒-終了UNIX秒` 形式の文字列 + * @returns このビルダー */ lastNovelUpdate(date: DateParam): this; /** * 作品の更新日時の範囲を指定します (lastupdate)。 - * @param from 開始日時 (UNIXタイムスタンプ) - * @param to 終了日時 (UNIXタイムスタンプ) + * 範囲は両端を含みます。 + * @param from 開始日時 (UNIXタイムスタンプ、秒) + * @param to 終了日時 (UNIXタイムスタンプ、秒) * @return {this} */ lastNovelUpdate(from: number, to: number): this; /** * 作品の更新日時の範囲を指定します (lastupdate)。 + * 範囲は両端を含み、ミリ秒部分は切り捨てます。 * @param from 開始日時 (Dateオブジェクト) * @param to 終了日時 (Dateオブジェクト) * @return {this} */ lastNovelUpdate(from: Date, to: Date): this; - lastNovelUpdate(x: string | number | Date, y?: number | Date): this { + /** + * 作品の最終更新日時に秒単位で一致する条件を指定します (lastupdate)。 + * + * @remarks + * `Date` のミリ秒部分は切り捨てます。日付全体ではなく、その1秒に一致します。 + * + * @param date - Dateオブジェクト、または0以上の整数のUNIXタイムスタンプ(秒) + * @returns このビルダー + * @throws RangeError — 無効な日付、負数、非整数、または非有限の数値の場合。 + * + * @example + * ```ts + * search().lastNovelUpdate(new Date("2026-01-01T00:00:00+09:00")); + * search().lastNovelUpdate(1767193200); + * ``` + */ + lastNovelUpdate(date: number | Date): this; + /** + * 作品の最終更新日時の範囲をオブジェクトで指定します (lastupdate)。 + * + * @remarks + * `from`・`to` は境界値を含み、片側だけでも指定できます。 + * 値には `Date` または0以上の整数のUNIXタイムスタンプ(秒)を使用できます。 + * 両者の混在も可能です。`Date` のミリ秒部分は切り捨てます。 + * + * @param condition - 開始日時・終了日時 + * @returns このビルダー + * @throws TypeError — 開始日時・終了日時が両方とも未指定の場合。 + * @throws RangeError — 日時が無効、または開始日時が終了日時を超える場合。 + * + * @example + * ```ts + * search().lastNovelUpdate({ + * from: new Date("2026-01-01T00:00:00+09:00"), + * to: new Date("2026-01-31T23:59:59+09:00"), + * }); + * search().lastNovelUpdate({ from: 1767193200 }); + * search().lastNovelUpdate({ to: 1769871599 }); + * ``` + */ + lastNovelUpdate(condition: DateSearchCondition): this; + + lastNovelUpdate( + x: string | number | Date | DateSearchCondition, + y?: number | Date + ): this { + if (typeof x === "object" && !(x instanceof Date)) { + this.set({ lastupdate: formatDateCondition(x) }); + return this; + } + if (typeof x !== "string" && y === undefined) { + this.set({ lastupdate: formatExactDate(x) }); + return this; + } let date: string; if (typeof x == "string") { date = x; diff --git a/src/util/search-condition.ts b/src/util/search-condition.ts new file mode 100644 index 0000000..1d19757 --- /dev/null +++ b/src/util/search-condition.ts @@ -0,0 +1,80 @@ +/** + * {@link NovelSearchBuilderBase.lastUpdate}・{@link NovelSearchBuilderBase.lastNovelUpdate} の日時範囲。 + * + * @remarks + * `from`・`to` は境界を含み、少なくとも片方の指定が必要です。 + * 省略した側には制限を設けません。 + * 値は `Date` または0以上の整数のUNIXタイムスタンプ(秒)で、混在も可能です。 + * `Date` のミリ秒部分は切り捨てます。開始日時は終了日時以下にしてください。 + * + * @example + * ```ts + * const range: DateSearchCondition = { from: new Date("2026-01-01T00:00:00Z") }; + * ``` + */ +export type DateSearchCondition = + | { from: number | Date; to?: number | Date } + | { from?: number | Date; to: number | Date }; + +/** + * 文字数・会話率・挿絵数・読了時間に使用する数値範囲。 + * + * @remarks + * `min`・`max` は境界を含み、片側だけでも指定できます。 + * 値は0以上の整数で、下限は上限以下にしてください。 + * 単位は各検索メソッドに従います。完全一致は数値をメソッドに直接渡します。 + * + * @example + * ```ts + * const range: NumberSearchCondition = { min: 1000, max: 10000 }; + * const minimum: NumberSearchCondition = { min: 1000 }; + * const maximum: NumberSearchCondition = { max: 10000 }; + * ``` + */ +export type NumberSearchCondition = + | { min: number; max?: number } + | { min?: number; max: number }; + +/** 数値条件をAPIの範囲指定に変換する。 */ +export function formatNumberCondition( + condition: NumberSearchCondition +): `${number | ""}-${number | ""}` { + return formatRange(condition.min, condition.max); +} + +function conditionValue(value: number | Date): number { + const number = + value instanceof Date ? Math.floor(value.getTime() / 1000) : value; + if (!Number.isFinite(number) || !Number.isInteger(number) || number < 0) { + throw new RangeError( + "検索条件には有効な日付または0以上の整数を指定してください" + ); + } + return number; +} + +/** 日付条件をAPIの範囲指定に変換する。 */ +export function formatDateCondition(condition: DateSearchCondition): string { + return formatRange(condition.from, condition.to); +} + +/** 単一日時を秒単位の一致条件に変換する。 */ +export function formatExactDate(value: number | Date): string { + const timestamp = conditionValue(value); + return `${timestamp}-${timestamp}`; +} + +function formatRange( + from: number | Date | undefined, + to: number | Date | undefined +): `${number | ""}-${number | ""}` { + if (from === undefined && to === undefined) { + throw new TypeError("範囲の下限または上限を指定してください"); + } + const min = from === undefined ? undefined : conditionValue(from); + const max = to === undefined ? undefined : conditionValue(to); + if (min !== undefined && max !== undefined && min > max) { + throw new RangeError("範囲の下限は上限以下にしてください"); + } + return `${min ?? ""}-${max ?? ""}`; +} diff --git a/test/search-condition.test.ts b/test/search-condition.test.ts new file mode 100644 index 0000000..ada33ea --- /dev/null +++ b/test/search-condition.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test, vi } from "vitest"; +import { search, searchR18 } from "../src/index.js"; +import type { + DateSearchCondition, + NumberSearchCondition, + SearchParams, +} from "../src/index.js"; +import NarouNovelFetch from "../src/narou-fetch.js"; + +// 公開APIに渡す直前のパラメータを検証し、外部通信は行わない。 +describe.each([search, searchR18])( + "オブジェクト検索条件: %s", + (createBuilder) => { + function setup() { + const api = new NarouNovelFetch(); + const execute = vi.fn<(params: SearchParams) => Promise>(); + api.executeNovel = execute; + api.executeNovel18 = execute; + return { builder: createBuilder("", api), execute }; + } + + describe.each(["length", "kaiwaritu", "sasie", "time"] as const)( + "%s", + (method) => { + test.each<[NumberSearchCondition, string]>([ + [{ min: 0 }, "0-"], + [{ max: 10 }, "-10"], + [{ min: 0, max: 10 }, "0-10"], + [{ min: 10, max: 10 }, "10-10"], + ])("%j → %s", async (condition, expected) => { + const { builder, execute } = setup(); + expect(builder[method](condition)).toBe(builder); + await builder.execute(); + expect(execute).toHaveBeenCalledWith( + { [method]: expected }, + undefined + ); + }); + + test.each([ + {}, + { min: 2, max: 1 }, + { min: NaN }, + { max: Infinity }, + { min: -1 }, + { max: 1.5 }, + ])("不正な条件 %j を拒否", (condition) => { + const { builder, execute } = setup(); + expect(() => + builder[method](condition as NumberSearchCondition) + ).toThrow(); + expect(execute).not.toHaveBeenCalled(); + }); + } + ); + + describe.each([ + ["lastUpdate", "lastup"], + ["lastNovelUpdate", "lastupdate"], + ] as const)("%s", (method, param) => { + test.each<[DateSearchCondition, string]>([ + [{ from: 0 }, "0-"], + [{ to: new Date(10999) }, "-10"], + [{ from: new Date(0), to: 10 }, "0-10"], + [{ from: 10, to: new Date(10999) }, "10-10"], + ])("%j → %s", async (condition, expected) => { + const { builder, execute } = setup(); + expect(builder[method](condition)).toBe(builder); + await builder.execute(); + expect(execute).toHaveBeenCalledWith({ [param]: expected }, undefined); + }); + + test.each([ + "thisweek", + "lastweek", + "sevenday", + "thismonth", + "lastmonth", + "0-10", + "20260101000000", + ])("既存の文字列 %s はそのまま渡す", async (value) => { + const { builder, execute } = setup(); + await builder[method](value).execute(); + expect(execute).toHaveBeenCalledWith({ [param]: value }, undefined); + }); + + test("既存の日時2引数は数値・Dateとも維持", async () => { + const { builder, execute } = setup(); + await builder[method](0, 10).execute(); + expect(execute).toHaveBeenLastCalledWith( + { [param]: "0-10" }, + undefined + ); + await builder[method](new Date(0), new Date(10999)).execute(); + expect(execute).toHaveBeenLastCalledWith( + { [param]: "0-10" }, + undefined + ); + }); + + test.each<[number | Date, string]>([ + [0, "0-0"], + [10, "10-10"], + [new Date(10999), "10-10"], + ])("単一日時 %s → %s", async (value, expected) => { + const { builder, execute } = setup(); + expect(builder[method](value)).toBe(builder); + await builder.execute(); + expect(execute).toHaveBeenCalledWith({ [param]: expected }, undefined); + }); + + test.each([new Date(NaN), -1, Infinity, 0.5])( + "無効な単一日時 %s を拒否", + (value) => { + const { builder } = setup(); + expect(() => builder[method](value)).toThrow(); + } + ); + + test.each([ + {}, + { from: 2, to: 1 }, + { from: new Date(NaN) }, + { to: Infinity }, + { from: -1 }, + ])("不正な条件 %j を拒否", (condition) => { + const { builder, execute } = setup(); + expect(() => + builder[method](condition as DateSearchCondition) + ).toThrow(); + expect(execute).not.toHaveBeenCalled(); + }); + }); + + test("既存形式とオブジェクト形式をチェーンし、条件を上書きできる", async () => { + const { builder, execute } = setup(); + await builder + .length([10, 20]) + .length({ min: 5, max: 30 }) + .length(15) + .sasie([0, 0]) + .time(10) + .kaiwaritu(10, 20) + .lastUpdate("thisweek") + .lastNovelUpdate(new Date(0), new Date(10000)) + .execute(); + expect(execute).toHaveBeenCalledWith( + { + length: "15", + sasie: "0", + time: "10", + kaiwaritu: "10-20", + lastup: "thisweek", + lastupdate: "0-10", + }, + undefined + ); + }); + } +); + +test("無効なオブジェクト条件を型で拒否", () => { + const builder = search(); + function invalidConditions() { + // @ts-expect-error 空の条件は指定できない + builder.length({}); + // @ts-expect-error 一致条件は値を直接渡す + builder.time({ exact: 1, min: 0 }); + // @ts-expect-error 数値には min/max を使う + builder.sasie({ before: 10 }); + // @ts-expect-error 日付には from/to を使う + builder.lastUpdate({ min: 0 }); + // @ts-expect-error 日付の一致条件は値を直接渡す + builder.lastNovelUpdate({ exact: new Date(), from: 0 }); + } + expect(invalidConditions).toBeTypeOf("function"); +}); From abc4695c05b0531a309d63663e3d4f192102dc84 Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 01:04:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E6=97=A5=E4=BB=98=E7=AF=84=E5=9B=B2?= =?UTF-8?q?=E3=81=AE=E7=9C=81=E7=95=A5=E7=AB=AF=E3=81=AB=E8=B5=B7=E7=82=B9?= =?UTF-8?q?=E3=81=A8=E7=8F=BE=E5=9C=A8=E6=99=82=E5=88=BB=E3=82=92=E8=A3=9C?= =?UTF-8?q?=E5=AE=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/search-builder.ts | 4 ++++ src/util/search-condition.ts | 11 +++++++++-- test/search-condition.test.ts | 20 +++++++++++++++++--- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/search-builder.ts b/src/search-builder.ts index 5510004..9b66f31 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -550,6 +550,8 @@ export abstract class NovelSearchBuilderBase< * * @remarks * `from`・`to` は境界値を含み、片側だけでも指定できます。 + * `from` の省略時はUNIXタイムスタンプ0、`to` の省略時はこのメソッドの呼び出し時刻を使用します。 + * ライブラリ側で両端を補い、APIには `開始UNIX秒-終了UNIX秒` 形式で送信します。 * 値には `Date` または0以上の整数のUNIXタイムスタンプ(秒)を使用できます。 * 両者の混在も可能です。`Date` のミリ秒部分は切り捨てます。 * @@ -647,6 +649,8 @@ export abstract class NovelSearchBuilderBase< * * @remarks * `from`・`to` は境界値を含み、片側だけでも指定できます。 + * `from` の省略時はUNIXタイムスタンプ0、`to` の省略時はこのメソッドの呼び出し時刻を使用します。 + * ライブラリ側で両端を補い、APIには `開始UNIX秒-終了UNIX秒` 形式で送信します。 * 値には `Date` または0以上の整数のUNIXタイムスタンプ(秒)を使用できます。 * 両者の混在も可能です。`Date` のミリ秒部分は切り捨てます。 * diff --git a/src/util/search-condition.ts b/src/util/search-condition.ts index 1d19757..b7b4d73 100644 --- a/src/util/search-condition.ts +++ b/src/util/search-condition.ts @@ -3,7 +3,7 @@ * * @remarks * `from`・`to` は境界を含み、少なくとも片方の指定が必要です。 - * 省略した側には制限を設けません。 + * `from` の省略時はUNIXタイムスタンプ0、`to` の省略時は条件指定時の現在時刻を使用します。 * 値は `Date` または0以上の整数のUNIXタイムスタンプ(秒)で、混在も可能です。 * `Date` のミリ秒部分は切り捨てます。開始日時は終了日時以下にしてください。 * @@ -55,7 +55,14 @@ function conditionValue(value: number | Date): number { /** 日付条件をAPIの範囲指定に変換する。 */ export function formatDateCondition(condition: DateSearchCondition): string { - return formatRange(condition.from, condition.to); + const { from, to } = condition; + if (from === undefined && to === undefined) { + throw new TypeError("開始日時または終了日時を指定してください"); + } + return formatRange( + from === undefined ? 0 : from, + to === undefined ? Math.floor(Date.now() / 1000) : to + ); } /** 単一日時を秒単位の一致条件に変換する。 */ diff --git a/test/search-condition.test.ts b/test/search-condition.test.ts index ada33ea..a77a736 100644 --- a/test/search-condition.test.ts +++ b/test/search-condition.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { search, searchR18 } from "../src/index.js"; import type { DateSearchCondition, @@ -11,6 +11,8 @@ import NarouNovelFetch from "../src/narou-fetch.js"; describe.each([search, searchR18])( "オブジェクト検索条件: %s", (createBuilder) => { + afterEach(() => vi.restoreAllMocks()); + function setup() { const api = new NarouNovelFetch(); const execute = vi.fn<(params: SearchParams) => Promise>(); @@ -59,17 +61,29 @@ describe.each([search, searchR18])( ["lastNovelUpdate", "lastupdate"], ] as const)("%s", (method, param) => { test.each<[DateSearchCondition, string]>([ - [{ from: 0 }, "0-"], - [{ to: new Date(10999) }, "-10"], + [{ from: 0 }, "0-20"], + [{ from: new Date(10999) }, "10-20"], + [{ to: 0 }, "0-0"], + [{ to: new Date(10999) }, "0-10"], [{ from: new Date(0), to: 10 }, "0-10"], [{ from: 10, to: new Date(10999) }, "10-10"], ])("%j → %s", async (condition, expected) => { + vi.spyOn(Date, "now").mockReturnValue(20999); const { builder, execute } = setup(); expect(builder[method](condition)).toBe(builder); await builder.execute(); expect(execute).toHaveBeenCalledWith({ [param]: expected }, undefined); }); + test("終了省略時の現在時刻は条件指定時に確定する", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(20999); + const { builder, execute } = setup(); + builder[method]({ from: 10 }); + now.mockReturnValue(30999); + await builder.execute(); + expect(execute).toHaveBeenCalledWith({ [param]: "10-20" }, undefined); + }); + test.each([ "thisweek", "lastweek", From 03c5e9a6d32afc3a7d5f3fb4d15e595b901331cd Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 01:04:38 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E6=97=A5=E4=BB=98=E7=AF=84=E5=9B=B2?= =?UTF-8?q?=E3=81=AE=E7=89=87=E5=81=B4=E7=9C=81=E7=95=A5=E3=82=92=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E3=81=99=E3=82=8B=E5=AE=9FAPI=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/narou.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/test/narou.test.ts b/test/narou.test.ts index 65d6ea0..c09aed2 100644 --- a/test/narou.test.ts +++ b/test/narou.test.ts @@ -42,6 +42,87 @@ describe("narou-test", () => { expect(result.values).toHaveLength(1); }); }); + describe.each(["search", "searchR18"] as const)( + "%s 日付範囲の片側省略(実API)", + (searchMethod) => { + describe.each(["lastUpdate", "lastNovelUpdate"] as const)( + "%s", + (dateMethod) => { + it("終了日時を補完せず送信しても下限で絞り込める", async () => { + const builder = + searchMethod === "search" + ? NarouAPI.search().fields([Fields.ncode]) + : NarouAPI.searchR18().fields([R18Fields.ncode]); + // 2100年以降の更新は存在しない。条件が無視されれば0件にはならない。 + const from = Math.floor( + new Date("2100-01-01T00:00:00Z").getTime() / 1000 + ); + const result = await builder + .limit(1) + [dateMethod](`${from}-`) + .execute(); + + expect(result.allcount).toBe(0); + expect(result.values).toHaveLength(0); + }); + + it("開始日時を補完せず送信しても上限で絞り込める", async () => { + const builder = + searchMethod === "search" + ? NarouAPI.search().fields([ + Fields.ncode, + Fields.general_lastup, + Fields.novelupdated_at, + ]) + : NarouAPI.searchR18().fields([ + R18Fields.ncode, + R18Fields.general_lastup, + R18Fields.novelupdated_at, + ]); + const result = await builder + .limit(1) + [dateMethod]("-1262304000") + .execute(); + + expect(result.allcount).toBeGreaterThan(0); + expect(result.values).toHaveLength(1); + const date = + dateMethod === "lastUpdate" + ? result.values[0].general_lastup + : result.values[0].novelupdated_at; + // APIの日付表記はJST。上限は2010-01-01T00:00:00Z。 + expect( + Date.parse(`${date.replace(" ", "T")}+09:00`) + ).toBeLessThanOrEqual(1262304000000); + }); + + it.each([ + { + name: "終了日時を省略", + condition: { from: new Date("2000-01-01T00:00:00Z") }, + }, + { + name: "開始日時を省略", + condition: { to: new Date() }, + }, + ])("$name して検索結果を取得できる", async ({ condition }) => { + const builder = + searchMethod === "search" + ? NarouAPI.search().fields([Fields.ncode]) + : NarouAPI.searchR18().fields([R18Fields.ncode]); + const result = await builder + .limit(1) + [dateMethod](condition) + .execute(); + + expect(result.allcount).toBeGreaterThan(0); + expect(result.values).toHaveLength(1); + expect(result.values[0].ncode).toEqual(expect.any(String)); + }); + } + ); + } + ); describe("searchUser", () => { it("if limit = 1 then length = 1", async () => { const result = await NarouAPI.searchUser()