グカネータ スコア補正 (#376) (#377)

Reviewed-on: #377
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #377 でマージされました.
このコミットが含まれているのは:
2026-06-18 01:15:54 +09:00
committed by みてるぞ
コミット ffd28c0f9e
9個のファイルの変更1100行の追加224行の削除
+1 -1
ファイルの表示
@@ -50,7 +50,7 @@ class GekanatorGamesController < ApplicationController
questions, questions,
post_id: game.correct_post_id, post_id: game.correct_post_id,
user: current_user, user: current_user,
limit: 2) limit: 6)
render json: { render json: {
questions: selected.map { |question| extra_question_json(question) } questions: selected.map { |question| extra_question_json(question) }
+4
ファイルの表示
@@ -1,6 +1,10 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe TagNameSanitisationRule, type: :model do RSpec.describe TagNameSanitisationRule, type: :model do
before do
described_class.unscoped.delete_all
end
describe '.sanitise' do describe '.sanitise' do
before do before do
described_class.create!(priority: 10, source_pattern: '_', replacement: '') described_class.create!(priority: 10, source_pattern: '_', replacement: '')
+104 -8
ファイルの表示
@@ -206,6 +206,40 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(example.gekanator_game_id).to eq(json['id']) expect(example.gekanator_game_id).to eq(json['id'])
end end
it 'learns accepted post_similarity answers from main game logs' do
sign_in_as admin
question = create_post_similarity_question!(text: '泣いてる?')
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{
question_id: "post-similarity:#{question.id}",
question_text: '泣いてる?',
answer: 'partial',
original_answer: 'partial'
}
]
}
}.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: question.id,
post_id: correct_post.id,
user_id: admin.id,
answer: 'partial',
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 it 'does not learn fact questions or nico tag questions from main game logs' do
sign_in_as admin sign_in_as admin
@@ -475,28 +509,59 @@ RSpec.describe 'Gekanator learning API', type: :request do
end end
describe 'GET /gekanator/games/:id/extra_questions' do describe 'GET /gekanator/games/:id/extra_questions' do
it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do it 'returns at most six accepted user_suggested post_similarity questions without duplicates' do
sign_in_as admin sign_in_as admin
lowest = create_post_similarity_question!(
text: 'lowest?',
priority_weight: 0.5
)
low = create_post_similarity_question!( low = create_post_similarity_question!(
text: 'low?', text: 'low?',
priority_weight: 1.0 priority_weight: 1.0
) )
high = create_post_similarity_question!(
text: 'high?',
priority_weight: 3.0
)
middle = create_post_similarity_question!( middle = create_post_similarity_question!(
text: 'middle?', text: 'middle?',
priority_weight: 1.5
)
medium_high = create_post_similarity_question!(
text: 'medium high?',
priority_weight: 2.0 priority_weight: 2.0
) )
high = create_post_similarity_question!(
text: 'high?',
priority_weight: 2.5
)
higher = create_post_similarity_question!(
text: 'higher?',
priority_weight: 2.8
)
highest = create_post_similarity_question!(
text: 'highest?',
priority_weight: 3.0
)
overflow = create_post_similarity_question!(
text: 'overflow?',
priority_weight: 2.2
)
get "/gekanator/games/#{game.id}/extra_questions" get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['questions'].length).to eq(2) expect(json['questions'].length).to eq(6)
expect(json['questions'].map { _1['id'] }.uniq.length).to eq(2) expect(json['questions'].map { _1['id'] }.uniq.length).to eq(6)
expect(json['questions'].map { _1['id'] }).to all(be_in([low.id, high.id, middle.id])) expect(json['questions'].map { _1['id'] }).to all(
be_in([
lowest.id,
low.id,
middle.id,
medium_high.id,
high.id,
higher.id,
highest.id,
overflow.id,
])
)
end end
it 'can return questions that already have an example for the correct post' do it 'can return questions that already have an example for the correct post' do
@@ -519,6 +584,37 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(json['questions'].map { _1['id'] }).to include(existing.id) expect(json['questions'].map { _1['id'] }).to include(existing.id)
end end
it 'prioritizes questions the current user has not answered' do
sign_in_as admin
answered = create_post_similarity_question!(
text: 'already answered?',
priority_weight: 3.0
)
GekanatorQuestionExample.create!(
gekanator_question: answered,
post: other_post,
user: admin,
answer: 'yes',
source: 'post_game_extra'
)
unanswered =
6.times.map { |index|
create_post_similarity_question!(
text: "unanswered #{index}?",
priority_weight: 0.5
)
}
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to match_array(
unanswered.map(&:id)
)
end
it 'can return questions already asked in the game using snake_case question_id' do it 'can return questions already asked in the game using snake_case question_id' do
sign_in_as admin sign_in_as admin
+37 -1
ファイルの表示
@@ -4,6 +4,7 @@ import { apiPost } from '@/lib/api'
import { import {
buildGekanatorQuestions, buildGekanatorQuestions,
expectedAnswerForQuestion, expectedAnswerForQuestion,
learnedSemanticSideForPost,
questionIdForCondition, questionIdForCondition,
restoreGekanatorQuestion, restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers, saveGekanatorExtraQuestionAnswers,
@@ -188,6 +189,33 @@ describe('expectedAnswerForQuestion', () => {
}) })
}) })
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', () => { describe('restoreGekanatorQuestion', () => {
it('uses default source and priority weight when omitted', () => { it('uses default source and priority weight when omitted', () => {
const question = restoreGekanatorQuestion({ const question = restoreGekanatorQuestion({
@@ -248,7 +276,7 @@ describe('restoreGekanatorQuestion', () => {
}) })
expect(question.test(post({ id: 1 }))).toBe(true) expect(question.test(post({ id: 1 }))).toBe(true)
expect(question.test(post({ id: 2 }))).toBe(false) expect(question.test(post({ id: 2 }))).toBe(true)
}) })
it('normalizes legacy title-length-greater-than questions', () => { it('normalizes legacy title-length-greater-than questions', () => {
@@ -372,6 +400,10 @@ describe('Gekanator API writers', () => {
type: 'tag', type: 'tag',
key: 'character:喜多郁代', key: 'character:喜多郁代',
}, },
questionMode: 'normal',
questionPurpose: 'effective_user_suggested',
effectiveQuestion: true,
learningQuestion: false,
answer: 'yes', answer: 'yes',
originalAnswer: 'partial', originalAnswer: 'partial',
}, },
@@ -396,6 +428,10 @@ describe('Gekanator API writers', () => {
type: 'tag', type: 'tag',
key: 'character:喜多郁代', key: 'character:喜多郁代',
}, },
question_mode: 'normal',
question_purpose: 'effective_user_suggested',
effective_question: true,
learning_question: false,
answer: 'yes', answer: 'yes',
original_answer: 'partial', original_answer: 'partial',
}, },
+54 -7
ファイルの表示
@@ -9,11 +9,24 @@ export type GekanatorAnswerValue =
| 'probably_no' | 'probably_no'
| 'unknown' | 'unknown'
export type LearnedSemanticSide =
| 'positive'
| 'negative'
| 'unknown'
export type GekanatorQuestionPurpose =
| 'effective_user_suggested'
| 'learning_user_suggested'
| 'normal'
export type GekanatorAnswerLog = { export type GekanatorAnswerLog = {
questionId: string questionId: string
questionText: string questionText: string
questionCondition?: GekanatorQuestionCondition questionCondition?: GekanatorQuestionCondition
questionMode?: 'normal' | 'winning_run' questionMode?: 'normal' | 'winning_run'
questionPurpose?: GekanatorQuestionPurpose
effectiveQuestion?: boolean
learningQuestion?: boolean
answer: GekanatorAnswerValue answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue } originalAnswer: GekanatorAnswerValue }
@@ -163,6 +176,26 @@ const directExampleAnswerFor = (
return null return null
} }
export const isLearnedSemanticQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion,
): boolean =>
question.kind === 'post_similarity'
&& question.source === 'user_suggested'
export const learnedSemanticSideForAnswer = (
answer: GekanatorAnswerValue | null,
): LearnedSemanticSide => {
if (answer === 'yes' || answer === 'partial')
return 'positive'
if (answer === 'no' || answer === 'probably_no')
return 'negative'
return 'unknown'
}
const countBy = <T extends string | number> (values: T[]): Map<T, number> => { const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
const counts = new Map<T, number> () const counts = new Map<T, number> ()
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1)) values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
@@ -285,8 +318,8 @@ const questionMatches = (
): boolean => { ): boolean => {
const directAnswer = directExampleAnswerFor (question, post) const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer) if (directAnswer)
return question.condition.type === 'post-similarity' return question.kind === 'post_similarity'
? directAnswer === question.condition.answer ? learnedSemanticSideForAnswer (directAnswer) === 'positive'
: directAnswer === 'yes' : directAnswer === 'yes'
switch (question.condition.type) switch (question.condition.type)
@@ -328,6 +361,11 @@ export const expectedAnswerForQuestion = (
switch (question.condition.type) switch (question.condition.type)
{ {
case 'post-similarity':
if (question.condition.postId === post.id)
return question.condition.answer
return null
case 'tag': case 'tag':
case 'source': case 'source':
case 'original-year': case 'original-year':
@@ -338,12 +376,17 @@ export const expectedAnswerForQuestion = (
case 'title-has-ascii': case 'title-has-ascii':
case 'title-contains': case 'title-contains':
return questionMatches (post, question) ? 'yes' : 'no' return questionMatches (post, question) ? 'yes' : 'no'
case 'post-similarity':
return null
} }
} }
export const learnedSemanticSideForPost = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): LearnedSemanticSide =>
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
export const restoreGekanatorQuestion = ( export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion,
): GekanatorQuestion => { ): GekanatorQuestion => {
@@ -423,15 +466,15 @@ export const buildGekanatorQuestions = (
const originalYears = countBy ( const originalYears = countBy (
posts posts
.map (originalYearOf) .map (originalYearOf)
.filter ((year): year is number => year !== null)) .filter ((year): year is number => year != null))
const originalMonths = countBy ( const originalMonths = countBy (
posts posts
.map (originalMonthOf) .map (originalMonthOf)
.filter ((month): month is number => month !== null)) .filter ((month): month is number => month != null))
const originalMonthDays = countBy ( const originalMonthDays = countBy (
posts posts
.map (originalMonthDayOf) .map (originalMonthDayOf)
.filter ((monthDay): monthDay is string => monthDay !== null)) .filter ((monthDay): monthDay is string => monthDay != null))
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
const titleWordCounts = const titleWordCounts =
includeTitleContains includeTitleContains
@@ -593,6 +636,10 @@ export const saveGekanatorGame = async ({
question_id: answer.questionId, question_id: answer.questionId,
question_text: answer.questionText, question_text: answer.questionText,
question_condition: answer.questionCondition ?? null, question_condition: answer.questionCondition ?? null,
question_mode: answer.questionMode,
question_purpose: answer.questionPurpose,
effective_question: answer.effectiveQuestion,
learning_question: answer.learningQuestion,
answer: answer.answer, answer: answer.answer,
original_answer: answer.originalAnswer })) }) original_answer: answer.originalAnswer })) })
+23 -10
ファイルの表示
@@ -11,6 +11,7 @@ import type {
GekanatorAnswerValue, GekanatorAnswerValue,
GekanatorQuestion, GekanatorQuestion,
} from '@/lib/gekanator' } from '@/lib/gekanator'
import type { RecoveredCandidateState } from '@/lib/gekanatorCandidateRecovery'
import type { Post } from '@/types' import type { Post } from '@/types'
@@ -78,6 +79,15 @@ const answer = (
}) })
const recoveredState = (
answerCountAtRecovery: number,
scoreAtRecovery = 0,
): RecoveredCandidateState => ({
answerCountAtRecovery,
scoreAtRecovery,
})
describe('candidatePostsFor', () => { describe('candidatePostsFor', () => {
it('does not hard-filter semantic post_similarity answers', () => { it('does not hard-filter semantic post_similarity answers', () => {
const posts = [post (1), post (2), post (3)] const posts = [post (1), post (2), post (3)]
@@ -99,8 +109,8 @@ describe('candidatePostsFor', () => {
softenedQuestionIds: new Set (), softenedQuestionIds: new Set (),
rejectedPostIds: new Set (), rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([ recoveredCandidatePosts: new Map ([
[1, 1], [1, recoveredState (1)],
[3, 1], [3, recoveredState (1)],
]) }) ]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3]) expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3])
@@ -122,8 +132,8 @@ describe('candidatePostsFor', () => {
softenedQuestionIds: new Set (), softenedQuestionIds: new Set (),
rejectedPostIds: new Set (), rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([ recoveredCandidatePosts: new Map ([
[1, 1], [1, recoveredState (1)],
[3, 1], [3, recoveredState (1)],
]) }) ]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([3]) expect(candidates.map (candidate => candidate.id)).toEqual ([3])
@@ -142,7 +152,7 @@ describe('candidatePostsFor', () => {
answers: [answer (question, 'yes')], answers: [answer (question, 'yes')],
softenedQuestionIds: new Set (), softenedQuestionIds: new Set (),
rejectedPostIds: new Set ([1]), rejectedPostIds: new Set ([1]),
recoveredCandidatePosts: new Map ([[1, 1]]) }) recoveredCandidatePosts: new Map ([[1, recoveredState (1)]]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([2]) expect(candidates.map (candidate => candidate.id)).toEqual ([2])
}) })
@@ -209,7 +219,7 @@ describe('recoverCandidatePosts', () => {
posts, posts,
scores, scores,
rejectedPostIds: new Set ([10]), rejectedPostIds: new Set ([10]),
recoveredCandidatePosts: new Map ([[8, 1]]), recoveredCandidatePosts: new Map ([[8, recoveredState (1, 8)]]),
eligiblePostIds: new Set ([9]), eligiblePostIds: new Set ([9]),
answerCountAtRecovery: 2, answerCountAtRecovery: 2,
recoveryStepCount: 0, recoveryStepCount: 0,
@@ -218,7 +228,10 @@ describe('recoverCandidatePosts', () => {
expect(recovered?.recoveryStepCount).toBe (1) expect(recovered?.recoveryStepCount).toBe (1)
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])]) expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
.toEqual ([8, 7, 6, 5, 4]) .toEqual ([8, 7, 6, 5, 4])
expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2) expect(recovered?.recoveredCandidatePosts.get (7)).toEqual ({
answerCountAtRecovery: 2,
scoreAtRecovery: 7,
})
}) })
it('does not add posts when recovered and eligible candidates already hit the target', () => { it('does not add posts when recovered and eligible candidates already hit the target', () => {
@@ -230,9 +243,9 @@ describe('recoverCandidatePosts', () => {
scores, scores,
rejectedPostIds: new Set (), rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([ recoveredCandidatePosts: new Map ([
[1, 1], [1, recoveredState (1, 1)],
[2, 1], [2, recoveredState (1, 2)],
[3, 1], [3, recoveredState (1, 3)],
]), ]),
eligiblePostIds: new Set ([4, 5, 6]), eligiblePostIds: new Set ([4, 5, 6]),
answerCountAtRecovery: 2, answerCountAtRecovery: 2,
+28 -16
ファイルの表示
@@ -1,15 +1,21 @@
import { expectedAnswerForQuestion } from '@/lib/gekanator' import { isLearnedSemanticQuestion,
learnedSemanticSideForPost } from '@/lib/gekanator'
import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator' import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types' import type { Post } from '@/types'
export type RecoveredCandidatePost = { export type RecoveredCandidatePost = {
postId: number postId: number
answerCountAtRecovery: number } answerCountAtRecovery: number
scoreAtRecovery: number }
export type RecoveredCandidateState = {
answerCountAtRecovery: number
scoreAtRecovery: number }
const questionIsFactLikeForHardFiltering = (question: GekanatorQuestion): boolean => const questionSupportsAnswerBasedHardFiltering = (question: GekanatorQuestion): boolean =>
!(question.kind === 'post_similarity' !(isLearnedSemanticQuestion (question)
|| (question.kind === 'tag' || (question.kind === 'tag'
&& question.condition.type === 'tag' && question.condition.type === 'tag'
&& !(question.condition.key.startsWith ('nico:')))) && !(question.condition.key.startsWith ('nico:'))))
@@ -26,7 +32,7 @@ export const candidatePostsFor = (
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string> softenedQuestionIds: Set<string>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number> }, recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
): Post[] => { ): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question])) const questionById = new Map (questions.map (question => [question.id, question]))
@@ -34,10 +40,10 @@ export const candidatePostsFor = (
if (rejectedPostIds.has (post.id)) if (rejectedPostIds.has (post.id))
return false return false
const answerCountAtRecovery = recoveredCandidatePosts.get (post.id) const recoveredCandidate = recoveredCandidatePosts.get (post.id)
return answers.every ((answer, index) => { return answers.every ((answer, index) => {
if (answerCountAtRecovery != null && index < answerCountAtRecovery) if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery)
return true return true
if (softenedQuestionIds.has (answer.questionId)) if (softenedQuestionIds.has (answer.questionId))
@@ -46,7 +52,7 @@ export const candidatePostsFor = (
const question = questionById.get (answer.questionId) const question = questionById.get (answer.questionId)
if (!(question)) if (!(question))
return true return true
if (!(questionIsFactLikeForHardFiltering (question))) if (!(questionSupportsAnswerBasedHardFiltering (question)))
return true return true
switch (answer.answer) switch (answer.answer)
@@ -54,8 +60,10 @@ export const candidatePostsFor = (
case 'yes': case 'yes':
case 'no': case 'no':
{ {
const expected = expectedAnswerForQuestion (question, post) const expected = learnedSemanticSideForPost (question, post)
return expected === null || expected === 'unknown' || expected === answer.answer return expected === 'unknown'
|| (answer.answer === 'yes' && expected === 'positive')
|| (answer.answer === 'no' && expected === 'negative')
} }
default: default:
return true return true
@@ -70,15 +78,17 @@ export const hardFilteredPostsForAnswer = (
question: GekanatorQuestion question: GekanatorQuestion
answer: GekanatorAnswerValue }, answer: GekanatorAnswerValue },
): Post[] => { ): Post[] => {
if (!(questionIsFactLikeForHardFiltering (question))) if (!(questionSupportsAnswerBasedHardFiltering (question)))
return posts return posts
if (!(answer === 'yes' || answer === 'no')) if (!(answer === 'yes' || answer === 'no'))
return posts return posts
return posts.filter (post => { return posts.filter (post => {
const expected = expectedAnswerForQuestion (question, post) const side = learnedSemanticSideForPost (question, post)
return expected == null || expected === 'unknown' || expected === answer return side === 'unknown'
|| (answer === 'yes' && side === 'positive')
|| (answer === 'no' && side === 'negative')
}) })
} }
@@ -112,11 +122,11 @@ export const recoverCandidatePosts = (
recoveryStepCount }: { posts: Post[] recoveryStepCount }: { posts: Post[]
scores: Map<number, number> scores: Map<number, number>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number> recoveredCandidatePosts: Map<number, RecoveredCandidateState>
eligiblePostIds: Set<number> eligiblePostIds: Set<number>
answerCountAtRecovery: number answerCountAtRecovery: number
recoveryStepCount: number }, recoveryStepCount: number },
): { recoveredCandidatePosts: Map<number, number> ): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number } | null => { recoveryStepCount: number } | null => {
const recovered = new Map (recoveredCandidatePosts) const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount) const targetSize = nextRecoveryTargetSize (recoveryStepCount)
@@ -140,7 +150,9 @@ export const recoverCandidatePosts = (
if (candidates.length === 0) if (candidates.length === 0)
return null return null
candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery)) candidates.forEach (post => recovered.set (post.id, {
answerCountAtRecovery,
scoreAtRecovery: scores.get (post.id) ?? 0 }))
return { recoveredCandidatePosts: recovered, return { recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 } recoveryStepCount: recoveryStepCount + 1 }
+32
ファイルの表示
@@ -60,6 +60,14 @@ const gekanatorBackdropSource = gekanatorPageSource.slice (
gekanatorPageSource.indexOf ('const GekanatorBackdrop'), gekanatorPageSource.indexOf ('const GekanatorBackdrop'),
gekanatorPageSource.indexOf ('const expectedAnswerFor')) gekanatorPageSource.indexOf ('const expectedAnswerFor'))
const gekanatorChooseQuestionSource = gekanatorPageSource.slice (
gekanatorPageSource.indexOf ('const chooseQuestion'),
gekanatorPageSource.indexOf ('const winningRunPriorityFor'))
const gekanatorFallbackQuestionSource = gekanatorPageSource.slice (
gekanatorPageSource.indexOf ('const chooseFallbackQuestion'),
gekanatorPageSource.indexOf ('const shouldEnterGuessPhase'))
describe('GekanatorBackdrop regression structure', () => { describe('GekanatorBackdrop regression structure', () => {
it('keeps displayedBackdropMode as the render-time source of truth', () => { it('keeps displayedBackdropMode as the render-time source of truth', () => {
@@ -103,6 +111,30 @@ describe('GekanatorBackdrop regression structure', () => {
}) })
describe('Gekanator question selection regression structure', () => {
it('prefers normal questions after user_suggested quota has been met', () => {
const normalFallbackIndex = gekanatorChooseQuestionSource.indexOf (
'else if (normalPool.length > 0)')
const effectiveFallbackIndex = gekanatorChooseQuestionSource.indexOf (
'else if (effectiveUserSuggestedPool.length > 0)')
expect(normalFallbackIndex).toBeGreaterThan(0)
expect(effectiveFallbackIndex).toBeGreaterThan(0)
expect(normalFallbackIndex).toBeLessThan(effectiveFallbackIndex)
})
it('does not let fallback questions bypass user_suggested purpose tracking', () => {
expect(gekanatorFallbackQuestionSource).toContain (
"question.source !== 'user_suggested'")
})
it('does not show a fixed extra-question count in the extra learning UI', () => {
expect(gekanatorPageSource).not.toContain ('追加で 2 問まで答えてください。')
expect(gekanatorPageSource).toContain ('追加で質問に答えてください。')
})
})
describe('isQuestionHardFilteredAfterAnswers', () => { describe('isQuestionHardFilteredAfterAnswers', () => {
it('blocks only contradictory or redundant month questions after a yes answer', () => { it('blocks only contradictory or redundant month questions after a yes answer', () => {
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 } const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
ファイル差分が大きすぎるため省略します 差分を読込み