コミットを比較
2 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| c1d9e3638b | |||
| f1181e8510 |
@@ -154,6 +154,14 @@ npm run preview
|
||||
一つの文字列補間へまとめる、中間変数へ分ける、`format` を使ふ、heredoc を
|
||||
使ふ、array 又は Hash を組み立ててから処理する、条件式全体を括弧で囲む、
|
||||
method へ抽出する、のいづれかへ書き換へる。
|
||||
- Ruby では、一つの文字列を、改行をまたいだ隣接文字列 literal として記述しない。
|
||||
- Ruby では、method argument 内でも、複数の文字列 literal を区切りなしで縦に
|
||||
並べない。
|
||||
- RSpec の `describe`、`context`、`it` 等の description が長い場合は、意味を
|
||||
保ったまま一行へ収まる文言へ短縮する。
|
||||
- 文字列を短縮できない場合は、用途に応じて `format`、heredoc 又は中間変数を
|
||||
検討する。
|
||||
- ただし RSpec description では、原則として簡潔な一行の文字列を使ふ。
|
||||
- formatter 又は自動修正にも、Ruby の行末バックスラッシュを生成させない。
|
||||
- 新規 code だけでなく、今回触れる Ruby code にも行末バックスラッシュを残さない。
|
||||
- 例へば class body 内の array は、class body の 2 空白を基準に、更に 4 空白
|
||||
@@ -251,6 +259,21 @@ result = first_value + \
|
||||
|
||||
Bad:
|
||||
|
||||
```rb
|
||||
it(
|
||||
'returns 409 when stale changes '
|
||||
'do not conflict'
|
||||
) do
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```rb
|
||||
it 'returns mergeable 409 for stale non-conflicting changes' do
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```rb
|
||||
records.each {
|
||||
do_work(_1) }
|
||||
@@ -551,8 +574,32 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
||||
wording and placement before implementing it.
|
||||
- Do not invent replacement copy when removing unrequested wording.
|
||||
- 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.
|
||||
test work. When the user asks for tests, keep working within the permitted
|
||||
test-file scope and rerun them until they pass or the remaining failure is
|
||||
clearly blocked.
|
||||
- Test-only work includes adding, updating, deleting, reorganising, or fixing
|
||||
SyntaxError in tests. During test-only work, do not modify production code.
|
||||
- During test-only work, do not change production constants, behaviour, API
|
||||
contracts, validation, routes, authentication, permissions, UI, copy,
|
||||
dependencies, limits, thresholds, defaults, migrations, schema, or
|
||||
environment settings to satisfy tests.
|
||||
- Do not make production code match failing tests, mock assumptions, fixtures,
|
||||
snapshots, old expectations, or stale setup. This includes changing
|
||||
production constants merely because a test expects a different value.
|
||||
- If test work reveals a production bug, spec mismatch, or missing behaviour,
|
||||
stop without modifying production code and report: the failing test or
|
||||
discovered issue, the related production file, the actual behaviour, the
|
||||
expected behaviour, and why a production change appears necessary.
|
||||
- Modify production code for test failures only when the user explicitly asks
|
||||
for that production change. Do not expand a test task into a production task
|
||||
on your own authority.
|
||||
- If the user explicitly asks for both production implementation and test
|
||||
updates, implement production code to the confirmed specification first,
|
||||
then add or update tests to verify that specification. Never roll production
|
||||
behaviour back to satisfy old tests.
|
||||
- If it is unclear whether the test or the production implementation is stale,
|
||||
or a test cannot be corrected without changing production code, ask the user
|
||||
instead of guessing.
|
||||
|
||||
## Backend rules
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0]
|
||||
RESOLUTION_GRACE = 1.second
|
||||
SECTION_LITERAL_PATTERN = /\[[^\[\]\s]*-[^\[\]\s]*\]\z/
|
||||
|
||||
class MigrationPostVersion < ActiveRecord::Base
|
||||
self.table_name = 'post_versions'
|
||||
end
|
||||
|
||||
class MigrationTag < ActiveRecord::Base
|
||||
self.table_name = 'tags'
|
||||
end
|
||||
|
||||
class MigrationTagName < ActiveRecord::Base
|
||||
self.table_name = 'tag_names'
|
||||
end
|
||||
|
||||
class MigrationTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'tag_versions'
|
||||
end
|
||||
|
||||
class MigrationNicoTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'nico_tag_versions'
|
||||
end
|
||||
|
||||
def up
|
||||
add_column :post_versions, :tags_json, :json, after: :tags
|
||||
MigrationPostVersion.reset_column_information
|
||||
|
||||
backfill_missing_initial_tag_versions!
|
||||
intervals_by_name = build_intervals_by_name
|
||||
|
||||
say_with_time 'Backfilling post_versions.tags_json' do
|
||||
MigrationPostVersion
|
||||
.where(tags_json: nil)
|
||||
.find_each(batch_size: 500) do |version|
|
||||
version.update_columns(
|
||||
tags_json: build_tags_json(version, intervals_by_name))
|
||||
end
|
||||
end
|
||||
|
||||
assert_backfill_complete!
|
||||
change_column_null :post_versions, :tags_json, false
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :post_versions, :tags_json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def backfill_missing_initial_tag_versions!
|
||||
say_with_time 'Backfilling missing initial tag versions' do
|
||||
tag_rows = missing_initial_version_rows(MigrationTagVersion, nico: false)
|
||||
nico_rows = missing_initial_version_rows(MigrationNicoTagVersion, nico: true)
|
||||
|
||||
MigrationTagVersion.insert_all!(tag_rows) if tag_rows.any?
|
||||
MigrationNicoTagVersion.insert_all!(nico_rows) if nico_rows.any?
|
||||
|
||||
tag_rows.length + nico_rows.length
|
||||
end
|
||||
end
|
||||
|
||||
def missing_initial_version_rows version_class, nico:
|
||||
first_versions =
|
||||
version_class
|
||||
.order(:tag_id, :version_no)
|
||||
.to_a
|
||||
.group_by(&:tag_id)
|
||||
.transform_values(&:first)
|
||||
rows = []
|
||||
|
||||
MigrationTag.find_each do |tag|
|
||||
next if (tag.category == 'nico') != nico
|
||||
|
||||
first_version = first_versions[tag.id]
|
||||
next unless first_version
|
||||
next if valid_initial_version?(first_version)
|
||||
|
||||
assert_inferable_initial_version!(tag, first_version)
|
||||
rows << initial_version_row(tag, first_version, nico:)
|
||||
end
|
||||
|
||||
rows
|
||||
end
|
||||
|
||||
def valid_initial_version? version
|
||||
version.version_no == 1 && version.event_type == 'create'
|
||||
end
|
||||
|
||||
def assert_inferable_initial_version! tag, version
|
||||
inferable =
|
||||
version.version_no == 2 &&
|
||||
version.event_type == 'discard' &&
|
||||
tag.created_at < version.created_at
|
||||
return if inferable
|
||||
|
||||
details = [
|
||||
"tag_id=#{ tag.id }",
|
||||
"version_no=#{ version.version_no }",
|
||||
"event_type=#{ version.event_type.inspect }",
|
||||
"tag_created_at=#{ tag.created_at.iso8601(6) }",
|
||||
"version_created_at=#{ version.created_at.iso8601(6) }"]
|
||||
|
||||
raise "Cannot infer initial tag version: #{ details.join(', ') }"
|
||||
end
|
||||
|
||||
def initial_version_row tag, discard_version, nico:
|
||||
row = {
|
||||
tag_id: tag.id,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: discard_version.name,
|
||||
created_at: tag.created_at,
|
||||
created_by_user_id: nil }
|
||||
|
||||
if nico
|
||||
return row.merge(linked_tags: discard_version.linked_tags)
|
||||
end
|
||||
|
||||
row.merge(
|
||||
category: discard_version.category,
|
||||
aliases: discard_version.aliases,
|
||||
parent_tag_ids: discard_version.parent_tag_ids,
|
||||
deprecated_at: discard_version.deprecated_at)
|
||||
end
|
||||
|
||||
def build_intervals_by_name
|
||||
intervals_by_name = Hash.new { |hash, name| hash[name] = [] }
|
||||
versions_by_kind = {
|
||||
tag: versions_by_tag_id(MigrationTagVersion),
|
||||
nico: versions_by_tag_id(MigrationNicoTagVersion) }
|
||||
current_names = current_names_by_tag_id
|
||||
|
||||
MigrationTag.find_each do |tag|
|
||||
nico = tag.category == 'nico'
|
||||
kind = nico ? :nico : :tag
|
||||
versions = versions_by_kind.fetch(kind).fetch(tag.id, [])
|
||||
|
||||
intervals_for(
|
||||
tag,
|
||||
versions,
|
||||
current_name: current_names.fetch(tag.id),
|
||||
nico:).each do |interval|
|
||||
name = interval.delete(:name)
|
||||
intervals_by_name[name] << interval
|
||||
end
|
||||
end
|
||||
|
||||
intervals_by_name
|
||||
end
|
||||
|
||||
def versions_by_tag_id version_class
|
||||
version_class
|
||||
.order(:tag_id, :version_no)
|
||||
.to_a
|
||||
.group_by(&:tag_id)
|
||||
end
|
||||
|
||||
def current_names_by_tag_id
|
||||
MigrationTagName
|
||||
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
|
||||
.pluck('tags.id', 'tag_names.name')
|
||||
.to_h
|
||||
end
|
||||
|
||||
def intervals_for tag, versions, current_name:, nico:
|
||||
if versions.empty?
|
||||
return [{
|
||||
name: current_name,
|
||||
tag_id: tag.id,
|
||||
version_no: tag.version_no,
|
||||
category: nico ? 'nico' : tag.category,
|
||||
from: tag.created_at,
|
||||
to: tag.discarded_at }]
|
||||
end
|
||||
|
||||
versions.each_with_index.filter_map do |version, index|
|
||||
next if version.event_type == 'discard'
|
||||
|
||||
{
|
||||
name: version.name,
|
||||
tag_id: tag.id,
|
||||
version_no: version.version_no,
|
||||
category: nico ? 'nico' : version.category,
|
||||
from: version.created_at,
|
||||
to: versions[index + 1]&.created_at || tag.discarded_at }
|
||||
end
|
||||
end
|
||||
|
||||
def build_tags_json version, intervals_by_name
|
||||
entries = version.tags.to_s.split.map do |literal|
|
||||
name = tag_name_from_literal(literal)
|
||||
interval = resolve_tag!(intervals_by_name.fetch(name, []), name:, version:)
|
||||
|
||||
{ 'id' => interval.fetch(:tag_id),
|
||||
'version_no' => interval.fetch(:version_no),
|
||||
'name' => name,
|
||||
'category' => interval.fetch(:category),
|
||||
'sections' => [] }
|
||||
end
|
||||
|
||||
assert_unique_tag_ids!(version, entries)
|
||||
|
||||
entries.sort_by { |entry| entry.fetch('id') }
|
||||
end
|
||||
|
||||
def tag_name_from_literal literal
|
||||
name = literal.dup
|
||||
name.sub!(SECTION_LITERAL_PATTERN, '') while name.match?(
|
||||
SECTION_LITERAL_PATTERN)
|
||||
|
||||
if name.empty? || name.include?('[') || name.include?(']')
|
||||
raise "Invalid legacy tag literal: #{ literal.inspect }"
|
||||
end
|
||||
|
||||
name
|
||||
end
|
||||
|
||||
def resolve_tag! intervals, name:, version:
|
||||
time = version.created_at
|
||||
candidates = intervals.select do |interval|
|
||||
interval.fetch(:from) <= time &&
|
||||
(interval[:to].nil? || time < interval.fetch(:to))
|
||||
end
|
||||
|
||||
candidates = future_candidates(intervals, time) if candidates.empty?
|
||||
|
||||
return candidates.first if candidates.one?
|
||||
|
||||
candidate_versions = candidates.map do |candidate|
|
||||
[candidate.fetch(:tag_id), candidate.fetch(:version_no)]
|
||||
end
|
||||
details = [
|
||||
"post_version_id=#{ version.id }",
|
||||
"post_id=#{ version.post_id }",
|
||||
"name=#{ name.inspect }",
|
||||
"created_at=#{ time.iso8601(6) }",
|
||||
"candidates=#{ candidate_versions.inspect }"].join(', ')
|
||||
|
||||
raise "Could not resolve tag snapshot: #{ details }"
|
||||
end
|
||||
|
||||
def future_candidates intervals, time
|
||||
candidates = intervals.select do |interval|
|
||||
interval.fetch(:from) > time &&
|
||||
interval.fetch(:from) <= time + RESOLUTION_GRACE
|
||||
end
|
||||
return [] if candidates.empty?
|
||||
|
||||
nearest_from = candidates.map { |interval| interval.fetch(:from) }.min
|
||||
|
||||
candidates.select do |interval|
|
||||
interval.fetch(:from) == nearest_from
|
||||
end
|
||||
end
|
||||
|
||||
def assert_unique_tag_ids! version, entries
|
||||
duplicate_tag_ids =
|
||||
entries
|
||||
.map { |entry| entry.fetch('id') }
|
||||
.tally
|
||||
.select { |_tag_id, count| count > 1 }
|
||||
.keys
|
||||
return if duplicate_tag_ids.empty?
|
||||
|
||||
details = [
|
||||
"post_version_id=#{ version.id }",
|
||||
"duplicate_tag_ids=#{ duplicate_tag_ids.inspect }"].join(', ')
|
||||
|
||||
raise "Duplicate tag IDs: #{ details }"
|
||||
end
|
||||
|
||||
def assert_backfill_complete!
|
||||
missing_count = MigrationPostVersion.where(tags_json: nil).count
|
||||
return if missing_count.zero?
|
||||
|
||||
raise "#{ missing_count } post versions were not backfilled"
|
||||
end
|
||||
end
|
||||
生成ファイル
+2
-1
@@ -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_07_13_000000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) 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
|
||||
@@ -349,6 +349,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
|
||||
t.string "url", limit: 768, null: false
|
||||
t.string "thumbnail_base", limit: 2000
|
||||
t.text "tags", null: false
|
||||
t.json "tags_json", null: false
|
||||
t.text "parent_post_ids", null: false
|
||||
t.datetime "original_created_from"
|
||||
t.datetime "original_created_before"
|
||||
|
||||
@@ -1712,10 +1712,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(post_record.reload.title).to eq('updated by other user')
|
||||
end
|
||||
|
||||
it(
|
||||
'returns 409 with mergeable true when stale tag changes '
|
||||
'do not conflict but merge is not requested'
|
||||
) do
|
||||
it 'returns mergeable 409 for stale non-conflicting tag changes without merge' do
|
||||
sign_in_as(member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki body search', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
it 'searches wiki pages by body text' do
|
||||
pending '#336 で対応予定'
|
||||
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: TagName.create!(name: 'wiki_body_search_hit'),
|
||||
body: 'unique body keyword for wiki search',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: TagName.create!(name: 'wiki_body_search_miss'),
|
||||
body: 'ordinary body',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
|
||||
get '/wiki/search', params: { body: 'unique body keyword' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit')
|
||||
expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss')
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki restore', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
end
|
||||
|
||||
it 'restores wiki page to previous version' do
|
||||
pending '#337 で対応予定'
|
||||
|
||||
page =
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: TagName.create!(name: 'wiki_restore_page'),
|
||||
body: 'v1',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
|
||||
v1 = page.wiki_versions.order(:version_no).last
|
||||
|
||||
Wiki::Commit.content!(
|
||||
page:,
|
||||
body: 'v2',
|
||||
created_user: user,
|
||||
message: 'edit',
|
||||
base_revision_id: page.current_revision.id)
|
||||
|
||||
post "/wiki/#{ page.id }/restore",
|
||||
params: { version_no: v1.version_no },
|
||||
headers: auth_headers(user)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(page.reload.body).to eq('v1')
|
||||
expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore')
|
||||
end
|
||||
end
|
||||
@@ -29,15 +29,15 @@ describe ('post new review URL state', () => {
|
||||
.toEqual (['one', 'two'])
|
||||
})
|
||||
|
||||
it ('allows at most a 4095-byte request target', () => {
|
||||
it ('allows at most a 6 143 byte request target', () => {
|
||||
const baseUrl = 'https://example.com/'
|
||||
const baseLength = postNewReviewPathByteLength ([baseUrl])
|
||||
const allowed = `${ baseUrl }${ 'a'.repeat (4_095 - baseLength) }`
|
||||
const allowed = `${ baseUrl }${ 'a'.repeat (6_143 - baseLength) }`
|
||||
const denied = `${ allowed }a`
|
||||
|
||||
expect (postNewReviewPathByteLength ([allowed])).toBe (4_095)
|
||||
expect (postNewReviewPathByteLength ([allowed])).toBe (6_143)
|
||||
expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true)
|
||||
expect (postNewReviewPathByteLength ([denied])).toBe (4_096)
|
||||
expect (postNewReviewPathByteLength ([denied])).toBe (6_144)
|
||||
expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
|
||||
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4_096
|
||||
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 6_144
|
||||
|
||||
const textEncoder = new TextEncoder ()
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ describe ('PostImportSourcePage', () => {
|
||||
const input = screen.getByRole ('textbox', { name: '' })
|
||||
|
||||
fireEvent.change (input, {
|
||||
target: { value: `https://example.com/${ 'a'.repeat (4_100) }` } })
|
||||
target: { value: `https://example.com/${ 'a'.repeat (6_200) }` } })
|
||||
|
||||
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
|
||||
})
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe ('pending high-level browser coverage', () => {
|
||||
it.todo ('adds MSW-backed API boundary tests in a follow-up issue')
|
||||
it.todo ('covers TheatreDetailPage with timer polling, comment posting, and next-post updates')
|
||||
it.todo ('covers NicoTagListPage linking and pagination against realistic API payloads')
|
||||
it.todo ('covers TagDetailSidebar drag/drop parent-child editing with pointer-event fidelity')
|
||||
it.todo ('covers TopNav desktop and mobile menu flows as browser-level integration tests')
|
||||
it.todo ('covers full App bootstrap for user creation, user verification, and 503 handling')
|
||||
})
|
||||
新しい課題から参照
ユーザをブロックする