コミットを比較

..

6 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 1d11c01247 #371 ごみファイル削除 2026-06-17 01:04:26 +09:00
みてるぞ cb7b9ee808 #371 2026-06-17 00:56:31 +09:00
みてるぞ f9f0010e03 #371 2026-06-16 23:48:29 +09:00
みてるぞ 62d0830aec #371 2026-06-16 23:18:11 +09:00
みてるぞ cda90b76d2 #371 2026-06-16 22:31:03 +09:00
みてるぞ 673a5dbd23 #371 2026-06-16 21:52:10 +09:00
14個のファイルの変更1111行の追加693行の削除
+58
ファイルの表示
@@ -125,6 +125,64 @@ npm run preview
- TypeScript and TSX use 4-space logical indentation. - TypeScript and TSX use 4-space logical indentation.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab. - In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
- Tabs are only for leading indentation, never for spaces after non-space text. - Tabs are only for leading indentation, never for spaces after non-space text.
- TypeScript and TSX imports may stay on one line if they remain within the
line limit; do not expand short type-only imports mechanically.
- In TypeScript and TSX, when a function takes one destructured object
argument plus an inline type, prefer this shape when it fits locally:
```ts
const helper = (
{ value, flag }: { value: string
flag: boolean },
): Result => {
// ...
}
```
- In TypeScript and TSX, put `switch` case block braces on their own lines
when a case needs a lexical block:
```ts
case 'yes':
case 'no':
{
const expected = valueFor (item)
return expected == null || expected === answer
}
```
- In TypeScript and TSX, use `value == null` and `value != null` as the
default nullish checks. Do not use `=== null`, `=== undefined`,
`!== null`, or `!== undefined`.
- If code appears to need a distinction between `null` and `undefined`, treat
that as a design smell and revise the logic to avoid the distinction.
External library APIs that explicitly require distinguishing the two are the
only exception.
- In TypeScript and TSX, keep short arrays on one line when they fit under the
line limit; break arrays only when readability or line length requires it.
- In TypeScript and TSX, when a ternary expression is split across multiple
lines, align `?` and `:` with the condition expression. Do not indent `?` and
`:` one extra level under the condition.
```ts
const value =
condition
? consequent
: alternate
```
- In TypeScript and TSX, keep short ternary expressions on one line when they
fit cleanly under the line limit.
- In TypeScript and TSX, prefer ternary expressions for simple conditional
value selection. Do not replace a clear ternary with `if` statements, and do
not introduce immediately invoked functions just to avoid or reformat a
ternary expression.
- In TypeScript and TSX, do not write `let` followed by later `if` assignments
when the value can be expressed as a single `const` initializer. Prefer
`const` because it prevents accidental later reassignment.
- When fixing formatting, change formatting only. Do not change expression
structure, control flow, or variable mutability unless the requested style
explicitly requires it.
- Do not add production dependencies without explicit approval. - Do not add production dependencies without explicit approval.
- Do not create, modify, or run tests unless the user explicitly asks for - Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working and rerun them until test work. When the user asks for tests, keep working and rerun them until
+12 -1
ファイルの表示
@@ -192,7 +192,10 @@ class GekanatorGamesController < ApplicationController
answer_value = answer['answer'].to_s answer_value = answer['answer'].to_s
next if answer_value.blank? || answer_value == 'unknown' next if answer_value.blank? || answer_value == 'unknown'
question = accepted_questions[answer['question_id'].to_s] question_id = game_answer_question_id(answer)
next if question_id.blank?
question = accepted_questions[question_id.to_s]
next unless learnable_game_answer_question?(question) next unless learnable_game_answer_question?(question)
example = example =
@@ -251,6 +254,7 @@ class GekanatorGamesController < ApplicationController
end end
def learnable_game_answer_question? question def learnable_game_answer_question? question
return false if question.nil?
return true if question.kind == 'post_similarity' return true if question.kind == 'post_similarity'
return false unless question.kind == 'tag' return false unless question.kind == 'tag'
@@ -258,4 +262,11 @@ class GekanatorGamesController < ApplicationController
key = condition[:key].to_s key = condition[:key].to_s
!key.start_with?('nico:') !key.start_with?('nico:')
end end
def game_answer_question_id answer
answer['question_id'] ||
answer[:question_id] ||
answer['questionId'] ||
answer[:questionId]
end
end end
+152 -3
ファイルの表示
@@ -151,6 +151,154 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end 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 end
describe 'POST /gekanator/question_suggestions' do 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) expect(GekanatorQuestionSuggestion.last.processed).to eq(false)
end end
it 'limits suggestions to three per game' do it 'allows more than three suggestions per game' do
sign_in_as admin sign_in_as admin
3.times do |i| 3.times do |i|
@@ -267,9 +415,10 @@ RSpec.describe 'Gekanator learning API', type: :request do
question_text: 'fourth question?', question_text: 'fourth question?',
answer: 'yes' 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 end
it 'allows a non-admin user to suggest a question for their own game' do it 'allows a non-admin user to suggest a question for their own game' do
バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 559 KiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 146 KiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 1.2 MiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 188 KiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 201 KiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 196 KiB

バイナリファイルは表示されません.

変更前

幅:  |  高さ:  |  サイズ: 179 KiB

+75 -3
ファイルの表示
@@ -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 = ( const answer = (
question: GekanatorQuestion, question: GekanatorQuestion,
value: GekanatorAnswerValue, value: GekanatorAnswerValue,
@@ -64,7 +79,7 @@ const answer = (
describe('candidatePostsFor', () => { 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 posts = [post (1), post (2), post (3)]
const oldQuestion = postSimilarityQuestion ('old', { const oldQuestion = postSimilarityQuestion ('old', {
1: 'no', 1: 'no',
@@ -77,6 +92,29 @@ describe('candidatePostsFor', () => {
3: 'yes', 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 ({ const candidates = candidatePostsFor ({
posts, posts,
questions: [oldQuestion, laterQuestion], questions: [oldQuestion, laterQuestion],
@@ -112,7 +150,7 @@ describe('candidatePostsFor', () => {
describe('hardFilteredPostsForAnswer', () => { 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 posts = [post (1), post (2)]
const question = postSimilarityQuestion ('question', { const question = postSimilarityQuestion ('question', {
1: 'yes', 1: 'yes',
@@ -123,7 +161,41 @@ describe('hardFilteredPostsForAnswer', () => {
posts, posts,
question, question,
answer: 'no', 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)
}) })
}) })
+35 -59
ファイルの表示
@@ -1,43 +1,33 @@
import { expectedAnswerForQuestion } from '@/lib/gekanator' import { expectedAnswerForQuestion } from '@/lib/gekanator'
import type { import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator'
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 }
const questionIsFactLikeForHardFiltering = ( const questionIsFactLikeForHardFiltering = (question: GekanatorQuestion): boolean =>
question: GekanatorQuestion,
): boolean =>
!(question.kind === 'post_similarity' !(question.kind === 'post_similarity'
|| ( || (question.kind === 'tag'
question.kind === 'tag'
&& question.condition.type === 'tag' && question.condition.type === 'tag'
&& !(question.condition.key.startsWith ('nico:')))) && !(question.condition.key.startsWith ('nico:'))))
export const candidatePostsFor = ({ export const candidatePostsFor = (
posts, { posts,
questions, questions,
answers, answers,
softenedQuestionIds, softenedQuestionIds,
rejectedPostIds, rejectedPostIds,
recoveredCandidatePosts, recoveredCandidatePosts }: { posts: Post[]
}: {
posts: Post[]
questions: GekanatorQuestion[] questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string> softenedQuestionIds: Set<string>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number> recoveredCandidatePosts: Map<number, number> },
}): Post[] => { ): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question])) const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => { return posts.filter (post => {
@@ -47,7 +37,7 @@ export const candidatePostsFor = ({
const answerCountAtRecovery = recoveredCandidatePosts.get (post.id) const answerCountAtRecovery = recoveredCandidatePosts.get (post.id)
return answers.every ((answer, index) => { return answers.every ((answer, index) => {
if (answerCountAtRecovery !== undefined && index < answerCountAtRecovery) if (answerCountAtRecovery != null && index < answerCountAtRecovery)
return true return true
if (softenedQuestionIds.has (answer.questionId)) if (softenedQuestionIds.has (answer.questionId))
@@ -62,7 +52,8 @@ export const candidatePostsFor = ({
switch (answer.answer) switch (answer.answer)
{ {
case 'yes': case 'yes':
case 'no': { case 'no':
{
const expected = expectedAnswerForQuestion (question, post) const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer.answer return expected === null || expected === 'unknown' || expected === answer.answer
} }
@@ -74,33 +65,25 @@ export const candidatePostsFor = ({
} }
export const hardFilteredPostsForAnswer = ({ export const hardFilteredPostsForAnswer = (
posts, { posts, question, answer }: { posts: Post[]
question,
answer,
}: {
posts: Post[]
question: GekanatorQuestion question: GekanatorQuestion
answer: GekanatorAnswerValue answer: GekanatorAnswerValue },
}): Post[] => { ): Post[] => {
if (!(questionIsFactLikeForHardFiltering (question))) if (!(questionIsFactLikeForHardFiltering (question)))
return posts return posts
if (answer === 'unknown') if (!(answer === 'yes' || answer === 'no'))
return posts return posts
return posts.filter (post => { return posts.filter (post => {
const expected = expectedAnswerForQuestion (question, post) const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer return expected == null || expected === 'unknown' || expected === answer
}) })
} }
const concreteAnswerOptions: GekanatorAnswerValue[] = [ const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', 'probably_no']
'yes',
'no',
'partial',
'probably_no']
export const allConcreteAnswerOptionsExhausted = ( export const allConcreteAnswerOptionsExhausted = (
@@ -119,45 +102,39 @@ const nextRecoveryTargetSize = (recoveryStepCount: number): number =>
6 * (2 ** recoveryStepCount) 6 * (2 ** recoveryStepCount)
export const recoverCandidatePosts = ({ export const recoverCandidatePosts = (
posts, { posts,
scores, scores,
rejectedPostIds, rejectedPostIds,
recoveredCandidatePosts, recoveredCandidatePosts,
eligiblePostIds, eligiblePostIds,
answerCountAtRecovery, answerCountAtRecovery,
recoveryStepCount, recoveryStepCount }: { posts: Post[]
}: {
posts: Post[]
scores: Map<number, number> scores: Map<number, number>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number> recoveredCandidatePosts: Map<number, number>
eligiblePostIds: Set<number> eligiblePostIds: Set<number>
answerCountAtRecovery: number answerCountAtRecovery: number
recoveryStepCount: number recoveryStepCount: number },
}): { ): { recoveredCandidatePosts: Map<number, number>
recoveredCandidatePosts: Map<number, number> recoveryStepCount: number } | null => {
recoveryStepCount: number
} | null => {
const recovered = new Map (recoveredCandidatePosts) const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount) const targetSize = nextRecoveryTargetSize (recoveryStepCount)
const countedPostIds = new Set ([ const countedPostIds = new Set ([...eligiblePostIds, ...recovered.keys ()])
...eligiblePostIds,
...recovered.keys ()])
const addCount = targetSize - countedPostIds.size const addCount = targetSize - countedPostIds.size
if (addCount <= 0) if (addCount <= 0)
return { {
recoveredCandidatePosts: recovered, return { recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 } recoveryStepCount: recoveryStepCount + 1 }
}
const candidates = posts const candidates =
.filter (post => posts
!(rejectedPostIds.has (post.id)) .filter (post => (!(rejectedPostIds.has (post.id))
&& !(eligiblePostIds.has (post.id)) && !(eligiblePostIds.has (post.id))
&& !(recovered.has (post.id))) && !(recovered.has (post.id))))
.sort ((a, b) => .sort ((a, b) => ((scores.get (b.id) ?? Number.NEGATIVE_INFINITY)
(scores.get (b.id) ?? Number.NEGATIVE_INFINITY) - (scores.get (a.id) ?? Number.NEGATIVE_INFINITY)))
- (scores.get (a.id) ?? Number.NEGATIVE_INFINITY))
.slice (0, addCount) .slice (0, addCount)
if (candidates.length === 0) if (candidates.length === 0)
@@ -165,7 +142,6 @@ export const recoverCandidatePosts = ({
candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery)) candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery))
return { return { recoveredCandidatePosts: recovered,
recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 } recoveryStepCount: recoveryStepCount + 1 }
} }
+54
ファイルの表示
@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters' import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters'
@@ -49,6 +52,57 @@ const blocked = (
isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)]) 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', () => { 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 }
ファイル差分が大きすぎるため省略します 差分を読込み