コミットを比較

..

29 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 53446807c2 #41 ウィニング・ラン修正 2026-06-12 02:08:03 +09:00
みてるぞ 5fbb737c70 Merge remote-tracking branch 'origin/main' into feature/041 2026-06-12 01:35:42 +09:00
みてるぞ def6870f06 グカネータ / 質問パターン見直し (#41) (#365)
Reviewed-on: #365
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-12 01:35:31 +09:00
みてるぞ a5d08c99cf #41 2026-06-12 01:33:40 +09:00
みてるぞ 2522485f6a Merge remote-tracking branch 'origin/main' into feature/041 2026-06-11 23:58:00 +09:00
みてるぞ de5db81e16 #41 2026-06-11 23:54:55 +09:00
みてるぞ c361c561c2 グカネータ作成 / 質問パターン修正 (#41) (#364)
Reviewed-on: #364
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-11 23:21:44 +09:00
みてるぞ fd90ef3b15 Merge remote-tracking branch 'origin/main' into feature/041 2026-06-11 23:18:57 +09:00
みてるぞ 4caea6213a #41 少々しやぅ修正 2026-06-11 23:18:37 +09:00
みてるぞ 979ccf598e グカネータ作成 / テスト型バグ修正 (#41) (#363)
Reviewed-on: #363
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-10 23:43:50 +09:00
みてるぞ 884a7bc3da Merge remote-tracking branch 'origin/main' into feature/041 2026-06-10 23:43:24 +09:00
みてるぞ f936c1e5ce #41 テスト型バグ修正 2026-06-10 23:41:45 +09:00
みてるぞ 37ade2a988 グカネータ作成 (#041) (#362)
Reviewed-on: #362
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-10 23:33:56 +09:00
みてるぞ 8bf51bbb4a #41 2026-06-10 23:17:12 +09:00
みてるぞ 480a06caaf #41 2026-06-10 22:24:01 +09:00
みてるぞ 7fe7dbd909 #41 2026-06-10 20:02:08 +09:00
みてるぞ 159ad5ed5a #41 2026-06-09 23:36:24 +09:00
みてるぞ ae1deaac8c #41 2026-06-09 23:05:37 +09:00
みてるぞ be5359eb84 #41 2026-06-09 08:17:16 +09:00
みてるぞ a1ea35a7ec #41 2026-06-09 01:29:43 +09:00
みてるぞ 49d42d576a #41 2026-06-09 00:35:25 +09:00
みてるぞ 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
みてるぞ 3980e9651e 上映会改修 (#302) (#357)
Reviewed-on: #357
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-07 02:51:25 +09:00
みてるぞ 750aa40e8e フォームのバリデーションとニコ連携の画面変更 (#090) (#355)
Reviewed-on: #355
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-05 01:59:46 +09:00
133個のファイルの変更10895行の追加1390行の削除
+110 -19
ファイルの表示
@@ -12,16 +12,21 @@ BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
## Stack
- Backend: Ruby `3.2.2` from `backend/.ruby-version`, Rails `~> 8.0.2`.
- Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`, `factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`, `aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`.
- Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`,
`factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`,
`aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`.
- Frontend: React `^19.1.0`, TypeScript `~5.8.3`, Vite `^6.3.5`.
- Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS, Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and Zustand.
- Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS,
Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and
Zustand.
## Main directories
- `backend/app/controllers`: Rails API controllers.
- `backend/app/models`: Active Record models.
- `backend/app/representations`: API response representation classes.
- `backend/app/services`: domain services such as version recording, wiki commit, YouTube sync, and similarity calculation.
- `backend/app/services`: domain services such as version recording,
wiki commit, YouTube sync, and similarity calculation.
- `backend/config/routes.rb`: API routes.
- `backend/db/migrate`: migrations.
- `backend/db/schema.rb`: current schema snapshot.
@@ -84,52 +89,137 @@ cd frontend
npm run dev
npm run build
npm run lint
npm run test
npm run test:run
npm run preview
```
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs `node scripts/generate-sitemap.js`.
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs
`node scripts/generate-sitemap.js`.
Do not write or report `npm test` as a repository command unless a `test` script is added to `frontend/package.json`.
`npm run test` runs Vitest in watch mode. Use `npm run test:run` for a non-watch frontend test run.
## Coding style
- Prefer precise, minimal changes.
- Do not flatter or over-explain.
- Explain risks directly.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
- Prefer single quotes for strings unless interpolation or escaping makes
double quotes better.
- Ruby: never put a space before method-call parentheses.
- Ruby: never put a line break immediately before `)`.
- Ruby: do not use `%w` or `%i`.
- TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid.
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
- Ruby hashes keep the first pair on the same line as `{` unless line length
requires a break.
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
indentation.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- TypeScript and Python: use GNU-style spacing before parentheses where
syntactically valid.
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
- Aim to keep Ruby, TypeScript, and TSX lines within 79 characters where practical.
- TypeScript and TSX use 4-space logical indentation.
- 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`.
- Prefer RSpec for new backend tests; existing minitest files under `backend/test` do not make minitest the default for new coverage.
- 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` 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.
- Preserve the `X-Transfer-Code` user identification flow unless the task explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency, wiki revisions, and restore/diff behavior.
- Preserve the `X-Transfer-Code` user identification flow unless the task
explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
- Be sensitive to N+1 queries; avoid introducing them and proactively fix
existing N+1 issues in the code path being edited.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema.
## Frontend rules
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`; avoid ad hoc query key arrays.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
avoid ad hoc query key arrays.
- Encode URL path-segment values with `encodeURIComponent`.
- React hooks must be called unconditionally.
- Keep page-level code under `frontend/src/pages` and shared UI/feature code under `frontend/src/components` unless existing patterns point elsewhere.
- Keep page-level code under `frontend/src/pages` and shared UI/feature code
under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions.
### Frontend TSX style
- Preserve the local TSX formatting style.
- Do not normalize TSX to common Prettier-style React formatting unless
explicitly asked.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent with 4-space logical indentation.
- A leading tab is exactly equivalent to 8 leading spaces.
- Keep a tag's closing marker on the same line as the final prop when the tag
spans multiple lines.
- Do not put `/>` or `>` on its own line unless the existing surrounding code
does so.
- Keep JSX closing parentheses in the existing compact style, for example
`</div>)` rather than moving `)` onto a separate line.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
Preferred:
```tsx
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
return (
<TextArea
{...rest}
ref={ref}
value={tags}
invalid={errors && errors.length > 0}
onChange={ev => setTags (ev.target.value)}/>)
}
```
Avoid:
```tsx
function PostFormTagsArea ({ tags, setTags }: Props) {
return (
<TextArea
value={tags}
onChange={ev => setTags (ev.target.value)}
/>
)
}
```
## Codex workflow
- First inspect existing patterns; do not invent new architecture when a local convention exists.
- Keep changes scoped to the requested issue.
- Do not scan or summarize dependency/generated/runtime directories such as `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` and `npm run lint`.
- If backend code changes, run the relevant RSpec command; for broad backend changes, run `bundle exec rspec`.
- Do not scan or summarize dependency/generated/runtime directories such as
`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 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
@@ -137,7 +227,8 @@ Do not write or report `npm test` as a repository command unless a `test` script
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.
+97 -25
ファイルの表示
@@ -4,7 +4,9 @@
These rules apply to work under `backend/`.
This is a Rails API app using Active Record, RSpec, request specs, service objects, representation classes, and version tables for post/tag/wiki history.
This is a Rails API app using Active Record, RSpec, request specs,
service objects, representation classes, and version tables for post/tag/wiki
history.
## Commands
@@ -45,37 +47,66 @@ 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.
- `app/models`: Active Record models and concerns.
- `app/representations`: JSON response shaping.
- `app/services`: domain services such as version recorders, wiki commit, YouTube sync, and similarity calculation.
- `app/services`: domain services such as version recorders, wiki commit,
YouTube sync, and similarity calculation.
- `config/routes.rb`: public API routes.
- `db/migrate`: migrations.
- `db/schema.rb`: schema snapshot.
- `lib/tasks`: custom Rake tasks.
- `spec`: RSpec tests.
Before changing behavior, inspect the matching route, controller, model, service, representation, and spec.
Before changing behavior, inspect the matching route, controller, model,
service, representation, and spec.
## Ruby style
- Prefer precise, minimal changes.
- Use single quotes unless interpolation or escaping makes double quotes better.
- Do not put a space before Ruby method-call parentheses.
- Never put a line break immediately before `)` in Ruby.
- Do not use `%w` or `%i` in new Ruby code.
- Never write a Ruby line longer than 99 characters.
- Aim to keep Ruby lines within 79 characters where practical.
- For small Ruby method definitions that take keyword arguments, match the
local no-parentheses style when nearby code uses it.
- Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate
rules.
- Do not format Ruby hashes like Ruby blocks.
- For Ruby hashes, keep the closing `}` on the same line as the final pair.
- Keep the first pair on the same line as `{` by default.
- If the hash would exceed the line limit, break after `{` and indent pairs
by 4 spaces.
- Put one logical pair per line when the expression would otherwise become
dense.
- For Ruby arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- For Ruby blocks, use 2-space indentation for the block body.
- Keep comments short and useful; avoid narrating obvious code.
- Do not add production dependencies without approval.
## Authentication and authorization
- Authentication is handled through the `X-Transfer-Code` header in `ApplicationController#authenticate_user`.
- Authentication is handled through the `X-Transfer-Code` header in
`ApplicationController#authenticate_user`.
- `current_user` is set by looking up `User.inheritance_code`.
- Do not bypass or weaken the `X-Transfer-Code` flow unless the task explicitly changes authentication.
- Unauthenticated write actions should return `:unauthorized` consistently with existing controllers.
- Do not bypass or weaken the `X-Transfer-Code` flow unless the task
explicitly changes authentication.
- Unauthenticated write actions should return `:unauthorized` consistently
with existing controllers.
- Role checks use `User` enum roles: `guest`, `member`, and `admin`.
- Use `current_user.gte_member?` for member-or-admin write permissions where existing controllers do so.
- Use `current_user.gte_member?` for member-or-admin write permissions where
existing controllers do so.
- Use `current_user.admin?` only for admin-only paths, such as tag child relationship changes.
- Do not replace role checks with looser presence checks.
@@ -88,7 +119,9 @@ Before changing behavior, inspect the matching route, controller, model, service
- User and IP bans use `banned_at`, not a boolean `banned` column.
- `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.
- If changing request authentication or controller before actions, add or
update request specs covering banned users and banned IP addresses only when
the user explicitly asks for tests.
## RSpec
@@ -99,49 +132,88 @@ Before changing behavior, inspect the matching route, controller, model, service
- Put Rake task coverage under `spec/tasks`.
- `spec/rails_helper.rb` loads `spec/support/**/*.rb`.
- Request specs include `AuthHelper` and `JsonHelper`.
- `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.
- `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 only when the user
explicitly asks for tests, especially status codes, permissions, response
shape, and version conflict behavior.
## Migrations
- Keep migrations and `db/schema.rb` consistent.
- Use reversible migrations where practical; otherwise define explicit `up` and `down`.
- For data backfills inside migrations, follow the existing pattern of defining migration-local `ActiveRecord::Base` classes with `self.table_name`.
- For data backfills inside migrations, follow the existing pattern of
defining migration-local `ActiveRecord::Base` classes with
`self.table_name`.
- Preserve existing indexes, foreign keys, check constraints, and null constraints.
- Be careful with MySQL-specific options already present in migrations, such as `after:`.
- Do not edit old migrations just to change current behavior unless explicitly requested; add a new migration.
- Do not edit old migrations just to change current behavior unless
explicitly requested; add a new migration.
## Version tables
- Versioned records include posts, tags, nico tags, and wiki pages.
- Current records have `version_no`; version tables have positive `version_no` with unique indexes scoped to the parent record.
- Current records have `version_no`; version tables have positive
`version_no` with unique indexes scoped to the parent record.
- Version event types are `create`, `update`, `discard`, and `restore`.
- Version rows are readonly through the `VersionRecord` concern.
- Use the existing recorder services instead of manually inserting version rows in application code:
- Use the existing recorder services instead of manually inserting version
rows in application code:
- `PostVersionRecorder`
- `TagVersionRecorder`
- `NicoTagVersionRecorder`
- `WikiVersionRecorder`
- `TagVersioning`
- `VersionRecorder` locks the current record, validates sequence consistency, skips unchanged update snapshots, creates the next version row, and updates the record `version_no`.
- `VersionRecorder` locks the current record, validates sequence consistency,
skips unchanged update snapshots, creates the next version row, and updates
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.
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and
`merge` semantics. Cover conflicts in request specs only when the user
explicitly asks for tests.
## Domain cautions
- Posts have tag snapshots, parent post implications, original-created ranges, viewed state, and version conflict behavior.
- Tags have canonical names, aliases through `TagName`, categories, parent implications, discard behavior, and version snapshots.
- Nico tags have separate relation/version behavior; do not treat them like normal editable tags without checking existing code.
- Wiki pages involve page content, revisions/history, version rows, title/tag-name behavior, and diff/restore paths.
- Materials, theatres, and comments have user and permission checks; inspect the controller before changing them.
- Posts have tag snapshots, parent post implications, original-created ranges,
viewed state, and version conflict behavior.
- Tags have canonical names, aliases through `TagName`, categories, parent
implications, discard behavior, and version snapshots.
- Nico tags have separate relation/version behavior; do not treat them like
normal editable tags without checking existing code.
- Wiki pages involve page content, revisions/history, version rows,
title/tag-name behavior, and diff/restore paths.
- Materials, theatres, and comments have user and permission checks; inspect
the controller before changing them.
## API responses
- Use representation classes under `app/representations` when existing endpoints do.
- Keep response keys consistent with existing JSON contracts; frontend code expects camelCase conversion client-side, while Rails params and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions: `:unauthorized` for no user, `:forbidden` for insufficient role or banned user, `:not_found` for missing records, and `:unprocessable_entity` for validation failures.
- Keep response keys consistent with existing JSON contracts.
- Frontend code expects camelCase conversion client-side, while Rails params
and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions:
`:unauthorized` for no user, `:forbidden` for insufficient role or banned
user, `:not_found` for missing records, and `:unprocessable_entity` for
validation failures.
- For diagnostic or internal helper JSON, prefer a deliberately light response
shape over full representation classes when callers only need identifiers,
labels, URLs, or weights.
## Active Record performance
- When a controller action serializes nested associations, preload the
associations it will touch instead of allowing N+1 queries.
- Be sensitive to N+1 queries in all backend work.
- Avoid introducing N+1 queries, and proactively fix existing N+1 issues when
you find them in the code path you are editing.
- When an association may already be preloaded, prefer loaded-association
checks that reuse the preloaded data without losing the efficient database
path.
## Files to avoid in routine work
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency directories unless explicitly needed.
- Do not modify generated schema or migration output without the corresponding migration when schema changes are made.
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency
directories unless explicitly needed.
- Do not modify generated schema or migration output without the corresponding
migration when schema changes are made.
+2
ファイルの表示
@@ -69,3 +69,5 @@ gem 'discard'
gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
gem 'aws-sdk-s3', require: false
gem 'rails-i18n', '~> 8.0.0'
+4
ファイルの表示
@@ -306,6 +306,9 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
@@ -477,6 +480,7 @@ DEPENDENCIES
puma (>= 5.0)
rack-cors
rails (~> 8.0.2)
rails-i18n (~> 8.0.0)
rspec-rails (~> 8.0)
rubocop-rails-omakase
sprockets-rails
+48
ファイルの表示
@@ -1,4 +1,7 @@
class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
before_action :reject_banned_ip_address!
before_action :authenticate_user
before_action :reject_banned_user!
@@ -25,6 +28,27 @@ class ApplicationController < ActionController::API
end
end
def render_bad_request message = 'リクエストが不正です.'
render json: { type: 'bad_request',
message:,
errors: { },
base_errors: [message] },
status: :bad_request
end
def render_unprocessable_entity message = '入力を確認してください.', field: nil
render_validation_error(fields: field ? { field => [message] } : { },
base: field ? [] : [message])
end
def render_record_invalid error
render_validation_error error.record
end
def render_record_not_unique _error = nil
render_validation_error base: ['すでに存在してゐます.']
end
def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?
@@ -37,4 +61,28 @@ class ApplicationController < ActionController::API
head :forbidden
end
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
errors = { }
if record
record.errors.each do |error|
errors[error.attribute] ||= []
errors[error.attribute] << error.message
end
end
fields.each do |attr, messages|
errors[attr.to_sym] ||= []
errors[attr.to_sym].concat(Array(messages))
end
base_errors = Array(base) + Array(errors.delete(:base))
render json: { type: 'validation_error',
message: '入力内容を確認してください.',
errors:,
base_errors: },
status:
end
end
+7 -3
ファイルの表示
@@ -2,7 +2,8 @@ class DeerjikistsController < ApplicationController
def show
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
return head :bad_request if platform.blank? || code.blank?
return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.') if code.blank?
deerjikist = Deerjikist
.joins(:tag)
@@ -22,7 +23,9 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
tag_id = params[:tag_id].to_i
return head :bad_request if platform.blank? || code.blank? || tag_id <= 0
return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.') if code.blank?
return render_bad_request('tag_id が不正です.') if tag_id <= 0
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d|
d.tag_id = tag_id
@@ -38,7 +41,8 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
return head :bad_request if platform.blank? || code.blank?
return render_bad_request('platform は必須です.') if platform.blank?
return render_bad_request('code は必須です.') if code.blank?
Deerjikist.find([platform, code]).destroy!
+140
ファイルの表示
@@ -0,0 +1,140 @@
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
def extra_questions
return head :not_found unless current_user&.admin?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
questions =
GekanatorQuestion
.accepted
.includes(:gekanator_question_examples)
.where(kind: 'post_similarity', source: 'user_suggested')
.to_a
selected = weighted_sample_questions(
questions,
post_id: game.correct_post_id,
limit: 2)
render json: {
questions: selected.map { |question| extra_question_json(question) }
}
end
def extra_question_answers
return head :not_found unless current_user&.admin?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
answer_params = params.require(:answers)
if !answer_params.is_a?(Array)
return render_validation_error fields: { answers: ['配列で指定してください.'] }
end
answers = answer_params.map { |answer|
{
question_id: answer.require(:question_id).to_i,
answer: answer.require(:answer)
}
}
questions = GekanatorQuestion.where(id: answers.map { _1[:question_id] })
question_by_id = questions.index_by(&:id)
if questions.length != answers.length
return render_validation_error fields: { answers: ['質問が見つかりません.'] }
end
if questions.any? { |question| question.status != 'accepted' || question.kind != 'post_similarity' }
return render_validation_error fields: { answers: ['質問が不正です.'] }
end
ActiveRecord::Base.transaction do
answers.each do |item|
question = question_by_id[item[:question_id]]
example =
GekanatorQuestionExample.find_or_initialize_by(
gekanator_question: question,
post: game.correct_post,
user: current_user)
example.record_answer!(
answer: item[:answer],
source: 'post_game_extra',
gekanator_game: game)
example.save!
end
end
render json: { count: answers.length }, status: :created
end
private
def extra_question_json question
{
id: question.id,
text: question.text,
source: question.source,
priority_weight: question.priority_weight
}
end
def weighted_sample_questions questions, post_id:, limit:
remaining = questions.uniq(&:id)
selected = []
while selected.length < limit && remaining.any?
weighted =
remaining.map { |question|
[question, selection_weight_for(question, post_id: post_id)]
}
total_weight = weighted.sum { |_question, weight| weight }
break if total_weight <= 0
target = rand * total_weight
cumulative = 0.0
chosen =
weighted.find do |_question, weight|
cumulative += weight
cumulative >= target
end&.first || weighted.first.first
selected << chosen
remaining.reject! { |question| question.id == chosen.id }
end
selected
end
def selection_weight_for question, post_id:
sample_count =
question.gekanator_question_examples.sum { |example|
next 0 unless example.post_id == post_id
example.sample_count.presence || 1
}
question.priority_weight.to_f / (1.0 + sample_count * 0.15)
end
end
+46
ファイルの表示
@@ -0,0 +1,46 @@
class GekanatorPostsController < ApplicationController
def index
return head :not_found unless current_user&.admin?
posts =
Post
.preload(tags: :tag_name)
.with_attached_thumbnail
.order(Arel.sql(
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
render json: { posts: posts.map { |post| post_json(post) } }
end
private
def post_json post
{
id: post.id,
url: post.url,
title: post.title,
thumbnail: thumbnail_url(post),
thumbnail_base: post.thumbnail_base,
original_created_from: post.original_created_from,
original_created_before: post.original_created_before,
tags: post.tags.map { |tag| tag_json(tag) }
}
end
def tag_json tag
{
id: tag.id,
name: tag.name,
category: tag.category
}
end
def thumbnail_url post
return nil unless post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false)
rescue
nil
end
end
+53
ファイルの表示
@@ -0,0 +1,53 @@
class GekanatorQuestionSuggestionsController < ApplicationController
def create
return head :not_found unless current_user&.admin?
game = GekanatorGame.find_by(id: params.require(:gekanator_game_id))
return head :not_found unless game
suggestion = GekanatorQuestionSuggestion.new(
gekanator_game: game,
user: current_user,
question_text: params.require(:question_text),
answer: params.require(:answer))
if suggestion.valid?
ActiveRecord::Base.transaction do
suggestion.save!
Gekanator::QuestionSuggestionPromoter.call(
suggestion: suggestion,
user: current_user)
end
render json: {
id: suggestion.id,
count: game.question_suggestions.count
}, status: :created
else
render_validation_error suggestion
end
end
def ai_convert
return head :not_found unless current_user&.admin?
suggestion = GekanatorQuestionSuggestion.find_by(id: params[:id])
return head :not_found unless suggestion
if Gekanator::AiRunBudget.exceeded_after_next_run?
suggestion.gekanator_ai_runs.create!(
model: 'budget_guard',
status: 'blocked_budget',
input_tokens: 0,
output_tokens: 0,
estimated_cost_jpy: 0)
return head :payment_required
end
Gekanator::QuestionSuggestionAiConverter.call(
suggestion: suggestion,
user: current_user)
head :no_content
rescue NotImplementedError
head :not_implemented
end
end
+100
ファイルの表示
@@ -0,0 +1,100 @@
class GekanatorQuestionsController < ApplicationController
def index
return head :not_found unless current_user&.admin?
questions =
GekanatorQuestion
.accepted
.includes(:gekanator_question_examples)
.order(priority_weight: :desc, id: :asc)
render json: {
questions: questions.map { |question| question_json(question) }
}
end
private
def question_json question
condition = condition_json(question.condition).deep_symbolize_keys
json = {
id: question_id_for(question, condition),
text: question_text_for(question, condition),
kind: question.kind,
condition: condition,
source: question.source,
priority_weight: question.priority_weight
}
if question.kind == 'post_similarity'
json[:example_answers] = example_answers_json(question)
end
json
end
def question_id_for question, condition
case condition[:type]
when 'tag'
"tag:#{ condition[:key] }"
when 'source'
"source:#{ condition[:host] }"
when 'original-year'
"original-year:#{ condition[:year] }"
when 'original-month'
"original-month:#{ condition[:month] }"
when 'original-month-day'
"original-month-day:#{ condition[:monthDay] || condition[:month_day] }"
when 'title-length-at-least'
"title:length-at-least:#{ condition[:length] }"
when 'title-length-greater-than'
"title:length-at-least:#{ condition[:length].to_i + 1 }"
when 'title-has-ascii'
'title:ascii'
when 'post-similarity'
"post-similarity:#{ question.id }"
else
"catalog:#{ question.id }"
end
end
def condition_json condition
json = condition.deep_dup.as_json
if json['type'] == 'original-month-day' && json['monthDay'].blank?
json['monthDay'] = json.delete('month_day')
end
if json['type'] == 'title-length-greater-than'
json['type'] = 'title-length-at-least'
json['length'] = json['length'].to_i + 1
end
json
end
def question_text_for question, condition
return question.text unless question.kind == 'title'
case condition[:type]
when 'title-length-at-least'
"タイトルは #{ condition[:length] } 文字以上?"
else
question.text
end
end
def example_answers_json question
question
.gekanator_question_examples
.group_by(&:post_id)
.transform_values { |examples| aggregate_answer(examples) }
end
def aggregate_answer examples
examples
.group_by(&:answer)
.map { |answer, grouped| [answer, grouped.sum(&:weight), grouped.max_by(&:updated_at)&.updated_at] }
.sort_by { |(_answer, weight, updated_at)| [-weight, -(updated_at&.to_f || 0)] }
.first
&.first
end
end
+12 -4
ファイルの表示
@@ -40,7 +40,11 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip
file = params[:file]
url = params[:url].to_s.strip.presence
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
@@ -54,7 +58,7 @@ class MaterialsController < ApplicationController
if material.save
render json: MaterialRepr.base(material, host: request.base_url), status: :created
else
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
render_validation_error material
end
end
@@ -68,7 +72,11 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip
file = params[:file]
url = params[:url].to_s.strip.presence
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank?
if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
@@ -84,7 +92,7 @@ class MaterialsController < ApplicationController
if material.save
render json: MaterialRepr.base(material, host: request.base_url)
else
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
render_validation_error material
end
end
+82 -19
ファイルの表示
@@ -1,26 +1,69 @@
class NicoTagsController < ApplicationController
def index
limit = (params[:limit] || 20).to_i
cursor = params[:cursor].presence
name = params[:name].presence
linked_tag = params[:linked_tag].presence
link_status = params[:link_status].presence
order = params[:order].to_s.split(':', 2).map(&:strip)
order[0] = 'updated_at' unless order[0].in?(['name', 'created_at', 'updated_at'])
unless order[1].in?(['asc', 'desc'])
order[1] = order[0] == 'name' ? 'asc' : 'desc'
end
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
post_tag_max_sql =
PostTag
.select('tag_id, MAX(created_at) AS max_created_at')
.group('tag_id')
.to_sql
q = Tag.nico_tags
.joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
.order(updated_at: :desc)
q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor
tags = q.limit(limit + 1).to_a
next_cursor = nil
if tags.size > limit
next_cursor = tags.last.updated_at.iso8601(6)
tags = tags.first(limit)
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
if linked_tag
linked_tag_ids =
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
q = q.where(id: linked_nico_tag_ids)
end
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end
count = q.count
sort_sql =
case order[0]
when 'name'
'tag_names.name'
when 'updated_at'
'post_tag_max.max_created_at'
else
"tags.#{ order[0] }"
end
tags = q.reselect('tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset((page - 1) * limit)
.to_a
render json: { tags: tags.map { |tag|
TagRepr.base(tag).merge(linked_tags: tag.linked_tags.map { |lt|
TagRepr.base(lt)
})
}, next_cursor: }
TagRepr.base(tag).merge(
recent_post_tag_created_at: tag.recent_post_tag_created_at,
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
}, count: }
end
def update
@@ -30,14 +73,18 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i
tag = Tag.find(id)
return head :bad_request unless tag.nico?
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
linked_tag_names = params[:tags].to_s.split
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
return head :bad_request if linked_tags.any? { |t| t.nico? }
linked_tags = nil
ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags
@@ -47,5 +94,21 @@ class NicoTagsController < ApplicationController
end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
rescue ActiveRecord::RecordInvalid => e
render_nico_tag_form_record_invalid e.record
end
private
def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end
end
+73 -65
ファイルの表示
@@ -44,7 +44,8 @@ class PostsController < ApplicationController
filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.preload(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -95,22 +96,36 @@ class PostsController < ApplicationController
end
def random
post = filtered_posts
.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.order('RAND()')
.first
post = filtered_posts.preload(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
.order('RAND()')
.first
return head :not_found unless post
render json: PostRepr.base(post, current_user)
end
def show
post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
post =
Post
.includes(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
.find_by(id: params[:id])
return head :not_found unless post
render json: PostRepr.base(post, current_user)
.merge(tags: build_tag_tree_for(post),
related: PostRepr.many(post.related(limit: 20)))
parent_posts = post.parents.with_attached_thumbnail.order(:id).to_a
child_posts = post.children.with_attached_thumbnail.order(:id).to_a
sibling_posts = sibling_posts_by_parent(parent_posts.map(&:id))
related = post.related(limit: 20).to_a
render json: PostRepr.detail(post, current_user,
parent_posts:,
child_posts:,
sibling_posts:,
related:)
.merge(tags: build_tag_tree_for(post.tags))
end
def create
@@ -133,11 +148,11 @@ class PostsController < ApplicationController
ApplicationRecord.transaction do
post.save!
Tag.normalise_tags!(tag_names, with_sections: true) => { tags:, sections: }
tags = Tag.normalise_tags!(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags, sections)
sync_post_tags!(post, tags)
sync_parent_posts!(post, parent_post_ids)
@@ -149,11 +164,11 @@ class PostsController < ApplicationController
post.reload
render json: PostRepr.base(post), status: :created
rescue Tag::NicoTagNormalisationError
head :bad_request
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue ArgumentError => e
render json: { errors: [e.message] }, status: :unprocessable_entity
render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
render_post_form_record_invalid e.record
end
def viewed
@@ -176,10 +191,10 @@ class PostsController < ApplicationController
force = bool?(:force)
merge = bool?(:merge)
return head :bad_request if force && merge
return render_bad_request('force と merge は同時に指定できません.') if force && merge
base_version_no = parse_base_version_no
return head :bad_request if !(force) && !(base_version_no)
return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no)
title = params[:title].presence
tag_names = params[:tags].to_s.split
@@ -236,14 +251,14 @@ class PostsController < ApplicationController
post.reload
json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post)
json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok
rescue Tag::NicoTagNormalisationError
head :bad_request
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
rescue ArgumentError => e
render json: { errors: [e.message] }, status: :unprocessable_entity
render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
render_post_form_record_invalid e.record
end
def changes
@@ -338,7 +353,7 @@ class PostsController < ApplicationController
def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def sync_post_tags! post, desired_tags, sections
def sync_post_tags! post, desired_tags
desired_tags.each do |t|
t.save! if t.new_record?
end
@@ -357,20 +372,13 @@ class PostsController < ApplicationController
end
end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:)
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user)
end
end
def build_tag_tree_for post
tags = post.tags.to_a
def build_tag_tree_for tags
tags = tags.to_a
tag_ids = tags.map(&:id)
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
@@ -392,11 +400,8 @@ class PostsController < ApplicationController
tag = tags_by_id[tag_id]
return nil unless tag
sections = PostTagSection.where(post_id: post.id, tag_id:)
.as_json(only: [:begin_ms, :end_ms])
if path.include?(tag_id)
return TagRepr.base(tag).merge(children: [], sections:)
return TagRepr.inline(tag).merge(children: [])
end
if memo.key?(tag_id)
@@ -408,18 +413,32 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.base(tag).merge(children:, sections:)
memo[tag_id] = TagRepr.inline(tag).merge(children:)
end
root_ids.filter_map { |id| build_node.call(id, []) }
end
def sibling_posts_by_parent parent_post_ids
return { } if parent_post_ids.blank?
implications =
PostImplication
.where(parent_post_id: parent_post_ids)
.includes(post: { thumbnail_attachment: :blob })
.order(:parent_post_id, :post_id)
implications.group_by(&:parent_post_id).transform_values { |items|
items.map(&:post)
}
end
def parse_parent_post_ids
raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids)
params[:parent_post_ids].to_s.split.map { |token|
id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if !(id) || id <= 0
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id
}.uniq
@@ -427,7 +446,7 @@ class PostsController < ApplicationController
def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id)
post.errors.add(:base, '自分自身を親投稿にはできません.')
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
raise ActiveRecord::RecordInvalid, post
end
@@ -435,7 +454,8 @@ class PostsController < ApplicationController
missing_ids = parent_post_ids - existing_ids
if missing_ids.present?
post.errors.add(:base, "存在しない親投稿 ID があります: #{ missing_ids.join(' ') }")
post.errors.add :parent_post_ids,
"存在しない親投稿 Id. があります: #{ missing_ids.join(' ') }"
raise ActiveRecord::RecordInvalid, post
end
@@ -481,21 +501,7 @@ class PostsController < ApplicationController
end
def editable_tag_names_from_post post
post
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
@@ -527,16 +533,9 @@ class PostsController < ApplicationController
end
def incoming_tag_names_for_snapshot raw_tag_names
Tag.normalise_tags!(raw_tag_names, with_tagme: false, with_sections: true) =>
{ tags:, sections: }
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
Tag.expand_parent_tags(tags).uniq(&:id).map { |tag|
"#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
}.sort
end
def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ Post.ms_to_time(section[1]) }]"
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
end
def post_conflict_json post:, base_version_no:, base_snapshot:,
@@ -623,8 +622,7 @@ class PostsController < ApplicationController
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, with_sections: true) =>
{ tags: editable_tags, sections: }
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
@@ -632,7 +630,7 @@ class PostsController < ApplicationController
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags, sections)
sync_post_tags!(post, tags)
sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
@@ -673,4 +671,14 @@ class PostsController < ApplicationController
merged.uniq.sort
end
def render_post_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end
end
+8 -4
ファイルの表示
@@ -4,7 +4,7 @@ class PreviewController < ApplicationController
return head :unauthorized unless current_user
url = params[:url]
return head :bad_request unless url.present?
return render_bad_request('URL は必須です.') unless url.present?
unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url
@@ -16,7 +16,7 @@ class PreviewController < ApplicationController
render json: { title: title }
rescue => e
render json: { error: e.message }, status: :bad_request
render_bad_request(e.message)
end
def thumbnail
@@ -25,7 +25,7 @@ class PreviewController < ApplicationController
return head :unauthorized unless current_user
url = params[:url]
return head :bad_request if url.blank?
return render_bad_request('URL は必須です.') if url.blank?
unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url
@@ -40,7 +40,11 @@ class PreviewController < ApplicationController
File.delete(path) rescue nil
send_file image.path, type: 'image/png', disposition: 'inline'
else
render json: { error: 'Failed to generate thumbnail' }, status: :internal_server_error
render json: { type: 'internal_server_error',
message: 'サムネールを生成できませんでした.',
errors: { },
base_errors: ['サムネールを生成できませんでした.'] },
status: :internal_server_error
end
end
end
+6 -4
ファイルの表示
@@ -5,11 +5,12 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id]
child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank?
return render_bad_request('parent_id は必須です.') if parent_id.blank?
return render_bad_request('child_id は必須です.') if child_id.blank?
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return head :bad_request if parent.nico? || child.nico?
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -27,11 +28,12 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id]
child_id = params[:child_id]
return head :bad_request if parent_id.blank? || child_id.blank?
return render_bad_request('parent_id は必須です.') if parent_id.blank?
return render_bad_request('child_id は必須です.') if child_id.blank?
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return head :bad_request if parent.nico? || child.nico?
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+34 -14
ファイルの表示
@@ -168,7 +168,7 @@ class TagsController < ApplicationController
def show_by_name
name = params[:name].to_s.strip
return head :bad_request if name.blank?
return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -192,7 +192,7 @@ class TagsController < ApplicationController
def deerjikists_by_name
name = params[:name].to_s.strip
return head :bad_request if name.blank?
return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
@@ -214,21 +214,24 @@ class TagsController < ApplicationController
ApplicationRecord.transaction do
tag.deerjikists = []
params[:_json].each do
platform = _1[:platform]
code = normalise_deerjikist_code(platform, _1[:code])
params[:_json].each.with_index do |item, i|
platform = item[:platform]
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
deerjikist.save!
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
raise ActiveRecord::Rollback if performed?
end
end
return if performed?
render json: DeerjikistRepr.many(tag.reload.deerjikists)
end
def materials_by_name
name = params[:name].to_s.strip
return head :bad_request if name.blank?
return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -247,17 +250,16 @@ class TagsController < ApplicationController
name = params[:name].to_s.strip
category = params[:category].to_s.strip
return head :unprocessable_entity if name.blank? || category.blank?
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
if name != tag.name &&
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
return render json: { error: 'システム・タグの名称は変更できません.' },
status: :unprocessable_entity
return render_unprocessable_entity('システム・タグの名称は変更できません.', field: :name)
end
if tag.nico? || category == 'nico'
return render json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
end
alias_names = params[:aliases].to_s.split.uniq
@@ -302,8 +304,7 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id])
if tag.nico? || (category.present? && category == 'nico')
return render json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
end
ApplicationRecord.transaction do
@@ -437,4 +438,23 @@ class TagsController < ApplicationController
rescue
nil
end
def render_deerjikist_form_record_invalid deerjikist, index
fields = { }
deerjikist.errors.each do |error|
field =
case error.attribute
when :platform, :code
"deerjikists.#{ index }.#{ error.attribute }"
else
:deerjikists
end
fields[field] ||= []
fields[field] << error.full_message
end
render_validation_error fields:
end
end
+25 -3
ファイルの表示
@@ -1,21 +1,28 @@
class TheatreCommentsController < ApplicationController
def index
limit = params[:limit].to_i
limit = 20 if limit <= 0
no_gt = params[:no_gt].to_i
no_gt = 0 if no_gt.negative?
no_gt = 0 if no_gt < 0
comments = TheatreComment
.where(theatre_id: params[:theatre_id])
.where('no > ?', no_gt)
.order(no: :desc)
.limit(limit)
render json: comments.as_json(include: { user: { only: [:id, :name] } })
render json: comments.map {
_1.as_json(include: { user: { only: [:id, :name] } })
.merge(content: _1.discarded? ? nil : _1.content, deleted: _1.discarded?)
}
end
def create
return head :unauthorized unless current_user
content = params[:content]
return head :unprocessable_entity if content.blank?
return render_unprocessable_entity('本文は必須です.', field: :content) if content.blank?
theatre = Theatre.find_by(id: params[:theatre_id])
return head :not_found unless theatre
@@ -29,4 +36,19 @@ class TheatreCommentsController < ApplicationController
render json: comment, status: :created
end
def destroy
return head :unauthorized unless current_user
theatre_id = params[:theatre_id].to_i
no = params[:id].to_i
comment = TheatreComment.find_by(theatre_id:, no:)
return head :not_found unless comment
return head :forbidden unless comment.user == current_user
comment.discard!
head :no_content
end
end
+22
ファイルの表示
@@ -0,0 +1,22 @@
class TheatreProgrammesController < ApplicationController
def index
limit = params[:limit].to_i
limit = 100 if limit <= 0
position_gt = params[:position_gt].to_i
position_gt = 0 if position_gt < 0
programmes = TheatreProgramme
.where(theatre_id: params[:theatre_id])
.where('position > ?', position_gt)
.includes(:post)
.order(position: :desc).limit(100)
.limit(limit)
render json: programmes.map { |programme|
programme.as_json.merge(post: { id: programme.post.id,
title: programme.post.title,
url: programme.post.url })
}
end
end
+22
ファイルの表示
@@ -0,0 +1,22 @@
class TheatreSkipEventsController < ApplicationController
def index
limit = params[:limit].to_i
limit = 50 if limit <= 0
events =
TheatreSkipEvent
.where(theatre_id: params[:theatre_id])
.includes(:post, tags: :tag_name)
.order(created_at: :desc)
.limit(limit)
render json: events.map { |event|
{ id: event.id,
theatre_id: event.theatre_id,
post: { id: event.post.id, title: event.post.title, url: event.post.url },
tags: event.tags.map { |tag| { id: tag.id, name: tag.name } },
programme_position: event.programme_position,
created_at: event.created_at }
}
end
end
+113 -8
ファイルの表示
@@ -31,9 +31,7 @@ class TheatresController < ApplicationController
post_started_at = theatre.current_post_started_at
end
render json: {
host_flg:, post_id:, post_started_at:,
watching_users: theatre.watching_users.as_json(only: [:id, :name]) }
render json: theatre_info_json(theatre, host_flg:, post_id:, post_started_at:)
end
def next_post
@@ -43,12 +41,119 @@ class TheatresController < ApplicationController
return head :not_found unless theatre
return head :forbidden if theatre.host_user != current_user
post = Post.where("url LIKE '%nicovideo.jp%'")
.or(Post.where("url LIKE '%youtube.com%'"))
.order('RAND()')
.first
theatre.update!(current_post: post, current_post_started_at: Time.current)
ApplicationRecord.transaction do
theatre.lock!
TheatrePostAdvancer.call(theatre:)
end
head :no_content
end
def skip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
skipped = false
conflicted = false
ApplicationRecord.transaction do
theatre.lock!
if theatre.current_post
TheatreWatchingUser.find_or_initialize_by(theatre:, user: current_user).tap {
_1.expires_at = 30.seconds.from_now
}.save!
if theatre.current_post_id != requested_post_id
conflicted = true
next
end
TheatreSkipVote.find_or_create_by!(theatre:, post_id: requested_post_id, user: current_user)
vote_status = skip_vote_status(theatre)
if vote_status[:votes_count] >= vote_status[:required_count]
TheatreSkipFinalizer.call(theatre:, user: current_user)
TheatrePostAdvancer.call(theatre:)
skipped = true
end
end
end
theatre.reload
return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted
render json: theatre_info_json(theatre, skipped:)
end
def unskip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
conflicted = false
theatre.with_lock do
if theatre.current_post
if theatre.current_post_id != requested_post_id
conflicted = true
else
TheatreSkipVote.where(theatre:, post_id: requested_post_id, user: current_user).delete_all
end
end
end
theatre.reload
return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted
render json: theatre_info_json(theatre, skipped: false)
end
def post_selection_weights
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
render json: TheatrePostSelector.new(theatre:).weight_json
end
private
def theatre_info_json(theatre, host_flg: nil, post_id: nil, post_started_at: nil, skipped: nil)
host_flg = theatre.host_user_id == current_user&.id if host_flg.nil?
post_id = theatre.current_post_id if post_id.nil?
post_started_at = theatre.current_post_started_at if post_started_at.nil?
json = { host_flg:,
post_id:,
post_started_at:,
post_elapsed_ms: post_started_at ? ((Time.current - post_started_at) * 1000).floor : nil,
watching_users: theatre.watching_users.as_json(only: [:id, :name]),
skip_vote: skip_vote_status(theatre) }
json[:skipped] = skipped unless skipped.nil?
json
end
def skip_vote_status(theatre)
watching_user_ids = theatre.watching_users.ids
watching_users_count = watching_user_ids.size
required_count = (watching_users_count / 2) + 1
post = theatre.current_post
votes =
if post
TheatreSkipVote.where(theatre:, post:, user_id: watching_user_ids)
else
TheatreSkipVote.none
end
{ votes_count: post ? votes.count : 0,
required_count:,
watching_users_count:,
voted: post && current_user ? votes.exists?(user_id: current_user.id) : false }
end
end
+2 -2
ファイルの表示
@@ -42,12 +42,12 @@ class UsersController < ApplicationController
return head :unauthorized if user&.id != params[:id].to_i
name = params[:name]
return head :bad_request if name.blank?
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
if user.update(name:)
render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok
else
render json: user.errors, status: :unprocessable_entity
render_validation_error user
end
end
+10 -6
ファイルの表示
@@ -46,7 +46,7 @@ class WikiPagesController < ApplicationController
def diff
id = params[:id]
return head :bad_request if id.blank?
return render_bad_request('id は必須です.') if id.blank?
from = params[:from].presence
to = params[:to].presence
@@ -56,7 +56,7 @@ class WikiPagesController < ApplicationController
from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?))
return head :unprocessable_entity
return render_unprocessable_entity('差分を表示できない版です.')
end
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
@@ -89,7 +89,8 @@ class WikiPagesController < ApplicationController
body = params[:body].to_s
message = params[:message].presence
return head :unprocessable_entity if title.blank? || body.blank?
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name: title)
@@ -101,8 +102,10 @@ class WikiPagesController < ApplicationController
message:)
render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
head :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
rescue ActiveRecord::RecordNotUnique
render_record_not_unique
end
def update
@@ -112,7 +115,8 @@ class WikiPagesController < ApplicationController
title = params[:title]&.strip
body = params[:body].to_s
return head :unprocessable_entity if title.blank? || body.blank?
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
page = WikiPage.find(params[:id])
base_revision_id = params[:base_revision_id].presence
+21
ファイルの表示
@@ -0,0 +1,21 @@
class GekanatorAiRun < ApplicationRecord
STATUSES = ['pending', 'running', 'succeeded', 'failed', 'blocked_budget'].freeze
belongs_to :gekanator_question_suggestion
validates :model, presence: true, length: { maximum: 255 }
validates :status, presence: true, inclusion: { in: STATUSES }
validates :input_tokens,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :output_tokens,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :estimated_cost_jpy,
presence: true,
numericality: { greater_than_or_equal_to: 0 }
scope :this_month, lambda {
where(created_at: Time.current.beginning_of_month..Time.current.end_of_month)
}
end
+15
ファイルの表示
@@ -0,0 +1,15 @@
class GekanatorGame < ApplicationRecord
belongs_to :user
belongs_to :guessed_post, class_name: 'Post'
belongs_to :correct_post, class_name: 'Post'
has_many :question_suggestions,
class_name: 'GekanatorQuestionSuggestion',
dependent: :delete_all
has_many :question_examples,
class_name: 'GekanatorQuestionExample',
dependent: :delete_all
validates :answers, presence: true
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
validates :won, inclusion: { in: [true, false] }
end
+23
ファイルの表示
@@ -0,0 +1,23 @@
class GekanatorQuestion < ApplicationRecord
KINDS = ['tag', 'source', 'title', 'original_date', 'post_similarity'].freeze
SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze
STATUSES = ['pending', 'accepted', 'rejected', 'disabled'].freeze
belongs_to :gekanator_question_suggestion, optional: true
belongs_to :created_by, class_name: 'User', optional: true
has_many :gekanator_question_examples, dependent: :delete_all
validates :kind, presence: true, inclusion: { in: KINDS }
validates :source, presence: true, inclusion: { in: SOURCES }
validates :status, presence: true, inclusion: { in: STATUSES }
validates :text, presence: true, length: { maximum: 1000 }
validates :condition, presence: true
validates :priority_weight,
presence: true,
numericality: {
greater_than: 0,
less_than_or_equal_to: 3
}
scope :accepted, -> { where(status: 'accepted') }
end
+97
ファイルの表示
@@ -0,0 +1,97 @@
class GekanatorQuestionExample < ApplicationRecord
ANSWERS = GekanatorQuestionSuggestion::ANSWERS
NON_UNKNOWN_ANSWERS = ANSWERS - ['unknown']
SOURCES = ['initial_suggestion', 'post_game_extra'].freeze
belongs_to :gekanator_question
belongs_to :post
belongs_to :user
belongs_to :gekanator_game, optional: true
validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :answer_counts, presence: true
validates :sample_count,
presence: true,
numericality: {
only_integer: true,
greater_than: 0
}
validates :source, presence: true, inclusion: { in: SOURCES }
validates :weight,
presence: true,
numericality: {
greater_than: 0
}
before_validation :normalize_learning_state
def record_answer!(answer:, source:, gekanator_game: nil)
answer = answer.to_s
raise ArgumentError, 'invalid answer' unless ANSWERS.include?(answer)
counts = normalized_answer_counts
counts[answer] += 1
self.answer_counts = counts
self.sample_count = counts.values.sum
self.gekanator_game = gekanator_game if gekanator_game.present?
self.source = source if new_record?
apply_aggregated_answer!(preferred_answer: answer)
self
end
private
def normalize_learning_state
counts = normalized_answer_counts
if counts.values.sum.zero? && answer.present?
counts[answer] = 1
end
self.answer_counts = counts
self.sample_count = counts.values.sum
apply_aggregated_answer!
end
def apply_aggregated_answer!(preferred_answer: nil)
counts = normalized_answer_counts
known_counts = counts.slice(*NON_UNKNOWN_ANSWERS)
known_total = known_counts.values.sum
if known_total.zero?
self.answer = 'unknown'
self.weight = 0.1
return
else
max_count = known_counts.values.max
candidates = known_counts.select { |_answer, count| count == max_count }.keys
self.answer =
if preferred_answer.present? && candidates.include?(preferred_answer)
preferred_answer
elsif answer.present? && candidates.include?(answer)
answer
else
candidates.first
end
end
consensus = max_count.to_f / known_total
self.weight = Math.sqrt(known_total) * consensus
end
def normalized_answer_counts
base = ANSWERS.index_with(0)
answer_counts.to_h.each do |key, value|
answer_key = key.to_s
next unless ANSWERS.include?(answer_key)
base[answer_key] = value.to_i
end
base
end
end
+25
ファイルの表示
@@ -0,0 +1,25 @@
class GekanatorQuestionSuggestion < ApplicationRecord
MAX_QUESTIONS_PER_GAME = 3
ANSWERS = ['yes', 'no', 'partial', 'probably_no', 'unknown'].freeze
belongs_to :gekanator_game
belongs_to :user
has_many :gekanator_questions, dependent: :nullify
has_many :gekanator_ai_runs, dependent: :destroy
validates :question_text, presence: true, length: { maximum: 1000 }
validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :processed, inclusion: { in: [true, false] }
validate :question_suggestion_limit_per_game, on: :create
private
def question_suggestion_limit_per_game
return if gekanator_game_id.blank?
count = GekanatorQuestionSuggestion.where(gekanator_game_id:).count
if count >= MAX_QUESTIONS_PER_GAME
errors.add(:base, '質問追加数を超えてゐます.')
end
end
end
+13 -33
ファイルの表示
@@ -11,6 +11,17 @@ class Post < ApplicationRecord
has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all
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: :delete_all,
inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications,
class_name: 'PostImplication',
@@ -56,38 +67,7 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil)
end
def snapshot_tag_names
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-#{ Post.ms_to_time(section.end_ms) }]"
end
def self.ms_to_time ms
total_s = ms / 1_000
s = total_s % 60
min = (total_s / 60) % 60
h = total_s / 3_600
if h.positive?
'%d:%02d:%02d' % [h, min, s]
else
'%d:%02d' % [min, s]
end
end
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
@@ -125,7 +105,7 @@ class Post < ApplicationRecord
return if !(f) || !(b)
if f >= b
errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.'
errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.'
end
end
-6
ファイルの表示
@@ -10,12 +10,6 @@ class PostTag < ApplicationRecord
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
dependent: :delete_all,
inverse_of: :post_tag
validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: {
-20
ファイルの表示
@@ -1,20 +0,0 @@
class PostTagSection < ApplicationRecord
self.primary_key = :post_id, :tag_id, :begin_ms
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
validates :begin_ms, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :end_ms, presence: true,
numericality: { only_integer: true, greater_than: :begin_ms }
end
+4 -51
ファイルの表示
@@ -81,7 +81,7 @@ class Tag < ApplicationRecord
def material_id = materials.first&.id
def has_deerjikists = deerjikists.present?
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
@@ -92,50 +92,22 @@ class Tag < ApplicationRecord
def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true,
with_sections: false
deny_nico: true
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
sections = { }
tags = tag_names.map do |name|
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = name.sub(/\A#{ pf }/i, '')
sections_by_tag = []
while n = name.sub!(/^(\S*?)\[([0-9:.]*?)-([0-9:.]*?)\](\S*?)$/, '\1\4 \2 \3')
name, *section_raw = n.split
begin_ms, end_ms = section_raw.map { time_to_ms(_1) }
next if begin_ms == end_ms
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
sections_by_tag << [begin_ms, end_ms]
end
name = TagName.canonicalise(name).first
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
tag.update!(category: cat) if cat && tag.category != cat
next if sections_by_tag.empty?
sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag)
end
end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq!(&:id)
if with_sections
{ tags:, sections: }
else
tags
end
tags.uniq(&:id)
end
def self.expand_parent_tags tags
@@ -256,23 +228,4 @@ class Tag < ApplicationRecord
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
end
end
def self.time_to_ms str
parts = str.split(':')
s_part = parts.pop
s, ms = s_part.split('.')
total_s = s.to_i
if parts.length >= 1
total_s += parts.pop.to_i * 60
end
if parts.length >= 1
total_s += parts.pop.to_i * 3_600
end
total_s * 1_000 + ms.to_s.ljust(3, '0')[0, 3].to_i
end
end
+4
ファイルの表示
@@ -7,6 +7,10 @@ class Theatre < ApplicationRecord
class_name: 'TheatreWatchingUser', inverse_of: :theatre
has_many :watching_users, through: :active_theatre_watching_users, source: :user
has_many :programmes, class_name: 'TheatreProgramme'
has_many :skip_votes, class_name: 'TheatreSkipVote', dependent: :delete_all
has_many :skip_events, class_name: 'TheatreSkipEvent', dependent: :delete_all
belongs_to :host_user, class_name: 'User', optional: true
belongs_to :current_post, class_name: 'Post', optional: true
belongs_to :created_by_user, class_name: 'User'
+6
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreProgramme < ApplicationRecord
self.primary_key = :theatre_id, :position
belongs_to :theatre
belongs_to :post
end
+10
ファイルの表示
@@ -0,0 +1,10 @@
class TheatreSkipEvent < ApplicationRecord
belongs_to :theatre
belongs_to :post
belongs_to :skipped_by_user, class_name: 'User'
has_many :voters, class_name: 'TheatreSkipEventVoter', dependent: :delete_all
has_many :event_tags, class_name: 'TheatreSkipEventTag', dependent: :delete_all
has_many :users, through: :voters
has_many :tags, through: :event_tags
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreSkipEventTag < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :tag_id
belongs_to :theatre_skip_event
belongs_to :tag
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreSkipEventVoter < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :user_id
belongs_to :theatre_skip_event
belongs_to :user
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class TheatreSkipVote < ApplicationRecord
self.primary_key = :theatre_id, :post_id, :user_id
belongs_to :theatre
belongs_to :post
belongs_to :user
end
+51 -6
ファイルの表示
@@ -2,20 +2,65 @@
module PostRepr
BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE },
methods: [:parent_posts, :child_posts, :sibling_posts] }.freeze
BASE_FIELDS = [
:id,
:version_no,
:url,
:title,
:thumbnail_base,
:original_created_from,
:original_created_before,
:created_at,
:updated_at
].freeze
module_function
def base post, current_user = nil
json = post.as_json(BASE)
return json.merge(viewed: false) unless current_user
json = common(post)
json['tags'] = tag_json(post.tags)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
json
end
viewed = current_user.viewed?(post)
json.merge(viewed:)
def detail post, current_user = nil, parent_posts: [], child_posts: [],
sibling_posts: { }, related: []
base(post, current_user).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts)
},
'related' => cards(related))
end
def card post
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
def cards posts
posts.map { |post| card(post) }
end
def many posts, current_user = nil
posts.map { |p| base(p, current_user) }
end
def common post
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
.merge('thumbnail' => thumbnail_url(post))
end
def tag_json tags
tags.map { |tag| TagRepr.inline(tag) }
end
def thumbnail_url post
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
rescue
nil
end
end
+4
ファイルの表示
@@ -12,5 +12,9 @@ module TagRepr
parents: tag.parents.map { _1.as_json(BASE) })
end
def inline tag
tag.as_json(BASE).merge(aliases: [], parents: [])
end
def many(tags) = tags.map { |t| base(t) }
end
+22
ファイルの表示
@@ -0,0 +1,22 @@
module Gekanator
class AiRunBudget
MONTHLY_LIMIT_JPY = BigDecimal('450').freeze
MAX_RUN_ESTIMATED_COST_JPY = BigDecimal('5').freeze
def self.remaining_monthly_budget_jpy
MONTHLY_LIMIT_JPY - monthly_cost_jpy
end
def self.monthly_cost_jpy
GekanatorAiRun.this_month.sum(:estimated_cost_jpy)
end
def self.exceeded?
monthly_cost_jpy >= MONTHLY_LIMIT_JPY
end
def self.exceeded_after_next_run?
monthly_cost_jpy + MAX_RUN_ESTIMATED_COST_JPY >= MONTHLY_LIMIT_JPY
end
end
end
+18
ファイルの表示
@@ -0,0 +1,18 @@
module Gekanator
class QuestionSuggestionAiConverter
def self.call(...) = new(...).call
def initialize suggestion:, user:
@suggestion = suggestion
@user = user
end
def call
raise NotImplementedError, 'AI question conversion is not implemented yet.'
end
private
attr_reader :suggestion, :user
end
end
+52
ファイルの表示
@@ -0,0 +1,52 @@
module Gekanator
class QuestionSuggestionPromoter
def self.call(...) = new(...).call
def initialize suggestion:, user:
@suggestion = suggestion
@user = user
end
def call
suggestion.with_lock do
return promoted_question if suggestion.processed?
return suggestion if suggestion.answer == 'unknown'
question = GekanatorQuestion.create!(
text: suggestion.question_text,
kind: 'post_similarity',
source: 'user_suggested',
status: 'accepted',
priority_weight: 1.2,
condition: {
type: 'post-similarity',
postId: suggestion.gekanator_game.correct_post_id,
answer: suggestion.answer,
threshold: 0.65
},
gekanator_question_suggestion: suggestion,
created_by: user)
example =
GekanatorQuestionExample.new(
gekanator_question: question,
post: suggestion.gekanator_game.correct_post,
user: user)
example.record_answer!(
answer: suggestion.answer,
source: 'initial_suggestion',
gekanator_game: suggestion.gekanator_game)
example.save!
suggestion.update!(processed: true)
question
end
end
private
attr_reader :suggestion, :user
def promoted_question
suggestion.gekanator_questions.order(id: :desc).first
end
end
end
+29
ファイルの表示
@@ -0,0 +1,29 @@
class TheatrePostAdvancer
def self.call(theatre:)
new(theatre:).call
end
def initialize(theatre:)
@theatre = theatre
end
def call
previous_post = theatre.current_post
post = TheatrePostSelector.new(theatre:).select
TheatreSkipVote.where(theatre:, post: previous_post).delete_all if previous_post
theatre.update!(current_post: post, current_post_started_at: post ? Time.current : nil)
if post
position = (theatre.programmes.maximum(:position) || 0) + 1
theatre.programmes.create!(position:, post:)
end
post
end
private
attr_reader :theatre
end
+119
ファイルの表示
@@ -0,0 +1,119 @@
class TheatrePostSelector
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true)
ELIGIBLE_POST_URL_CONDITION =
["url LIKE '%nicovideo.jp%'",
"url LIKE '%youtube.com/watch%'",
"url LIKE '%youtu.be/%'"]
.join(' OR ')
def initialize theatre:
@theatre = theatre
end
def select
candidates = weighted_candidates
return nil if candidates.empty?
total = candidates.sum(&:weight)
target = rand * total
candidates.each do |candidate|
target -= candidate.weight
return candidate.post if target <= 0
end
candidates.last.post
end
def weight_json limit: 20
candidates = weighted_candidates
sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] }
{ tag_penalties: tag_penalty_json,
lightest_posts: post_weight_json(sorted.first(limit)),
heaviest_posts: post_weight_json(sorted.reverse.first(limit)) }
end
private
attr_reader :theatre
def weighted_candidates
@weighted_candidates ||= begin
penalties = tag_penalties
posts = eligible_posts.includes(tags: :tag_name).to_a
posts.map do |post|
post_tags = post.tags.to_a
penalty = post_tags.sum { |tag| penalties[tag.id].to_i }
Candidate.new(
post:,
penalty:,
tags: post_tags,
weight: 1.0 / (1.0 + penalty))
end
end
end
def eligible_posts
posts = Post.where(ELIGIBLE_POST_URL_CONDITION)
posts = posts.where.not(id: theatre.current_post_id) if theatre.current_post_id
posts
end
def active_user_ids
@active_user_ids ||= theatre.watching_users.ids
end
def tag_penalties
@tag_penalties ||=
if active_user_ids.empty?
{}
else
TheatreSkipEventVoter
.joins(theatre_skip_event: :event_tags)
.where(user_id: active_user_ids)
.group('theatre_skip_event_tags.tag_id')
.count
end
end
def tag_penalty_json
return [] if tag_penalties.empty?
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
tag_penalties
.map { |tag_id, penalty|
tag = tags[tag_id]
next unless tag
{ tag: light_tag_json(tag),
penalty: }
}
.compact
.sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] }
end
def post_weight_json candidates
candidates.map { |candidate|
{ post: light_post_json(candidate.post),
weight: candidate.weight,
penalty: candidate.penalty,
tags: candidate.tags.map { |tag| light_tag_json(tag) } }
}
end
def light_post_json post
{ id: post.id,
title: post.title,
url: post.url }
end
def light_tag_json tag
{ id: tag.id,
name: tag.name,
category: tag.category }
end
end
+40
ファイルの表示
@@ -0,0 +1,40 @@
class TheatreSkipFinalizer
def self.call(theatre:, user:)
new(theatre:, user:).call
end
def initialize(theatre:, user:)
@theatre = theatre
@user = user
end
def call
return unless theatre.current_post
post = theatre.current_post
voters = TheatreSkipVote.where(theatre:, post:).includes(:user).map(&:user)
return if voters.empty?
event = TheatreSkipEvent.create!(
theatre:,
post:,
skipped_by_user: user,
programme_position: theatre.programmes.maximum(:position))
voters.uniq(&:id).each do |voter|
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: voter)
end
post.tags.find_each do |tag|
TheatreSkipEventTag.create!(theatre_skip_event: event, tag:)
end
TheatreSkipVote.where(theatre:, post:).delete_all
event
end
private
attr_reader :theatre, :user
end
+24 -1
ファイルの表示
@@ -63,6 +63,24 @@ Rails.application.routes.draw do
end
end
namespace :gekanator do
resources :games, only: [:create], controller: '/gekanator_games' do
member do
get :extra_questions
post :extra_question_answers
end
end
resources :posts, only: [:index], controller: '/gekanator_posts'
resources :questions, only: [:index], controller: '/gekanator_questions'
resources :question_suggestions,
only: [:create],
controller: '/gekanator_question_suggestions' do
member do
post :ai_convert
end
end
end
resources :users, only: [:create, :update] do
collection do
post :verify
@@ -85,9 +103,14 @@ Rails.application.routes.draw do
member do
put :watching
patch :next_post
put :skip_vote
delete :skip_vote, action: :unskip_vote
get :post_selection_weights
end
resources :comments, controller: :theatre_comments, only: [:index, :create]
resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy]
resources :programmes, controller: :theatre_programmes, only: [:index]
resources :skip_events, controller: :theatre_skip_events, only: [:index]
end
resources :materials, only: [:index, :show, :create, :update, :destroy]
+10
ファイルの表示
@@ -0,0 +1,10 @@
class CreateTheatreProgrammes < ActiveRecord::Migration[8.0]
def change
create_table :theatre_programmes, primary_key: [:theatre_id, :position] do |t|
t.references :theatre, null: false, foreign_key: true
t.integer :position, null: false
t.references :post, null: false, foreign_key: true
t.datetime :created_at, null: false
end
end
end
-19
ファイルの表示
@@ -1,19 +0,0 @@
class CreatePostTagSections < ActiveRecord::Migration[8.0]
def change
create_table :post_tag_sections, primary_key: [:post_id, :tag_id, :begin_ms] do |t|
t.references :post, null: false, foreign_key: true, index: false
t.references :tag, null: false, foreign_key: true, index: false
t.integer :begin_ms, null: false
t.integer :end_ms, null: false
t.timestamps
t.index [:post_id, :begin_ms], name: 'idx_post_tag_sections_post_id_begin_ms'
t.check_constraint 'begin_ms >= 0',
name: 'chk_post_tag_sections_begin_ms_natural'
t.check_constraint 'begin_ms < end_ms',
name: 'chk_post_tag_sections_end_ms_after_begin_ms'
end
end
end
-9
ファイルの表示
@@ -1,9 +0,0 @@
class AddVideoMsToPosts < ActiveRecord::Migration[8.0]
def change
add_column :posts, :video_ms, :integer
add_index :posts, [:video_ms, :id], name: 'idx_posts_video_ms_id'
add_check_constraint :posts, 'video_ms IS NULL OR video_ms > 0',
name: 'chk_posts_video_ms_positive'
end
end
+36
ファイルの表示
@@ -0,0 +1,36 @@
class CreateTheatreSkipVotesAndEvents < ActiveRecord::Migration[8.0]
def change
create_table :theatre_skip_votes, primary_key: [:theatre_id, :post_id, :user_id] do |t|
t.references :theatre, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.timestamps
end
create_table :theatre_skip_events do |t|
t.references :theatre, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :skipped_by_user, null: false, foreign_key: { to_table: :users }
t.integer :programme_position
t.datetime :created_at, null: false
end
create_table :theatre_skip_event_voters, primary_key: [:theatre_skip_event_id, :user_id] do |t|
t.references :theatre_skip_event, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
end
create_table :theatre_skip_event_tags, primary_key: [:theatre_skip_event_id, :tag_id] do |t|
t.references :theatre_skip_event, null: false, foreign_key: true
t.references :tag, null: false, foreign_key: true
end
add_index :theatre_skip_events, [:theatre_id, :created_at]
add_index :theatre_skip_votes, [:theatre_id, :post_id, :created_at],
name: 'idx_theatre_skip_votes_theatre_post_created'
add_index :theatre_skip_event_voters, [:user_id, :theatre_skip_event_id],
name: 'idx_theatre_skip_event_voters_user_event'
add_index :theatre_skip_event_tags, [:tag_id, :theatre_skip_event_id],
name: 'idx_theatre_skip_event_tags_tag_event'
end
end
+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
+14
ファイルの表示
@@ -0,0 +1,14 @@
class CreateGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
def change
create_table :gekanator_question_suggestions do |t|
t.references :gekanator_game,
null: false,
foreign_key: { on_delete: :cascade }
t.references :user, null: false, foreign_key: true
t.text :question_text, null: false
t.boolean :processed, null: false, default: false
t.timestamps
end
end
end
@@ -0,0 +1,5 @@
class AddAnswerToGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
def change
add_column :gekanator_question_suggestions, :answer, :string, null: false
end
end
+19
ファイルの表示
@@ -0,0 +1,19 @@
class CreateGekanatorQuestions < ActiveRecord::Migration[8.0]
def change
create_table :gekanator_questions do |t|
t.string :text, null: false
t.string :kind, null: false
t.json :condition, null: false
t.string :source, null: false, default: 'ai_generated'
t.string :status, null: false, default: 'pending'
t.float :priority_weight, null: false, default: 1.0
t.references :gekanator_question_suggestion,
null: true,
foreign_key: true
t.references :created_by,
null: true,
foreign_key: { to_table: :users }
t.timestamps
end
end
end
+13
ファイルの表示
@@ -0,0 +1,13 @@
class CreateGekanatorAiRuns < ActiveRecord::Migration[8.0]
def change
create_table :gekanator_ai_runs do |t|
t.string :model, null: false
t.integer :input_tokens, null: false, default: 0
t.integer :output_tokens, null: false, default: 0
t.decimal :estimated_cost_jpy, precision: 8, scale: 3, null: false, default: 0
t.string :status, null: false, default: 'pending'
t.references :gekanator_question_suggestion, null: false, foreign_key: true
t.timestamps
end
end
end
+19
ファイルの表示
@@ -0,0 +1,19 @@
class CreateGekanatorQuestionExamples < ActiveRecord::Migration[8.0]
def change
create_table :gekanator_question_examples do |t|
t.references :gekanator_question, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.references :gekanator_game, null: true, foreign_key: true
t.string :answer, null: false
t.string :source, null: false, default: 'post_game_extra'
t.float :weight, null: false, default: 1.0
t.timestamps
end
add_index :gekanator_question_examples,
[:gekanator_question_id, :post_id, :user_id],
unique: true,
name: 'idx_gekanator_question_examples_on_question_post_user'
end
end
@@ -0,0 +1,40 @@
class AddAnswerStatisticsToGekanatorQuestionExamples < ActiveRecord::Migration[8.0]
class MigrationGekanatorQuestionExample < ApplicationRecord
self.table_name = 'gekanator_question_examples'
end
def up
add_column :gekanator_question_examples,
:answer_counts,
:json,
null: true
add_column :gekanator_question_examples,
:sample_count,
:integer,
null: false,
default: 1
MigrationGekanatorQuestionExample.reset_column_information
MigrationGekanatorQuestionExample.find_each do |example|
counts = {
'yes' => 0,
'no' => 0,
'partial' => 0,
'probably_no' => 0,
'unknown' => 0
}
counts[example.answer] = 1 if counts.key?(example.answer)
example.update_columns(
answer_counts: counts,
sample_count: 1)
end
change_column_null :gekanator_question_examples, :answer_counts, false
end
def down
remove_column :gekanator_question_examples, :sample_count
remove_column :gekanator_question_examples, :answer_counts
end
end
生成ファイル
+147 -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_05_19_013100) do
ActiveRecord::Schema[8.0].define(version: 2026_06_12_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
@@ -48,6 +48,79 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end
create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "model", null: false
t.integer "input_tokens", default: 0, null: false
t.integer "output_tokens", default: 0, null: false
t.decimal "estimated_cost_jpy", precision: 8, scale: 3, default: "0.0", null: false
t.string "status", default: "pending", null: false
t.bigint "gekanator_question_suggestion_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_ai_runs_on_gekanator_question_suggestion_id"
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 "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "gekanator_question_id", null: false
t.bigint "post_id", null: false
t.bigint "user_id", null: false
t.bigint "gekanator_game_id"
t.string "answer", null: false
t.string "source", default: "post_game_extra", null: false
t.float "weight", default: 1.0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.json "answer_counts", null: false
t.integer "sample_count", default: 1, null: false
t.index ["gekanator_game_id"], name: "index_gekanator_question_examples_on_gekanator_game_id"
t.index ["gekanator_question_id", "post_id", "user_id"], name: "idx_gekanator_question_examples_on_question_post_user", unique: true
t.index ["gekanator_question_id"], name: "index_gekanator_question_examples_on_gekanator_question_id"
t.index ["post_id"], name: "index_gekanator_question_examples_on_post_id"
t.index ["user_id"], name: "index_gekanator_question_examples_on_user_id"
end
create_table "gekanator_question_suggestions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "gekanator_game_id", null: false
t.bigint "user_id", null: false
t.text "question_text", null: false
t.boolean "processed", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "answer", null: false
t.index ["gekanator_game_id"], name: "index_gekanator_question_suggestions_on_gekanator_game_id"
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
end
create_table "gekanator_questions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "text", null: false
t.string "kind", null: false
t.json "condition", null: false
t.string "source", default: "ai_generated", null: false
t.string "status", default: "pending", null: false
t.float "priority_weight", default: 1.0, null: false
t.bigint "gekanator_question_suggestion_id"
t.bigint "created_by_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_id"], name: "index_gekanator_questions_on_created_by_id"
t.index ["gekanator_question_suggestion_id"], name: "index_gekanator_questions_on_gekanator_question_suggestion_id"
end
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false
t.datetime "banned_at"
@@ -137,19 +210,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) 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
@@ -200,11 +260,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) 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
@@ -299,6 +356,55 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) do
t.index ["user_id"], name: "index_theatre_comments_on_user_id"
end
create_table "theatre_programmes", primary_key: ["theatre_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.integer "position", null: false
t.bigint "post_id", null: false
t.datetime "created_at", null: false
t.index ["post_id"], name: "index_theatre_programmes_on_post_id"
t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id"
end
create_table "theatre_skip_event_tags", primary_key: ["theatre_skip_event_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_skip_event_id", null: false
t.bigint "tag_id", null: false
t.index ["tag_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_tags_tag_event"
t.index ["tag_id"], name: "index_theatre_skip_event_tags_on_tag_id"
t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_tags_on_theatre_skip_event_id"
end
create_table "theatre_skip_event_voters", primary_key: ["theatre_skip_event_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_skip_event_id", null: false
t.bigint "user_id", null: false
t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_voters_on_theatre_skip_event_id"
t.index ["user_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_voters_user_event"
t.index ["user_id"], name: "index_theatre_skip_event_voters_on_user_id"
end
create_table "theatre_skip_events", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "post_id", null: false
t.bigint "skipped_by_user_id", null: false
t.integer "programme_position"
t.datetime "created_at", null: false
t.index ["post_id"], name: "index_theatre_skip_events_on_post_id"
t.index ["skipped_by_user_id"], name: "index_theatre_skip_events_on_skipped_by_user_id"
t.index ["theatre_id", "created_at"], name: "index_theatre_skip_events_on_theatre_id_and_created_at"
t.index ["theatre_id"], name: "index_theatre_skip_events_on_theatre_id"
end
create_table "theatre_skip_votes", primary_key: ["theatre_id", "post_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "post_id", null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id"], name: "index_theatre_skip_votes_on_post_id"
t.index ["theatre_id", "post_id", "created_at"], name: "idx_theatre_skip_votes_theatre_post_created"
t.index ["theatre_id"], name: "index_theatre_skip_votes_on_theatre_id"
t.index ["user_id"], name: "index_theatre_skip_votes_on_user_id"
end
create_table "theatre_watching_users", primary_key: ["theatre_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "user_id", null: false
@@ -306,7 +412,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) 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"
@@ -446,6 +551,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) do
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 "gekanator_ai_runs", "gekanator_question_suggestions"
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 "gekanator_question_examples", "gekanator_games"
add_foreign_key "gekanator_question_examples", "gekanator_questions"
add_foreign_key "gekanator_question_examples", "posts"
add_foreign_key "gekanator_question_examples", "users"
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
add_foreign_key "gekanator_questions", "users", column: "created_by_id"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
@@ -463,8 +580,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) 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"
@@ -483,6 +598,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) do
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users"
add_foreign_key "theatre_programmes", "posts"
add_foreign_key "theatre_programmes", "theatres"
add_foreign_key "theatre_skip_event_tags", "tags"
add_foreign_key "theatre_skip_event_tags", "theatre_skip_events"
add_foreign_key "theatre_skip_event_voters", "theatre_skip_events"
add_foreign_key "theatre_skip_event_voters", "users"
add_foreign_key "theatre_skip_events", "posts"
add_foreign_key "theatre_skip_events", "theatres"
add_foreign_key "theatre_skip_events", "users", column: "skipped_by_user_id"
add_foreign_key "theatre_skip_votes", "posts"
add_foreign_key "theatre_skip_votes", "theatres"
add_foreign_key "theatre_skip_votes", "users"
add_foreign_key "theatre_watching_users", "theatres"
add_foreign_key "theatre_watching_users", "users"
add_foreign_key "theatres", "posts", column: "current_post_id"
-8
ファイルの表示
@@ -1,8 +0,0 @@
FactoryBot.define do
factory :post_tag_section do
association :post
association :tag
begin_ms { 1_000 }
end_ms { 2_000 }
end
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :post_tag do
association :post
association :tag
end
end
-8
ファイルの表示
@@ -1,8 +0,0 @@
FactoryBot.define do
factory :post do
sequence(:url) { |n| "https://example.com/factory-post-#{ n }" }
title { 'factory post' }
thumbnail_base { nil }
uploaded_user { nil }
end
end
-29
ファイルの表示
@@ -1,29 +0,0 @@
RSpec.describe PostTag, type: :model do
describe '#sections' do
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to contain_exactly(section)
end
it 'does not load sections for another tag on the same post' do
post = create(:post)
tag = create(:tag)
other_tag = create(:tag)
post_tag = create(:post_tag, post:, tag:)
create(:post_tag_section,
post:,
tag: other_tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to be_empty
end
end
end
+47
ファイルの表示
@@ -0,0 +1,47 @@
require 'rails_helper'
RSpec.describe 'error responses', type: :request do
describe 'manual input errors' do
it 'returns a stable payload for bad requests' do
get '/tags/name/%20/deerjikists'
expect(response).to have_http_status(:bad_request)
expect(json).to include(
'type' => 'bad_request',
'message' => be_present,
'errors' => {},
'base_errors' => [be_present])
end
it 'returns a stable field-error payload for unprocessable requests' do
member = create(:user, :member)
tag = create(:tag, :general, name: 'error_response_tag')
sign_in_as(member)
patch "/tags/#{ tag.id }", params: { category: 'nico' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'category' => ['ニコタグは変更できません.'])
end
end
describe 'model validation errors' do
it 'returns field messages for model errors' do
user = create(:user)
sign_in_as(user)
put "/users/#{ user.id }", params: { name: 'a' * 256 }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.')
expect(json.fetch('errors').fetch('name')).to include(be_present)
end
end
end
+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
+612
ファイルの表示
@@ -0,0 +1,612 @@
require 'rails_helper'
RSpec.describe 'Gekanator learning API', type: :request do
let(:admin) { create(:user, :admin) }
let(:member) { create(:user, :member) }
let(:other_user) { create(:user, :member) }
let!(:guessed_post) do
Post.create!(
title: 'guessed',
url: 'https://example.com/guessed'
)
end
let!(:correct_post) do
Post.create!(
title: 'correct',
url: 'https://example.com/correct'
)
end
let!(:other_post) do
Post.create!(
title: 'other',
url: 'https://example.com/other'
)
end
let!(:game) do
GekanatorGame.create!(
user: admin,
guessed_post: guessed_post,
correct_post: correct_post,
won: false,
question_count: 1,
answers: [
{
'question_id' => 'tag:character:喜多郁代',
'question_text' => '喜多ちゃんが関係してる?',
'answer' => 'yes',
'original_answer' => 'yes'
}
]
)
end
def create_post_similarity_question!(
text: '喜多ちゃんが泣いてる?',
post: correct_post,
answer: 'yes',
status: 'accepted',
source: 'user_suggested',
priority_weight: 1.2
)
GekanatorQuestion.create!(
text: text,
kind: 'post_similarity',
source: source,
status: status,
priority_weight: priority_weight,
condition: {
type: 'post-similarity',
postId: post.id,
answer: answer,
threshold: 0.65
},
created_by: admin
)
end
describe 'POST /gekanator/games' do
it 'stores a game result for an admin user' do
sign_in_as admin
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{
question_id: 'tag:character:喜多郁代',
question_text: '喜多ちゃんが関係してる?',
answer: 'yes',
original_answer: 'yes'
}
]
}
expect(response).to have_http_status(:created)
created = GekanatorGame.find(json['id'])
expect(created.user).to eq(admin)
expect(created.guessed_post).to eq(guessed_post)
expect(created.correct_post).to eq(correct_post)
expect(created.won).to eq(false)
expect(created.question_count).to eq(1)
expect(created.answers).to eq([
{
'question_id' => 'tag:character:喜多郁代',
'question_text' => '喜多ちゃんが関係してる?',
'answer' => 'yes',
'original_answer' => 'yes'
}
])
end
it 'stores a won game when guessed_post_id equals correct_post_id' do
sign_in_as admin
post '/gekanator/games', params: {
guessed_post_id: correct_post.id,
correct_post_id: correct_post.id,
answers: []
}
expect(response).to have_http_status(:created)
expect(GekanatorGame.find(json['id']).won).to eq(true)
end
it 'rejects a game without correct_post_id' do
sign_in_as admin
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
answers: []
}
}.not_to change { GekanatorGame.count }
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns not found for a non-admin user' do
sign_in_as member
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: []
}
expect(response).to have_http_status(:not_found)
end
end
describe 'POST /gekanator/question_suggestions' do
it 'creates a suggestion and promotes yes answer to an accepted post_similarity question' do
sign_in_as admin
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id,
question_text: '喜多ちゃんが泣いてる?',
answer: 'yes'
}
}.to change { GekanatorQuestionSuggestion.count }.by(1)
.and change { GekanatorQuestion.count }.by(1)
.and change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
suggestion = GekanatorQuestionSuggestion.last
question = GekanatorQuestion.last
example = GekanatorQuestionExample.last
expect(json).to include(
'id' => suggestion.id,
'count' => 1
)
expect(suggestion).to have_attributes(
gekanator_game_id: game.id,
user_id: admin.id,
question_text: '喜多ちゃんが泣いてる?',
answer: 'yes',
processed: true
)
expect(question).to have_attributes(
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
status: 'accepted',
priority_weight: 1.2,
gekanator_question_suggestion_id: suggestion.id,
created_by_id: admin.id
)
expect(question.condition).to include(
'type' => 'post-similarity',
'postId' => correct_post.id,
'answer' => 'yes',
'threshold' => 0.65
)
expect(example).to have_attributes(
gekanator_question_id: question.id,
post_id: correct_post.id,
user_id: admin.id,
gekanator_game_id: game.id,
answer: 'yes',
source: 'initial_suggestion',
weight: 1.0
)
end
it 'promotes no, partial, and probably_no answers' do
sign_in_as admin
['no', 'partial', 'probably_no'].each do |answer|
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id,
question_text: "answer #{answer} question?",
answer: answer
}
}.to change { GekanatorQuestion.count }.by(1)
.and change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
expect(GekanatorQuestion.last.condition['answer']).to eq(answer)
expect(GekanatorQuestionExample.last.answer).to eq(answer)
end
end
it 'does not promote unknown answers' do
sign_in_as admin
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id,
question_text: 'よく分からない質問?',
answer: 'unknown'
}
}.to change { GekanatorQuestionSuggestion.count }.by(1)
.and change { GekanatorQuestion.count }.by(0)
.and change { GekanatorQuestionExample.count }.by(0)
expect(response).to have_http_status(:created)
expect(GekanatorQuestionSuggestion.last.processed).to eq(false)
end
it 'limits suggestions to three per game' do
sign_in_as admin
3.times do |i|
GekanatorQuestionSuggestion.create!(
gekanator_game: game,
user: admin,
question_text: "existing question #{i}",
answer: 'unknown'
)
end
expect {
post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id,
question_text: 'fourth question?',
answer: 'yes'
}
}.not_to change { GekanatorQuestionSuggestion.count }
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns not found for a non-admin user' do
sign_in_as member
post '/gekanator/question_suggestions', params: {
gekanator_game_id: game.id,
question_text: 'member question?',
answer: 'yes'
}
expect(response).to have_http_status(:not_found)
end
end
describe 'GET /gekanator/games/:id/extra_questions' do
it 'returns at most two accepted user_suggested post_similarity questions without duplicates' do
sign_in_as admin
low = create_post_similarity_question!(
text: 'low?',
priority_weight: 1.0
)
high = create_post_similarity_question!(
text: 'high?',
priority_weight: 3.0
)
middle = create_post_similarity_question!(
text: 'middle?',
priority_weight: 2.0
)
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].length).to eq(2)
expect(json['questions'].map { _1['id'] }.uniq.length).to eq(2)
expect(json['questions'].map { _1['id'] }).to all(be_in([low.id, high.id, middle.id]))
end
it 'can return questions that already have an example for the correct post' do
sign_in_as admin
existing = create_post_similarity_question!(text: 'already learned?')
GekanatorQuestionExample.create!(
gekanator_question: existing,
post: correct_post,
user: admin,
gekanator_game: game,
answer: 'yes',
source: 'post_game_extra'
)
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(existing.id)
end
it 'can return questions already asked in the game using snake_case question_id' do
sign_in_as admin
asked = create_post_similarity_question!(text: 'already asked?')
game.update!(
answers: [
{
'question_id' => "post-similarity:#{asked.id}",
'answer' => 'yes'
}
]
)
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(asked.id)
end
it 'can return questions already asked in the game using camelCase questionId' do
sign_in_as admin
asked = create_post_similarity_question!(text: 'already asked?')
game.update!(
answers: [
{
'questionId' => "post-similarity:#{asked.id}",
'answer' => 'yes'
}
]
)
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to include(asked.id)
end
it 'does not return non-accepted, non-user_suggested, or non-post_similarity questions' do
sign_in_as admin
accepted = create_post_similarity_question!(text: 'accepted?')
create_post_similarity_question!(text: 'disabled?', status: 'disabled')
create_post_similarity_question!(text: 'ai?', source: 'ai_generated')
GekanatorQuestion.create!(
text: 'tag?',
kind: 'tag',
source: 'user_suggested',
status: 'accepted',
priority_weight: 1.0,
condition: { type: 'tag', key: 'character:喜多郁代' }
)
get "/gekanator/games/#{game.id}/extra_questions"
expect(response).to have_http_status(:ok)
expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id)
end
end
describe 'POST /gekanator/games/:id/extra_question_answers' do
it 'creates examples for extra question answers' do
sign_in_as admin
question = create_post_similarity_question!(text: 'extra?')
expect {
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id.to_s,
answer: 'partial'
}
]
}
}.to change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
expect(json).to include('count' => 1)
example = GekanatorQuestionExample.last
expect(example).to have_attributes(
gekanator_question_id: question.id,
post_id: correct_post.id,
user_id: admin.id,
gekanator_game_id: game.id,
answer: 'partial',
source: 'post_game_extra',
weight: 1.0
)
end
it 'updates an existing example for the same question, post, and user' do
sign_in_as admin
question = create_post_similarity_question!(text: 'extra?')
existing = GekanatorQuestionExample.create!(
gekanator_question: question,
post: correct_post,
user: admin,
answer: 'no',
source: 'post_game_extra',
weight: 1.0
)
expect {
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:created)
expect(existing.reload).to have_attributes(
answer: 'yes',
source: 'post_game_extra',
gekanator_game_id: game.id
)
end
it 'rejects missing questions' do
sign_in_as admin
expect {
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: 999_999_999,
answer: 'yes'
}
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects non-accepted questions' do
sign_in_as admin
question = create_post_similarity_question!(
text: 'disabled?',
status: 'disabled'
)
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects non-post_similarity questions' do
sign_in_as admin
question = GekanatorQuestion.create!(
text: 'tag?',
kind: 'tag',
source: 'user_suggested',
status: 'accepted',
priority_weight: 1.0,
condition: { type: 'tag', key: 'character:喜多郁代' }
)
post "/gekanator/games/#{game.id}/extra_question_answers", params: {
answers: [
{
question_id: question.id,
answer: 'yes'
}
]
}
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'GET /gekanator/questions' do
it 'returns accepted questions only and includes example_answers for post_similarity questions' do
sign_in_as admin
accepted = create_post_similarity_question!(text: 'accepted?')
create_post_similarity_question!(
text: 'disabled?',
status: 'disabled'
)
GekanatorQuestionExample.create!(
gekanator_question: accepted,
post: correct_post,
user: admin,
answer: 'yes',
source: 'initial_suggestion',
weight: 1.0
)
get '/gekanator/questions'
expect(response).to have_http_status(:ok)
expect(json['questions'].length).to eq(1)
question_json = json['questions'].first
expect(question_json).to include(
'id' => "post-similarity:#{accepted.id}",
'text' => 'accepted?',
'kind' => 'post_similarity',
'source' => 'user_suggested',
'priority_weight' => 1.2
)
expect(question_json['condition']).to include(
'type' => 'post-similarity',
'postId' => correct_post.id,
'answer' => 'yes',
'threshold' => 0.65
)
expect(question_json['example_answers']).to include(
correct_post.id.to_s => 'yes'
)
end
it 'aggregates example_answers by weight' do
sign_in_as admin
question = create_post_similarity_question!(text: 'weighted?')
GekanatorQuestionExample.create!(
gekanator_question: question,
post: other_post,
user: admin,
answer: 'yes',
source: 'post_game_extra',
weight: 1.0
)
GekanatorQuestionExample.create!(
gekanator_question: question,
post: other_post,
user: other_user,
answer: 'no',
source: 'post_game_extra',
weight: 2.0
)
get '/gekanator/questions'
expect(response).to have_http_status(:ok)
question_json = json['questions'].find { _1['id'] == "post-similarity:#{question.id}" }
expect(question_json['example_answers']).to include(
other_post.id.to_s => 'no'
)
end
it 'normalizes legacy title length questions' do
sign_in_as admin
GekanatorQuestion.create!(
text: '題名が長めの投稿?',
kind: 'title',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: {
type: 'title-length-greater-than',
length: 20
},
created_by: admin
)
get '/gekanator/questions'
expect(response).to have_http_status(:ok)
question_json = json['questions'].find { _1['id'] == 'title:length-at-least:21' }
expect(question_json).to include(
'text' => 'タイトルは 21 文字以上?',
'kind' => 'title'
)
expect(question_json['condition']).to include(
'type' => 'title-length-at-least',
'length' => 21
)
end
end
end
+18 -8
ファイルの表示
@@ -141,16 +141,21 @@ RSpec.describe 'Materials API', type: :request do
context 'when logged in' do
before { sign_in_as(guest_user) }
it 'returns 400 when tag is blank' do
it 'returns 422 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload }
expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
end
it 'returns 400 when both file and url are blank' do
it 'returns 422 when both file and url are blank' do
post '/materials', params: { tag: 'material_create_blank' }
expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
end
it 'creates a material with an attached file' do
@@ -261,21 +266,26 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:not_found)
end
it 'returns 400 when tag is blank' do
it 'returns 422 when tag is blank' do
put "/materials/#{ material.id }", params: {
tag: ' ',
file: dummy_upload
}
expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
end
it 'returns 400 when both file and url are blank' do
it 'returns 422 when both file and url are blank' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload'
}
expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
end
it 'updates tag, url, file, and updated_by_user' do
+93 -7
ファイルの表示
@@ -3,12 +3,68 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do
it 'returns tags and next_cursor when overflowing limit' do
create_list(:tag, 21, :nico)
get '/tags/nico', params: { limit: 20 }
it 'returns paginated tags and total count' do
create_list(:tag, 3, :nico)
get '/tags/nico', params: { page: 2, limit: 2 }
expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(20)
expect(json['next_cursor']).to be_present
expect(json['tags'].size).to eq(1)
expect(json['count']).to eq(3)
end
it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:tag, :nico)
linked.tag_name.update!(name: 'nico:search_linked')
unlinked = create(:tag, :nico)
unlinked.tag_name.update!(name: 'nico:search_unlinked')
other = create(:tag, :nico)
other.tag_name.update!(name: 'nico:other')
destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination)
NicoTagRelation.create!(nico_tag: other, tag: create(:tag, :general))
get '/tags/nico', params: {
name: 'search_',
linked_tag: 'destination_',
link_status: 'linked'
}
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([linked.id])
get '/tags/nico', params: { name: 'search_', link_status: 'unlinked' }
expect(json['count']).to eq(1)
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end
it 'sorts by name and timestamps' do
older = create(:tag, :nico)
older.tag_name.update!(name: 'nico:a')
older.update_columns(created_at: 2.days.ago)
newer = create(:tag, :nico)
newer.tag_name.update!(name: 'nico:b')
newer.update_columns(created_at: 1.day.ago)
older_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
newer_post_tag.update_columns(created_at: 2.hours.ago)
get '/tags/nico', params: { order: 'name:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
get '/tags/nico', params: { order: 'created_at:asc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
get '/tags/nico', params: { order: 'updated_at:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
.to be_within(1.second).of(older_post_tag.created_at)
end
end
@@ -75,7 +131,7 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.last.created_by_user_id).to eq(admin.id)
end
it '400 when linked tag normalises to nico tag' do
it 'returns 422 when linked tag normalises to nico tag' do
sign_in_as(member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
@@ -87,7 +143,37 @@ RSpec.describe 'NicoTags', type: :request do
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns the tags field error when a nico tag is specified directly' do
sign_in_as(member)
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'nico:linked_ng' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns tag name validation errors on the tags field and rolls back created tags' do
sign_in_as(member)
TagNameSanitisationRule.create!(
priority: 1,
source_pattern: 'invalid',
replacement: 'valid'
)
nico_tag
expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'created_first invalid' }
}.not_to change(TagName, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors').fetch('tags')).to include(
a_string_including('タグ名 “invalid”:', '名前に使用できない文字が含まれてゐます.'))
end
end
end
+86 -4
ファイルの表示
@@ -57,6 +57,23 @@ RSpec.describe 'Posts API', type: :request do
post_write_params({ base_version_no: base_version.version_no }.merge(params))
end
def count_sql_queries
count = 0
callback = lambda do |_name, _started, _finished, _id, payload|
next if payload[:cached]
next if ['SCHEMA', 'TRANSACTION'].include?(payload[:name])
count += 1
end
ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do
yield
end
count
end
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
@@ -558,6 +575,59 @@ RSpec.describe 'Posts API', type: :request do
expect(sibling_ids).to include(sibling_post.id)
end
end
it 'does not issue a query per tag or related post' do
user = create_member_user!
tags =
15.times.map do |i|
tag_name = TagName.create!(name: "show_query_tag_#{ i }")
tag = Tag.create!(tag_name:, category: :general)
TagName.create!(name: "show_query_alias_#{ i }", canonical: tag_name)
PostTag.create!(post: post_record, tag:)
tag
end
tags.each_cons(2) do |parent_tag, child_tag|
TagImplication.create!(parent_tag:, tag: child_tag)
end
parent_post = Post.create!(
title: 'query parent post',
url: 'https://example.com/query-parent-post'
)
sibling_post = Post.create!(
title: 'query sibling post',
url: 'https://example.com/query-sibling-post'
)
child_post = Post.create!(
title: 'query child post',
url: 'https://example.com/query-child-post'
)
PostImplication.create!(post: post_record, parent_post:)
PostImplication.create!(post: sibling_post, parent_post:)
PostImplication.create!(post: child_post, parent_post: post_record)
20.times do |i|
related_post = Post.create!(
title: "query related post #{ i }",
url: "https://example.com/query-related-post-#{ i }"
)
PostSimilarity.create!(post: post_record,
target_post: related_post,
cos: 1.0 - (i / 100.0))
end
query_count =
count_sql_queries do
get "/posts/#{ post_record.id }",
headers: { 'X-Transfer-Code' => user.inheritance_code }
end
expect(response).to have_http_status(:ok)
expect(query_count).to be <= 45
end
end
context 'when post does not exist' do
@@ -634,7 +704,7 @@ RSpec.describe 'Posts API', type: :request do
category: :nico)
end
it 'return 400' do
it 'returns 422 with tag field errors' do
sign_in_as(member)
post '/posts', params: post_write_params(
@@ -644,7 +714,13 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload
)
expect(response).to have_http_status(:bad_request), response.body
expect(response).to have_http_status(:unprocessable_entity), response.body
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグは直接指定できません.'])
end
end
@@ -861,7 +937,7 @@ RSpec.describe 'Posts API', type: :request do
category: :nico)
end
it 'return 400' do
it 'returns 422 with tag field errors' do
sign_in_as(member)
put "/posts/#{post_record.id}", params: post_update_params(
@@ -869,7 +945,13 @@ RSpec.describe 'Posts API', type: :request do
title: 'updated title',
tags: 'nico:nico_tag')
expect(response).to have_http_status(:bad_request), response.body
expect(response).to have_http_status(:unprocessable_entity), response.body
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグは直接指定できません.'])
end
end
+24
ファイルの表示
@@ -275,6 +275,30 @@ RSpec.describe 'Tags deerjikists API', type: :request do
end
end
context 'when a row is invalid' do
let(:payload) do
[
{ platform: '', code: code1 },
]
end
it 'returns 422 with indexed field errors and does not replace existing deerjikists' do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
expect {
do_request
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'deerjikists.0.platform' => [be_present])
end
end
context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do
+60
ファイルの表示
@@ -80,6 +80,26 @@ RSpec.describe 'TheatreComments', type: :request do
expect(response).to have_http_status(:ok)
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
end
it '削除済みコメントは deleted として返し、本文を隠す' do
comment_2.discard!
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
expect(response).to have_http_status(:ok)
deleted_comment = response.parsed_body.find { _1['no'] == 2 }
expect(deleted_comment).to include(
'deleted' => true,
'content' => nil
)
visible_comment = response.parsed_body.find { _1['no'] == 3 }
expect(visible_comment).to include(
'deleted' => false,
'content' => 'third comment'
)
end
end
describe 'POST /theatres/:theatre_id/comments' do
@@ -147,4 +167,44 @@ RSpec.describe 'TheatreComments', type: :request do
})
end
end
describe 'DELETE /theatres/:theatre_id/comments/:id' do
let(:theatre) { create(:theatre) }
let(:alice) { create(:user, name: 'Alice') }
let(:bob) { create(:user, name: 'Bob') }
let!(:comment) do
create(
:theatre_comment,
theatre: theatre,
no: 1,
user: alice,
content: 'delete target'
)
end
it 'returns 401 when not logged in' do
delete "/theatres/#{theatre.id}/comments/#{comment.no}"
expect(response).to have_http_status(:unauthorized)
expect(comment.reload.discarded?).to eq(false)
end
it 'allows the comment owner to delete it' do
sign_in_as(alice)
delete "/theatres/#{theatre.id}/comments/#{comment.no}"
expect(response).to have_http_status(:no_content)
expect(comment.reload.discarded?).to eq(true)
end
it 'returns 403 when another user tries to delete it' do
sign_in_as(bob)
delete "/theatres/#{theatre.id}/comments/#{comment.no}"
expect(response).to have_http_status(:forbidden)
expect(comment.reload.discarded?).to eq(false)
end
end
end
+38
ファイルの表示
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe 'TheatreProgrammes', type: :request do
describe 'GET /theatres/:theatre_id/programmes' do
let(:theatre) { create(:theatre) }
let(:other_theatre) { create(:theatre) }
let(:post_1) { Post.create!(title: 'first', url: 'https://www.nicovideo.jp/watch/sm1') }
let(:post_2) { Post.create!(title: 'second', url: 'https://www.nicovideo.jp/watch/sm2') }
let(:other_post) { Post.create!(title: 'other', url: 'https://www.nicovideo.jp/watch/sm3') }
before do
TheatreProgramme.create!(theatre:, position: 1, post: post_1, created_at: 2.minutes.ago)
TheatreProgramme.create!(theatre:, position: 2, post: post_2, created_at: 1.minute.ago)
TheatreProgramme.create!(
theatre: other_theatre,
position: 1,
post: other_post,
created_at: 1.minute.ago
)
end
it 'returns programmes for the theatre in descending position with post json' do
get "/theatres/#{theatre.id}/programmes"
expect(response).to have_http_status(:ok)
expect(json.map { _1['position'] }).to eq([2, 1])
expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first'])
expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url)
end
it 'filters programmes by position_gt' do
get "/theatres/#{theatre.id}/programmes", params: { position_gt: 1 }
expect(response).to have_http_status(:ok)
expect(json.map { _1['position'] }).to eq([2])
end
end
end
+226 -8
ファイルの表示
@@ -14,10 +14,24 @@ RSpec.describe 'Theatres API', type: :request do
let(:member) { create(:user, :member, name: 'member user') }
let(:other_user) { create(:user, :member, name: 'other user') }
let!(:niconico_post) do
Post.create!(
title: 'niconico post',
url: 'https://www.nicovideo.jp/watch/sm123'
)
end
let!(:second_niconico_post) do
Post.create!(
title: 'second niconico post',
url: 'https://www.nicovideo.jp/watch/sm456'
)
end
let!(:youtube_post) do
Post.create!(
title: 'youtube post',
url: 'https://www.youtube.com/watch?v=spec123'
url: 'https://www.youtube.com/watch?v=yt123'
)
end
@@ -120,7 +134,8 @@ RSpec.describe 'Theatres API', type: :request do
expect(json).to include(
'host_flg' => true,
'post_id' => nil,
'post_started_at' => nil
'post_started_at' => nil,
'post_elapsed_ms' => nil
)
expect(json.fetch('watching_users')).to contain_exactly(
@@ -177,7 +192,8 @@ RSpec.describe 'Theatres API', type: :request do
expect(json).to include(
'host_flg' => false,
'post_id' => nil,
'post_started_at' => nil
'post_started_at' => nil,
'post_elapsed_ms' => nil
)
expect(json.fetch('watching_users')).to contain_exactly(
@@ -204,7 +220,7 @@ RSpec.describe 'Theatres API', type: :request do
)
theatre.update!(
host_user: other_user,
current_post: youtube_post,
current_post: niconico_post,
current_post_started_at: started_at
)
sign_in_as(member)
@@ -220,9 +236,11 @@ RSpec.describe 'Theatres API', type: :request do
expect(theatre.host_user_id).to eq(member.id)
expect(json['host_flg']).to eq(true)
expect(json['post_id']).to eq(youtube_post.id)
expect(json['post_id']).to eq(niconico_post.id)
expect(Time.zone.parse(json['post_started_at']))
.to be_within(1.second).of(started_at)
expect(json['post_elapsed_ms'])
.to be_within(1_000).of(120_000)
end
end
end
@@ -273,16 +291,36 @@ RSpec.describe 'Theatres API', type: :request do
it 'sets current_post to an eligible post and updates current_post_started_at' do
expect { do_request }
.to change { theatre.reload.current_post_id }
.from(nil).to(youtube_post.id)
expect(response).to have_http_status(:no_content)
expect([niconico_post.id, second_niconico_post.id, youtube_post.id])
.to include(theatre.reload.current_post_id)
expect(theatre.reload.current_post_started_at)
.to be_within(1.second).of(Time.current)
expect(theatre.programmes.count).to eq(1)
end
end
context 'when only a YouTube post is eligible' do
before do
niconico_post.destroy!
second_niconico_post.destroy!
theatre.update!(host_user: member)
sign_in_as(member)
end
it 'sets current_post to the YouTube post' do
do_request
expect(response).to have_http_status(:no_content)
expect(theatre.reload.current_post_id).to eq(youtube_post.id)
end
end
context 'when current user is host and no eligible post exists' do
before do
niconico_post.destroy!
second_niconico_post.destroy!
youtube_post.destroy!
theatre.update!(
host_user: member,
@@ -299,9 +337,189 @@ RSpec.describe 'Theatres API', type: :request do
theatre.reload
expect(theatre.current_post_id).to be_nil
expect(theatre.current_post_started_at)
.to be_within(1.second).of(Time.current)
expect(theatre.current_post_started_at).to be_nil
end
end
end
describe 'PUT /theatres/:id/skip_vote' do
subject(:do_request) do
put "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id }
end
let(:third_user) { create(:user, :member, name: 'third user') }
let(:requested_post_id) { niconico_post.id }
before do
theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago)
[member, other_user, third_user].each do |user|
TheatreWatchingUser.create!(
theatre:,
user:,
expires_at: 10.seconds.from_now
)
end
end
it 'returns 401 when not logged in' do
sign_out
expect { do_request }.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:unauthorized)
end
it 'returns 422 when post_id is invalid' do
sign_in_as(member)
expect {
put "/theatres/#{theatre.id}/skip_vote", params: { post_id: 'invalid' }
}.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'records a vote and returns the current vote status before majority' do
sign_in_as(member)
expect { do_request }.to change(TheatreSkipVote, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['skipped']).to eq(false)
expect(json['post_id']).to eq(niconico_post.id)
expect(json['skip_vote']).to include(
'votes_count' => 1,
'required_count' => 2,
'watching_users_count' => 3,
'voted' => true
)
end
it 'finalizes skip when votes reach majority and stores voters and tag snapshots' do
tag = create(:tag, name: 'skip-target')
PostTag.create!(post: niconico_post, tag:)
TheatreSkipVote.create!(theatre:, post: niconico_post, user: member)
sign_in_as(other_user)
expect { do_request }
.to change(TheatreSkipEvent, :count).by(1)
.and change(TheatreSkipEventVoter, :count).by(2)
.and change(TheatreSkipEventTag, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['skipped']).to eq(true)
expect([second_niconico_post.id, youtube_post.id]).to include(json['post_id'])
event = TheatreSkipEvent.last
expect(event.post).to eq(niconico_post)
expect(event.users).to contain_exactly(member, other_user)
expect(event.tags).to contain_exactly(tag)
expect(TheatreSkipVote.where(theatre:, post: niconico_post)).to be_empty
end
it 'does not record a vote when requested post is no longer current' do
theatre.update!(current_post: second_niconico_post)
sign_in_as(member)
expect { do_request }.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:conflict)
expect(json['post_id']).to eq(second_niconico_post.id)
expect(json['skip_vote']).to include(
'votes_count' => 0,
'voted' => false
)
end
end
describe 'DELETE /theatres/:id/skip_vote' do
let(:requested_post_id) { niconico_post.id }
before do
theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago)
TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now)
TheatreSkipVote.create!(theatre:, post: niconico_post, user: member)
sign_in_as(member)
end
it 'removes the current user vote' do
expect {
delete "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id }
}.to change(TheatreSkipVote, :count).by(-1)
expect(response).to have_http_status(:ok)
expect(json['skip_vote']).to include(
'votes_count' => 0,
'required_count' => 1,
'watching_users_count' => 1,
'voted' => false
)
end
it 'does not remove a vote when requested post is no longer current' do
theatre.update!(current_post: second_niconico_post)
expect {
delete "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id }
}.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:conflict)
expect(json['post_id']).to eq(second_niconico_post.id)
end
end
describe 'GET /theatres/:id/skip_events' do
before do
sign_in_as(member)
end
it 'does not expose skip voters' do
event = TheatreSkipEvent.create!(
theatre:,
post: niconico_post,
skipped_by_user: member,
created_at: Time.current
)
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member)
get "/theatres/#{theatre.id}/skip_events"
expect(response).to have_http_status(:ok)
expect(json.first).to include(
'id' => event.id,
'theatre_id' => theatre.id
)
expect(json.first).not_to have_key('voters')
expect(json.first).not_to have_key('skipped_by_user')
end
end
describe 'GET /theatres/:id/post_selection_weights' do
before do
theatre.update!(current_post: niconico_post)
TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now)
sign_in_as(member)
end
it 'returns tag penalties and candidate weights for the current watchers' do
tag = create(:tag, name: 'heavy-tag')
PostTag.create!(post: second_niconico_post, tag:)
event = TheatreSkipEvent.create!(
theatre:,
post: niconico_post,
skipped_by_user: member,
created_at: Time.current
)
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member)
TheatreSkipEventTag.create!(theatre_skip_event: event, tag:)
get "/theatres/#{theatre.id}/post_selection_weights"
expect(response).to have_http_status(:ok)
expect(json['tag_penalties'].first['penalty']).to eq(1)
expect(json['lightest_posts'].first['post']['id']).to eq(second_niconico_post.id)
expect(json['lightest_posts'].first['penalty']).to eq(1)
end
end
end
+4 -2
ファイルの表示
@@ -90,12 +90,14 @@ RSpec.describe 'Users', type: :request do
expect(response).to have_http_status(:unauthorized)
end
it 'returns 400 when name is blank' do
it 'returns 422 when name is blank' do
put "/users/#{user.id}",
params: { name: ' ' },
headers: auth_headers(user)
expect(response).to have_http_status(:bad_request)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'name' => ['名前は必須です.'])
end
it 'updates name and returns user slice' do
+72 -16
ファイルの表示
@@ -4,7 +4,8 @@
These rules apply to work under `frontend/`.
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS, Framer Motion, Radix UI-style components, MDX, and Zustand.
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS,
Framer Motion, Radix UI-style components, MDX, and Zustand.
## Commands
@@ -17,9 +18,11 @@ npm run lint
npm run preview
```
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs `node scripts/generate-sitemap.js`.
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs
`node scripts/generate-sitemap.js`.
There is currently no `test` script in `package.json`. Do not run or report `npm test` unless a test script is added.
There is currently no `test` script in `package.json`. Do not run or report
`npm test` unless a test script is added.
After frontend changes, run:
@@ -30,20 +33,43 @@ 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`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
`noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`,
`noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
- Keep types explicit at module boundaries, API helpers, and exported utilities.
- Use `import type` for type-only imports.
- Prefer existing shared types from `src/types.ts` before adding local duplicate types.
- Preserve the repository's existing spacing style in TypeScript, including GNU-style spacing before call parentheses where it is already used.
- Preserve the repository's existing spacing style in TypeScript, including
GNU-style spacing before call parentheses where it is already used.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
- Never write a TypeScript or TSX line longer than 99 characters.
- Aim to keep TypeScript and TSX lines within 79 characters where practical.
- Use 4-space logical indentation in TypeScript and TSX.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab
to reduce bytes.
- Treat one leading tab as exactly equivalent to 8 leading spaces.
- Use tabs only for leading indentation. Never replace spaces that occur after
a non-space character on the same line.
## React
- Use function components.
- Existing page components commonly export an anonymous function satisfying `FC`; match nearby file style when editing.
- Existing page components commonly export an anonymous function satisfying
`FC`; match nearby file style when editing.
- React hooks must be called unconditionally and at the top level of components or custom hooks.
- Gate editing and other privileged controls with shared permission helpers
such as `canEditContent`, instead of showing controls and relying only on a
later API failure.
- Keep page-level components under `src/pages`.
- Keep shared and feature components under `src/components`.
- Use `react-router-dom` route params and navigation patterns already present in `src/App.tsx`.
@@ -52,17 +78,23 @@ If either command cannot be run or fails, report the exact command and failure.
## TanStack Query
- Use `@tanstack/react-query` for server state.
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there instead of using ad hoc arrays in components.
- Fetch functions should live in domain helpers under `src/lib`, such as `posts.ts`, `tags.ts`, or `wiki.ts`.
- Use `useQueryClient().invalidateQueries` with the shared root keys when mutations affect cached lists or detail views.
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create additional clients in feature code.
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there
instead of using ad hoc arrays in components.
- Fetch functions should live in domain helpers under `src/lib`, such as
`posts.ts`, `tags.ts`, or `wiki.ts`.
- Use `useQueryClient().invalidateQueries` with the shared root keys when
mutations affect cached lists or detail views.
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create
additional clients in feature code.
## API calls
- Use `src/lib/api.ts` for HTTP calls.
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts non-blob responses to camelCase.
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts
non-blob responses to camelCase.
- Send Rails snake_case params and request body keys where the backend expects them.
- Do not bypass the API wrapper unless there is a specific reason, such as a third-party request outside the Rails API.
- Do not bypass the API wrapper unless there is a specific reason, such as a
third-party request outside the Rails API.
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
## Imports and aliases
@@ -76,17 +108,41 @@ If either command cannot be run or fails, report the exact command and failure.
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- Reuse components from `src/components/common`, `src/components/layout`, and `src/components/ui` before adding new primitives.
- Reuse components from `src/components/common`, `src/components/layout`, and
`src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist if the class cannot be statically detected.
- Prefer restrained, content-first UI chrome: avoid adding card backgrounds,
heavy borders, or nested panel decoration unless the surrounding screen
already uses them.
- Keep operational screens dense and direct; trim explanatory copy and use
short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist
if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
## TSX formatting
- Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them.
- For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
- Avoid reformatting unrelated JSX.
## Lint and build constraints
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, and `eslint-plugin-react-refresh`.
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`,
and `eslint-plugin-react-refresh`.
- The hooks rules are enforced; fix hook ordering instead of disabling the rule.
- `react-refresh/only-export-components` is enabled as a warning with `allowConstantExport`.
- Build failures from unused locals or unused parameters are TypeScript errors, not lint-only issues.
- Build failures from unused locals or unused parameters are TypeScript
errors, not lint-only issues.
## Files to avoid in routine work
+19 -3
ファイルの表示
@@ -18,6 +18,7 @@ import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
@@ -39,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'
@@ -62,8 +63,9 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
@@ -79,6 +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={
<AdminOnly user={user}>
<GekanatorPage/>
</AdminOnly>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
@@ -86,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
@@ -158,4 +174,4 @@ const App: FC = () => {
</>)
}
export default App
export default App
+83
ファイルの表示
@@ -0,0 +1,83 @@
import { act, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createRef } from 'react'
import NicoViewer from '@/components/NicoViewer'
import type { NiconicoViewerHandle } from '@/types'
describe ('NicoViewer', () => {
afterEach (() => {
vi.useRealTimers ()
})
it ('does not time out after metadata reports a playable duration', () => {
vi.useFakeTimers ()
const onError = vi.fn ()
const onMetadataChange = vi.fn ()
const { container } = render (
<NicoViewer
id="sm12345"
width={640}
height={360}
onMetadataChange={onMetadataChange}
onError={onError}/>,
)
const iframe = container.querySelector ('iframe')
expect (iframe).not.toBeNull ()
fireEvent.load (iframe!)
act (() => {
window.dispatchEvent (new MessageEvent ('message', {
origin: 'https://embed.nicovideo.jp',
source: iframe!.contentWindow,
data: {
eventName: 'playerMetadataChange',
data: {
currentTime: 7,
duration: 120,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
},
},
}))
})
act (() => {
vi.advanceTimersByTime (8_000)
})
expect (onMetadataChange).toHaveBeenCalled ()
expect (onError).not.toHaveBeenCalled ()
})
it ('seeks with milliseconds', () => {
const ref = createRef<NiconicoViewerHandle> ()
const { container } = render (
<NicoViewer
ref={ref}
id="sm12345"
width={640}
height={360}/>,
)
const iframe = container.querySelector ('iframe')!
const postMessage = vi.spyOn (iframe.contentWindow!, 'postMessage')
act (() => {
ref.current!.seek (7_000)
})
expect (postMessage).toHaveBeenCalledWith (
expect.objectContaining ({
eventName: 'seek',
data: { time: 7_000 },
}),
'https://embed.nicovideo.jp',
)
})
})
+86 -40
ファイルの表示
@@ -1,11 +1,11 @@
import { forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState } from 'react'
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState } from 'react'
import type { CSSProperties, ForwardedRef } from 'react'
@@ -14,10 +14,20 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from '
type NiconicoPlayerMessage =
| { eventName: 'enterProgrammaticFullScreen' }
| { eventName: 'exitProgrammaticFullScreen' }
| { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } }
| { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata }
| { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown }
| { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string }
| { eventName: 'loadComplete'
playerId?: string
data: { videoInfo: NiconicoVideoInfo } }
| { eventName: 'playerMetadataChange'
playerId?: string
data: NiconicoMetadata }
| { eventName: 'playerStatusChange' | 'statusChange'
playerId?: string
data?: unknown }
| { eventName: 'error'
playerId?: string
data?: unknown
code?: string
message?: string }
type NiconicoCommand =
| { eventName: 'play'; sourceConnectorType: 1; playerId: string }
@@ -30,6 +40,7 @@ type NiconicoCommand =
data: { commentVisibility: boolean } }
const EMBED_ORIGIN = 'https://embed.nicovideo.jp'
const LOAD_COMPLETE_TIMEOUT_MS = 8_000
type Props = {
id: string
@@ -37,14 +48,18 @@ type Props = {
height: number
style?: CSSProperties
onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void }
onMetadataChange?: (meta: NiconicoMetadata) => void
onError?: (data: unknown) => void }
export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props
const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props
const iframeRef = useRef<HTMLIFrameElement> (null)
const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id])
const loadCompleteTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const playerId = useMemo (
() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`,
[id])
const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> ()
const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
@@ -64,21 +79,39 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const styleFullScreen: CSSProperties =
fullScreen
? { top: 0,
left: landscape ? 0 : '100%',
position: 'fixed',
width: screenWidth,
height: screenHeight,
zIndex: 2_147_483_647,
maxWidth: 'none',
transformOrigin: '0% 0%',
transform: landscape ? 'none' : 'rotate(90deg)',
WebkitTransformOrigin: '0% 0%',
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
left: landscape ? 0 : '100%',
position: 'fixed',
width: screenWidth,
height: screenHeight,
zIndex: 2_147_483_647,
maxWidth: 'none',
transformOrigin: '0% 0%',
transform: landscape ? 'none' : 'rotate(90deg)',
WebkitTransformOrigin: '0% 0%',
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
: { }
const margedStyle: CSSProperties =
{ border: 'none', maxWidth: '100%', ...style, ...styleFullScreen }
const clearLoadCompleteTimer = useCallback (() => {
if (!(loadCompleteTimerRef.current))
return
clearTimeout (loadCompleteTimerRef.current)
loadCompleteTimerRef.current = null
}, [])
const startLoadCompleteTimer = useCallback (() => {
clearLoadCompleteTimer ()
loadCompleteTimerRef.current = setTimeout (() => {
onError?.({
eventName: 'loadCompleteTimeout',
reason: 'niconico video length was not reported by embed',
})
}, LOAD_COMPLETE_TIMEOUT_MS)
}, [clearLoadCompleteTimer, onError])
const postToPlayer = useCallback ((message: NiconicoCommand) => {
const win = iframeRef.current?.contentWindow
if (!(win))
@@ -96,7 +129,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
}, [playerId, postToPlayer])
const seek = useCallback ((time: number) => {
postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } })
postToPlayer (
{ eventName: 'seek', sourceConnectorType: 1, playerId,
data: { time } })
}, [playerId, postToPlayer])
const mute = useCallback (() => {
@@ -132,21 +167,21 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
useEffect (() => {
const onMessage = (event: MessageEvent<NiconicoPlayerMessage>) => {
if (!(iframeRef.current)
|| (event.source !== iframeRef.current.contentWindow)
|| (event.origin !== EMBED_ORIGIN))
return
|| (event.source !== iframeRef.current.contentWindow)
|| (event.origin !== EMBED_ORIGIN))
return
const data = event.data
if (!(data)
|| typeof data !== 'object'
|| !('eventName' in data))
return
return
if (('playerId' in data)
&& data.playerId
&& data.playerId !== playerId)
return
return
if (data.eventName === 'enterProgrammaticFullScreen')
{
@@ -162,24 +197,34 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
if (data.eventName === 'loadComplete')
{
clearLoadCompleteTimer ()
onLoadComplete?.(data.data.videoInfo)
return
}
if (data.eventName === 'playerMetadataChange')
{
if (Number.isFinite (data.data.duration) && data.data.duration > 0)
clearLoadCompleteTimer ()
onMetadataChange?.(data.data)
return
}
if (data.eventName === 'error')
console.error ('niconico player error:', data)
{
clearLoadCompleteTimer ()
console.error ('niconico player error:', data)
onError?.(data)
}
}
addEventListener ('message', onMessage)
return () => removeEventListener ('message', onMessage)
}, [onLoadComplete, onMetadataChange, playerId])
}, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId])
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
useLayoutEffect (() => {
if (!(fullScreen))
@@ -192,7 +237,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const pollingResize = () => {
if (ended)
return
return
const isLandscape = innerWidth >= innerHeight
const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px`
@@ -206,9 +251,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const startPollingResize = () => {
if (requestAnimationFrame)
requestAnimationFrame (pollingResize)
requestAnimationFrame (pollingResize)
else
pollingResize ()
pollingResize ()
}
startPollingResize ()
@@ -231,9 +276,10 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
<iframe
ref={iframeRef}
src={src}
width={width}
height={height}
style={margedStyle}
allowFullScreen
allow="autoplay"/>)
width={width}
height={height}
style={margedStyle}
onLoad={startLoadCompleteTimer}
allowFullScreen
allow="autoplay"/>)
})
+53 -36
ファイルの表示
@@ -2,34 +2,36 @@ import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import Label from '@/components/common/Label'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api'
import { updatePost } from '@/lib/posts'
import { msToTime } from '@/lib/utils'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types'
import type { Post, Tag } from '@/types'
type PostFormField =
'parentPostIds' | 'tags' | 'originalCreatedAt'
const tagsToStr = (tags: TagWithSections[]): string => {
const result: Omit<TagWithSections, 'children'>[] = []
const tagsToStr = (tags: Tag[]): string => {
const result: Tag[] = []
const walk = (tag: TagWithSections) => {
const walk = (tag: Tag) => {
const { children, ...rest } = tag
result.push (rest)
children.forEach (walk)
children?.forEach (walk)
}
tags.filter (t => t.category !== 'nico').forEach (walk)
return [...(new Set (result.map (t =>
`${ t.name }${ t.sections
.map (s => `[${ msToTime (s.beginMs) }-${ msToTime (s.endMs) }]`)
.join ('') }`)))].join (' ')
return [...(new Set (result.map (t => t.name)))].join (' ')
}
@@ -39,6 +41,8 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [disabled, setDisabled] = useState (false)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] =
@@ -51,6 +55,8 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => {
clearValidationErrors ()
try
{
const data = await updatePost (...args)
@@ -71,7 +77,14 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
if (response?.status !== 409)
{
toast ({ description: '更新はできなかったよ……' })
if (applyValidationError (e))
{
toast ({ description: '更新はできなかったよ……' })
return
}
toast ({ title: '失敗……', description: '入力を確認してください.' })
return
}
@@ -121,38 +134,43 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
}
useEffect (() => {
setTags (tagsToStr (post.tags))
setTags(tagsToStr (post.tags))
}, [post])
return (
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/>
{/* タイトル */}
<div>
<Label></Label>
<input
type="text"
disabled={disabled}
className="w-full border rounded p-2"
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>
</div>
<FormField label="タイトル">
{({ invalid }) => (
<input
type="text"
disabled={disabled}
className={inputClass (invalid)}
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>)}
</FormField>
{/* 親投稿 */}
<div>
<Label>稿</Label>
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */}
<PostFormTagsArea
disabled={disabled}
tags={tags}
setTags={setTags}/>
setTags={setTags}
errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
@@ -160,12 +178,11 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}/>
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
{/* 送信 */}
<Button
type="submit"
disabled={disabled}>
<Button type="submit" disabled={disabled}>
</Button>
</form>)
+66 -1
ファイルの表示
@@ -8,12 +8,19 @@ const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
}))
const nicoViewer = vi.hoisted (() => ({
props: vi.fn (),
}))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
}))
vi.mock ('@/components/NicoViewer', () => ({
default: ({ id }: { id: string }) => <div>Nico:{id}</div>,
default: (props: { id: string }) => {
nicoViewer.props (props)
return <div>Nico:{props.id}</div>
},
}))
vi.mock ('react-youtube', () => ({
@@ -31,6 +38,64 @@ describe ('PostEmbed', () => {
expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
})
it ('reports niconico metadata as milliseconds', () => {
const onVideoReady = vi.fn ()
const onPlaybackChange = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}
onPlaybackChange={onPlaybackChange}/>,
)
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledWith (120_000)
expect (onPlaybackChange).toHaveBeenCalledWith (7_000)
})
it ('reports niconico video readiness only once', () => {
const onVideoReady = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}/>,
)
nicoViewer.props.mock.calls[0][0].onLoadComplete ({
title: '動画',
videoId: 'sm12345',
lengthInSeconds: 120,
thumbnailUrl: 'https://example.com/thumb.jpg',
description: '',
viewCount: 1,
commentCount: 2,
mylistCount: 3,
postedAt: '2026-01-02T03:04:05.000Z',
watchId: 12345,
})
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledTimes (1)
expect (onVideoReady).toHaveBeenCalledWith (120_000)
})
it ('embeds x/twitter status URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>)
+106 -6
ファイルの表示
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
@@ -8,17 +8,113 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, RefObject } from 'react'
import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types'
import type { YouTubePlayer } from 'react-youtube'
type YouTubeEvent<T = unknown> = {
data: T
target: YouTubePlayer }
type Props = {
ref?: RefObject<NiconicoViewerHandle | null>
post: Post
onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void }
onMetadataChange?: (meta: NiconicoMetadata) => void
onVideoReady?: (durationMs: number) => void
onPlaybackChange?: (currentTimeMs: number) => number | void
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) => {
const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const dialogue = useDialogue ()
const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false)
const notifyNiconicoVideoReady = useCallback ((durationMs: number) => {
if (niconicoVideoReadyRef.current
|| !(Number.isFinite (durationMs))
|| durationMs <= 0)
return
niconicoVideoReadyRef.current = true
onVideoReady?.(durationMs)
}, [onVideoReady])
const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => {
const currentTime = await player.getCurrentTime ()
const currentTimeMs = currentTime * 1_000
const targetTimeMs = onPlaybackChange?.(currentTimeMs)
if (typeof targetTimeMs !== 'number')
return
if (Math.abs (currentTimeMs - targetTimeMs) > 5_000)
await player.seekTo (targetTimeMs / 1_000, true)
}, [onPlaybackChange])
const handleYoutubeReady = async (event: YouTubeEvent) => {
setYoutubePlayer (event.target)
try
{
await event.target.playVideo ()
const duration = await event.target.getDuration ()
const durationMs = duration * 1_000
onVideoReady?.(durationMs)
if (!(Number.isFinite (durationMs)) || durationMs <= 0)
return
await reportYoutubePlayback (event.target)
}
catch (error)
{
onError?.({ platform: 'youtube', error })
}
}
const handleYoutubeStateChange = (event: YouTubeEvent<number>) => {
void reportYoutubePlayback (event.target)
}
const handleYoutubeError = (event: YouTubeEvent<number>) => {
onError?.({ platform: 'youtube', code: event.data })
}
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
notifyNiconicoVideoReady (info.lengthInSeconds * 1_000)
onLoadComplete?.(info)
}
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
notifyNiconicoVideoReady (meta.duration)
onPlaybackChange?.(meta.currentTime)
onMetadataChange?.(meta)
}
useEffect (() => {
niconicoVideoReadyRef.current = false
}, [post.url])
useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange))
return
const timer = setInterval (
() => void reportYoutubePlayback (youtubePlayer),
1_000)
return () => clearInterval (timer)
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url)
@@ -38,8 +134,9 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
id={videoId}
width={640}
height={360}
onLoadComplete={onLoadComplete}
onMetadataChange={onMetadataChange}/>)
onLoadComplete={handleNiconicoLoadComplete}
onMetadataChange={handleNiconicoMetadataChange}
onError={onError}/>)
}
case 'twitter.com':
@@ -69,7 +166,10 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
mute: 0,
loop: 1,
width: '640',
height: '360' } }}/>)
height: '360' } }}
onReady={handleYoutubeReady}
onStateChange={handleYoutubeStateChange}
onError={handleYoutubeError}/>)
}
}
+33 -28
ファイルの表示
@@ -3,7 +3,7 @@
import { useRef, useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox'
import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api'
@@ -33,10 +33,11 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
tags: string
setTags: (tags: string) => void }
setTags: (tags: string) => void
errors?: string[] }
const PostFormTagsArea: FC<Props> = ({ tags, setTags, ...rest }) => {
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
@@ -73,30 +74,34 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, ...rest }) => {
}
return (
<div className="relative w-full">
<Label></Label>
<TextArea
{...rest}
ref={ref}
value={tags}
onChange={ev => setTags (ev.target.value)}
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos)
}}
onFocus={() => setFocused (true)}
onBlur={() => {
setFocused (false)
setSuggestionsVsbl (false)
}}/>
{focused && (
<TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>)}
</div>)
<FormField className="relative w-full" label="タグ" messages={errors}>
{({ describedBy, invalid }) => (
<>
<TextArea
{...rest}
ref={ref}
value={tags}
aria-describedby={describedBy}
invalid={invalid}
onChange={ev => setTags (ev.target.value)}
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos)
}}
onFocus={() => setFocused (true)}
onBlur={() => {
setFocused (false)
setSuggestionsVsbl (false)
}}/>
{focused && (
<TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>)}
</>)}
</FormField>)
}
export default PostFormTagsArea
export default PostFormTagsArea
+75 -62
ファイルの表示
@@ -1,5 +1,5 @@
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
import type { FC } from 'react'
@@ -9,68 +9,81 @@ type Props = {
originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void }
setOriginalCreatedBefore: (x: string | null) => void
errors?: string[] }
const PostOriginalCreatedTimeField: FC<Props> = ({ disabled,
originalCreatedFrom,
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore }) => (
<div>
<Label></Label>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const PostOriginalCreatedTimeField: FC<Props> = (
{ disabled,
originalCreatedFrom,
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props,
) => (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</div>)
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
export default PostOriginalCreatedTimeField
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</>)}
</FormField>)
export default PostOriginalCreatedTimeField
+8 -8
ファイルの表示
@@ -26,13 +26,13 @@ import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, TagWithSections } from '@/types'
import type { Category, Post, Tag } from '@/types'
type TagByCategory = { [key in Category]: TagWithSections[] }
type TagByCategory = { [key in Category]: Tag[] }
const renderTagTree = (
tag: TagWithSections,
tag: Tag,
nestLevel: number,
path: string,
suppressClickRef: MutableRefObject<boolean>,
@@ -63,7 +63,7 @@ const renderTagTree = (
const isDescendant = (
root: TagWithSections,
root: Tag,
targetId: number,
): boolean => {
if (!(root.children))
@@ -84,8 +84,8 @@ const isDescendant = (
const findTag = (
byCat: TagByCategory,
id: number,
): TagWithSections | undefined => {
const walk = (nodes: TagWithSections[]): TagWithSections | undefined => {
): Tag | undefined => {
const walk = (nodes: Tag[]): Tag | undefined => {
for (const t of nodes)
{
if (t.id === id)
@@ -167,7 +167,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
}
for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[])
tagsTmp[cat].sort ((tagA: TagWithSections, tagB: TagWithSections) => tagA.name < tagB.name ? -1 : 1)
tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1)
return tagsTmp
}, [post])
@@ -378,4 +378,4 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
</SidebarComponent>)
}
export default TagDetailSidebar
export default TagDetailSidebar
+4 -2
ファイルの表示
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox'
@@ -110,11 +111,12 @@ const TagSearch: FC = () => {
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-800 dark:text-white"/>
className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
}
export default TagSearch
export default TagSearch
+10 -10
ファイルの表示
@@ -55,12 +55,6 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '追加', to: '/materials/new' },
{ name: '全体履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
{ name: <>&thinsp;1&thinsp;</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },
@@ -71,6 +65,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false },
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
@@ -132,8 +128,12 @@ const TopNav: FC<Props> = ({ user }) => {
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const moreMenu = menu.filter (item =>
!(item.visible ?? true)
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -244,9 +244,9 @@ const TopNav: FC<Props> = ({ user }) => {
<motion.div
key="submenu-shell"
layout
className="relative hidden md:block overflow-hidden
className="relative z-20 hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950"
style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }}
animate={{ height: submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
@@ -257,7 +257,7 @@ const TopNav: FC<Props> = ({ user }) => {
}}>
{moreVsbl
? (
menu.map ((item, i) => (
moreMenu.map ((item, i) => (
<div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3">
<motion.div
@@ -267,7 +267,7 @@ const TopNav: FC<Props> = ({ user }) => {
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } })}
className="z-10 h-full flex items-center px-3 font-bold w-24">
className="z-10 h-full flex items-center px-3 font-bold w-28">
<h2>{item.name}</h2>
</motion.div>
{item.subMenu
+15 -4
ファイルの表示
@@ -22,10 +22,11 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
value?: string
onChange?: (isoUTC: string | null) => void
className?: string
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void }
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void
invalid?: boolean }
const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, ...rest }) => {
const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid, ...rest }) => {
const [local, setLocal] = useState ('')
useEffect (() => {
@@ -35,9 +36,19 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, ...rest
return (
<input
{...rest}
className={cn ('border rounded p-2', className)}
className={cn ('border rounded p-2',
(invalid
? ['border-red-500 bg-red-50 text-red-900',
'focus:border-red-500 focus:outline-none',
'focus:ring-2 focus:ring-red-200',
'dark:border-red-500 dark:bg-red-950/30 dark:text-red-100']
: ['border-gray-300',
'focus:border-blue-500 focus:outline-none',
'focus:ring-2 focus:ring-blue-200']),
className)}
type="datetime-local"
value={local}
aria-invalid={invalid}
onChange={ev => {
const v = ev.target.value
setLocal (v)
@@ -46,4 +57,4 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, ...rest
onBlur={onBlur}/>)
}
export default DateTimeField
export default DateTimeField
+18
ファイルの表示
@@ -0,0 +1,18 @@
import type { FC } from 'react'
type Props = { id?: string
messages?: string[] }
export const FieldError: FC<Props> = ({ id, messages }: Props) => {
if (!(messages) || messages.length === 0)
return null
return (
<ul id={id} className="mt-1 space-y-1 text-red-700 dark:text-red-300">
{messages.map ((message, i) => <li key={i}>{message}</li>)}
</ul>)
}
export default FieldError
+36
ファイルの表示
@@ -0,0 +1,36 @@
import { useId } from 'react'
import FieldError from '@/components/common/FieldError'
import Label from '@/components/common/Label'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type FieldState = { describedBy?: string
invalid: boolean }
type Props = {
children: (state: FieldState) => ReactNode
checkBox?: { label: string
checked: boolean
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
className?: string
label: ReactNode
messages?: string[] }
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
const id = useId ()
const invalid = messages != null && messages.length > 0
const errorId = invalid ? `${ id }-error` : undefined
return (
<div className={cn (className)}>
<Label checkBox={checkBox} invalid={invalid}>{label}</Label>
{children ({ describedBy: errorId, invalid })}
<FieldError id={errorId} messages={messages}/>
</div>)
}
export default FormField
+21 -14
ファイルの表示
@@ -1,32 +1,39 @@
import React from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
type Props = { children: React.ReactNode
checkBox?: { label: string
checked: boolean
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void } }
checkBox?: { label: string
checked: boolean
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
invalid?: boolean }
const Label: FC<Props> = ({ children, checkBox }) => {
const Label: FC<Props> = ({ children, checkBox, invalid }: Props) => {
const labelClassName = cn ('block font-semibold mb-1',
invalid && 'text-red-700 dark:text-red-300')
if (!(checkBox))
{
return (
<label className="block font-semibold mb-1">
{children}
</label>)
<label className={labelClassName}>
{children}
</label>)
}
return (
<div className="flex gap-2 mb-1">
<label className="flex-1 block font-semibold">{children}</label>
<label className="flex items-center block gap-1">
<input type="checkbox"
checked={checkBox.checked}
onChange={checkBox.onChange}/>
{checkBox.label}
</label>
<label className="flex-1 block font-semibold">{children}</label>
<label className="flex items-center block gap-1">
<input type="checkbox"
checked={checkBox.checked}
onChange={checkBox.onChange}/>
{checkBox.label}
</label>
</div>)
}
export default Label
+11 -5
ファイルの表示
@@ -2,6 +2,7 @@ import { useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import type { FC, ChangeEvent, KeyboardEvent } from 'react'
@@ -9,10 +10,13 @@ import type { Tag } from '@/types'
type Props = {
value: string
setValue: (value: string) => void }
describedBy?: string
invalid?: boolean
value: string
setValue: (value: string) => void }
const TagInput: FC<Props> = ({ value, setValue }) => {
const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
@@ -85,12 +89,14 @@ const TagInput: FC<Props> = ({ value, setValue }) => {
<div className="relative">
<input
type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
value={value}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
className={inputClass (invalid)}/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
@@ -99,4 +105,4 @@ const TagInput: FC<Props> = ({ value, setValue }) => {
</div>)
}
export default TagInput
export default TagInput
+19 -3
ファイルの表示
@@ -1,9 +1,25 @@
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
import type { TextareaHTMLAttributes } from 'react'
type Props = TextareaHTMLAttributes<HTMLTextAreaElement>
type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & { invalid?: boolean }
export default forwardRef<HTMLTextAreaElement, Props> (({ ...props }, ref) => (
<textarea ref={ref} className="rounded border w-full p-2 h-32" {...props}/>))
export default forwardRef<HTMLTextAreaElement, Props> (
({ className, invalid = false, ...props }, ref) => (
<textarea
ref={ref}
aria-invalid={invalid}
className={cn ('rounded border w-full p-2 h-32',
(invalid
? ['border-red-500 bg-red-50 text-red-900',
'focus:border-red-500 focus:outline-none focus:ring-2',
'focus:ring-red-200',
'dark:border-red-500 dark:bg-red-950/30 dark:text-red-100']
: ['border-gray-300',
'focus:border-blue-500 focus:outline-none focus:ring-2',
'focus:ring-blue-200']),
className)}
{...props}/>))
+6 -3
ファイルの表示
@@ -64,11 +64,14 @@ export const apiPatch = async <T> (
): Promise<T> => apiP ('patch', path, body, opt)
export const apiDelete = async (
export const apiDelete = async <T = void> (
path: string,
opt?: Opt,
): Promise<void> => {
await client.delete (path, withUserCode (opt))
): Promise<T> => {
const res = await client.delete (path, withUserCode (opt))
if (res.data == null || res.data === '')
return undefined as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
}
+79
ファイルの表示
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
const api = vi.hoisted (() => ({
isApiError: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('extractValidationError', () => {
it ('extracts field and base errors from 422 validation responses', async () => {
api.isApiError.mockReturnValueOnce (true)
const { extractValidationError } = await import ('@/lib/apiErrors')
const validationError = extractValidationError<'name'> ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { name: ['名前は必須です.'] },
base_errors: ['全体エラー'],
},
},
})
expect (validationError).toEqual ({
message: '入力内容を確認してください.',
fieldErrors: { name: ['名前は必須です.'] },
baseErrors: ['全体エラー'],
})
})
it ('preserves dotted field keys for indexed form rows', async () => {
api.isApiError.mockReturnValueOnce (true)
const { extractValidationError } = await import ('@/lib/apiErrors')
const validationError = extractValidationError<'deerjikists.0.platform'> ({
response: {
status: 422,
data: {
type: 'validation_error',
errors: { 'deerjikists.0.platform': ['プラットフォームを入力してください.'] },
base_errors: [],
},
},
})
expect (validationError?.fieldErrors).toEqual ({
'deerjikists0Platform': ['プラットフォームを入力してください.'],
})
})
it ('does not treat 400 bad requests as form validation errors', async () => {
api.isApiError.mockReturnValueOnce (true)
const { extractValidationError } = await import ('@/lib/apiErrors')
const validationError = extractValidationError ({
response: {
status: 400,
data: {
type: 'bad_request',
message: 'リクエストが不正です.',
errors: {},
base_errors: ['リクエストが不正です.'],
},
},
})
expect (validationError).toBeNull ()
})
it ('ignores non-api errors', async () => {
api.isApiError.mockReturnValueOnce (false)
const { extractValidationError } = await import ('@/lib/apiErrors')
expect (extractValidationError (new Error ('network'))).toBeNull ()
})
})
+36
ファイルの表示
@@ -0,0 +1,36 @@
import toCamel from 'camelcase-keys'
import { isApiError } from '@/lib/api'
export type FieldErrors<T extends string = string> = Partial<Record<T, string[]>>
export type ValidationError<T extends string = string> =
{ message: string
fieldErrors: FieldErrors<T>
baseErrors: string[] }
type RawValidationError = { type?: string
message?: string
errors?: Record<string, string[]>
baseErrors?: string[] }
export const extractValidationError = <T extends string = string> (err: unknown) => {
if (!(isApiError (err)) || err.response?.status !== 422)
return null
const rawData = toCamel ((err.response.data ?? { }) as Record<string, unknown>,
{ deep: true }) as RawValidationError
const data: RawValidationError = {
type: rawData.type as string | undefined,
message: rawData.message as string | undefined,
errors: rawData.errors as Record<string, string[]> | undefined,
baseErrors: rawData.baseErrors as string[] | undefined }
if (data.type !== 'validation_error' && !(data.errors))
return null
return { message: data.message ?? '入力内容を確認してください.',
fieldErrors: (data.errors ?? { }) as FieldErrors<T>,
baseErrors: data.baseErrors ?? [] }
}
+375
ファイルの表示
@@ -0,0 +1,375 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiPost } from '@/lib/api'
import {
buildGekanatorQuestions,
expectedAnswerForQuestion,
restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers,
saveGekanatorGame,
saveGekanatorQuestionSuggestion,
} from '@/lib/gekanator'
import type {
GekanatorAnswerLog,
StoredGekanatorQuestion,
} from '@/lib/gekanator'
import type { Post } from '@/types'
vi.mock('@/lib/api', () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}))
const mockedApiPost = vi.mocked(apiPost)
const post = (overrides: Partial<Post> = {}): Post => ({
id: 1,
versionNo: 1,
url: 'https://example.com/posts/1',
title: 'post title',
thumbnail: null,
thumbnailBase: null,
tags: [],
viewed: false,
related: [],
originalCreatedFrom: null,
originalCreatedBefore: null,
createdAt: '2026-06-10T00:00:00.000Z',
updatedAt: '2026-06-10T00:00:00.000Z',
uploadedUser: null,
...overrides,
})
describe('expectedAnswerForQuestion', () => {
it('returns a direct example answer when present', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 999,
answer: 'yes',
threshold: 0.65,
},
exampleAnswers: {
1: 'partial',
},
}
expect(expectedAnswerForQuestion(question, post({ id: 1 }))).toBe('partial')
})
it('returns the condition answer for the original post_similarity post', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 123,
answer: 'probably_no',
threshold: 0.65,
},
}
expect(expectedAnswerForQuestion(question, post({ id: 123 }))).toBe('probably_no')
})
it('returns null for an unrelated post_similarity post without examples', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 123,
answer: 'yes',
threshold: 0.65,
},
}
expect(expectedAnswerForQuestion(question, post({ id: 456 }))).toBeNull()
})
it('returns yes for a matching tag question', () => {
const question: StoredGekanatorQuestion = {
id: 'tag:character:喜多郁代',
text: '喜多ちゃんが関係してる?',
kind: 'tag',
condition: {
type: 'tag',
key: 'character:喜多郁代',
},
}
expect(
expectedAnswerForQuestion(
question,
post({
tags: [
{
id: 1,
name: '喜多郁代',
category: 'character',
aliases: [],
parents: [],
postCount: 1,
createdAt: '2026-06-10T00:00:00.000Z',
updatedAt: '2026-06-10T00:00:00.000Z',
hasWiki: false,
hasDeerjikists: false,
materialId: null,
},
],
}),
),
).toBe('yes')
})
it('returns no for a non-matching tag question', () => {
const question: StoredGekanatorQuestion = {
id: 'tag:character:喜多郁代',
text: '喜多ちゃんが関係してる?',
kind: 'tag',
condition: {
type: 'tag',
key: 'character:喜多郁代',
},
}
expect(expectedAnswerForQuestion(question, post({ tags: [] }))).toBe('no')
})
it('ignores example answers for direct title facts', () => {
const question: StoredGekanatorQuestion = {
id: 'title:length-at-least:20',
text: 'タイトルは 20 文字以上?',
kind: 'title',
condition: {
type: 'title-length-at-least',
length: 20,
},
exampleAnswers: {
1: 'yes',
},
}
expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no')
})
})
describe('restoreGekanatorQuestion', () => {
it('uses default source and priority weight when omitted', () => {
const question = restoreGekanatorQuestion({
id: 'tag:character:喜多郁代',
text: '喜多ちゃんが関係してる?',
kind: 'tag',
condition: {
type: 'tag',
key: 'character:喜多郁代',
},
})
expect(question.source).toBe('default')
expect(question.priorityWeight).toBe(1)
})
it('tests a post_similarity question using direct examples', () => {
const question = restoreGekanatorQuestion({
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 999,
answer: 'yes',
threshold: 0.65,
},
exampleAnswers: {
1: 'yes',
2: 'no',
},
})
expect(question.test(post({ id: 1 }))).toBe(true)
expect(question.test(post({ id: 2 }))).toBe(false)
expect(question.test(post({ id: 3 }))).toBe(false)
})
it('tests a post_similarity question against its configured partial answer', () => {
const question = restoreGekanatorQuestion({
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 999,
answer: 'partial',
threshold: 0.65,
},
exampleAnswers: {
1: 'partial',
2: 'yes',
},
})
expect(question.test(post({ id: 1 }))).toBe(true)
expect(question.test(post({ id: 2 }))).toBe(false)
})
it('normalizes legacy title-length-greater-than questions', () => {
const question = restoreGekanatorQuestion({
id: 'title:length-greater-than:20',
text: '題名が長めの投稿?',
kind: 'title',
condition: {
type: 'title-length-greater-than',
length: 20,
},
})
expect(question.id).toBe('title:length-at-least:21')
expect(question.condition).toEqual({
type: 'title-length-at-least',
length: 21,
})
expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false)
expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true)
})
})
describe('buildGekanatorQuestions', () => {
it('builds quantitative title length questions', () => {
const questions = buildGekanatorQuestions([
post({ id: 1, title: 'a' }),
post({ id: 2, title: 'bb' }),
post({ id: 3, title: 'ccc' }),
post({ id: 4, title: 'dddd' }),
])
const titleQuestion = questions.find(question =>
question.condition.type === 'title-length-at-least')
expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/)
expect(titleQuestion?.id).toMatch(/^title:length-at-least:\d+$/)
})
})
describe('Gekanator API writers', () => {
beforeEach(() => {
mockedApiPost.mockReset()
})
it('sends game results using snake_case request keys', async () => {
mockedApiPost.mockResolvedValue({ id: 100 })
const answers: GekanatorAnswerLog[] = [
{
questionId: 'tag:character:喜多郁代',
questionText: '喜多ちゃんが関係してる?',
questionCondition: {
type: 'tag',
key: 'character:喜多郁代',
},
answer: 'yes',
originalAnswer: 'partial',
},
]
await expect(
saveGekanatorGame({
guessedPostId: 1,
correctPostId: 2,
answers,
}),
).resolves.toEqual({ id: 100 })
expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/games', {
guessed_post_id: 1,
correct_post_id: 2,
answers: [
{
question_id: 'tag:character:喜多郁代',
question_text: '喜多ちゃんが関係してる?',
question_condition: {
type: 'tag',
key: 'character:喜多郁代',
},
answer: 'yes',
original_answer: 'partial',
},
],
})
})
it('sends question suggestions using snake_case request keys', async () => {
mockedApiPost.mockResolvedValue({
id: 10,
count: 1,
})
await expect(
saveGekanatorQuestionSuggestion({
gekanatorGameId: 100,
questionText: '喜多ちゃんが泣いてる?',
answer: 'yes',
}),
).resolves.toEqual({
id: 10,
count: 1,
})
expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/question_suggestions', {
gekanator_game_id: 100,
question_text: '喜多ちゃんが泣いてる?',
answer: 'yes',
})
})
it('sends extra question answers using snake_case request keys', async () => {
mockedApiPost.mockResolvedValue({
count: 2,
})
await saveGekanatorExtraQuestionAnswers({
gameId: 100,
answers: [
{
questionId: 10,
answer: 'yes',
},
{
questionId: 11,
answer: 'probably_no',
},
],
})
expect(mockedApiPost).toHaveBeenCalledWith(
'/gekanator/games/100/extra_question_answers',
{
answers: [
{
question_id: 10,
answer: 'yes',
},
{
question_id: 11,
answer: 'probably_no',
},
],
},
)
})
})
+564
ファイルの表示
@@ -0,0 +1,564 @@
import { apiGet, apiPost } from '@/lib/api'
import type { Post } from '@/types'
export type GekanatorAnswerValue =
| 'yes'
| 'no'
| 'partial'
| 'probably_no'
| 'unknown'
export type GekanatorAnswerLog = {
questionId: string
questionText: string
questionCondition?: GekanatorQuestionCondition
answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue }
export type GekanatorQuestionKind =
| 'tag'
| 'source'
| 'title'
| 'original_date'
| 'post_similarity'
export type GekanatorQuestionSource =
| 'default'
| 'user_suggested'
| 'ai_generated'
| 'admin_curated'
export type GekanatorQuestionCondition =
| { type: 'tag'; key: string }
| { type: 'source'; host: string }
| { type: 'original-year'; year: number }
| { type: 'original-month'; month: number }
| { type: 'original-month-day'; monthDay: string }
| { type: 'title-length-at-least'; length: number }
| { type: 'title-length-greater-than'; length: number }
| { type: 'title-has-ascii' }
| {
type: 'post-similarity'
postId: number
answer: GekanatorAnswerValue
threshold: number
}
type NonPostSimilarityCondition = Exclude<
GekanatorQuestionCondition,
{ type: 'post-similarity' }
>
export type GekanatorExtraQuestion = {
id: number
text: string
source: GekanatorQuestionSource
priorityWeight: number }
export type StoredGekanatorQuestion = {
id: string
text: string
kind: GekanatorQuestionKind
condition: GekanatorQuestionCondition
source?: GekanatorQuestionSource
priorityWeight?: number
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
export type GekanatorQuestion = {
id: string
text: string
kind: GekanatorQuestionKind
condition: GekanatorQuestionCondition
source: GekanatorQuestionSource
priorityWeight: number
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
test: (post: Post) => boolean }
export const normalizeTitleLengthCondition = (
condition: GekanatorQuestionCondition,
): GekanatorQuestionCondition => {
switch (condition.type)
{
case 'title-length-greater-than':
return {
type: 'title-length-at-least',
length: condition.length + 1 }
default:
return condition
}
}
export const titleLengthMinimumForCondition = (
condition: GekanatorQuestionCondition,
): number | null => {
switch (condition.type)
{
case 'title-length-at-least':
return condition.length
case 'title-length-greater-than':
return condition.length + 1
default:
return null
}
}
export const questionIdForCondition = (
condition: NonPostSimilarityCondition,
): string => {
switch (condition.type)
{
case 'tag':
return `tag:${ condition.key }`
case 'source':
return `source:${ condition.host }`
case 'original-year':
return `original-year:${ condition.year }`
case 'original-month':
return `original-month:${ condition.month }`
case 'original-month-day':
return `original-month-day:${ condition.monthDay }`
case 'title-length-at-least':
case 'title-length-greater-than':
return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }`
case 'title-has-ascii':
return 'title:ascii'
}
}
const directExampleAnswerFor = (
question: StoredGekanatorQuestion,
post: Post,
): GekanatorAnswerValue | null => {
if (question.kind !== 'post_similarity')
return null
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
if (direct)
return direct
if (question.condition.type === 'post-similarity' && question.condition.postId === post.id)
return question.condition.answer
return null
}
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 originalDateOf = (post: Post): Date | 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
}
const originalMonthOf = (post: Post): number | null => {
const date = originalDateOf (post)
if (!(date))
return null
return date.getMonth () + 1
}
const originalMonthDayOf = (post: Post): string | null => {
const date = originalDateOf (post)
if (!(date))
return null
return `${ date.getMonth () + 1 }-${ date.getDate () }`
}
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 tagQuestionText = (category: string, label: string): string => {
switch (category)
{
case 'deerjikist':
return `作者・ニジラーとして「${ label }」に関係してゐる?`
case 'meme':
return `元ネタ・ミームとして「${ label }」に関係しさう?`
case 'character':
return `${ label }」といふキャラクターが関係してゐる?`
case 'material':
return `素材として「${ label }」に関係してゐる?`
case 'nico':
return `ニコニコに「${ label }」といふタグが付いてゐる?`
case 'general':
case 'meta':
default:
return `内容として「${ label }」に関係しさう?`
}
}
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操作'))))
}
const questionMatches = (
post: Post,
question: StoredGekanatorQuestion,
): boolean => {
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return question.condition.type === 'post-similarity'
? directAnswer === question.condition.answer
: directAnswer === 'yes'
switch (question.condition.type)
{
case 'tag':
return questionableTag (post, question.condition.key)
case 'source':
return hostOf (post) === question.condition.host
case 'original-year':
return originalYearOf (post) === question.condition.year
case 'original-month':
return originalMonthOf (post) === question.condition.month
case 'original-month-day':
return originalMonthDayOf (post) === question.condition.monthDay
case 'title-length-at-least':
return (post.title?.length ?? 0) >= question.condition.length
case 'title-length-greater-than':
return (post.title?.length ?? 0) > question.condition.length
case 'title-has-ascii':
return /[A-Za-z0-9]/.test (post.title ?? '')
case 'post-similarity':
return false
}
}
export const expectedAnswerForQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): GekanatorAnswerValue | null => {
if (!(question) || !(post))
return null
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return directAnswer
switch (question.condition.type)
{
case 'tag':
case 'source':
case 'original-year':
case 'original-month':
case 'original-month-day':
case 'title-length-at-least':
case 'title-length-greater-than':
case 'title-has-ascii':
return questionMatches (post, question) ? 'yes' : 'no'
case 'post-similarity':
return null
}
}
export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion,
): GekanatorQuestion => {
const normalizedCondition = normalizeTitleLengthCondition (question.condition)
const normalizedQuestion = {
...question,
id: normalizedCondition.type === 'title-length-at-least'
? `title:length-at-least:${ normalizedCondition.length }`
: question.id,
condition: normalizedCondition,
source: question.source ?? 'default',
priorityWeight: question.priorityWeight ?? 1 }
return {
...normalizedQuestion,
test: (post: Post) => questionMatches (post, normalizedQuestion) }
}
export const storeGekanatorQuestion = (
question: GekanatorQuestion,
): StoredGekanatorQuestion => ({
id: question.condition.type === 'title-length-greater-than'
? `title:length-at-least:${ question.condition.length + 1 }`
: question.id,
text: question.text,
kind: question.kind,
condition: normalizeTitleLengthCondition (question.condition),
source: question.source,
priorityWeight: question.priorityWeight,
exampleAnswers: question.exampleAnswers })
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
return data.posts
}
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
return data.questions
}
export const fetchGekanatorExtraQuestions = async (
gameId: number,
nonce?: string,
): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined })
return data.questions
}
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 originalMonths = countBy (
posts
.map (originalMonthOf)
.filter ((month): month is number => month !== null))
const originalMonthDays = countBy (
posts
.map (originalMonthDayOf)
.filter ((monthDay): monthDay is string => monthDay !== 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: tagQuestionText (category, label),
kind: 'tag' as const,
condition: { type: 'tag' as const, key: String (key) },
source: 'default' as const,
priorityWeight: 1,
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,
condition: { type: 'source' as const, host },
source: 'default' as const,
priorityWeight: 1,
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,
condition: { type: 'original-year' as const, year },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => originalYearOf (post) === year }))
const originalMonthQuestions = usefulEntries (originalMonths)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([month]) => ({
id: `original-month:${ month }`,
text: `オリジナルの投稿月は ${ month } 月?`,
kind: 'original_date' as const,
condition: { type: 'original-month' as const, month },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => originalMonthOf (post) === month }))
const originalMonthDayQuestions = usefulEntries (originalMonthDays)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([monthDay]) => {
const [month, day] = String (monthDay).split ('-')
return {
id: `original-month-day:${ monthDay }`,
text: `オリジナルの投稿日は ${ month }${ day } 日?`,
kind: 'original_date' as const,
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => originalMonthDayOf (post) === monthDay }
})
const titleQuestions = [
{
id: `title:length-at-least:${ titleLengthMedian }`,
text: `タイトルは ${ titleLengthMedian } 文字以上?`,
kind: 'title' as const,
condition: {
type: 'title-length-at-least' as const,
length: titleLengthMedian },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => (post.title?.length ?? 0) >= titleLengthMedian },
{
id: 'title:ascii',
text: '題名に英数字が混じってゐる?',
kind: 'title' as const,
condition: { type: 'title-has-ascii' as const },
source: 'default' as const,
priorityWeight: 1,
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,
...originalMonthQuestions,
...originalMonthDayQuestions,
...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,
question_condition: answer.questionCondition ?? null,
answer: answer.answer,
original_answer: answer.originalAnswer })) })
export const saveGekanatorQuestionSuggestion = async ({
gekanatorGameId,
questionText,
answer,
}: {
gekanatorGameId: number
questionText: string
answer: GekanatorAnswerValue
}): Promise<{ id: number; count: number }> =>
await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId,
question_text: questionText,
answer })
export const saveGekanatorExtraQuestionAnswers = async ({
gameId,
answers,
}: {
gameId: number
answers: { questionId: number; answer: GekanatorAnswerValue }[]
}) =>
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
answers: answers.map (item => ({
question_id: item.questionId,
answer: item.answer })) })
+151
ファイルの表示
@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest'
import {
candidatePostsFor,
hardFilteredPostsForAnswer,
recoverCandidatePosts,
} from '@/lib/gekanatorCandidateRecovery'
import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
} from '@/lib/gekanator'
import type { Post } from '@/types'
const post = (id: number): Post => ({
id,
versionNo: 1,
url: `https://example.com/posts/${ id }`,
title: `post ${ id }`,
thumbnail: null,
thumbnailBase: null,
tags: [],
viewed: false,
related: [],
originalCreatedFrom: null,
originalCreatedBefore: null,
createdAt: '2026-06-10T00:00:00.000Z',
updatedAt: '2026-06-10T00:00:00.000Z',
uploadedUser: null,
})
const postSimilarityQuestion = (
id: string,
answers: Record<`${ number }`, GekanatorAnswerValue>,
): GekanatorQuestion => ({
id,
text: `${ id }?`,
kind: 'post_similarity',
condition: {
type: 'post-similarity',
postId: 9999,
answer: 'yes',
threshold: 0.65 },
source: 'user_suggested',
priorityWeight: 1,
exampleAnswers: answers,
test: candidate => answers[String (candidate.id) as `${ number }`] === 'yes',
})
const answer = (
question: GekanatorQuestion,
value: GekanatorAnswerValue,
): GekanatorAnswerLog => ({
questionId: question.id,
questionText: question.text,
questionCondition: question.condition,
answer: value,
originalAnswer: value,
})
describe('candidatePostsFor', () => {
it('lets recovered candidates ignore old answers but not later answers', () => {
const posts = [post (1), post (2), post (3)]
const oldQuestion = postSimilarityQuestion ('old', {
1: 'no',
2: 'yes',
3: 'yes',
})
const laterQuestion = postSimilarityQuestion ('later', {
1: 'no',
2: 'no',
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 ([3])
})
it('does not let recovered candidates bypass explicit rejected posts', () => {
const posts = [post (1), post (2)]
const question = postSimilarityQuestion ('question', {
1: 'yes',
2: 'yes',
})
const candidates = candidatePostsFor ({
posts,
questions: [question],
answers: [answer (question, 'yes')],
softenedQuestionIds: new Set (),
rejectedPostIds: new Set ([1]),
recoveredCandidatePosts: new Map ([[1, 1]]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([2])
})
})
describe('hardFilteredPostsForAnswer', () => {
it('returns zero candidates without falling back to the original pool', () => {
const posts = [post (1), post (2)]
const question = postSimilarityQuestion ('question', {
1: 'yes',
2: 'yes',
})
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'no',
})).toEqual ([])
})
})
describe('recoverCandidatePosts', () => {
it('recovers high-score non-rejected, non-eligible candidates in staged batches', () => {
const posts = Array.from ({ length: 10 }, (_value, index) => post (index + 1))
const scores = new Map (posts.map (candidate => [candidate.id, candidate.id]))
const recovered = recoverCandidatePosts ({
posts,
scores,
rejectedPostIds: new Set ([10]),
recoveredCandidatePosts: new Map ([[8, 1]]),
eligiblePostIds: new Set ([9]),
answerCountAtRecovery: 2,
recoveryStepCount: 0,
})
expect(recovered?.recoveryStepCount).toBe (1)
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
.toEqual ([8, 7, 6, 5, 4, 3, 2])
expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2)
})
})
+146
ファイルの表示
@@ -0,0 +1,146 @@
import { expectedAnswerForQuestion } from '@/lib/gekanator'
import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
} from '@/lib/gekanator'
import type { Post } from '@/types'
export type RecoveredCandidatePost = {
postId: number
answerCountAtRecovery: number }
export const candidatePostsFor = ({
posts,
questions,
answers,
softenedQuestionIds,
rejectedPostIds,
recoveredCandidatePosts,
}: {
posts: Post[]
questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number>
}): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
if (rejectedPostIds.has (post.id))
return false
const answerCountAtRecovery = recoveredCandidatePosts.get (post.id)
return answers.every ((answer, index) => {
if (answerCountAtRecovery !== undefined && index < answerCountAtRecovery)
return true
if (softenedQuestionIds.has (answer.questionId))
return true
const question = questionById.get (answer.questionId)
if (!(question))
return true
switch (answer.answer)
{
case 'yes':
case 'no': {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer.answer
}
default:
return true
}
})
})
}
export const hardFilteredPostsForAnswer = ({
posts,
question,
answer,
}: {
posts: Post[]
question: GekanatorQuestion
answer: GekanatorAnswerValue
}): Post[] => {
if (answer === 'unknown')
return posts
return posts.filter (post => {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer
})
}
const concreteAnswerOptions: GekanatorAnswerValue[] = [
'yes',
'no',
'partial',
'probably_no']
export const allConcreteAnswerOptionsExhausted = (
posts: Post[],
question: GekanatorQuestion | null,
): boolean => {
if (!(question))
return false
return concreteAnswerOptions.every (answer =>
hardFilteredPostsForAnswer ({ posts, question, answer }).length === 0)
}
const nextRecoveryBatchSize = (recoveryStepCount: number): number =>
6 * (2 ** recoveryStepCount)
export const recoverCandidatePosts = ({
posts,
scores,
rejectedPostIds,
recoveredCandidatePosts,
eligiblePostIds,
answerCountAtRecovery,
recoveryStepCount,
}: {
posts: Post[]
scores: Map<number, number>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, number>
eligiblePostIds: Set<number>
answerCountAtRecovery: number
recoveryStepCount: number
}): {
recoveredCandidatePosts: Map<number, number>
recoveryStepCount: number
} | null => {
const recovered = new Map (recoveredCandidatePosts)
const candidates = posts
.filter (post =>
!(rejectedPostIds.has (post.id))
&& !(eligiblePostIds.has (post.id))
&& !(recovered.has (post.id)))
.sort ((a, b) =>
(scores.get (b.id) ?? Number.NEGATIVE_INFINITY)
- (scores.get (a.id) ?? Number.NEGATIVE_INFINITY))
.slice (0, nextRecoveryBatchSize (recoveryStepCount))
if (candidates.length === 0)
return null
candidates.forEach (post => recovered.set (post.id, answerCountAtRecovery))
return {
recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 }
}
+167
ファイルの表示
@@ -0,0 +1,167 @@
import { titleLengthMinimumForCondition } from '@/lib/gekanator'
import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
} from '@/lib/gekanator'
export const monthForCondition = (
condition: GekanatorQuestion['condition'],
): number | null => {
if (condition.type === 'original-month')
return condition.month
if (condition.type !== 'original-month-day')
return null
const month = Number (condition.monthDay.split ('-')[0])
return Number.isInteger (month) ? month : null
}
const isTitleLengthContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
const candidateLength = titleLengthMinimumForCondition (candidate)
const previousLength = titleLengthMinimumForCondition (previous)
if (candidateLength === null || previousLength === null)
return false
switch (answer)
{
case 'yes':
return candidateLength <= previousLength
case 'no':
return candidateLength >= previousLength
default:
return false
}
}
const isQuestionRedundantAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
const previous = answer.questionCondition
return previous !== undefined
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
})
const isSourceFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (candidate.type !== 'source' || previous.type !== 'source')
return false
switch (answer)
{
case 'yes':
return true
case 'no':
return candidate.host === previous.host
default:
return false
}
}
const isOriginalYearFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (candidate.type !== 'original-year' || previous.type !== 'original-year')
return false
switch (answer)
{
case 'yes':
return true
case 'no':
return candidate.year === previous.year
default:
return false
}
}
const isOriginalMonthFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
switch (answer)
{
case 'yes':
if (previous.type === 'original-month')
{
if (candidate.type === 'original-month')
return true
if (candidate.type === 'original-month-day')
return monthForCondition (candidate) !== previous.month
return false
}
if (previous.type === 'original-month-day')
return candidate.type === 'original-month'
|| candidate.type === 'original-month-day'
return false
case 'no':
if (previous.type === 'original-month')
{
if (candidate.type === 'original-month')
return candidate.month === previous.month
if (candidate.type === 'original-month-day')
return monthForCondition (candidate) === previous.month
return false
}
if (previous.type === 'original-month-day')
return candidate.type === 'original-month-day'
&& candidate.monthDay === previous.monthDay
return false
default:
return false
}
}
const isFactQuestionBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (!(answer === 'yes' || answer === 'no'))
return false
return isSourceFactBlocked (candidate, previous, answer)
|| isOriginalYearFactBlocked (candidate, previous, answer)
|| isOriginalMonthFactBlocked (candidate, previous, answer)
}
export const isQuestionHardFilteredAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
const previous = answer.questionCondition
if (previous === undefined)
return false
return isQuestionRedundantAfterAnswers (question, [answer])
|| isFactQuestionBlocked (question.condition, previous, answer.answer)
})
+28
ファイルの表示
@@ -10,6 +10,7 @@ const postsApi = vi.hoisted (() => ({
}))
const tagsApi = vi.hoisted (() => ({
fetchNicoTags: vi.fn (),
fetchTag: vi.fn (),
fetchTagByName: vi.fn (),
fetchTagChanges: vi.fn (),
@@ -37,6 +38,7 @@ describe ('prefetchForURL', () => {
postsApi.fetchPost.mockResolvedValue ({ id: 1 })
postsApi.fetchPostChanges.mockResolvedValue ({ versions: [], count: 0 })
tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 })
tagsApi.fetchNicoTags.mockResolvedValue ({ tags: [], count: 0 })
tagsApi.fetchTag.mockResolvedValue ({ id: 1 })
tagsApi.fetchTagByName.mockResolvedValue (null)
tagsApi.fetchTagChanges.mockResolvedValue ({ versions: [], count: 0 })
@@ -85,6 +87,32 @@ describe ('prefetchForURL', () => {
)
})
it ('prefetches nico tag indexes and their alias from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/tags/nico?name=source&linked_tag=destination'
+ '&link_status=linked&page=3&limit=10',
)
await prefetchForURL (qc (), 'http://localhost/nico/tags?page=2')
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (1, {
name: 'source',
linkedTag: 'destination',
linkStatus: 'linked',
page: 3,
limit: 10,
order: 'updated_at:desc',
})
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (2, {
name: '',
linkedTag: '',
linkStatus: 'all',
page: 2,
limit: 20,
order: 'updated_at:desc',
})
})
it ('prefetches wiki show pages and related tag/post data', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({
id: 3,

変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示