グカネータ / 質問パターン見直し (#41) #365

マージ済み
みてるぞ が 20 個のコミットを feature/041 から main へマージ 2026-06-12 01:35:32 +09:00
12個のファイルの変更70行の追加64行の削除
コミット 543f051f8f の変更だけを表示してゐます - すべてのコミットを表示
+12 -6
ファイルの表示
@@ -126,12 +126,16 @@ npm run preview
- 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.
- Do not add production dependencies without explicit approval.
- 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
they pass or the remaining failure is clearly blocked.
## Backend rules
- Inspect existing routes, controllers, models, services, and specs before
editing backend behavior.
- For API behavior changes, add or update request specs under `backend/spec/requests`.
- For API behavior changes, add or update request specs under
`backend/spec/requests` only when the user explicitly asks for tests.
- Prefer RSpec for new backend tests; existing minitest files under
`backend/test` do not make minitest the default for new coverage.
- Do not weaken authentication, BAN user checks, or IP BAN checks.
@@ -211,10 +215,11 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
behavior, inspect the related request specs and service objects.
- If frontend code changes, run the existing frontend verification commands
that apply: `npm run build`, `npm run lint`, and `npm run test:run`.
- If backend code changes, run the relevant RSpec command; for broad backend
changes, run `bundle exec rspec`.
- If frontend code changes, run only non-test verification commands that
apply, such as `npm run build` and `npm run lint`. Run `npm run test:run`
only when the user explicitly asks for tests.
- If backend code changes, do not run RSpec unless the user explicitly asks
for tests.
- If a verification command cannot be run or fails, report the exact command and failure.
## Completion criteria
@@ -222,7 +227,8 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
A task is complete only when:
- implementation is complete,
- relevant verification commands pass, or failures are clearly explained,
- relevant non-test verification commands pass, or failures are clearly
explained,
- unrelated files are not changed,
- migrations and schema are consistent when schema changes are made,
- user-facing behavior is documented when needed.
+11 -4
ファイルの表示
@@ -47,6 +47,10 @@ bundle exec rspec
If a command cannot be run or fails, report the exact command and failure.
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 they
pass or the remaining failure is clearly blocked.
## Rails structure
- `app/controllers`: API controllers.
@@ -116,7 +120,8 @@ service, representation, and spec.
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
- Do not weaken BAN or IP BAN behavior.
- If changing request authentication or controller before actions, add or
update request specs covering banned users and banned IP addresses.
update request specs covering banned users and banned IP addresses only when
the user explicitly asks for tests.
## RSpec
@@ -130,8 +135,9 @@ service, representation, and spec.
- `AuthHelper#sign_in_as(user)` stubs
`ApplicationController#current_user`; use it when matching existing
request spec style.
- Add or update request specs for API behavior changes, especially status
codes, permissions, response shape, and version conflict behavior.
- Add or update request specs for API behavior changes only when the user
explicitly asks for tests, especially status codes, permissions, response
shape, and version conflict behavior.
## Migrations
@@ -164,7 +170,8 @@ service, representation, and spec.
the record `version_no`.
- Do not update versioned records without considering whether a version snapshot must be created.
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and
`merge` semantics and cover conflicts in request specs.
`merge` semantics. Cover conflicts in request specs only when the user
explicitly asks for tests.
## Domain cautions
+2 -2
ファイルの表示
@@ -1,6 +1,6 @@
class GekanatorGamesController < ApplicationController
def create
return head :unauthorized unless current_user
return head :not_found unless current_user&.admin?
guessed_post_id = params.require(:guessed_post_id)
correct_post_id = params[:correct_post_id].presence
@@ -11,7 +11,7 @@ class GekanatorGamesController < ApplicationController
guessed_post_id:,
correct_post_id:,
won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i,
question_count: params.require(:question_count),
question_count: answers.length,
answers:)
if game.save
+2 -2
ファイルの表示
@@ -1,9 +1,9 @@
class CreateGekanatorGames < ActiveRecord::Migration[8.0]
def change
create_table :gekanator_games do |t|
t.references :user, foreign_key: true
t.references :user, null: false, foreign_key: true
t.references :guessed_post, null: false, foreign_key: { to_table: :posts }
t.references :correct_post, foreign_key: { to_table: :posts }
t.references :correct_post, null: false, foreign_key: { to_table: :posts }
t.boolean :won, null: false
t.integer :question_count, null: false
t.json :answers, null: false
@@ -1,17 +0,0 @@
class RequireGekanatorGameUserAndCorrectPost < ActiveRecord::Migration[8.0]
def up
execute <<~SQL.squish
UPDATE gekanator_games
SET correct_post_id = guessed_post_id
WHERE correct_post_id IS NULL
SQL
change_column_null :gekanator_games, :user_id, false
change_column_null :gekanator_games, :correct_post_id, false
end
def down
change_column_null :gekanator_games, :correct_post_id, true
change_column_null :gekanator_games, :user_id, true
end
end
生成ファイル
+1 -20
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do
ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -152,19 +152,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.integer "begin_ms", null: false
t.integer "end_ms", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
t.index ["tag_id"], name: "fk_rails_8be3847903"
t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms"
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
@@ -215,11 +202,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do
t.datetime "original_created_before"
t.datetime "updated_at", null: false
t.integer "version_no", null: false
t.integer "video_ms"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end
@@ -370,7 +354,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at"
t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42"
t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at"
t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id"
t.index ["user_id"], name: "index_theatre_watching_users_on_user_id"
@@ -530,8 +513,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id"
+20 -8
ファイルの表示
@@ -1,31 +1,32 @@
require 'rails_helper'
RSpec.describe 'Gekanator games API', type: :request do
let!(:admin) { create_admin_user! }
let!(:user) { create_member_user! }
let!(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') }
let!(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') }
describe 'POST /gekanator/games' do
it 'stores a won game' do
sign_in_as user
sign_in_as admin
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: guessed_post.id,
question_count: 3,
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
expect(response).to have_http_status(:created)
game = GekanatorGame.find(json['id'])
expect(game.user).to eq(user)
expect(game.user).to eq(admin)
expect(game.guessed_post).to eq(guessed_post)
expect(game.correct_post).to eq(guessed_post)
expect(game.won).to eq(true)
expect(game.question_count).to eq(1)
expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }])
end
it 'stores a lost game with the correct post' do
sign_in_as user
sign_in_as admin
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
@@ -37,10 +38,11 @@ RSpec.describe 'Gekanator games API', type: :request do
game = GekanatorGame.find(json['id'])
expect(game.correct_post).to eq(correct_post)
expect(game.won).to eq(false)
expect(game.question_count).to eq(1)
end
it 'rejects a game without the correct post' do
sign_in_as user
sign_in_as admin
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
@@ -50,14 +52,24 @@ RSpec.describe 'Gekanator games API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
it 'requires a user' do
it 'returns not found without an admin user' do
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: guessed_post.id,
question_count: 1,
answers: [] }
expect(response).to have_http_status(:unauthorized)
expect(response).to have_http_status(:not_found)
end
it 'returns not found for a non-admin user' do
sign_in_as user
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: guessed_post.id,
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
expect(response).to have_http_status(:not_found)
end
end
end
+4
ファイルの表示
@@ -33,6 +33,10 @@ npm run lint
If either command cannot be run or fails, report the exact command and failure.
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 they
pass or the remaining failure is clearly blocked.
## TypeScript
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
+15 -2
ファイルの表示
@@ -40,7 +40,7 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import WikiNewPage from '@/pages/wiki/WikiNewPage'
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
import type { Dispatch, FC, SetStateAction } from 'react'
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { User } from '@/types'
@@ -81,7 +81,10 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage/>}/>
<Route path="/gekanator" element={
<AdminOnly user={user}>
<GekanatorPage/>
</AdminOnly>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
@@ -89,6 +92,16 @@ const RouteTransitionWrapper = ({ user, setUser }: {
}
const AdminOnly = ({ user, children }: {
user: User | null
children: ReactNode }) => {
if (user?.role !== 'admin')
return <NotFound/>
return <>{children}</>
}
const PostDetailRoute = ({ user }: { user: User | null }) => {
const location = useLocation ()
const key = location.pathname
-1
ファイルの表示
@@ -66,7 +66,6 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: 'グカネータ', to: '/gekanator' },
{ name: '上映会 (β)', to: '/theatres/1' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false },
-1
ファイルの表示
@@ -208,7 +208,6 @@ export const saveGekanatorGame = async ({
await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId,
correct_post_id: correctPostId,
question_count: answers.length,
answers: answers.map (answer => ({
question_id: answer.questionId,
question_text: answer.questionText,
+3 -1
ファイルの表示
@@ -335,6 +335,8 @@ const chooseQuestion = ({
candidates: { post: Post; score: number }[],
) => {
const redundant = redundantSignatures (candidates)
const nonTagCount =
questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length
return questionsToRank
.map (question => {
@@ -348,7 +350,7 @@ const chooseQuestion = ({
return null
const splitScore = Math.abs (candidates.length / 2 - yes)
const tagPenalty = question.kind === 'tag' ? 0 : 20
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? 12 : 0
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0