From 62d0830aec51160fc17a442ade31990aafb25876 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 16 Jun 2026 23:18:11 +0900 Subject: [PATCH] #371 --- .../spec/requests/gekanator_learning_spec.rb | 155 +++++++++++++++++- .../lib/gekanatorCandidateRecovery.test.ts | 78 ++++++++- .../src/lib/gekanatorCandidateRecovery.ts | 2 +- frontend/src/pages/GekanatorPage.test.tsx | 54 ++++++ 4 files changed, 282 insertions(+), 7 deletions(-) diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 7ae3f1f..9b71440 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -151,6 +151,154 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:unauthorized) end + + it 'learns accepted non-nico tag answers from camelCase main game logs' do + sign_in_as admin + + tag_question = GekanatorQuestion.create!( + text: 'MAD 要素がある?', + kind: 'tag', + source: 'admin_curated', + status: 'accepted', + priority_weight: 1.0, + condition: { type: 'tag', key: 'meme:MAD' }, + created_by: admin + ) + + expect { + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [ + { + questionId: 'tag:meme:MAD', + question_text: 'MAD 要素がある?', + answer: 'yes', + original_answer: 'yes' + }, + { + questionId: 'tag:meme:missing', + question_text: '存在しない質問?', + answer: 'yes', + original_answer: 'yes' + }, + { + questionId: 'tag:meme:MAD', + question_text: 'MAD 要素がある?', + answer: 'unknown', + original_answer: 'unknown' + } + ] + } + }.to change { GekanatorQuestionExample.count }.by(1) + + expect(response).to have_http_status(:created) + expect(json['learned_example_count']).to eq(1) + + example = GekanatorQuestionExample.last + expect(example).to have_attributes( + gekanator_question_id: tag_question.id, + post_id: correct_post.id, + user_id: admin.id, + answer: 'yes', + source: 'post_game_answer' + ) + expect(example.gekanator_game_id).to eq(json['id']) + end + + it 'does not learn fact questions or nico tag questions from main game logs' do + sign_in_as admin + + [ + { + text: 'example.com 由来?', + kind: 'source', + condition: { type: 'source', host: 'example.com' } + }, + { + text: '題名に結束バンドを含む?', + kind: 'title', + condition: { type: 'title-contains', text: '結束バンド' } + }, + { + text: '2024 年投稿?', + kind: 'original_date', + condition: { type: 'original-year', year: 2024 } + }, + { + text: 'ニコニコにぼっちタグ?', + kind: 'tag', + condition: { type: 'tag', key: 'nico:ぼっち' } + } + ].each do |attributes| + GekanatorQuestion.create!( + text: attributes[:text], + kind: attributes[:kind], + source: 'admin_curated', + status: 'accepted', + priority_weight: 1.0, + condition: attributes[:condition], + created_by: admin + ) + end + + expect { + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [ + { question_id: 'source:example.com', answer: 'yes' }, + { question_id: 'title:contains:結束バンド', answer: 'yes' }, + { question_id: 'original-year:2024', answer: 'yes' }, + { question_id: 'tag:nico:ぼっち', answer: 'yes' } + ] + } + }.not_to change { GekanatorQuestionExample.count } + + expect(response).to have_http_status(:created) + expect(json['learned_example_count']).to eq(0) + end + + it 'updates an existing main game example instead of duplicating it' do + sign_in_as admin + + tag_question = GekanatorQuestion.create!( + text: '喜多ちゃんが関係してる?', + kind: 'tag', + source: 'admin_curated', + status: 'accepted', + priority_weight: 1.0, + condition: { type: 'tag', key: 'character:喜多郁代' }, + created_by: admin + ) + existing = GekanatorQuestionExample.create!( + gekanator_question: tag_question, + post: correct_post, + user: admin, + answer: 'no', + source: 'post_game_answer', + weight: 1.0 + ) + + expect { + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [ + { + question_id: 'tag:character:喜多郁代', + answer: 'yes', + original_answer: 'yes' + } + ] + } + }.not_to change { GekanatorQuestionExample.count } + + expect(response).to have_http_status(:created) + expect(json['learned_example_count']).to eq(1) + expect(existing.reload.answer).to eq('yes') + expect(existing.sample_count).to eq(2) + end end describe 'POST /gekanator/question_suggestions' do @@ -249,7 +397,7 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(GekanatorQuestionSuggestion.last.processed).to eq(false) end - it 'limits suggestions to three per game' do + it 'allows more than three suggestions per game' do sign_in_as admin 3.times do |i| @@ -267,9 +415,10 @@ RSpec.describe 'Gekanator learning API', type: :request do question_text: 'fourth question?', answer: 'yes' } - }.not_to change { GekanatorQuestionSuggestion.count } + }.to change { GekanatorQuestionSuggestion.count }.by(1) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:created) + expect(json['count']).to eq(4) end it 'allows a non-admin user to suggest a question for their own game' do diff --git a/frontend/src/lib/gekanatorCandidateRecovery.test.ts b/frontend/src/lib/gekanatorCandidateRecovery.test.ts index 8e06f91..221fb4c 100644 --- a/frontend/src/lib/gekanatorCandidateRecovery.test.ts +++ b/frontend/src/lib/gekanatorCandidateRecovery.test.ts @@ -51,6 +51,21 @@ const postSimilarityQuestion = ( }) +const sourceQuestion = ( + host: string, +): GekanatorQuestion => ({ + id: `source:${ host }`, + text: `${ host }?`, + kind: 'source', + condition: { + type: 'source', + host }, + source: 'default', + priorityWeight: 1, + test: candidate => new URL (candidate.url).hostname === host, +}) + + const answer = ( question: GekanatorQuestion, value: GekanatorAnswerValue, @@ -64,7 +79,7 @@ const answer = ( describe('candidatePostsFor', () => { - it('lets recovered candidates ignore old answers but not later answers', () => { + it('does not hard-filter semantic post_similarity answers', () => { const posts = [post (1), post (2), post (3)] const oldQuestion = postSimilarityQuestion ('old', { 1: 'no', @@ -77,6 +92,29 @@ describe('candidatePostsFor', () => { 3: 'yes', }) + const candidates = candidatePostsFor ({ + posts, + questions: [oldQuestion, laterQuestion], + answers: [answer (oldQuestion, 'yes'), answer (laterQuestion, 'yes')], + softenedQuestionIds: new Set (), + rejectedPostIds: new Set (), + recoveredCandidatePosts: new Map ([ + [1, 1], + [3, 1], + ]) }) + + expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3]) + }) + + it('lets recovered candidates ignore old fact answers but not later fact answers', () => { + const posts = [ + { ...post (1), url: 'https://other.example/posts/1' }, + post (2), + { ...post (3), url: 'https://example.com/posts/3' }, + ] + const oldQuestion = sourceQuestion ('old.example.com') + const laterQuestion = sourceQuestion ('example.com') + const candidates = candidatePostsFor ({ posts, questions: [oldQuestion, laterQuestion], @@ -112,7 +150,7 @@ describe('candidatePostsFor', () => { describe('hardFilteredPostsForAnswer', () => { - it('returns zero candidates without falling back to the original pool', () => { + it('keeps the original pool for semantic post_similarity answers', () => { const posts = [post (1), post (2)] const question = postSimilarityQuestion ('question', { 1: 'yes', @@ -123,7 +161,41 @@ describe('hardFilteredPostsForAnswer', () => { posts, question, answer: 'no', - })).toEqual ([]) + })).toEqual (posts) + }) + + it('hard-filters fact answers only for yes and no', () => { + const posts = [ + { ...post (1), url: 'https://example.com/posts/1' }, + { ...post (2), url: 'https://other.example/posts/2' }, + ] + const question = sourceQuestion ('example.com') + + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'yes', + }).map (candidate => candidate.id)).toEqual ([1]) + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'no', + }).map (candidate => candidate.id)).toEqual ([2]) + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'partial', + })).toEqual (posts) + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'probably_no', + })).toEqual (posts) + expect(hardFilteredPostsForAnswer ({ + posts, + question, + answer: 'unknown', + })).toEqual (posts) }) }) diff --git a/frontend/src/lib/gekanatorCandidateRecovery.ts b/frontend/src/lib/gekanatorCandidateRecovery.ts index 8635bfa..11f8eed 100644 --- a/frontend/src/lib/gekanatorCandidateRecovery.ts +++ b/frontend/src/lib/gekanatorCandidateRecovery.ts @@ -86,7 +86,7 @@ export const hardFilteredPostsForAnswer = ({ if (!(questionIsFactLikeForHardFiltering (question))) return posts - if (answer === 'unknown') + if (!(answer === 'yes' || answer === 'no')) return posts return posts.filter (post => { diff --git a/frontend/src/pages/GekanatorPage.test.tsx b/frontend/src/pages/GekanatorPage.test.tsx index e3c4bfa..ce98ff5 100644 --- a/frontend/src/pages/GekanatorPage.test.tsx +++ b/frontend/src/pages/GekanatorPage.test.tsx @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + import { describe, expect, it } from 'vitest' import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters' @@ -49,6 +52,57 @@ const blocked = ( isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)]) +const gekanatorPageSource = readFileSync ( + resolve (process.cwd (), 'src/pages/GekanatorPage.tsx'), + 'utf8') + +const gekanatorBackdropSource = gekanatorPageSource.slice ( + gekanatorPageSource.indexOf ('const GekanatorBackdrop'), + gekanatorPageSource.indexOf ('const expectedAnswerFor')) + + +describe('GekanatorBackdrop regression structure', () => { + it('keeps displayedBackdropMode as the render-time source of truth', () => { + expect(gekanatorBackdropSource).not.toContain ('isLeavingGuessBackdrop') + expect(gekanatorBackdropSource).not.toContain ('renderBackdropMode') + expect(gekanatorBackdropSource).not.toContain ('renderWinningRunCount') + expect(gekanatorBackdropSource).not.toContain ('renderThumbnails') + expect(gekanatorBackdropSource).not.toContain ('renderIsCrossfading') + + expect(gekanatorBackdropSource).toContain ( + "const renderedSettings = settingsForMode (displayedBackdropMode)") + expect(gekanatorBackdropSource).toContain ( + 'scaleForMode (displayedBackdropMode, displayedWinningRunCount)') + expect(gekanatorBackdropSource).toContain ( + "backdropMode === 'guess' || displayedBackdropMode === 'guess'") + }) + + it('does not split guess into a separate renderer or force a remount', () => { + expect(gekanatorBackdropSource).not.toContain ('renderStaticGuessBackdrop') + expect(gekanatorBackdropSource).not.toContain ('guessZoomAnimationKey') + expect(gekanatorBackdropSource).not.toContain ('shouldAnimateGuessZoomIn') + expect(gekanatorBackdropSource).not.toContain ('previousBackdropModeRef') + expect(gekanatorBackdropSource).not.toContain ( + 'if (isGuessPresentation && guessThumbnail)') + }) + + it('keeps tile keys independent from backdrop mode', () => { + expect(gekanatorBackdropSource).toContain ('key={duplicate}') + expect(gekanatorBackdropSource).toContain ('key={`${ duplicate }:${ index }`}') + expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*mode/) + expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*displayedBackdropMode/) + }) + + it('keeps guess on the shared scale, x, and y animation path', () => { + expect(gekanatorBackdropSource).toContain ('animate={{ scale: renderedScale') + expect(gekanatorBackdropSource).toContain ( + "x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%'") + expect(gekanatorBackdropSource).toContain ( + "y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%'") + }) +}) + + describe('isQuestionHardFilteredAfterAnswers', () => { it('blocks only contradictory or redundant month questions after a yes answer', () => { const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }