diff --git a/src/narou-search-results.ts b/src/narou-search-results.ts index 4b58aa0..ad0c195 100644 --- a/src/narou-search-results.ts +++ b/src/narou-search-results.ts @@ -187,7 +187,7 @@ export interface UserSearchResult { * ユーザ名のフリガナの頭文字 * ひらがな以外の場合はnullまたは空文字となります。 */ - name1st: string; + name1st: string | null; /** 小説投稿数 */ novel_cnt: number; /** レビュー投稿数 */ diff --git a/src/params.ts b/src/params.ts index f8d77da..94d0284 100644 --- a/src/params.ts +++ b/src/params.ts @@ -248,8 +248,6 @@ export const R18Fields = { title: "t", /** Nコード */ ncode: "n", - /** 作者のユーザID(数値) */ - userid: "u", /** 作者名 */ writer: "w", /** 小説のあらすじ */ @@ -321,7 +319,7 @@ export const R18Fields = { export type R18Fields = (typeof R18Fields)[keyof Omit< NarouSearchResult, - "novel_type" | "weekly_unique" | "biggenre" | "genre" | "isr15" + "novel_type" | "weekly_unique" | "biggenre" | "genre" | "isr15" | "userid" >]; /** diff --git a/src/search-builder-r18.ts b/src/search-builder-r18.ts index ce24840..8f18562 100644 --- a/src/search-builder-r18.ts +++ b/src/search-builder-r18.ts @@ -15,7 +15,7 @@ import type { export type DefaultR18SearchResultFields = keyof Omit< NarouSearchResult, - "weekly_unique" | "noveltype" | "biggenre" | "genre" | "isr15" | "id" + "weekly_unique" | "noveltype" | "biggenre" | "genre" | "isr15" | "userid" >; /** diff --git a/src/search-builder.ts b/src/search-builder.ts index 9b66f31..b4c5d32 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -161,6 +161,10 @@ export abstract class SearchBuilderBase< } } +/** + * 要素の抽出条件と除外条件は独立して保持します。 + * 同じ条件にtrueとfalseを指定すると両方をAPIへ送信し、検索結果は0件になります。 + */ export abstract class NovelSearchBuilderBase< T extends SearchResultFieldNames, > extends SearchBuilderBase { diff --git a/test/builder-types.test.ts b/test/builder-types.test.ts index bd4f67a..0672b9a 100644 --- a/test/builder-types.test.ts +++ b/test/builder-types.test.ts @@ -70,6 +70,35 @@ describe.each([ >().toEqualTypeOf(); }); + test("R18検索はuseridを取得項目に含めない", () => { + const r18 = api.searchR18(); + const novel = api.search(); + expectTypeOf< + Awaited>["values"][number] + >().not.toHaveProperty("userid"); + expectTypeOf< + Awaited>["values"][number] + >().toHaveProperty("userid").toEqualTypeOf(); + expectTypeOf(R18Fields).not.toHaveProperty("userid"); + + function invalidUsage() { + // @ts-expect-error R18 APIはユーザーIDの出力に対応していない + api.searchR18().fields(Fields.userid); + } + expectTypeOf(invalidUsage).returns.toBeVoid(); + }); + + test("ユーザーのname1stはnullを許容する", () => { + const all = api.searchUser(); + const selected = api.searchUser().fields(UserFields.name1st); + expectTypeOf< + Awaited>["values"][number]["name1st"] + >().toEqualTypeOf(); + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + }); + test("通常検索のoptとfieldsの順序に依存しない型推論", () => { const optFirst = api .search() diff --git a/test/narou-search-results.test.ts b/test/narou-search-results.test.ts index 2888d08..1a381dc 100644 --- a/test/narou-search-results.test.ts +++ b/test/narou-search-results.test.ts @@ -49,6 +49,14 @@ describe('NarouSearchResults', () => { expect(results.values).toEqual(data); }); + it('開始位置がページ境界でなくても従来の計算を維持する', () => { + const results = new NarouSearchResults( + [{ allcount: 100 }], { st: 21, lim: 20 } + ); + expect(results.start).toBe(21); + expect(results.page).toBe(1.05); + }); + it('should handle empty results', () => { const header = { allcount: 0 }; const data: Pick[] = []; diff --git a/test/search-builder-r18.test.ts b/test/search-builder-r18.test.ts index 139f332..1859584 100644 --- a/test/search-builder-r18.test.ts +++ b/test/search-builder-r18.test.ts @@ -56,6 +56,52 @@ describe("SearchBuilderR18", () => { afterEach(() => server.resetHandlers()); afterAll(() => server.close()); + describe("抽出・除外条件の併存", () => { + test.each([ + ["isBL", "isbl", "notbl"], + ["isGL", "isgl", "notgl"], + ["isZankoku", "iszankoku", "notzankoku"], + ["isTensei", "istensei", "nottensei"], + ["isTenni", "istenni", "nottenni"], + ] as const)( + "%sの両条件を呼び出し順に関係なく保持する", + async (method, include, exclude) => { + const queries: URLSearchParams[] = []; + server.use( + http.get(`https://api.syosetu.com/novel18api/api/`, ({ request }) => { + queries.push(new URL(request.url).searchParams); + return responseGzipOrJson([{ allcount: 0 }], new URL(request.url)); + }) + ); + for (const first of [true, false]) { + const result = await NarouAPI.searchR18() + .gzip(0) + [method](first) + [method](!first) + .execute(); + expect(result.allcount).toBe(0); + expect(result.values).toEqual([]); + } + expect(queries).toHaveLength(2); + for (const query of queries) { + expect(query.get(include)).toBe("1"); + expect(query.get(exclude)).toBe("1"); + } + } + ); + }); + + describe("page", () => { + test.each([0, 1, 2])("page=%iの従来の開始位置を維持する", async (page) => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["st", "lim"], { ncode: "N1234AB" }); + const result = await NarouAPI.searchR18().page(page, 20).execute(); + expect(mockFn).toHaveBeenCalledWith(String(page * 20), "20", 4); + expect(result.start).toBe(page * 20); + expect(result.page).toBe(page); + }); + }); + describe("execute", () => { test("execute should call executeNovel18", async () => { const mockFn = vi.fn(); diff --git a/test/search-builder.test.ts b/test/search-builder.test.ts index 63423c7..fc85de3 100644 --- a/test/search-builder.test.ts +++ b/test/search-builder.test.ts @@ -60,6 +60,41 @@ describe("SearchBuilder", () => { afterEach(() => server.resetHandlers()); afterAll(() => server.close()); + describe("抽出・除外条件の併存", () => { + test.each([ + ["isBL", "isbl", "notbl"], + ["isGL", "isgl", "notgl"], + ["isZankoku", "iszankoku", "notzankoku"], + ["isTensei", "istensei", "nottensei"], + ["isTenni", "istenni", "nottenni"], + ] as const)( + "%sの両条件を呼び出し順に関係なく保持する", + async (method, include, exclude) => { + const queries: URLSearchParams[] = []; + server.use( + http.get(`https://api.syosetu.com/novelapi/api/`, ({ request }) => { + queries.push(new URL(request.url).searchParams); + return responseGzipOrJson([{ allcount: 0 }], new URL(request.url)); + }) + ); + for (const first of [true, false]) { + const result = await NarouAPI.search() + .gzip(0) + [method](first) + [method](!first) + .execute(); + expect(result.allcount).toBe(0); + expect(result.values).toEqual([]); + } + expect(queries).toHaveLength(2); + for (const query of queries) { + expect(query.get(include)).toBe("1"); + expect(query.get(exclude)).toBe("1"); + } + } + ); + }); + describe("gzip", () => { test("default", async () => { const mockFn = vi.fn(); @@ -176,11 +211,13 @@ describe("SearchBuilder", () => { describe("page", () => { describe("default count", () => { - test.each([1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { + test.each([0, 1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["lim", "st", "gzip", "out"]); const result = await NarouAPI.search().page(page).execute(); + expect(result.start).toBe(page * 20); + expect(result.page).toBe(page); expect(result.allcount).toBe(1); expect(result.length).toBe(1); expect(result.values).toHaveLength(1); @@ -197,11 +234,13 @@ describe("SearchBuilder", () => { }); describe.each([1, 10, 100, 500] as const)("if count = %i", (count) => { - test.each([1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { + test.each([0, 1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["lim", "st", "gzip", "out"]); const result = await NarouAPI.search().page(page, count).execute(); + expect(result.start).toBe(page * count); + expect(result.page).toBe(page); expect(result.allcount).toBe(1); expect(result.length).toBe(1); expect(result.values).toHaveLength(1); @@ -1319,6 +1358,24 @@ describe("SearchBuilder", () => { }); describe("isR15", () => { + test("R15の抽出・除外条件を両方保持する", async () => { + const queries: URLSearchParams[] = []; + server.use( + http.get("https://api.syosetu.com/novelapi/api/", ({ request }) => { + queries.push(new URL(request.url).searchParams); + return responseGzipOrJson([{ allcount: 0 }], new URL(request.url)); + }) + ); + for (const first of [true, false]) { + await NarouAPI.search().gzip(0).isR15(first).isR15(!first).execute(); + } + expect(queries).toHaveLength(2); + for (const query of queries) { + expect(query.get("isr15")).toBe("1"); + expect(query.get("notr15")).toBe("1"); + } + }); + test("default", async () => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["gzip", "out"]); diff --git a/test/user-search.test.ts b/test/user-search.test.ts index bda239b..c801a5f 100644 --- a/test/user-search.test.ts +++ b/test/user-search.test.ts @@ -169,11 +169,13 @@ describe("UserSearchBuilder", () => { describe("page", () => { describe("default count", () => { - test.each([1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { + test.each([0, 1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["lim", "st", "gzip", "out"]); const result = await NarouAPI.searchUser().page(page).execute(); + expect(result.start).toBe(page * 20); + expect(result.page).toBe(page); expect(result.allcount).toBe(1); expect(result.length).toBe(1); expect(result.values).toHaveLength(1); @@ -190,11 +192,13 @@ describe("UserSearchBuilder", () => { }); describe.each([1, 10, 100, 500] as const)("if count = %i", (count) => { - test.each([1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { + test.each([0, 1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["lim", "st", "gzip", "out"]); const result = await NarouAPI.searchUser().page(page, count).execute(); + expect(result.start).toBe(page * count); + expect(result.page).toBe(page); expect(result.allcount).toBe(1); expect(result.length).toBe(1); expect(result.values).toHaveLength(1); @@ -326,6 +330,22 @@ describe("UserSearchBuilder", () => { }); describe("name1st", () => { + test.each([null, "", "あ"])( + "ユーザーのname1st=%sを保持する", + async (name1st) => { + server.use( + http.get("https://api.syosetu.com/userapi/api/", ({ request }) => + responseGzipOrJson([{ allcount: 1 }, { name1st }], new URL(request.url)) + ) + ); + const result = await NarouAPI.searchUser() + .gzip(0) + .fields(UserFields.name1st) + .execute(); + expect(result.values).toEqual([{ name1st }]); + } + ); + test("default", async () => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["gzip", "out"]);