diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb new file mode 100644 index 0000000..4d11522 --- /dev/null +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -0,0 +1,588 @@ +require 'rails_helper' + +RSpec.describe 'Gekanator learning API', type: :request do + let(:admin) { create(:user, :admin) } + let(:member) { create(:user, :member) } + let(:other_user) { create(:user, :member) } + + let!(:guessed_post) do + Post.create!( + title: 'guessed', + url: 'https://example.com/guessed' + ) + end + + let!(:correct_post) do + Post.create!( + title: 'correct', + url: 'https://example.com/correct' + ) + end + + let!(:other_post) do + Post.create!( + title: 'other', + url: 'https://example.com/other' + ) + end + + let!(:game) do + GekanatorGame.create!( + user: admin, + guessed_post: guessed_post, + correct_post: correct_post, + won: false, + question_count: 1, + answers: [ + { + 'question_id' => 'tag:character:喜多郁代', + 'question_text' => '喜多ちゃんが関係してる?', + 'answer' => 'yes', + 'original_answer' => 'yes' + } + ] + ) + end + + def create_post_similarity_question!( + text: '喜多ちゃんが泣いてる?', + post: correct_post, + answer: 'yes', + status: 'accepted', + source: 'user_suggested', + priority_weight: 1.2 + ) + GekanatorQuestion.create!( + text: text, + kind: 'post_similarity', + source: source, + status: status, + priority_weight: priority_weight, + condition: { + type: 'post-similarity', + postId: post.id, + answer: answer, + threshold: 0.65 + }, + created_by: admin + ) + end + + describe 'POST /gekanator/games' do + it 'stores a game result for an admin user' do + sign_in_as admin + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [ + { + question_id: 'tag:character:喜多郁代', + question_text: '喜多ちゃんが関係してる?', + answer: 'yes', + original_answer: 'yes' + } + ] + } + + expect(response).to have_http_status(:created) + + created = GekanatorGame.find(json['id']) + expect(created.user).to eq(admin) + expect(created.guessed_post).to eq(guessed_post) + expect(created.correct_post).to eq(correct_post) + expect(created.won).to eq(false) + expect(created.question_count).to eq(1) + expect(created.answers).to eq([ + { + 'question_id' => 'tag:character:喜多郁代', + 'question_text' => '喜多ちゃんが関係してる?', + 'answer' => 'yes', + 'original_answer' => 'yes' + } + ]) + end + + it 'stores a won game when guessed_post_id equals correct_post_id' do + sign_in_as admin + + post '/gekanator/games', params: { + guessed_post_id: correct_post.id, + correct_post_id: correct_post.id, + answers: [] + } + + expect(response).to have_http_status(:created) + expect(GekanatorGame.find(json['id']).won).to eq(true) + end + + it 'rejects a game without correct_post_id' do + sign_in_as admin + + expect { + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + answers: [] + } + }.not_to change { GekanatorGame.count } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns not found for a non-admin user' do + sign_in_as member + + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [] + } + + expect(response).to have_http_status(:not_found) + end + end + + describe 'POST /gekanator/question_suggestions' do + it 'creates a suggestion and promotes yes answer to an accepted post_similarity question' do + sign_in_as admin + + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: '喜多ちゃんが泣いてる?', + answer: 'yes' + } + }.to change { GekanatorQuestionSuggestion.count }.by(1) + .and change { GekanatorQuestion.count }.by(1) + .and change { GekanatorQuestionExample.count }.by(1) + + expect(response).to have_http_status(:created) + + suggestion = GekanatorQuestionSuggestion.last + question = GekanatorQuestion.last + example = GekanatorQuestionExample.last + + expect(json).to include( + 'id' => suggestion.id, + 'count' => 1 + ) + + expect(suggestion).to have_attributes( + gekanator_game_id: game.id, + user_id: admin.id, + question_text: '喜多ちゃんが泣いてる?', + answer: 'yes', + processed: true + ) + + expect(question).to have_attributes( + text: '喜多ちゃんが泣いてる?', + kind: 'post_similarity', + source: 'user_suggested', + status: 'accepted', + priority_weight: 1.2, + gekanator_question_suggestion_id: suggestion.id, + created_by_id: admin.id + ) + expect(question.condition).to include( + 'type' => 'post-similarity', + 'postId' => correct_post.id, + 'answer' => 'yes', + 'threshold' => 0.65 + ) + + expect(example).to have_attributes( + gekanator_question_id: question.id, + post_id: correct_post.id, + user_id: admin.id, + gekanator_game_id: game.id, + answer: 'yes', + source: 'initial_suggestion', + weight: 1.0 + ) + end + + it 'promotes no, partial, and probably_no answers' do + sign_in_as admin + + ['no', 'partial', 'probably_no'].each do |answer| + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: "answer #{answer} question?", + answer: answer + } + }.to change { GekanatorQuestion.count }.by(1) + .and change { GekanatorQuestionExample.count }.by(1) + + expect(response).to have_http_status(:created) + expect(GekanatorQuestion.last.condition['answer']).to eq(answer) + expect(GekanatorQuestionExample.last.answer).to eq(answer) + end + end + + it 'does not promote unknown answers' do + sign_in_as admin + + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: 'よく分からない質問?', + answer: 'unknown' + } + }.to change { GekanatorQuestionSuggestion.count }.by(1) + .and change { GekanatorQuestion.count }.by(0) + .and change { GekanatorQuestionExample.count }.by(0) + + expect(response).to have_http_status(:created) + expect(GekanatorQuestionSuggestion.last.processed).to eq(false) + end + + it 'limits suggestions to three per game' do + sign_in_as admin + + 3.times do |i| + GekanatorQuestionSuggestion.create!( + gekanator_game: game, + user: admin, + question_text: "existing question #{i}", + answer: 'unknown' + ) + end + + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: 'fourth question?', + answer: 'yes' + } + }.not_to change { GekanatorQuestionSuggestion.count } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns not found for a non-admin user' do + sign_in_as member + + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: 'member question?', + answer: 'yes' + } + + expect(response).to have_http_status(:not_found) + end + end + + describe 'GET /gekanator/games/:id/extra_questions' do + it 'returns at most two accepted user_suggested post_similarity questions' do + sign_in_as admin + + low = create_post_similarity_question!( + text: 'low?', + priority_weight: 1.0 + ) + high = create_post_similarity_question!( + text: 'high?', + priority_weight: 3.0 + ) + middle = create_post_similarity_question!( + text: 'middle?', + priority_weight: 2.0 + ) + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].length).to eq(2) + expect(json['questions'].map { _1['id'] }).to eq([high.id, middle.id]) + expect(json['questions'].map { _1['id'] }).not_to include(low.id) + end + + it 'does not return questions that already have an example for the correct post' do + sign_in_as admin + + existing = create_post_similarity_question!(text: 'already learned?') + fresh = create_post_similarity_question!(text: 'fresh?') + + GekanatorQuestionExample.create!( + gekanator_question: existing, + post: correct_post, + user: admin, + gekanator_game: game, + answer: 'yes', + source: 'post_game_extra' + ) + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to include(fresh.id) + expect(json['questions'].map { _1['id'] }).not_to include(existing.id) + end + + it 'does not return questions already asked in the game using snake_case question_id' do + sign_in_as admin + + asked = create_post_similarity_question!(text: 'already asked?') + fresh = create_post_similarity_question!(text: 'fresh?') + game.update!( + answers: [ + { + 'question_id' => "post-similarity:#{asked.id}", + 'answer' => 'yes' + } + ] + ) + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to include(fresh.id) + expect(json['questions'].map { _1['id'] }).not_to include(asked.id) + end + + it 'does not return questions already asked in the game using camelCase questionId' do + sign_in_as admin + + asked = create_post_similarity_question!(text: 'already asked?') + fresh = create_post_similarity_question!(text: 'fresh?') + game.update!( + answers: [ + { + 'questionId' => "post-similarity:#{asked.id}", + 'answer' => 'yes' + } + ] + ) + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to include(fresh.id) + expect(json['questions'].map { _1['id'] }).not_to include(asked.id) + end + + it 'does not return non-accepted, non-user_suggested, or non-post_similarity questions' do + sign_in_as admin + + accepted = create_post_similarity_question!(text: 'accepted?') + create_post_similarity_question!(text: 'disabled?', status: 'disabled') + create_post_similarity_question!(text: 'ai?', source: 'ai_generated') + GekanatorQuestion.create!( + text: 'tag?', + kind: 'tag', + source: 'user_suggested', + status: 'accepted', + priority_weight: 1.0, + condition: { type: 'tag', key: 'character:喜多郁代' } + ) + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id) + end + end + + describe 'POST /gekanator/games/:id/extra_question_answers' do + it 'creates examples for extra question answers' do + sign_in_as admin + + question = create_post_similarity_question!(text: 'extra?') + + expect { + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id.to_s, + answer: 'partial' + } + ] + } + }.to change { GekanatorQuestionExample.count }.by(1) + + expect(response).to have_http_status(:created) + expect(json).to include('count' => 1) + + example = GekanatorQuestionExample.last + expect(example).to have_attributes( + gekanator_question_id: question.id, + post_id: correct_post.id, + user_id: admin.id, + gekanator_game_id: game.id, + answer: 'partial', + source: 'post_game_extra', + weight: 1.0 + ) + end + + it 'updates an existing example for the same question, post, and user' do + sign_in_as admin + + question = create_post_similarity_question!(text: 'extra?') + existing = GekanatorQuestionExample.create!( + gekanator_question: question, + post: correct_post, + user: admin, + answer: 'no', + source: 'post_game_extra', + weight: 1.0 + ) + + expect { + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + }.not_to change { GekanatorQuestionExample.count } + + expect(response).to have_http_status(:created) + expect(existing.reload).to have_attributes( + answer: 'yes', + source: 'post_game_extra', + gekanator_game_id: game.id + ) + end + + it 'rejects missing questions' do + sign_in_as admin + + expect { + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: 999_999_999, + answer: 'yes' + } + ] + } + }.not_to change { GekanatorQuestionExample.count } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'rejects non-accepted questions' do + sign_in_as admin + + question = create_post_similarity_question!( + text: 'disabled?', + status: 'disabled' + ) + + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'rejects non-post_similarity questions' do + sign_in_as admin + + question = GekanatorQuestion.create!( + text: 'tag?', + kind: 'tag', + source: 'user_suggested', + status: 'accepted', + priority_weight: 1.0, + condition: { type: 'tag', key: 'character:喜多郁代' } + ) + + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + describe 'GET /gekanator/questions' do + it 'returns accepted questions only and includes example_answers for post_similarity questions' do + sign_in_as admin + + accepted = create_post_similarity_question!(text: 'accepted?') + create_post_similarity_question!( + text: 'disabled?', + status: 'disabled' + ) + + GekanatorQuestionExample.create!( + gekanator_question: accepted, + post: correct_post, + user: admin, + answer: 'yes', + source: 'initial_suggestion', + weight: 1.0 + ) + + get '/gekanator/questions' + + expect(response).to have_http_status(:ok) + expect(json['questions'].length).to eq(1) + + question_json = json['questions'].first + expect(question_json).to include( + 'id' => "post-similarity:#{accepted.id}", + 'text' => 'accepted?', + 'kind' => 'post_similarity', + 'source' => 'user_suggested', + 'priority_weight' => 1.2 + ) + expect(question_json['condition']).to include( + 'type' => 'post-similarity', + 'postId' => correct_post.id, + 'answer' => 'yes', + 'threshold' => 0.65 + ) + expect(question_json['example_answers']).to include( + correct_post.id.to_s => 'yes' + ) + end + + it 'aggregates example_answers by weight' do + sign_in_as admin + + question = create_post_similarity_question!(text: 'weighted?') + + GekanatorQuestionExample.create!( + gekanator_question: question, + post: other_post, + user: admin, + answer: 'yes', + source: 'post_game_extra', + weight: 1.0 + ) + GekanatorQuestionExample.create!( + gekanator_question: question, + post: other_post, + user: other_user, + answer: 'no', + source: 'post_game_extra', + weight: 2.0 + ) + + get '/gekanator/questions' + + expect(response).to have_http_status(:ok) + question_json = json['questions'].find { _1['id'] == "post-similarity:#{question.id}" } + expect(question_json['example_answers']).to include( + other_post.id.to_s => 'no' + ) + end + end +end diff --git a/frontend/src/lib/gekanator.test.ts b/frontend/src/lib/gekanator.test.ts new file mode 100644 index 0000000..5afb8f7 --- /dev/null +++ b/frontend/src/lib/gekanator.test.ts @@ -0,0 +1,296 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { apiPost } from '@/lib/api' +import { + 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, + 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, + 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') + }) +}) + +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) + }) +}) + +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', + }, + ], + }, + ) + }) +})