グカネータ作成 / ウィニング・ラン修正 (#41) #366

マージ済み
みてるぞ が 22 個のコミットを feature/041 から main へマージ 2026-06-12 02:08:59 +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. - 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.
- 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
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 ## Backend rules
- Inspect existing routes, controllers, models, services, and specs before - Inspect existing routes, controllers, models, services, and specs before
editing backend behavior. 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 - Prefer RSpec for new backend tests; existing minitest files under
`backend/test` do not make minitest the default for new coverage. `backend/test` do not make minitest the default for new coverage.
- Do not weaken authentication, BAN user checks, or IP BAN checks. - 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. `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication - Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
behavior, inspect the related request specs and service objects. behavior, inspect the related request specs and service objects.
- If frontend code changes, run the existing frontend verification commands - If frontend code changes, run only non-test verification commands that
that apply: `npm run build`, `npm run lint`, and `npm run test:run`. apply, such as `npm run build` and `npm run lint`. Run `npm run test:run`
- If backend code changes, run the relevant RSpec command; for broad backend only when the user explicitly asks for tests.
changes, run `bundle exec rspec`. - 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. - If a verification command cannot be run or fails, report the exact command and failure.
## Completion criteria ## Completion criteria
@@ -222,7 +227,8 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
A task is complete only when: A task is complete only when:
- implementation is complete, - 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, - unrelated files are not changed,
- migrations and schema are consistent when schema changes are made, - migrations and schema are consistent when schema changes are made,
- user-facing behavior is documented when needed. - 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. 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 ## Rails structure
- `app/controllers`: API controllers. - `app/controllers`: API controllers.
@@ -116,7 +120,8 @@ service, representation, and spec.
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`. - `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
- Do not weaken BAN or IP BAN behavior. - Do not weaken BAN or IP BAN behavior.
- If changing request authentication or controller before actions, add or - 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 ## RSpec
@@ -130,8 +135,9 @@ service, representation, and spec.
- `AuthHelper#sign_in_as(user)` stubs - `AuthHelper#sign_in_as(user)` stubs
`ApplicationController#current_user`; use it when matching existing `ApplicationController#current_user`; use it when matching existing
request spec style. request spec style.
- Add or update request specs for API behavior changes, especially status - Add or update request specs for API behavior changes only when the user
codes, permissions, response shape, and version conflict behavior. explicitly asks for tests, especially status codes, permissions, response
shape, and version conflict behavior.
## Migrations ## Migrations
@@ -164,7 +170,8 @@ service, representation, and spec.
the record `version_no`. the record `version_no`.
- Do not update versioned records without considering whether a version snapshot must be created. - Do not update versioned records without considering whether a version snapshot must be created.
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and - 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 ## Domain cautions
+2 -2
ファイルの表示
@@ -1,6 +1,6 @@
class GekanatorGamesController < ApplicationController class GekanatorGamesController < ApplicationController
def create def create
return head :unauthorized unless current_user return head :not_found unless current_user&.admin?
guessed_post_id = params.require(:guessed_post_id) guessed_post_id = params.require(:guessed_post_id)
correct_post_id = params[:correct_post_id].presence correct_post_id = params[:correct_post_id].presence
@@ -11,7 +11,7 @@ class GekanatorGamesController < ApplicationController
guessed_post_id:, guessed_post_id:,
correct_post_id:, correct_post_id:,
won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i, 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:) answers:)
if game.save if game.save
+2 -2
ファイルの表示
@@ -1,9 +1,9 @@
class CreateGekanatorGames < ActiveRecord::Migration[8.0] class CreateGekanatorGames < ActiveRecord::Migration[8.0]
def change def change
create_table :gekanator_games do |t| 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 :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.boolean :won, null: false
t.integer :question_count, null: false t.integer :question_count, null: false
t.json :answers, 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. # 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| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", 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" t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end 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| create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "tag_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 "original_created_before"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "version_no", 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 ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true 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" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end end
@@ -370,7 +354,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at" 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", "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 ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id"
t.index ["user_id"], name: "index_theatre_watching_users_on_user_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_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id" 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", "posts"
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "created_user_id"
+20 -8
ファイルの表示
@@ -1,31 +1,32 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Gekanator games API', type: :request do RSpec.describe 'Gekanator games API', type: :request do
let!(:admin) { create_admin_user! }
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') } let!(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') }
let!(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') } let!(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') }
describe 'POST /gekanator/games' do describe 'POST /gekanator/games' do
it 'stores a won game' do it 'stores a won game' do
sign_in_as user sign_in_as admin
post '/gekanator/games', params: { post '/gekanator/games', params: {
guessed_post_id: guessed_post.id, guessed_post_id: guessed_post.id,
correct_post_id: guessed_post.id, correct_post_id: guessed_post.id,
question_count: 3,
answers: [{ question_id: 'tag:1', answer: 'yes' }] } answers: [{ question_id: 'tag:1', answer: 'yes' }] }
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
game = GekanatorGame.find(json['id']) 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.guessed_post).to eq(guessed_post)
expect(game.correct_post).to eq(guessed_post) expect(game.correct_post).to eq(guessed_post)
expect(game.won).to eq(true) expect(game.won).to eq(true)
expect(game.question_count).to eq(1)
expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }]) expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }])
end end
it 'stores a lost game with the correct post' do it 'stores a lost game with the correct post' do
sign_in_as user sign_in_as admin
post '/gekanator/games', params: { post '/gekanator/games', params: {
guessed_post_id: guessed_post.id, guessed_post_id: guessed_post.id,
@@ -37,10 +38,11 @@ RSpec.describe 'Gekanator games API', type: :request do
game = GekanatorGame.find(json['id']) game = GekanatorGame.find(json['id'])
expect(game.correct_post).to eq(correct_post) expect(game.correct_post).to eq(correct_post)
expect(game.won).to eq(false) expect(game.won).to eq(false)
expect(game.question_count).to eq(1)
end end
it 'rejects a game without the correct post' do it 'rejects a game without the correct post' do
sign_in_as user sign_in_as admin
post '/gekanator/games', params: { post '/gekanator/games', params: {
guessed_post_id: guessed_post.id, 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) expect(response).to have_http_status(:unprocessable_entity)
end end
it 'requires a user' do it 'returns not found without an admin user' do
post '/gekanator/games', params: { post '/gekanator/games', params: {
guessed_post_id: guessed_post.id, guessed_post_id: guessed_post.id,
correct_post_id: guessed_post.id, correct_post_id: guessed_post.id,
question_count: 1,
answers: [] } 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 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. 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
- TypeScript is strict. `tsconfig.app.json` enables `strict`, - 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 WikiNewPage from '@/pages/wiki/WikiNewPage'
import WikiSearchPage from '@/pages/wiki/WikiSearchPage' 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' 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="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/> <Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/> <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="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/> <Route path="*" element={<NotFound/>}/>
</Routes> </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 PostDetailRoute = ({ user }: { user: User | null }) => {
const location = useLocation () const location = useLocation ()
const key = location.pathname 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/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: 'おたのしみ', visible: false, subMenu: [ { name: 'おたのしみ', visible: false, subMenu: [
{ name: 'グカネータ', to: '/gekanator' },
{ name: '上映会 (β)', to: '/theatres/1' }] }, { name: '上映会 (β)', to: '/theatres/1' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [ { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false }, { name: '一覧', to: '/users', visible: false },
-1
ファイルの表示
@@ -208,7 +208,6 @@ export const saveGekanatorGame = async ({
await apiPost ('/gekanator/games', { await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId, guessed_post_id: guessedPostId,
correct_post_id: correctPostId, correct_post_id: correctPostId,
question_count: answers.length,
answers: answers.map (answer => ({ answers: answers.map (answer => ({
question_id: answer.questionId, question_id: answer.questionId,
question_text: answer.questionText, question_text: answer.questionText,
+3 -1
ファイルの表示
@@ -335,6 +335,8 @@ const chooseQuestion = ({
candidates: { post: Post; score: number }[], candidates: { post: Post; score: number }[],
) => { ) => {
const redundant = redundantSignatures (candidates) const redundant = redundantSignatures (candidates)
const nonTagCount =
questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length
return questionsToRank return questionsToRank
.map (question => { .map (question => {
@@ -348,7 +350,7 @@ const chooseQuestion = ({
return null return null
const splitScore = Math.abs (candidates.length / 2 - yes) 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 minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0 const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0