From 543f051f8f9b6e02c21b86c21660a99ea432a4a0 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 8 Jun 2026 12:44:45 +0900 Subject: [PATCH] #41 --- AGENTS.md | 18 ++++++++---- backend/AGENTS.md | 15 +++++++--- .../controllers/gekanator_games_controller.rb | 4 +-- .../20260607000000_create_gekanator_games.rb | 4 +-- ...re_gekanator_game_user_and_correct_post.rb | 17 ----------- backend/db/schema.rb | 21 +------------- backend/spec/requests/gekanator_games_spec.rb | 28 +++++++++++++------ frontend/AGENTS.md | 4 +++ frontend/src/App.tsx | 17 +++++++++-- frontend/src/components/TopNav.tsx | 1 - frontend/src/lib/gekanator.ts | 1 - frontend/src/pages/GekanatorPage.tsx | 4 ++- 12 files changed, 70 insertions(+), 64 deletions(-) delete mode 100644 backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb diff --git a/AGENTS.md b/AGENTS.md index 16aa3c1..7690e5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 9553712..9085134 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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 diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb index 9dba4a3..affd42e 100644 --- a/backend/app/controllers/gekanator_games_controller.rb +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -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 diff --git a/backend/db/migrate/20260607000000_create_gekanator_games.rb b/backend/db/migrate/20260607000000_create_gekanator_games.rb index 520559f..a357bf9 100644 --- a/backend/db/migrate/20260607000000_create_gekanator_games.rb +++ b/backend/db/migrate/20260607000000_create_gekanator_games.rb @@ -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 diff --git a/backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb b/backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb deleted file mode 100644 index 8071fab..0000000 --- a/backend/db/migrate/20260608000000_require_gekanator_game_user_and_correct_post.rb +++ /dev/null @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 3ef8127..ee3d86a 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -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" diff --git a/backend/spec/requests/gekanator_games_spec.rb b/backend/spec/requests/gekanator_games_spec.rb index 4db3d69..79c0f5a 100644 --- a/backend/spec/requests/gekanator_games_spec.rb +++ b/backend/spec/requests/gekanator_games_spec.rb @@ -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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 124c9b7..67f7b09 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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`, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a42f5fe..6809e3a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }: { }/> }/> }/> - }/> + + + }/> }/> }/> @@ -89,6 +92,16 @@ const RouteTransitionWrapper = ({ user, setUser }: { } +const AdminOnly = ({ user, children }: { + user: User | null + children: ReactNode }) => { + if (user?.role !== 'admin') + return + + return <>{children} +} + + const PostDetailRoute = ({ user }: { user: User | null }) => { const location = useLocation () const key = location.pathname diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 1f49488..4a7e20b 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -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 }, diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index 8e64139..eae4623 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -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, diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index b72ab99..7d2b68f 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -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