ファイル
btrc-hub/frontend/src/lib/gekanator.test.ts
T
みてるぞ ec2b3d2254 タグ “廃止” 追加 (#378) (#379)
Reviewed-on: #379
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-22 08:40:06 +09:00

524 行
15 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiGet, apiPost } from '@/lib/api'
import {
buildGekanatorQuestions,
expectedAnswerForQuestion,
fetchGekanatorPosts,
fetchGekanatorQuestions,
learnedSemanticSideForPost,
questionIdForCondition,
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 mockedApiGet = vi.mocked(apiGet)
const post = (overrides: Partial<Post> = {}): 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('Gekanator API functions', () => {
it('returns posts from the Gekanator posts endpoint', async () => {
const posts = [post()]
mockedApiGet.mockResolvedValueOnce({ posts })
await expect(fetchGekanatorPosts()).resolves.toEqual(posts)
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts')
})
it('returns questions from the Gekanator questions endpoint', async () => {
const questions: StoredGekanatorQuestion[] = []
mockedApiGet.mockResolvedValueOnce({ questions })
await expect(fetchGekanatorQuestions()).resolves.toEqual(questions)
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions')
})
})
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',
deprecatedAt: null,
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')
})
it('returns yes for matching title-contains questions', () => {
const question: StoredGekanatorQuestion = {
id: 'title:contains:結束バンド',
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
condition: {
type: 'title-contains',
text: '結束バンド',
},
}
expect(expectedAnswerForQuestion(
question,
post({ title: '結束バンドのライブ' }),
)).toBe('yes')
expect(expectedAnswerForQuestion(
question,
post({ title: '後藤ひとりの休日' }),
)).toBe('no')
})
})
describe('learnedSemanticSideForPost', () => {
it('classifies post_similarity examples as positive, negative, or unknown', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 123,
answer: 'partial',
threshold: 0.65,
},
exampleAnswers: {
1: 'yes',
2: 'probably_no',
},
}
expect(learnedSemanticSideForPost(question, post({ id: 1 }))).toBe('positive')
expect(learnedSemanticSideForPost(question, post({ id: 2 }))).toBe('negative')
expect(learnedSemanticSideForPost(question, post({ id: 3 }))).toBe('unknown')
expect(learnedSemanticSideForPost(question, post({ id: 123 }))).toBe('positive')
})
})
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(true)
})
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)
})
it('restores title-contains questions with a title matcher', () => {
const question = restoreGekanatorQuestion({
id: 'title:contains:結束バンド',
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
condition: {
type: 'title-contains',
text: '結束バンド',
},
})
expect(question.test(post({ title: '結束バンドのライブ' }))).toBe(true)
expect(question.test(post({ title: '後藤ひとりの休日' }))).toBe(false)
})
})
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+$/)
})
it('builds title-contains questions from repeated title words', () => {
const questions = buildGekanatorQuestions([
post({ id: 1, title: '結束バンド ライブ' }),
post({ id: 2, title: '結束バンド 新曲' }),
post({ id: 3, title: '後藤ひとり 練習' }),
post({ id: 4, title: '伊地知虹夏 練習' }),
])
const titleContainsQuestion = questions.find(question =>
question.condition.type === 'title-contains'
&& question.condition.text === '結束バンド')
expect(titleContainsQuestion).toMatchObject({
id: 'title:contains:結束バンド',
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
source: 'default',
priorityWeight: .96,
})
expect(titleContainsQuestion?.test(post({ title: '結束バンドのライブ' }))).toBe(true)
expect(titleContainsQuestion?.test(post({ title: '廣井きくりのライブ' }))).toBe(false)
})
it('honors question caps and title-contains toggles', () => {
const posts = [
post({ id: 1, title: '結束バンド ライブ' }),
post({ id: 2, title: '結束バンド 新曲' }),
post({ id: 3, title: '後藤ひとり 練習' }),
post({ id: 4, title: '伊地知虹夏 練習' }),
]
const capped = buildGekanatorQuestions(posts, {
titleContainsCap: 1,
totalQuestionCap: 1,
})
const withoutTitleContains = buildGekanatorQuestions(posts, {
includeTitleContains: false,
})
expect(capped).toHaveLength(1)
expect(withoutTitleContains.some(question =>
question.condition.type === 'title-contains')).toBe(false)
})
})
describe('questionIdForCondition', () => {
it('builds stable ids for title-contains questions', () => {
expect(questionIdForCondition({
type: 'title-contains',
text: '結束バンド',
})).toBe('title:contains:結束バンド')
})
})
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:喜多郁代',
},
questionMode: 'normal',
questionPurpose: 'effective_user_suggested',
effectiveQuestion: true,
learningQuestion: false,
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:喜多郁代',
},
question_mode: 'normal',
question_purpose: 'effective_user_suggested',
effective_question: true,
learning_question: false,
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',
},
],
},
)
})
})