ec2b3d2254
Reviewed-on: #379 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
524 行
15 KiB
TypeScript
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',
|
|
},
|
|
],
|
|
},
|
|
)
|
|
})
|
|
})
|