コミットを比較

..

6 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 77b5c8f262 #41 2026-06-08 17:47:19 +09:00
みてるぞ 543f051f8f #41 2026-06-08 12:44:45 +09:00
みてるぞ fb2b2a632c #41 2026-06-08 08:45:52 +09:00
みてるぞ de21141f5a #41 2026-06-08 08:41:52 +09:00
みてるぞ 96df2a4eaa #41 2026-06-08 00:30:20 +09:00
みてるぞ 7d48a8f694 上映会ニコニコ・バグ修正 (#358) (#359)
Reviewed-on: #359
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-07 09:08:41 +09:00
14個のファイルの変更1584行の追加12行の削除
+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
+23
ファイルの表示
@@ -0,0 +1,23 @@
class GekanatorGamesController < ApplicationController
def create
return head :not_found unless current_user&.admin?
guessed_post_id = params.require(:guessed_post_id)
correct_post_id = params[:correct_post_id].presence
answers = params.require(:answers).as_json
game = GekanatorGame.new(
user: current_user,
guessed_post_id:,
correct_post_id:,
won: correct_post_id.present? && guessed_post_id.to_i == correct_post_id.to_i,
question_count: answers.length,
answers:)
if game.save
render json: { id: game.id }, status: :created
else
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
end
end
end
+9
ファイルの表示
@@ -0,0 +1,9 @@
class GekanatorGame < ApplicationRecord
belongs_to :user
belongs_to :guessed_post, class_name: 'Post'
belongs_to :correct_post, class_name: 'Post'
validates :answers, presence: true
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
validates :won, inclusion: { in: [true, false] }
end
+10
ファイルの表示
@@ -11,6 +11,16 @@ class Post < ApplicationRecord
has_many :user_post_views, dependent: :delete_all has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
has_many :post_versions has_many :post_versions
has_many :gekanator_guessed_games,
class_name: 'GekanatorGame',
foreign_key: :guessed_post_id,
dependent: :delete_all,
inverse_of: :guessed_post
has_many :gekanator_correct_games,
class_name: 'GekanatorGame',
foreign_key: :correct_post_id,
dependent: :nullify,
inverse_of: :correct_post
has_many :parent_post_implications, has_many :parent_post_implications,
class_name: 'PostImplication', class_name: 'PostImplication',
+4
ファイルの表示
@@ -63,6 +63,10 @@ Rails.application.routes.draw do
end end
end end
namespace :gekanator do
resources :games, only: [:create], controller: '/gekanator_games'
end
resources :users, only: [:create, :update] do resources :users, only: [:create, :update] do
collection do collection do
post :verify post :verify
+18
ファイルの表示
@@ -0,0 +1,18 @@
class CreateGekanatorGames < ActiveRecord::Migration[8.0]
def change
create_table :gekanator_games do |t|
t.references :user, null: false, foreign_key: true
t.references :guessed_post, null: false, 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
t.timestamps
end
add_check_constraint :gekanator_games,
'question_count >= 0',
name: 'chk_gekanator_games_question_count_nonnegative'
end
end
生成ファイル
+19 -1
ファイルの表示
@@ -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_06_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
@@ -48,6 +48,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id" t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end end
create_table "gekanator_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "guessed_post_id", null: false
t.bigint "correct_post_id", null: false
t.boolean "won", null: false
t.integer "question_count", null: false
t.json "answers", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id"
t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
t.index ["user_id"], name: "index_gekanator_games_on_user_id"
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
end
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false t.binary "ip_address", limit: 16, null: false
t.datetime "banned_at" t.datetime "banned_at"
@@ -478,6 +493,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
add_foreign_key "gekanator_games", "users"
add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags" add_foreign_key "material_versions", "tags"
+75
ファイルの表示
@@ -0,0 +1,75 @@
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 admin
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(:created)
game = GekanatorGame.find(json['id'])
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 admin
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
question_count: 4,
answers: [{ question_id: 'tag:1', answer: 'no' }] }
expect(response).to have_http_status(:created)
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 admin
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
question_count: 4,
answers: [{ question_id: 'tag:1', answer: 'no' }] }
expect(response).to have_http_status(:unprocessable_entity)
end
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,
answers: [] }
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. 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`,
+16 -1
ファイルの表示
@@ -18,6 +18,7 @@ import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' // import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage' import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx' import TOSPage from '@/pages/TOSPage.mdx'
@@ -39,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'
@@ -80,6 +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={
<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>
@@ -87,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
+214
ファイルの表示
@@ -0,0 +1,214 @@
import { apiPost } from '@/lib/api'
import { fetchPosts } from '@/lib/posts'
import type { Post } from '@/types'
export type GekanatorAnswerValue =
| 'yes'
| 'no'
| 'partial'
| 'probably_no'
| 'unknown'
export type GekanatorAnswerLog = {
questionId: string
questionText: string
answer: GekanatorAnswerValue }
export type GekanatorQuestionKind =
| 'tag'
| 'source'
| 'title'
| 'original_date'
export type GekanatorQuestion = {
id: string
text: string
kind: GekanatorQuestionKind
test: (post: Post) => boolean }
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
const counts = new Map<T, number> ()
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
return counts
}
const median = (values: number[]): number => {
const sorted = [...values].sort ((a, b) => a - b)
return sorted[Math.floor (sorted.length / 2)] ?? 0
}
const hostOf = (post: Post): string | null => {
try
{
return new URL (post.url).hostname.replace (/^www\./, '')
}
catch
{
return null
}
}
const originalYearOf = (post: Post): number | null => {
const value = post.originalCreatedFrom || post.originalCreatedBefore
if (!(value))
return null
const date = new Date (value)
if (Number.isNaN (date.getTime ()))
return null
return date.getFullYear ()
}
const tagQuestionKey = ({ category, name }: { category: string; name: string }): string =>
`${ category }:${ name }`
const tagFromQuestionKey = (key: string): { category: string; name: string } => {
const [category, ...rest] = key.split (':')
return { category: category ?? '', name: rest.join (':') }
}
const nicoTagLabel = (name: string): string => name.replace (/^nico:/, '')
const questionableTag = (post: Post, key: string): boolean => {
const { category, name } = tagFromQuestionKey (key)
return (
post.tags.some (tag =>
tag.name === name
&& tag.category === category
&& !(tag.category === 'meta')
&& !(tag.name.includes ('タグ希望'))
&& !(tag.name.includes ('bot操作'))))
}
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
const limit = 200
const first = await fetchPosts ({
url: '', title: '', tags: '', match: 'all',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
page: 1, limit, order: 'original_created_at:desc' })
const posts = [...first.posts]
const totalPages = Math.ceil (first.count / limit)
for (let page = 2; page <= totalPages; page++)
{
const data = await fetchPosts ({
url: '', title: '', tags: '', match: 'all',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
page, limit, order: 'original_created_at:desc' })
posts.push (...data.posts)
}
return posts
}
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
const tagCounts = countBy (posts.flatMap (post =>
post.tags
.filter (tag =>
!(tag.category === 'meta')
&& !(tag.name.includes ('タグ希望'))
&& !(tag.name.includes ('bot操作')))
.map (tag => tagQuestionKey (tag))))
const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host)))
const originalYears = countBy (
posts
.map (originalYearOf)
.filter ((year): year is number => year !== null))
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
const usefulEntries = <T extends string | number> (counts: Map<T, number>) =>
[...counts.entries ()]
.filter (([, count]) => count > 0 && count < posts.length)
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
- Math.abs (posts.length / 2 - b[1]))
.slice (0, 80)
const tagQuestions = usefulEntries (tagCounts)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 80)
.map (([key]) => {
const { category, name } = tagFromQuestionKey (String (key))
const label = category === 'nico' ? nicoTagLabel (name) : name
return {
id: `tag:${ key }`,
text: category === 'nico'
? `ニコニコに「${ label }」といふタグが付いてゐる?`
: `内容として「${ label }」に関係しさう?`,
kind: 'tag' as const,
test: (post: Post) => questionableTag (post, String (key)) }
})
const sourceQuestions = usefulEntries (hosts)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([host]) => ({
id: `source:${ host }`,
text: `${ host } の投稿を思ひ浮かべてゐる?`,
kind: 'source' as const,
test: (post: Post) => hostOf (post) === host }))
const originalYearQuestions = usefulEntries (originalYears)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([year]) => ({
id: `original-year:${ year }`,
text: `オリジナルの投稿年は ${ year } 年?`,
kind: 'original_date' as const,
test: (post: Post) => originalYearOf (post) === year }))
const titleQuestions = [
{
id: 'title:long',
text: '題名が長めの投稿?',
kind: 'title' as const,
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
{
id: 'title:ascii',
text: '題名に英数字が混じってゐる?',
kind: 'title' as const,
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
.filter (question => {
const yes = posts.filter (post => question.test (post)).length
const no = posts.length - yes
return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7
})
return [
...sourceQuestions,
...originalYearQuestions,
...titleQuestions,
...tagQuestions]
}
export const saveGekanatorGame = async ({
guessedPostId,
correctPostId,
answers,
}: {
guessedPostId: number
correctPostId: number
answers: GekanatorAnswerLog[]
}): Promise<{ id: number }> =>
await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId,
correct_post_id: correctPostId,
answers: answers.map (answer => ({
question_id: answer.questionId,
question_text: answer.questionText,
answer: answer.answer })) })
+4
ファイルの表示
@@ -8,6 +8,10 @@ export const postsKeys = {
changes: (p: { post?: string; tag?: string; page: number; limit: number }) => changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
['posts', 'changes', p] as const } ['posts', 'changes', p] as const }
export const gekanatorKeys = {
root: ['gekanator'] as const,
posts: () => ['gekanator', 'posts'] as const }
export const tagsKeys = { export const tagsKeys = {
root: ['tags'] as const, root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const, index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
ファイル差分が大きすぎるため省略します 差分を読込み