コミットを比較
1 コミット
2f87669699
..
main
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| f1181e8510 |
@@ -124,7 +124,7 @@ npm run preview
|
||||
- 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.
|
||||
elements 4 spaces deeper than the statement's base indentation.
|
||||
|
||||
### Ruby delimiter and wrapping rules
|
||||
|
||||
@@ -140,6 +140,32 @@ npm run preview
|
||||
99 文字を超えるなら block 形式へ切り替へるか、message 定数化などで縮める。
|
||||
- Ruby の method chain や call argument を折り返す際、call-site の `)` を
|
||||
block close のやうに独立させない。
|
||||
- Ruby では、行末の `\` を用途を問はず一切使用しない。
|
||||
- Ruby では、文字列連結、logger message、method call、条件式、SQL 断片、
|
||||
正規表現その他すべての式で、行末バックスラッシュによる継続を禁止する。
|
||||
- Ruby の block body は、その基準位置から 2 空白深くする。
|
||||
- Ruby の wrapped expression、method argument、array element、hash pair などの
|
||||
continuation indentation は、その statement の基準位置から 4 空白深くする。
|
||||
- Ruby の continuation indentation を、行頭からの絶対空白数として扱はない。
|
||||
- Ruby では、暗黙的に継続可能な構文を優先し、method call、array、Hash 及び
|
||||
括弧内ではバックスラッシュなしで改行する。
|
||||
- Ruby では、行長制限を守るために行末バックスラッシュを導入してはならない。
|
||||
- Ruby で行末バックスラッシュが必要に見える場合は、括弧内で自然に改行する、
|
||||
一つの文字列補間へまとめる、中間変数へ分ける、`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 空白
|
||||
深くするため、結果として行頭から 6 空白になる。
|
||||
|
||||
Bad:
|
||||
|
||||
@@ -191,6 +217,63 @@ end
|
||||
|
||||
Bad:
|
||||
|
||||
```rb
|
||||
Rails.logger.info(
|
||||
"post_import_metadata_fetch_failure "\
|
||||
"#{ payload.to_json }")
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```rb
|
||||
payload = {
|
||||
error: e.class.name,
|
||||
message: e.message }
|
||||
|
||||
Rails.logger.info(
|
||||
"post_import_metadata_fetch_failure #{ payload.to_json }")
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```rb
|
||||
message = "first "\
|
||||
"second"
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```rb
|
||||
message = format(
|
||||
'%<first>s %<second>s',
|
||||
first: 'first',
|
||||
second: 'second')
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```rb
|
||||
result = first_value + \
|
||||
second_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) }
|
||||
@@ -225,20 +308,37 @@ records.each {
|
||||
formatting as the local reference shape: component and callback bodies use
|
||||
2-space block indentation, single-line bodies do not gain unnecessary
|
||||
braces, wrapped expressions use 4-space continuation indentation, multi-
|
||||
stage ternaries use explicit parentheses, and only complete leading runs
|
||||
of 8 spaces are compressed to tabs.
|
||||
- In TypeScript and TSX only, tabs are for leading 8-column compression only.
|
||||
stage ternaries use explicit parentheses, and TypeScript / TSX indentation
|
||||
and alignment whitespace compress every complete run of 8 spaces to tabs.
|
||||
- A tab does not represent one indentation level.
|
||||
- Do not replace 2-space or 4-space indentation with tabs.
|
||||
- First determine visible indentation using 2-space block indentation and
|
||||
4-space continuation indentation, then compress only complete leading runs
|
||||
of 8 spaces into tabs.
|
||||
- Tabs are only for leading indentation, never for spaces after non-space
|
||||
text.
|
||||
- Keep residual leading 2, 4, or 6 spaces after any tab compression.
|
||||
- In TypeScript and TSX, first determine visible indentation using 2-space
|
||||
block indentation and 4-space continuation indentation, then compress every
|
||||
complete run of 8 spaces used for indentation or column alignment to tabs.
|
||||
- In TypeScript and TSX, 8-space compression is mandatory, not optional.
|
||||
- In TypeScript and TSX, this applies both to leading indentation and to
|
||||
alignment whitespace after non-space text, such as aligned inline type
|
||||
columns.
|
||||
- In TypeScript and TSX, keep residual 2, 4, or 6 spaces after each tab
|
||||
compression.
|
||||
- In TypeScript and TSX, do not treat a tab as one logical indentation level.
|
||||
- In TypeScript and TSX, do not alter string literals, template-literal
|
||||
contents, regular expressions, or user-facing text merely to apply tab
|
||||
compression.
|
||||
- Examples: 2 columns = 2 spaces, 4 columns = 4 spaces, 6 columns = 6
|
||||
spaces, 8 columns = 1 tab, 10 columns = 1 tab + 2 spaces, 12 columns = 1
|
||||
tab + 4 spaces, 16 columns = 2 tabs.
|
||||
tab + 4 spaces, 14 columns = 1 tab + 6 spaces, 16 columns = 2 tabs.
|
||||
- When TypeScript or TSX code already uses column alignment, apply the same
|
||||
8-space compression rule to that alignment whitespace.
|
||||
- Example:
|
||||
|
||||
```ts
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
displayNumber?: number
|
||||
onEdit?: () => void
|
||||
onRetry?: () => void
|
||||
onToggleSkip?: (checked: boolean) => void }
|
||||
```
|
||||
- TypeScript and TSX imports may stay on one line if they remain within the
|
||||
line limit; do not expand short type-only imports mechanically.
|
||||
- Keep runtime value imports and type imports in separate declarations.
|
||||
@@ -474,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
|
||||
|
||||
@@ -598,10 +722,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
||||
beginning of a line.
|
||||
- The TSX-specific self-review must confirm JSX closing markers and closing
|
||||
parentheses keep the surrounding compact style.
|
||||
- The TypeScript/TSX self-review must confirm leading block indentation uses
|
||||
2 spaces per level, wrapped continuations use the repository's 4-space
|
||||
continuation alignment, and complete leading runs of 8 spaces may be
|
||||
compressed to tabs.
|
||||
- The TypeScript/TSX self-review must confirm block indentation uses 2 spaces
|
||||
per level, wrapped continuations use the repository's 4-space continuation
|
||||
alignment, and every complete run of 8 spaces used for indentation or
|
||||
alignment has been compressed to tabs.
|
||||
- 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.
|
||||
@@ -614,9 +738,18 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
||||
- Indent the block body 2 spaces deeper than the keyword and opening brace.
|
||||
- Put the closing `}` on its own line at the same indentation as the keyword.
|
||||
- Do not write `try {`, `catch {`, or `finally {`.
|
||||
- In TypeScript and TSX, convert every complete leading run of 8 spaces to a
|
||||
tab character.
|
||||
- A leading tab is exactly equivalent to 8 leading spaces.
|
||||
- In TypeScript and TSX, convert every complete run of 8 spaces used for
|
||||
indentation or alignment to a tab character.
|
||||
- In TypeScript and TSX, a tab is exactly equivalent to 8 columns, whether it
|
||||
appears at the beginning of a line or in alignment whitespace after
|
||||
non-space text.
|
||||
- In TSX, JSX nesting uses 2-space block indentation and wrapped JSX
|
||||
attributes use 4-space continuation indentation; after visible columns are
|
||||
determined, compress every complete run of 8 spaces in the resulting
|
||||
indentation or alignment to tabs.
|
||||
- In TSX, do not leave JSX subtree indentation at 8, 10, 12, 14, or 16
|
||||
columns as spaces alone; convert each complete run of 8 spaces to tabs and
|
||||
keep only the residual 2, 4, or 6 spaces.
|
||||
- In TypeScript and TSX function declarations, including `const` arrow
|
||||
function declarations, classify the parameter list before placing the closing
|
||||
`)`.
|
||||
@@ -1259,9 +1392,9 @@ to `.ts` and `.tsx`:
|
||||
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
|
||||
otherwise.
|
||||
8. JSX closing parentheses keep the compact local style.
|
||||
9. Leading block indentation uses 2 spaces per level, wrapped continuations
|
||||
use the repository's 4-space continuation alignment, and complete leading
|
||||
runs of 8 spaces may be compressed to tabs.
|
||||
9. Block indentation uses 2 spaces per level, wrapped continuations use the
|
||||
repository's 4-space continuation alignment, and every complete run of 8
|
||||
spaces used for indentation or alignment has been compressed to tabs.
|
||||
10. No line has trailing whitespace.
|
||||
|
||||
Preferred:
|
||||
|
||||
@@ -138,7 +138,10 @@ class Post < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.section_literal section
|
||||
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
|
||||
end_ms =
|
||||
section.end_ms ? Post.ms_to_time(section.end_ms) : ''
|
||||
|
||||
"[#{ Post.ms_to_time(section.begin_ms) }-#{ end_ms }]"
|
||||
end
|
||||
|
||||
def self.ms_to_time ms
|
||||
@@ -323,7 +326,9 @@ class Post < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.external_svg_reference?(attribute_name, attribute_value)
|
||||
return external_svg_url?(attribute_value) if ['href', 'xlink:href', 'src'].include?(attribute_name)
|
||||
if ['href', 'xlink:href', 'src'].include?(attribute_name)
|
||||
return external_svg_url?(attribute_value)
|
||||
end
|
||||
return style_contains_disallowed_urls?(attribute_value) if attribute_name == 'style'
|
||||
return svg_url_function_disallowed?(attribute_value) if attribute_value.match?(/url\s*\(/i)
|
||||
|
||||
@@ -344,7 +349,12 @@ class Post < ApplicationRecord
|
||||
|
||||
def self.svg_url_function_disallowed?(value)
|
||||
value.to_s.scan(/url\s*\(([^)]*)\)/i).flatten.any? do |entry|
|
||||
reference = entry.to_s.strip.delete_prefix("'").delete_prefix('"').delete_suffix("'").delete_suffix('"')
|
||||
reference =
|
||||
entry.to_s.strip
|
||||
.delete_prefix("'")
|
||||
.delete_prefix('"')
|
||||
.delete_suffix("'")
|
||||
.delete_suffix('"')
|
||||
reference.present? && !(reference.start_with?('#'))
|
||||
end
|
||||
end
|
||||
@@ -433,7 +443,12 @@ class Post < ApplicationRecord
|
||||
|
||||
match =
|
||||
value.match(
|
||||
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?\z/)
|
||||
/
|
||||
\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})
|
||||
(?::(\d{2})(?:\.(\d+))?)?
|
||||
(Z|[+-]\d{2}:?\d{2})?
|
||||
\z
|
||||
/x)
|
||||
return nil if match.nil?
|
||||
|
||||
year = match[1].to_i
|
||||
|
||||
@@ -106,11 +106,14 @@ module PostRepr
|
||||
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
|
||||
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
|
||||
Rails.logger.warn(
|
||||
"PostRepr.thumbnail_url failed post_id=#{post.id} " \
|
||||
"attachment_id=#{post.thumbnail.attachment&.id} " \
|
||||
"blob_id=#{post.thumbnail.blob&.id} " \
|
||||
"error_class=#{e.class} message=#{e.message}")
|
||||
payload = {
|
||||
post_id: post.id,
|
||||
attachment_id: post.thumbnail.attachment&.id,
|
||||
blob_id: post.thumbnail.blob&.id,
|
||||
error_class: e.class,
|
||||
message: e.message }
|
||||
|
||||
Rails.logger.warn("PostRepr.thumbnail_url failed #{ payload.to_json }")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -85,7 +85,9 @@ class PostCreatePlan
|
||||
end_raw: match[3],
|
||||
tag_name: name)
|
||||
end
|
||||
raise Tag::SectionLiteralParseError.new(raw_name, raw_name) if name.include?('[') || name.include?(']')
|
||||
if name.include?('[') || name.include?(']')
|
||||
raise Tag::SectionLiteralParseError.new(raw_name, raw_name)
|
||||
end
|
||||
|
||||
[resolved_tag_name(name), category&.to_sym, sections]
|
||||
end
|
||||
|
||||
@@ -225,15 +225,15 @@ class PostImportPreviewer
|
||||
end
|
||||
{ data:, warnings:, validation_errors: { } }
|
||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||
payload = { error: e.class.name, message: e.message }
|
||||
Rails.logger.info(
|
||||
"post_import_metadata_fetch_unsafe_url "\
|
||||
"#{ { error: e.class.name, message: e.message }.to_json }")
|
||||
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
|
||||
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
|
||||
rescue Preview::HttpFetcher::FetchFailed,
|
||||
Preview::HttpFetcher::ResponseTooLarge => e
|
||||
payload = { error: e.class.name, message: e.message }
|
||||
Rails.logger.info(
|
||||
"post_import_metadata_fetch_failure "\
|
||||
"#{ { error: e.class.name, message: e.message }.to_json }")
|
||||
"post_import_metadata_fetch_failure #{ payload.to_json }")
|
||||
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
|
||||
end
|
||||
|
||||
@@ -335,14 +335,14 @@ class PostImportPreviewer
|
||||
def safe_fetch_metadata url
|
||||
fetch_metadata(url)
|
||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||
payload = { error: e.class.name, message: e.message }
|
||||
Rails.logger.info(
|
||||
"post_import_metadata_fetch_unsafe_url "\
|
||||
"#{ { error: e.class.name, message: e.message }.to_json }")
|
||||
"post_import_metadata_fetch_unsafe_url #{ payload.to_json }")
|
||||
{ data: { }, warnings: { }, validation_errors: { url: [e.message] } }
|
||||
rescue StandardError => e
|
||||
payload = { error: e.class.name, message: e.message }
|
||||
Rails.logger.error(
|
||||
"post_import_metadata_fetch_unexpected_failure "\
|
||||
"#{ { error: e.class.name, message: e.message }.to_json }")
|
||||
"post_import_metadata_fetch_unexpected_failure #{ payload.to_json }")
|
||||
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
|
||||
end
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ require 'date'
|
||||
|
||||
class PostMetadataFetcher
|
||||
TIMESTAMP_PATTERN =
|
||||
Regexp.new(
|
||||
'\A(\d{4})-(\d{2})-(\d{2})T(\d{2})' \
|
||||
'(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?' \
|
||||
'(Z|[+-]\d{2}:?\d{2})?\z')
|
||||
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2})
|
||||
(?::(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?
|
||||
(Z|[+-]\d{2}:?\d{2})?\z/x
|
||||
|
||||
def self.fetch raw_url
|
||||
uri, = Preview::UrlSafety.validate(raw_url)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
require "active_support/core_ext/integer/time"
|
||||
require 'active_support/core_ext/integer/time'
|
||||
|
||||
Rails.application.configure do
|
||||
# Settings specified here will take precedence over those in config/application.rb.
|
||||
@@ -17,8 +17,8 @@ Rails.application.configure do
|
||||
|
||||
# Enable/disable Action Controller caching. By default Action Controller caching is disabled.
|
||||
# Run rails dev:cache to toggle Action Controller caching.
|
||||
if Rails.root.join("tmp/caching-dev.txt").exist?
|
||||
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
|
||||
if Rails.root.join('tmp/caching-dev.txt').exist?
|
||||
config.public_file_server.headers = { 'cache-control' => "public, max-age=#{2.days.to_i}" }
|
||||
else
|
||||
config.action_controller.perform_caching = false
|
||||
end
|
||||
|
||||
@@ -101,34 +101,34 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /posts" do
|
||||
describe 'GET /posts' do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
let!(:tag_name) { TagName.create!(name: "spec_tag") }
|
||||
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
|
||||
let!(:tag) { Tag.create!(tag_name:, category: :general) }
|
||||
let!(:tag_name2) { TagName.create!(name: 'unko') }
|
||||
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
|
||||
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
|
||||
|
||||
let!(:hit_post) do
|
||||
Post.create!(uploaded_user: user, title: "hello spec world",
|
||||
Post.create!(uploaded_user: user, title: 'hello spec world',
|
||||
url: 'https://example.com/spec2').tap do |p|
|
||||
PostTag.create!(post: p, tag:)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:miss_post) do
|
||||
Post.create!(uploaded_user: user, title: "unrelated title",
|
||||
Post.create!(uploaded_user: user, title: 'unrelated title',
|
||||
url: 'https://example.com/spec3').tap do |p|
|
||||
PostTag.create!(post: p, tag: tag2)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns posts with tag name in JSON" do
|
||||
get "/posts"
|
||||
it 'returns posts with tag name in JSON' do
|
||||
get '/posts'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
posts = json.fetch("posts")
|
||||
posts = json.fetch('posts')
|
||||
|
||||
# 全postの全tagが name を含むこと
|
||||
expect(posts).not_to be_empty
|
||||
@@ -141,8 +141,8 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(json['count']).to be_an(Integer)
|
||||
|
||||
# spec_tag を含む投稿が存在すること
|
||||
all_tag_names = posts.flat_map { |p| p["tags"].map { |t| t["name"] } }
|
||||
expect(all_tag_names).to include("spec_tag")
|
||||
all_tag_names = posts.flat_map { |p| p['tags'].map { |t| t['name'] } }
|
||||
expect(all_tag_names).to include('spec_tag')
|
||||
end
|
||||
|
||||
it 'keeps children and sections keys in non-detail tag responses' do
|
||||
@@ -161,9 +161,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
])
|
||||
end
|
||||
|
||||
context "when q is provided" do
|
||||
it "filters posts by q (hit case)" do
|
||||
get "/posts", params: { tags: "spec_tag" }
|
||||
context 'when q is provided' do
|
||||
it 'filters posts by q (hit case)' do
|
||||
get '/posts', params: { tags: 'spec_tag' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
posts = json.fetch('posts')
|
||||
@@ -181,8 +181,8 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it "filters posts by q (hit case by alias)" do
|
||||
get "/posts", params: { tags: "manko" }
|
||||
it 'filters posts by q (hit case by alias)' do
|
||||
get '/posts', params: { tags: 'manko' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
posts = json.fetch('posts')
|
||||
@@ -200,11 +200,11 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it "returns empty posts when nothing matches" do
|
||||
get "/posts", params: { tags: "no_such_keyword_12345" }
|
||||
it 'returns empty posts when nothing matches' do
|
||||
get '/posts', params: { tags: 'no_such_keyword_12345' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch("posts")).to eq([])
|
||||
expect(json.fetch('posts')).to eq([])
|
||||
expect(json.fetch('count')).to eq(0)
|
||||
end
|
||||
end
|
||||
@@ -1161,7 +1161,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
context "when nico tag already exists in tags" do
|
||||
context 'when nico tag already exists in tags' do
|
||||
before do
|
||||
Tag.find_undiscard_or_create_by!(
|
||||
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
|
||||
@@ -1455,7 +1455,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
context "when nico tag already exists in tags" do
|
||||
context 'when nico tag already exists in tags' do
|
||||
before do
|
||||
Tag.find_undiscard_or_create_by!(
|
||||
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
|
||||
@@ -1712,7 +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
|
||||
@@ -1,10 +1,10 @@
|
||||
require "rails_helper"
|
||||
require 'rails_helper'
|
||||
|
||||
|
||||
RSpec.describe "nico:sync" do
|
||||
RSpec.describe 'nico:sync' do
|
||||
def stub_python(json_array)
|
||||
status = instance_double(Process::Status, success?: true)
|
||||
allow(Open3).to receive(:capture3).and_return([json_array.to_json, "", status])
|
||||
allow(Open3).to receive(:capture3).and_return([json_array.to_json, '', status])
|
||||
end
|
||||
|
||||
def create_tag!(name, category:)
|
||||
@@ -16,21 +16,21 @@ RSpec.describe "nico:sync" do
|
||||
NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id)
|
||||
end
|
||||
|
||||
it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do
|
||||
it '既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける' do
|
||||
# 既存 post(正規表現で拾われるURL)
|
||||
post = Post.create!(
|
||||
title: "old",
|
||||
url: "https://www.nicovideo.jp/watch/sm9",
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
# 既存の非nicoタグ(kept_non_nico_ids)
|
||||
kept_general = create_tag!("spec_kept", category: "general")
|
||||
kept_general = create_tag!('spec_kept', category: 'general')
|
||||
PostTag.create!(post: post, tag: kept_general)
|
||||
|
||||
# 追加される linked tag を準備(nico tag に紐付く一般タグ)
|
||||
linked = create_tag!("spec_linked", category: "general")
|
||||
nico = create_tag!("nico:AAA", category: "nico")
|
||||
linked = create_tag!('spec_linked', category: 'general')
|
||||
nico = create_tag!('nico:AAA', category: 'nico')
|
||||
link_nico_to_tag!(nico, linked)
|
||||
|
||||
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
|
||||
@@ -46,22 +46,22 @@ RSpec.describe "nico:sync" do
|
||||
'deleted_at' => '2026-01-31 00:00:00' }])
|
||||
|
||||
# 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ
|
||||
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>"))
|
||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||
|
||||
run_rake_task("nico:sync")
|
||||
run_rake_task('nico:sync')
|
||||
|
||||
post.reload
|
||||
active_tag_names = post.tags.joins(:tag_name).pluck("tag_names.name")
|
||||
active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name')
|
||||
|
||||
expect(active_tag_names).to include("spec_kept")
|
||||
expect(active_tag_names).to include("nico:AAA")
|
||||
expect(active_tag_names).to include("spec_linked")
|
||||
expect(active_tag_names).to include('spec_kept')
|
||||
expect(active_tag_names).to include('nico:AAA')
|
||||
expect(active_tag_names).to include('spec_linked')
|
||||
|
||||
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
|
||||
expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z'))
|
||||
|
||||
# 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids)
|
||||
expect(active_tag_names).to include("bot操作")
|
||||
expect(active_tag_names).to include('bot操作')
|
||||
end
|
||||
|
||||
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
|
||||
@@ -108,29 +108,29 @@ RSpec.describe "nico:sync" do
|
||||
expect(calls).to eq(2)
|
||||
end
|
||||
|
||||
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
|
||||
it '既存 post にあった古い nico tag は active から外され、履歴として discard される' do
|
||||
post = Post.create!(
|
||||
title: "old",
|
||||
url: "https://www.nicovideo.jp/watch/sm9",
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
# 旧nicoタグ(今回の同期結果に含まれない)
|
||||
old_nico = create_tag!("nico:OLD", category: "nico")
|
||||
old_nico = create_tag!('nico:OLD', category: 'nico')
|
||||
old_pt = PostTag.create!(post: post, tag: old_nico)
|
||||
expect(old_pt.discarded_at).to be_nil
|
||||
|
||||
# 今回は NEW のみ欲しい
|
||||
new_nico = create_tag!("nico:NEW", category: "nico")
|
||||
new_nico = create_tag!('nico:NEW', category: 'nico')
|
||||
|
||||
# bot/tagme 念のため
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
|
||||
stub_python([{ "code" => "sm9", "title" => "t", "tags" => ["NEW"] }])
|
||||
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>"))
|
||||
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => ['NEW'] }])
|
||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||
|
||||
run_rake_task("nico:sync")
|
||||
run_rake_task('nico:sync')
|
||||
|
||||
# OLD は active から外れる(discarded_at が入る)
|
||||
old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a
|
||||
@@ -138,9 +138,9 @@ RSpec.describe "nico:sync" do
|
||||
|
||||
# NEW は active にいる
|
||||
post.reload
|
||||
active_names = post.tags.joins(:tag_name).pluck("tag_names.name")
|
||||
expect(active_names).to include("nico:NEW")
|
||||
expect(active_names).not_to include("nico:OLD")
|
||||
active_names = post.tags.joins(:tag_name).pluck('tag_names.name')
|
||||
expect(active_names).to include('nico:NEW')
|
||||
expect(active_names).not_to include('nico:OLD')
|
||||
end
|
||||
|
||||
def snapshot_tags(post)
|
||||
|
||||
@@ -92,7 +92,9 @@ describe ('PostEditForm', () => {
|
||||
.toHaveValue ('180.5')
|
||||
})
|
||||
|
||||
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
|
||||
it (
|
||||
'shows deduplicated original-created endpoint errors on the shared datetime field',
|
||||
async () => {
|
||||
const post = buildPost ()
|
||||
api.isApiError.mockReturnValue (true)
|
||||
postsApi.updatePost.mockRejectedValueOnce ({
|
||||
@@ -114,5 +116,5 @@ describe ('PostEditForm', () => {
|
||||
expect (await screen.findByText ('日時を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('終了を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getAllByText ('日時を確認してください.')).toHaveLength (1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,8 +75,6 @@ const TagLink: FC<Props> = ({ tag,
|
||||
const spanClass = 'tag-link-colour'
|
||||
const linkClass = 'tag-link-colour tag-link-hover-colour'
|
||||
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
|
||||
const rootClass =
|
||||
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
|
||||
const markerWrapClass = 'shrink-0 self-start md:self-auto'
|
||||
const countClass = 'shrink-0 self-end md:self-auto'
|
||||
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
|
||||
@@ -84,7 +82,10 @@ const TagLink: FC<Props> = ({ tag,
|
||||
?? (matchedAlias == null ? tag.name : `${ matchedAlias } → ${ tag.name }`)
|
||||
|
||||
return (
|
||||
<span className={rootClass}>
|
||||
<span
|
||||
className={cn (
|
||||
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline',
|
||||
'gap-x-1 md:items-baseline')}>
|
||||
{(linkFlg && withWiki && isFullTag (tag)) && (
|
||||
<span className={markerWrapClass}>
|
||||
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
@@ -15,111 +15,109 @@ const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className)}
|
||||
{...props}
|
||||
/>))
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn (
|
||||
'fixed inset-0 z-50 bg-black/80',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className)}
|
||||
{...props}/>))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn (
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
|
||||
'translate-x-[-50%] translate-y-[-50%]',
|
||||
'gap-5 rounded-2xl border border-border',
|
||||
'bg-background p-6 text-foreground shadow-2xl',
|
||||
'duration-200',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn (
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
|
||||
'translate-x-[-50%] translate-y-[-50%]',
|
||||
'gap-5 rounded-2xl border border-border',
|
||||
'bg-background p-6 text-foreground shadow-2xl',
|
||||
'duration-200',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
className)}
|
||||
{...props}>
|
||||
{children}
|
||||
|
||||
<DialogPrimitive.Close
|
||||
className={cn (
|
||||
'absolute left-4 top-4 rounded-full p-1',
|
||||
'text-slate-500 transition-colors',
|
||||
'hover:bg-slate-200 hover:text-slate-900',
|
||||
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
|
||||
<X className="h-4 w-4"/>
|
||||
<span className="sr-only">閉ぢる</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>))
|
||||
<DialogPrimitive.Close
|
||||
className={cn (
|
||||
'absolute left-4 top-4 rounded-full p-1',
|
||||
'text-slate-500 transition-colors',
|
||||
'hover:bg-slate-200 hover:text-slate-900',
|
||||
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
|
||||
<X className="h-4 w-4"/>
|
||||
<span className="sr-only">閉ぢる</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center md:text-left",
|
||||
className)}
|
||||
{...props}
|
||||
/>)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
const DialogHeader = (
|
||||
{ className, ...props }: React.HTMLAttributes<HTMLDivElement>,
|
||||
) => (
|
||||
<div
|
||||
className={cn (
|
||||
'flex flex-col space-y-1.5 text-center md:text-left',
|
||||
className)}
|
||||
{...props}/>)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse md:flex-row md:justify-end md:space-x-2",
|
||||
className)}
|
||||
{...props}
|
||||
/>)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
const DialogFooter = (
|
||||
{ className, ...props }: React.HTMLAttributes<HTMLDivElement>,
|
||||
) => (
|
||||
<div
|
||||
className={cn (
|
||||
'flex flex-col-reverse md:flex-row md:justify-end md:space-x-2',
|
||||
className)}
|
||||
{...props}/>)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className)}
|
||||
{...props}
|
||||
/>))
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn (
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className)}
|
||||
{...props}/>))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>))
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn ('text-sm text-muted-foreground', className)}
|
||||
{...props}/>))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
|
||||
import { materialsKeys } from '@/lib/queryKeys'
|
||||
import { dateString, inputClass } from '@/lib/utils'
|
||||
import { cn, dateString, inputClass } from '@/lib/utils'
|
||||
|
||||
import type { FC, FormEvent } from 'react'
|
||||
|
||||
@@ -113,10 +113,11 @@ const clearedTagSelectionPath = (
|
||||
|
||||
const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
|
||||
<div
|
||||
className={`flex aspect-square h-[180px] w-[180px] items-center justify-center
|
||||
overflow-hidden rounded-lg border border-stone-200 bg-white text-center
|
||||
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900
|
||||
dark:text-stone-100`}>
|
||||
className={cn (
|
||||
'flex aspect-square h-[180px] w-[180px] items-center justify-center',
|
||||
'overflow-hidden rounded-lg border border-stone-200 bg-white text-center',
|
||||
'text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900',
|
||||
'dark:text-stone-100')}>
|
||||
{material.thumbnail
|
||||
? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
|
||||
: (
|
||||
@@ -531,30 +532,32 @@ const MaterialListPage: FC = () => {
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery ({ view: 'card' })}
|
||||
className={`rounded-full border px-4 py-2 text-sm ${
|
||||
view === 'card'
|
||||
? [
|
||||
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
|
||||
: [
|
||||
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
||||
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery ({ view: 'card' })}
|
||||
className={cn (
|
||||
'rounded-full border px-4 py-2 text-sm',
|
||||
view === 'card'
|
||||
? [
|
||||
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||
'dark:bg-sky-950 dark:text-sky-100']
|
||||
: [
|
||||
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
||||
'dark:bg-stone-900 dark:text-stone-100'])}>
|
||||
アイコン
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery ({ view: 'list' })}
|
||||
className={`rounded-full border px-4 py-2 text-sm ${
|
||||
view === 'list'
|
||||
? [
|
||||
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
|
||||
: [
|
||||
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
||||
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateQuery ({ view: 'list' })}
|
||||
className={cn (
|
||||
'rounded-full border px-4 py-2 text-sm',
|
||||
view === 'list'
|
||||
? [
|
||||
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||
'dark:bg-sky-950 dark:text-sky-100']
|
||||
: [
|
||||
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
|
||||
'dark:bg-stone-900 dark:text-stone-100'])}>
|
||||
詳細
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
|
||||
@@ -262,7 +262,9 @@ describe ('TheatreDetailPage', () => {
|
||||
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
|
||||
})
|
||||
|
||||
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
|
||||
it (
|
||||
'shows child tags from the post tag tree in both vertical and horizontal layouts',
|
||||
async () => {
|
||||
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
|
||||
const parentTag = buildTag ({
|
||||
id: 11,
|
||||
@@ -307,7 +309,7 @@ describe ('TheatreDetailPage', () => {
|
||||
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
|
||||
.toHaveLength (1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it ('does not advance host post while video length is unknown', async () => {
|
||||
api.apiPut.mockImplementation ((path: string) => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
新しい課題から参照
ユーザをブロックする