import { beforeEach, describe, expect, it, vi } from 'vitest' import { apiPost } from '@/lib/api' import { buildGekanatorQuestions, expectedAnswerForQuestion, restoreGekanatorQuestion, saveGekanatorExtraQuestionAnswers, saveGekanatorGame, saveGekanatorQuestionSuggestion, } from '@/lib/gekanator' import type { GekanatorAnswerLog, StoredGekanatorQuestion, } from '@/lib/gekanator' import type { Post } from '@/types' vi.mock('@/lib/api', () => ({ apiGet: vi.fn(), apiPost: vi.fn(), })) const mockedApiPost = vi.mocked(apiPost) const post = (overrides: Partial = {}): Post => ({ id: 1, versionNo: 1, url: 'https://example.com/posts/1', title: 'post title', thumbnail: null, thumbnailBase: null, tags: [], viewed: false, related: [], originalCreatedFrom: null, originalCreatedBefore: null, createdAt: '2026-06-10T00:00:00.000Z', updatedAt: '2026-06-10T00:00:00.000Z', uploadedUser: null, ...overrides, }) describe('expectedAnswerForQuestion', () => { it('returns a direct example answer when present', () => { const question: StoredGekanatorQuestion = { id: 'post-similarity:10', text: '喜多ちゃんが泣いてる?', kind: 'post_similarity', source: 'user_suggested', priorityWeight: 1.2, condition: { type: 'post-similarity', postId: 999, answer: 'yes', threshold: 0.65, }, exampleAnswers: { 1: 'partial', }, } expect(expectedAnswerForQuestion(question, post({ id: 1 }))).toBe('partial') }) it('returns the condition answer for the original post_similarity post', () => { const question: StoredGekanatorQuestion = { id: 'post-similarity:10', text: '喜多ちゃんが泣いてる?', kind: 'post_similarity', source: 'user_suggested', priorityWeight: 1.2, condition: { type: 'post-similarity', postId: 123, answer: 'probably_no', threshold: 0.65, }, } expect(expectedAnswerForQuestion(question, post({ id: 123 }))).toBe('probably_no') }) it('returns null for an unrelated post_similarity post without examples', () => { const question: StoredGekanatorQuestion = { id: 'post-similarity:10', text: '喜多ちゃんが泣いてる?', kind: 'post_similarity', source: 'user_suggested', priorityWeight: 1.2, condition: { type: 'post-similarity', postId: 123, answer: 'yes', threshold: 0.65, }, } expect(expectedAnswerForQuestion(question, post({ id: 456 }))).toBeNull() }) it('returns yes for a matching tag question', () => { const question: StoredGekanatorQuestion = { id: 'tag:character:喜多郁代', text: '喜多ちゃんが関係してる?', kind: 'tag', condition: { type: 'tag', key: 'character:喜多郁代', }, } expect( expectedAnswerForQuestion( question, post({ tags: [ { id: 1, name: '喜多郁代', category: 'character', aliases: [], parents: [], postCount: 1, createdAt: '2026-06-10T00:00:00.000Z', updatedAt: '2026-06-10T00:00:00.000Z', hasWiki: false, hasDeerjikists: false, materialId: null, }, ], }), ), ).toBe('yes') }) it('returns no for a non-matching tag question', () => { const question: StoredGekanatorQuestion = { id: 'tag:character:喜多郁代', text: '喜多ちゃんが関係してる?', kind: 'tag', condition: { type: 'tag', key: 'character:喜多郁代', }, } expect(expectedAnswerForQuestion(question, post({ tags: [] }))).toBe('no') }) it('ignores example answers for direct title facts', () => { const question: StoredGekanatorQuestion = { id: 'title:length-at-least:20', text: 'タイトルは 20 文字以上?', kind: 'title', condition: { type: 'title-length-at-least', length: 20, }, exampleAnswers: { 1: 'yes', }, } expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no') }) }) describe('restoreGekanatorQuestion', () => { it('uses default source and priority weight when omitted', () => { const question = restoreGekanatorQuestion({ id: 'tag:character:喜多郁代', text: '喜多ちゃんが関係してる?', kind: 'tag', condition: { type: 'tag', key: 'character:喜多郁代', }, }) expect(question.source).toBe('default') expect(question.priorityWeight).toBe(1) }) it('tests a post_similarity question using direct examples', () => { const question = restoreGekanatorQuestion({ id: 'post-similarity:10', text: '喜多ちゃんが泣いてる?', kind: 'post_similarity', source: 'user_suggested', priorityWeight: 1.2, condition: { type: 'post-similarity', postId: 999, answer: 'yes', threshold: 0.65, }, exampleAnswers: { 1: 'yes', 2: 'no', }, }) expect(question.test(post({ id: 1 }))).toBe(true) expect(question.test(post({ id: 2 }))).toBe(false) expect(question.test(post({ id: 3 }))).toBe(false) }) it('tests a post_similarity question against its configured partial answer', () => { const question = restoreGekanatorQuestion({ id: 'post-similarity:10', text: '喜多ちゃんが泣いてる?', kind: 'post_similarity', source: 'user_suggested', priorityWeight: 1.2, condition: { type: 'post-similarity', postId: 999, answer: 'partial', threshold: 0.65, }, exampleAnswers: { 1: 'partial', 2: 'yes', }, }) expect(question.test(post({ id: 1 }))).toBe(true) expect(question.test(post({ id: 2 }))).toBe(false) }) it('normalizes legacy title-length-greater-than questions', () => { const question = restoreGekanatorQuestion({ id: 'title:length-greater-than:20', text: '題名が長めの投稿?', kind: 'title', condition: { type: 'title-length-greater-than', length: 20, }, }) expect(question.id).toBe('title:length-at-least:21') expect(question.condition).toEqual({ type: 'title-length-at-least', length: 21, }) expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false) expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true) }) }) describe('buildGekanatorQuestions', () => { it('builds quantitative title length questions', () => { const questions = buildGekanatorQuestions([ post({ id: 1, title: 'a' }), post({ id: 2, title: 'bb' }), post({ id: 3, title: 'ccc' }), post({ id: 4, title: 'dddd' }), ]) const titleQuestion = questions.find(question => question.condition.type === 'title-length-at-least') expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/) expect(titleQuestion?.id).toMatch(/^title:length-at-least:\d+$/) }) }) describe('Gekanator API writers', () => { beforeEach(() => { mockedApiPost.mockReset() }) it('sends game results using snake_case request keys', async () => { mockedApiPost.mockResolvedValue({ id: 100 }) const answers: GekanatorAnswerLog[] = [ { questionId: 'tag:character:喜多郁代', questionText: '喜多ちゃんが関係してる?', questionCondition: { type: 'tag', key: 'character:喜多郁代', }, answer: 'yes', originalAnswer: 'partial', }, ] await expect( saveGekanatorGame({ guessedPostId: 1, correctPostId: 2, answers, }), ).resolves.toEqual({ id: 100 }) expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/games', { guessed_post_id: 1, correct_post_id: 2, answers: [ { question_id: 'tag:character:喜多郁代', question_text: '喜多ちゃんが関係してる?', question_condition: { type: 'tag', key: 'character:喜多郁代', }, answer: 'yes', original_answer: 'partial', }, ], }) }) it('sends question suggestions using snake_case request keys', async () => { mockedApiPost.mockResolvedValue({ id: 10, count: 1, }) await expect( saveGekanatorQuestionSuggestion({ gekanatorGameId: 100, questionText: '喜多ちゃんが泣いてる?', answer: 'yes', }), ).resolves.toEqual({ id: 10, count: 1, }) expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/question_suggestions', { gekanator_game_id: 100, question_text: '喜多ちゃんが泣いてる?', answer: 'yes', }) }) it('sends extra question answers using snake_case request keys', async () => { mockedApiPost.mockResolvedValue({ count: 2, }) await saveGekanatorExtraQuestionAnswers({ gameId: 100, answers: [ { questionId: 10, answer: 'yes', }, { questionId: 11, answer: 'probably_no', }, ], }) expect(mockedApiPost).toHaveBeenCalledWith( '/gekanator/games/100/extra_question_answers', { answers: [ { question_id: 10, answer: 'yes', }, { question_id: 11, answer: 'probably_no', }, ], }, ) }) })