グカネータ作成 / 質問パターン修正 (#41) #364
@@ -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
|
||||||
@@ -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> = {}): 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
新しい課題から参照
ユーザをブロックする