From 791b52e894f87339fa4f66cdc7d5a83e74e6b107 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 14 Jun 2026 05:31:00 +0900 Subject: [PATCH] #361 --- backend/spec/requests/gekanator_games_spec.rb | 9 +- .../spec/requests/gekanator_learning_spec.rb | 190 +++++++++++++++++- .../question_suggestion_ai_converter_spec.rb | 112 +++++++++++ frontend/src/lib/gekanator.test.ts | 90 +++++++++ .../lib/gekanatorCandidateRecovery.test.ts | 25 ++- 5 files changed, 413 insertions(+), 13 deletions(-) create mode 100644 backend/spec/services/gekanator/question_suggestion_ai_converter_spec.rb diff --git a/backend/spec/requests/gekanator_games_spec.rb b/backend/spec/requests/gekanator_games_spec.rb index 79c0f5a..236f23e 100644 --- a/backend/spec/requests/gekanator_games_spec.rb +++ b/backend/spec/requests/gekanator_games_spec.rb @@ -52,16 +52,16 @@ RSpec.describe 'Gekanator games API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end - it 'returns not found without an admin user' do + it 'returns unauthorized without a user' do post '/gekanator/games', params: { guessed_post_id: guessed_post.id, correct_post_id: guessed_post.id, answers: [] } - expect(response).to have_http_status(:not_found) + expect(response).to have_http_status(:unauthorized) end - it 'returns not found for a non-admin user' do + it 'stores a game for a non-admin user' do sign_in_as user post '/gekanator/games', params: { @@ -69,7 +69,8 @@ RSpec.describe 'Gekanator games API', type: :request do correct_post_id: guessed_post.id, answers: [{ question_id: 'tag:1', answer: 'yes' }] } - expect(response).to have_http_status(:not_found) + expect(response).to have_http_status(:created) + expect(GekanatorGame.find(json['id']).user).to eq(user) end end end diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 16c5244..7ae3f1f 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -129,7 +129,7 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end - it 'returns not found for a non-admin user' do + it 'stores a game result for a non-admin user' do sign_in_as member post '/gekanator/games', params: { @@ -138,7 +138,18 @@ RSpec.describe 'Gekanator learning API', type: :request do answers: [] } - expect(response).to have_http_status(:not_found) + expect(response).to have_http_status(:created) + expect(GekanatorGame.find(json['id']).user).to eq(member) + end + + it 'returns unauthorized without a user' do + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [] + } + + expect(response).to have_http_status(:unauthorized) end end @@ -261,17 +272,57 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end - it 'returns not found for a non-admin user' do + it 'allows a non-admin user to suggest a question for their own game' do + member_game = GekanatorGame.create!( + user: member, + guessed_post: guessed_post, + correct_post: correct_post, + won: false, + question_count: 1, + answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] + ) sign_in_as member - post '/gekanator/question_suggestions', params: { - gekanator_game_id: game.id, - question_text: 'member question?', - answer: 'yes' - } + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: member_game.id, + question_text: 'member question?', + answer: 'yes' + } + }.to change { GekanatorQuestionSuggestion.count }.by(1) + + expect(response).to have_http_status(:created) + expect(GekanatorQuestionSuggestion.last).to have_attributes( + gekanator_game_id: member_game.id, + user_id: member.id + ) + end + + it 'returns not found for another user game' do + sign_in_as member + + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: 'member question?', + answer: 'yes' + } + }.not_to change { GekanatorQuestionSuggestion.count } expect(response).to have_http_status(:not_found) end + + it 'returns unauthorized without a user' do + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: 'member question?', + answer: 'yes' + } + }.not_to change { GekanatorQuestionSuggestion.count } + + expect(response).to have_http_status(:unauthorized) + end end describe 'GET /gekanator/games/:id/extra_questions' do @@ -377,6 +428,38 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:ok) expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id) end + + it 'allows a non-admin user to fetch extra questions for their own game' do + member_game = GekanatorGame.create!( + user: member, + guessed_post: guessed_post, + correct_post: correct_post, + won: false, + question_count: 1, + answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] + ) + accepted = create_post_similarity_question!(text: 'accepted?') + sign_in_as member + + get "/gekanator/games/#{member_game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to include(accepted.id) + end + + it 'returns not found for another user game' do + sign_in_as member + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:not_found) + end + + it 'returns unauthorized without a user' do + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:unauthorized) + end end describe 'POST /gekanator/games/:id/extra_question_answers' do @@ -503,6 +586,69 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end + + it 'allows a non-admin user to answer extra questions for their own game' do + member_game = GekanatorGame.create!( + user: member, + guessed_post: guessed_post, + correct_post: correct_post, + won: false, + question_count: 1, + answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] + ) + question = create_post_similarity_question!(text: 'extra?') + sign_in_as member + + expect { + post "/gekanator/games/#{member_game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + }.to change { GekanatorQuestionExample.count }.by(1) + + expect(response).to have_http_status(:created) + expect(GekanatorQuestionExample.last).to have_attributes( + user_id: member.id, + gekanator_game_id: member_game.id + ) + end + + it 'returns not found for another user game' do + question = create_post_similarity_question!(text: 'extra?') + sign_in_as member + + 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(:not_found) + end + + it 'returns unauthorized without a user' do + question = create_post_similarity_question!(text: 'extra?') + + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + + expect(response).to have_http_status(:unauthorized) + end end describe 'GET /gekanator/questions' do @@ -608,5 +754,33 @@ RSpec.describe 'Gekanator learning API', type: :request do 'length' => 21 ) end + + it 'returns title-contains questions without authentication' do + GekanatorQuestion.create!( + text: '題名に「結束バンド」が含まれる?', + kind: 'title', + source: 'ai_generated', + status: 'accepted', + priority_weight: 0.95, + condition: { + type: 'title-contains', + text: '結束バンド' + }, + created_by: admin + ) + + get '/gekanator/questions' + + expect(response).to have_http_status(:ok) + question_json = json['questions'].find { _1['id'] == 'title:contains:結束バンド' } + expect(question_json).to include( + 'text' => '題名に「結束バンド」が含まれる?', + 'kind' => 'title' + ) + expect(question_json['condition']).to include( + 'type' => 'title-contains', + 'text' => '結束バンド' + ) + end end end diff --git a/backend/spec/services/gekanator/question_suggestion_ai_converter_spec.rb b/backend/spec/services/gekanator/question_suggestion_ai_converter_spec.rb new file mode 100644 index 0000000..5b9023a --- /dev/null +++ b/backend/spec/services/gekanator/question_suggestion_ai_converter_spec.rb @@ -0,0 +1,112 @@ +require 'rails_helper' + +RSpec.describe Gekanator::QuestionSuggestionAiConverter do + let(:user) { create(:user, :member) } + let(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') } + let(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') } + let(:game) do + GekanatorGame.create!( + user: user, + guessed_post: guessed_post, + correct_post: correct_post, + won: false, + question_count: 1, + answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] + ) + end + + def create_suggestion!(question_text:, answer: 'yes') + GekanatorQuestionSuggestion.create!( + gekanator_game: game, + user: user, + question_text: question_text, + answer: answer + ) + end + + it 'converts title-contains suggestions to pending ai-generated questions' do + suggestion = create_suggestion!(question_text: '題名に「結束バンド」が含まれる?') + + expect { + described_class.call(suggestion: suggestion, user: user) + }.to change { GekanatorQuestion.count }.by(1) + .and change { GekanatorAiRun.count }.by(1) + + question = GekanatorQuestion.last + expect(question).to have_attributes( + text: '題名に「結束バンド」が含まれる?', + kind: 'title', + source: 'ai_generated', + status: 'pending', + priority_weight: 0.95, + gekanator_question_suggestion_id: suggestion.id, + created_by_id: user.id + ) + expect(question.condition).to include( + 'type' => 'title-contains', + 'text' => '結束バンド' + ) + expect(GekanatorAiRun.last).to have_attributes( + gekanator_question_suggestion_id: suggestion.id, + model: 'heuristic_converter_v1', + status: 'succeeded' + ) + end + + it 'converts concrete non-unknown suggestions to post-similarity questions' do + suggestion = create_suggestion!( + question_text: '喜多ちゃんが泣いてる?', + answer: 'partial' + ) + + question = described_class.call(suggestion: suggestion, user: user) + + expect(question).to have_attributes( + text: '喜多ちゃんが泣いてる?', + kind: 'post_similarity', + source: 'ai_generated', + status: 'pending', + priority_weight: 1.0 + ) + expect(question.condition).to include( + 'type' => 'post-similarity', + 'postId' => correct_post.id, + 'answer' => 'partial', + 'threshold' => 0.65 + ) + end + + it 'records a failed run when the suggestion cannot be converted' do + suggestion = create_suggestion!( + question_text: 'よく分からない質問?', + answer: 'unknown' + ) + + expect { + expect(described_class.call(suggestion: suggestion, user: user)).to be_nil + }.not_to change { GekanatorQuestion.count } + + expect(GekanatorAiRun.last).to have_attributes( + gekanator_question_suggestion_id: suggestion.id, + status: 'failed' + ) + end + + it 'returns an existing generated question without creating a duplicate run' do + suggestion = create_suggestion!(question_text: 'タイトルは 10 文字以上?') + existing = GekanatorQuestion.create!( + text: 'タイトルは 10 文字以上?', + kind: 'title', + source: 'ai_generated', + status: 'pending', + priority_weight: 0.95, + condition: { type: 'title-length-at-least', length: 10 }, + gekanator_question_suggestion: suggestion, + created_by: user + ) + + expect { + expect(described_class.call(suggestion: suggestion, user: user)).to eq(existing) + }.not_to change { GekanatorAiRun.count } + end +end diff --git a/frontend/src/lib/gekanator.test.ts b/frontend/src/lib/gekanator.test.ts index 7666bc8..b5b50ec 100644 --- a/frontend/src/lib/gekanator.test.ts +++ b/frontend/src/lib/gekanator.test.ts @@ -4,6 +4,7 @@ import { apiPost } from '@/lib/api' import { buildGekanatorQuestions, expectedAnswerForQuestion, + questionIdForCondition, restoreGekanatorQuestion, saveGekanatorExtraQuestionAnswers, saveGekanatorGame, @@ -164,6 +165,27 @@ describe('expectedAnswerForQuestion', () => { 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('restoreGekanatorQuestion', () => { @@ -248,6 +270,21 @@ describe('restoreGekanatorQuestion', () => { 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', () => { @@ -264,6 +301,59 @@ describe('buildGekanatorQuestions', () => { 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', () => { diff --git a/frontend/src/lib/gekanatorCandidateRecovery.test.ts b/frontend/src/lib/gekanatorCandidateRecovery.test.ts index 3716f3f..8e06f91 100644 --- a/frontend/src/lib/gekanatorCandidateRecovery.test.ts +++ b/frontend/src/lib/gekanatorCandidateRecovery.test.ts @@ -145,7 +145,30 @@ describe('recoverCandidatePosts', () => { expect(recovered?.recoveryStepCount).toBe (1) expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])]) - .toEqual ([8, 7, 6, 5, 4, 3, 2]) + .toEqual ([8, 7, 6, 5, 4]) expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2) }) + + it('does not add posts when recovered and eligible candidates already hit the target', () => { + const posts = Array.from ({ length: 10 }, (_value, index) => post (index + 1)) + const scores = new Map (posts.map (candidate => [candidate.id, candidate.id])) + + const recovered = recoverCandidatePosts ({ + posts, + scores, + rejectedPostIds: new Set (), + recoveredCandidatePosts: new Map ([ + [1, 1], + [2, 1], + [3, 1], + ]), + eligiblePostIds: new Set ([4, 5, 6]), + answerCountAtRecovery: 2, + recoveryStepCount: 0, + }) + + expect(recovered?.recoveryStepCount).toBe (1) + expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])]) + .toEqual ([1, 2, 3]) + }) })