From 46a2ad4f4d7e5d0f214896cc8844a65658d8dae7 Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 01:13:27 +0900 Subject: [PATCH 1/3] =?UTF-8?q?SearchBuilderR18=E3=81=AE=E5=8F=96=E5=BE=97?= =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB=E3=83=89=E3=81=AB=E5=BF=9C?= =?UTF-8?q?=E3=81=98=E3=81=9F=E6=88=BB=E3=82=8A=E5=80=A4=E5=9E=8B=E3=81=AE?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/search-builder-r18.ts | 4 +- test/search-builder-r18.test.ts | 67 +++++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/search-builder-r18.ts b/src/search-builder-r18.ts index 931164e..ce24840 100644 --- a/src/search-builder-r18.ts +++ b/src/search-builder-r18.ts @@ -62,11 +62,11 @@ export default class SearchBuilderR18< * 出力する項目を個別に指定します (of)。 * 未指定時は全項目出力されます。転送量軽減のため、このパラメータの使用が推奨されます。 * @param fields 出力するR18フィールド名、またはR18フィールド名の配列 - * @return {SearchBuilderR18>} 型が更新されたビルダー + * @return {SearchBuilderR18, TOpt>} 型が更新されたビルダー */ fields( fields: TFields | readonly TFields[] - ): SearchBuilderR18> { + ): SearchBuilderR18, TOpt> { this.set({ of: NovelSearchBuilderBase.array2string(fields) }); // oxlint-disable-next-line typescript/no-explicit-any return this as any; diff --git a/test/search-builder-r18.test.ts b/test/search-builder-r18.test.ts index 08be66a..139f332 100644 --- a/test/search-builder-r18.test.ts +++ b/test/search-builder-r18.test.ts @@ -5,6 +5,7 @@ import { describe, test, expect, + expectTypeOf, vi, } from "vitest"; import { setupServer } from "msw/node"; @@ -20,13 +21,14 @@ const server = setupServer(); const setupMockHandler = ( mockFn: (...args: unknown[]) => void, - watchParams: string[] = ["out"] + watchParams: string[] = ["out"], + novel: { ncode?: string; title?: string } ) => { server.use( http.get("https://api.syosetu.com/novel18api/api/", ({ request }) => { const url = new URL(request.url); const params = url.searchParams; - const response = [{ allcount: 1 }, { ncode: "N1234AB" }]; + const response = [{ allcount: 1 }, novel]; const values = watchParams.map(param => params.get(param)); mockFn(...values, params.size); @@ -57,7 +59,7 @@ describe("SearchBuilderR18", () => { describe("execute", () => { test("execute should call executeNovel18", async () => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["out"]); + setupMockHandler(mockFn, ["out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18().execute(); expect(result.allcount).toBe(1); @@ -72,7 +74,7 @@ describe("SearchBuilderR18", () => { describe("r18Site", () => { test("default", async () => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["nocgenre", "out"]); + setupMockHandler(mockFn, ["nocgenre", "out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18().execute(); expect(result.allcount).toBe(1); @@ -85,7 +87,7 @@ describe("SearchBuilderR18", () => { test.each(Object.values(R18Site))("if r18Site = %i", async (site) => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["nocgenre", "out"]); + setupMockHandler(mockFn, ["nocgenre", "out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18().r18Site(site).execute(); expect(result.allcount).toBe(1); @@ -98,7 +100,7 @@ describe("SearchBuilderR18", () => { test("if r18Site = [1, 2, 3]", async () => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["nocgenre", "out"]); + setupMockHandler(mockFn, ["nocgenre", "out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18() .r18Site([R18Site.Nocturne, R18Site.MoonLight, R18Site.MoonLightBL]) @@ -133,7 +135,7 @@ describe("SearchBuilderR18", () => { describe("xid", () => { test("default", async () => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["xid", "out"]); + setupMockHandler(mockFn, ["xid", "out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18().execute(); expect(result.allcount).toBe(1); @@ -146,7 +148,7 @@ describe("SearchBuilderR18", () => { test.each([123, 456, 789])("if xid = %i", async (id) => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["xid", "out"]); + setupMockHandler(mockFn, ["xid", "out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18().xid(id).execute(); expect(result.allcount).toBe(1); @@ -159,7 +161,7 @@ describe("SearchBuilderR18", () => { test("if xid = [123, 456, 789]", async () => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["xid", "out"]); + setupMockHandler(mockFn, ["xid", "out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18().xid([123, 456, 789]).execute(); expect(result.allcount).toBe(1); @@ -174,7 +176,7 @@ describe("SearchBuilderR18", () => { describe("fields", () => { test("default", async () => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["of", "out"]); + setupMockHandler(mockFn, ["of", "out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18().execute(); expect(result.allcount).toBe(1); @@ -187,7 +189,7 @@ describe("SearchBuilderR18", () => { test("if fields = R18Fields.title", async () => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["of", "out"]); + setupMockHandler(mockFn, ["of", "out"], { title: "テスト作品" }); const result = await NarouAPI.searchR18() .fields(R18Fields.title) @@ -195,18 +197,22 @@ describe("SearchBuilderR18", () => { expect(result.allcount).toBe(1); expect(result.length).toBe(1); expect(result.values).toHaveLength(1); - expect(result.values[0].ncode).toBe("N1234AB"); + expect(result.values[0].title).toBe("テスト作品"); + expectTypeOf(result.values).toEqualTypeOf(); expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith("t", "json", 3); }); test("if fields = [R18Fields.title, R18Fields.ncode]", async () => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["of", "out"]); + setupMockHandler(mockFn, ["of", "out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18() .fields([R18Fields.title, R18Fields.ncode]) .execute(); + expectTypeOf(result.values).toEqualTypeOf< + readonly { title: string; ncode: string }[] + >(); expect(result.allcount).toBe(1); expect(result.length).toBe(1); expect(result.values).toHaveLength(1); @@ -214,12 +220,43 @@ describe("SearchBuilderR18", () => { expect(mockFn).toHaveBeenCalledTimes(1); expect(mockFn).toHaveBeenCalledWith("t-n", "json", 3); }); + + test("readonly fields should narrow the result type", () => { + const fields = [R18Fields.title, R18Fields.ncode] as const; + const builder = NarouAPI.searchR18().fields(fields); + + expectTypeOf>["values"]>() + .toEqualTypeOf(); + }); + + test("fields should preserve optional fields in either chain order", () => { + const optFirst = NarouAPI.searchR18() + .opt(OptionalFields.weekly_unique) + .fields(R18Fields.title); + const fieldsFirst = NarouAPI.searchR18() + .fields(R18Fields.title) + .opt(OptionalFields.weekly_unique); + + expectTypeOf>["values"]>() + .toEqualTypeOf(); + expectTypeOf>["values"]>() + .toEqualTypeOf(); + }); + + test("fields should replace previously selected fields", () => { + const builder = NarouAPI.searchR18() + .fields([R18Fields.title, R18Fields.ncode]) + .fields(R18Fields.title); + + expectTypeOf>["values"]>() + .toEqualTypeOf(); + }); }); describe("opt", () => { test("default", async () => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["opt", "out"]); + setupMockHandler(mockFn, ["opt", "out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18().execute(); expect(result.allcount).toBe(1); @@ -232,7 +269,7 @@ describe("SearchBuilderR18", () => { test("if opt = OptionalFields.weekly_unique", async () => { const mockFn = vi.fn(); - setupMockHandler(mockFn, ["opt", "out"]); + setupMockHandler(mockFn, ["opt", "out"], { ncode: "N1234AB" }); const result = await NarouAPI.searchR18() .opt(OptionalFields.weekly_unique) From e8ef825fe23e19253fec36288a10dffceba928a5 Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 01:16:03 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E6=A4=9C=E7=B4=A2=E3=83=BB=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=82=AD=E3=83=B3=E3=82=B0=E3=83=93=E3=83=AB=E3=83=80?= =?UTF-8?q?=E3=83=BC=E3=81=AE=E5=85=AC=E9=96=8BAPI=E3=81=AE=E5=9E=8B?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/builder-types.test.ts | 199 +++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 test/builder-types.test.ts diff --git a/test/builder-types.test.ts b/test/builder-types.test.ts new file mode 100644 index 0000000..8248358 --- /dev/null +++ b/test/builder-types.test.ts @@ -0,0 +1,199 @@ +import { describe, expectTypeOf, test } from "vitest"; +import NarouAPI, { + Fields, + OptionalFields, + R18Fields, + UserFields, +} from "../src/index.js"; +import NarouBrowserAPI from "../src/index.browser.js"; +import type { + DefaultSearchResultFields, + NarouRankingResult, + NarouSearchResult, + UserSearchResult, +} from "../src/index.js"; + +// executeは呼び出さず、tsc --noEmitで公開APIの型を検証する。 +describe.each([ + { name: "Node", api: NarouAPI }, + { name: "browser", api: NarouBrowserAPI }, +])("$name builder types", ({ api }) => { + test("通常検索のデフォルト項目", () => { + const builder = api.search(); + + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf< + readonly Pick[] + >(); + }); + + test("通常検索の単一項目とreadonly配列", () => { + const single = api.search().fields(Fields.title); + const multiple = api.search().fields([Fields.title, Fields.ncode] as const); + + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + }); + + test("通常検索のoptとfieldsの順序に依存しない型推論", () => { + const optFirst = api + .search() + .opt(OptionalFields.weekly_unique) + .fields(Fields.title); + const fieldsFirst = api + .search() + .fields(Fields.title) + .opt([OptionalFields.weekly_unique] as const); + + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + }); + + test("通常検索の条件チェーンと取得項目の再指定", () => { + const builder = api + .search() + .fields([Fields.title, Fields.ncode]) + .opt(OptionalFields.weekly_unique) + .word("test") + .length({ min: 100 }) + .page(1, 10) + .fields(Fields.title); + + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + }); + + test("R18検索の専用項目と条件チェーン", () => { + const builder = api + .searchR18() + .fields([R18Fields.title, R18Fields.nocgenre] as const) + .opt(OptionalFields.weekly_unique) + .xid([1, 2]) + .word("test") + .limit(10); + + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf< + readonly { + title: string; + nocgenre: NarouSearchResult["nocgenre"]; + weekly_unique: number; + }[] + >(); + }); + + test("ユーザー検索のデフォルト項目", () => { + const builder = api.searchUser(); + + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + }); + + test("ユーザー検索の単一項目とreadonly配列", () => { + const single = api.searchUser().fields(UserFields.name); + const multiple = api + .searchUser() + .fields([UserFields.name, UserFields.userid] as const); + + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + }); + + test("ユーザー検索の条件チェーンと取得項目の再指定", () => { + const builder = api + .searchUser() + .fields([UserFields.name, UserFields.userid]) + .word("test") + .minNovel(1) + .page(1, 10) + .fields(UserFields.userid); + + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + }); + + test("ランキングの基本結果とデフォルトの詳細項目", () => { + const builder = api.ranking(); + + expectTypeOf(builder.execute).returns.toEqualTypeOf< + Promise + >(); + expectTypeOf(() => builder.executeWithFields()).returns.toEqualTypeOf< + Promise< + (NarouRankingResult & + Partial>)[] + > + >(); + }); + + test("ランキングの取得項目の指定とオプションの組み合わせ", () => { + const builder = api.ranking(); + + expectTypeOf(() => + builder.executeWithFields(Fields.title) + ).returns.toEqualTypeOf< + Promise<(NarouRankingResult & { title?: string })[]> + >(); + expectTypeOf(() => + builder.executeWithFields( + [Fields.title, Fields.writer], + [OptionalFields.weekly_unique] + ) + ).returns.toEqualTypeOf< + Promise< + (NarouRankingResult & { + title?: string; + writer?: string; + weekly_unique?: number; + })[] + > + >(); + expectTypeOf(() => + builder.executeWithFields([], OptionalFields.weekly_unique) + ).returns.toEqualTypeOf< + Promise< + (NarouRankingResult & + Partial< + Pick + >)[] + > + >(); + }); + + test("未取得の項目と他API専用のフィールドを型で拒否", () => { + // この関数は実行せず、@ts-expect-errorが有効かを型チェックする。 + function invalidUsage() { + // @ts-expect-error 通常検索ではR18専用のフィールドを指定できない + api.search().fields(R18Fields.nocgenre); + // @ts-expect-error R18検索では通常検索専用のフィールドを指定できない + api.searchR18().fields(Fields.genre); + // @ts-expect-error ユーザー検索では小説タイトルを指定できない + api.searchUser().fields(Fields.title); + + const result = api.search().fields(Fields.title).execute(); + void result.then(({ values }) => { + // @ts-expect-error 未取得のncodeは参照できない + return values[0].ncode; + }); + } + + expectTypeOf(invalidUsage).returns.toBeVoid(); + }); +}); From 25c7f5fba2b70bd002b61da906b33558cbf52a93 Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 01:24:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?novel=5Ftype=E3=81=A8noveltype=E3=81=AE?= =?UTF-8?q?=E5=87=BA=E5=8A=9B=E5=90=8D=E3=82=92=E6=A4=9C=E8=A8=BC=E3=81=99?= =?UTF-8?q?=E3=82=8B=E5=9E=8B=E3=83=BB=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/builder-types.test.ts | 30 ++++++++++++++++++++++++++++++ test/narou.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/test/builder-types.test.ts b/test/builder-types.test.ts index 8248358..bd4f67a 100644 --- a/test/builder-types.test.ts +++ b/test/builder-types.test.ts @@ -40,6 +40,36 @@ describe.each([ >().toEqualTypeOf(); }); + test("項目未指定時はnovel_typeを返す", () => { + const novel = api.search(); + const r18 = api.searchR18(); + + expectTypeOf>["values"][number]>() + .toHaveProperty("novel_type") + .toEqualTypeOf<1 | 2>(); + expectTypeOf< + Awaited>["values"][number] + >().not.toHaveProperty("noveltype"); + expectTypeOf>["values"][number]>() + .toHaveProperty("novel_type") + .toEqualTypeOf<1 | 2>(); + expectTypeOf< + Awaited>["values"][number] + >().not.toHaveProperty("noveltype"); + }); + + test("of=nt指定時はnovel_typeではなくnoveltypeを返す", () => { + const novel = api.search().fields(Fields.noveltype); + const r18 = api.searchR18().fields(R18Fields.noveltype); + + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + }); + test("通常検索のoptとfieldsの順序に依存しない型推論", () => { const optFirst = api .search() diff --git a/test/narou.test.ts b/test/narou.test.ts index c09aed2..d9f58ce 100644 --- a/test/narou.test.ts +++ b/test/narou.test.ts @@ -42,6 +42,29 @@ describe("narou-test", () => { expect(result.values).toHaveLength(1); }); }); + describe.each(["search", "searchR18"] as const)( + "%s novel_typeとnoveltypeの出力名(実API)", + (searchMethod) => { + it("項目未指定時はnovel_typeのみを返す", async () => { + const result = await NarouAPI[searchMethod]().limit(1).execute(); + + expect(result.values).toHaveLength(1); + expect([1, 2]).toContain(result.values[0].novel_type); + expect(result.values[0]).not.toHaveProperty("noveltype"); + }); + + it("of=nt指定時はnoveltypeのみを返す", async () => { + const builder = searchMethod === "search" + ? NarouAPI.search().fields(Fields.noveltype) + : NarouAPI.searchR18().fields(R18Fields.noveltype); + const result = await builder.limit(1).execute(); + + expect(result.values).toHaveLength(1); + expect([1, 2]).toContain(result.values[0].noveltype); + expect(Object.keys(result.values[0])).toEqual(["noveltype"]); + }); + } + ); describe.each(["search", "searchR18"] as const)( "%s 日付範囲の片側省略(実API)", (searchMethod) => {