このコミットが含まれているのは:
2026-10-29 19:49:05 +09:00
コミット e857a33ec3
14個のファイルの変更、1129行の追加、193行の削除
+67
ファイルの表示
@@ -0,0 +1,67 @@
require 'rails_helper'
RSpec.describe HistoryAccess do
include_context 'Ainu locale'
let!(:tag) { create(:tag) }
it 'uses the complete tag-description identity and version order at equal timestamps' do
locale
rows = ['ja', 'ain'].flat_map do |language|
['create', 'discard', 'restore'].each_with_index.map do |event, index|
TagDescriptionVersion.create!(tag_id: tag.id, language_code: language,
version_no: index + 1, event_type: language == 'ain' ? 'update' : event,
body: "#{ language } #{ index }", created_at: Time.utc(2024))
end
end
described_class.set(user: nil) do
expect(described_class.visible(TagDescriptionVersion.where(id: rows.map(&:id))).pluck(:id))
.to match_array([rows[2], *rows.last(3)].map(&:id))
expect { described_class.require!(rows.first) }.to raise_error(HistoryAccess::Forbidden)
end
described_class.set(user: create(:user, :admin)) do
expect(described_class.visible(TagDescriptionVersion.where(id: rows.map(&:id))).count).to eq(6)
end
end
it 'invalidates public artifacts after a new discard and binds admin artifacts to their actor' do
public_metadata = described_class.set(user: nil) { described_class.authority }
admin = create(:user, :admin)
admin_metadata = described_class.set(user: admin) { described_class.authority }
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private',
category: 'general', names: { ja: { name: 'private' } },
aliases: '', parent_tag_ids: '', created_at: Time.utc(2024))
described_class.set(user: nil) do
expect(described_class.authorised_artifact?(public_metadata)).to be(false)
expect(described_class.authorised_artifact?(admin_metadata)).to be(false)
end
described_class.set(user: create(:user, :admin)) do
expect(described_class.authorised_artifact?(admin_metadata)).to be(false)
end
described_class.set(user: admin) do
expect(described_class.authorised_artifact?(admin_metadata)).to be(true)
admin.update!(role: :guest)
expect(described_class.authorised_artifact?(admin_metadata)).to be(false)
end
end
it 'denies a blob attached to a discarded post including its derived attachment' do
post = create(:post)
blob = ActiveStorage::Blob.create_before_direct_upload!(filename: 'private.png',
byte_size: 1, checksum: Base64.strict_encode64('checksum'), content_type: 'image/png')
ActiveStorage::Attachment.create!(name: 'thumbnail', record: post, blob:)
PostVersion.create!(post:, version_no: 1, event_type: 'discard', url: post.url,
title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
variant = ActiveStorage::VariantRecord.create!(blob:, variation_digest: 'spec-variation')
derived = ActiveStorage::Blob.create_before_direct_upload!(filename: 'derived.png',
byte_size: 1, checksum: Base64.strict_encode64('checksum'), content_type: 'image/png')
ActiveStorage::Attachment.create!(name: 'image', record: variant, blob: derived)
described_class.set(user: nil) do
expect(described_class.blob_visible?(blob)).to be(false)
expect(described_class.blob_visible?(derived)).to be(false)
end
described_class.set(user: create(:user, :admin)) do
expect(described_class.blob_visible?(derived)).to be(true)
end
end
end
+40
ファイルの表示
@@ -0,0 +1,40 @@
require 'rails_helper'
RSpec.describe 'History boundaries shared with Counts', type: :request do
let!(:tag) { create(:tag, primary_name: 'description tag') }
let!(:description) do
TagDescription.create!(tag:, language_code: 'ja', version_no: 2, body: 'private body')
end
before do
['create', 'discard'].each_with_index do |event, index|
TagDescriptionVersion.create!(tag_id: tag.id, language_code: 'ja',
version_no: index + 1, event_type: event, body: 'private body',
created_at: Time.utc(2024))
end
end
it 'denies the same discarded description through Counts, the facade and the ordinary API' do
get '/counts/query', params: { resource: 'tag_descriptions', definition: 'history',
mode: 'details', locale: 'ja', execution_at: '2025-01-01T00:00:00Z' }
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to include('count' => 0, 'output_count' => 2,
'excluded_count' => 2, 'versions' => [])
["/wiki/#{ description.id }", "/tags/#{ tag.id }/description"].each do |path|
get path, params: { locale: 'ja' }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('private body')
end
get '/sitemap_entries/descriptions', params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
expect(response.parsed_body['ids']).not_to include(description.id)
end
it 'permits the server-authenticated administrator to read the discarded description' do
sign_in_as(create(:user, :admin))
get "/tags/#{ tag.id }/description", params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
expect(response.parsed_body['body']).to eq('private body')
expect(response.headers['Cache-Control']).to include('no-store')
end
end
+11 -10
ファイルの表示
@@ -1,6 +1,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Counts API', type: :request do RSpec.describe 'Legacy Counts API', type: :request do
let!(:actor) { create(:user, :member) } let!(:actor) { create(:user, :member) }
let!(:post) { create(:post) } let!(:post) { create(:post) }
let!(:tag) { create(:tag, primary_name: 'counts tag', category: :general) } let!(:tag) { create(:tag, primary_name: 'counts tag', category: :general) }
@@ -28,16 +28,16 @@ RSpec.describe 'Counts API', type: :request do
context resource do context resource do
let(:path) { "/counts/#{ resource }" } let(:path) { "/counts/#{ resource }" }
let(:id) { resource == 'posts' ? post.id : tag.id } let(:id) { resource == 'posts' ? post.id : tag.id }
let(:filters) { { resource_id: id, user_id: actor.id, let(:filters) { { resource_id: id, user_id: actor.id, latest: '0',
from: '2024-01-01T00:00Z', before: '2024-02-01T00:00Z' } } from: '2024-01-01T00:00Z', before: '2024-02-01T00:00Z' } }
it 'uses the same AND filters for SQL totals, groups and details' do it 'uses the same AND filters for SQL totals, groups and details' do
get path, params: filters.merge(axis: 'event_type') get path, params: filters.merge(axis: 'month')
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
summary = response.parsed_body summary = response.parsed_body
expect(summary['count']).to eq(2) expect(summary['count']).to eq(2)
expect(summary['groups'].sum { _1['count'] }).to eq(2) expect(summary['groups'].sum { _1['count'] }).to eq(2)
expect(summary['groups'].map { _1['key'] }).to eq(['create', 'update']) expect(summary['groups'].map { _1['key'] }).to eq(['2024-01'])
get path, params: filters.merge(mode: 'details') get path, params: filters.merge(mode: 'details')
expect(response.parsed_body['count']).to eq(2) expect(response.parsed_body['count']).to eq(2)
@@ -45,16 +45,16 @@ RSpec.describe 'Counts API', type: :request do
end end
it 'paginates details and groups independently from totals' do it 'paginates details and groups independently from totals' do
get path, params: { resource_id: id, mode: 'details', limit: 1, page: 2 } get path, params: { resource_id: id, latest: '0', mode: 'details', limit: 1, page: 2 }
expect(response.parsed_body['count']).to eq(3) expect(response.parsed_body['count']).to eq(3)
expect(response.parsed_body['versions'].sole['version_no']).to eq(2) expect(response.parsed_body['versions'].sole['version_no']).to eq(2)
get path, params: { resource_id: id, axis: 'month', limit: 1, page: 2 } get path, params: { resource_id: id, latest: '0', axis: 'month', limit: 1, page: 2 }
expect(response.parsed_body['group_count']).to eq(2) expect(response.parsed_body['group_count']).to eq(2)
expect(response.parsed_body['groups'].sole).to include('key' => '2024-02', 'count' => 1) expect(response.parsed_body['groups'].sole).to include('key' => '2024-02', 'count' => 1)
end end
it 'groups null actors and supports their drill-down filter' do it 'groups null actors and supports their drill-down filter' do
get path, params: { resource_id: id, axis: 'user' } get path, params: { resource_id: id, latest: '0', axis: 'user' }
expect(response.parsed_body['groups']).to include( expect(response.parsed_body['groups']).to include(
{ 'key' => nil, 'name' => nil, 'count' => 1 }) { 'key' => nil, 'name' => nil, 'count' => 1 })
get path, params: { resource_id: id, mode: 'details', user_id: 'none' } get path, params: { resource_id: id, mode: 'details', user_id: 'none' }
@@ -62,7 +62,7 @@ RSpec.describe 'Counts API', type: :request do
end end
it 'uses an inclusive start and exclusive end' do it 'uses an inclusive start and exclusive end' do
get path, params: { resource_id: id, get path, params: { resource_id: id, latest: '0',
from: '2024-01-01T00:00Z', before: '2024-01-02T00:00Z' } from: '2024-01-01T00:00Z', before: '2024-01-02T00:00Z' }
expect(response.parsed_body['count']).to eq(1) expect(response.parsed_body['count']).to eq(1)
end end
@@ -88,9 +88,10 @@ RSpec.describe 'Counts API', type: :request do
end end
it 'matches removed tags from the previous snapshot, not current post_tags' do it 'matches removed tags from the previous snapshot, not current post_tags' do
get '/counts/posts', params: { tag: tag.id, mode: 'details' } get '/counts/posts', params: { tag: tag.id, latest: '0', mode: 'details' }
expect(response.parsed_body['count']).to eq(2) expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1]) expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
expect(response.parsed_body['versions'].first['tags'].sole['type']).to eq('removed') expect(response.parsed_body['versions'].first['tags']).to be_empty
expect(response.parsed_body['versions'].last['tags'].sole['id']).to eq(tag.id)
end end
end end
+248
ファイルの表示
@@ -0,0 +1,248 @@
require 'rails_helper'
RSpec.describe 'Counts table API', type: :request do
include_context 'Ainu locale'
let!(:actor) { create(:user, :member, name: 'Counts actor') }
let!(:record) { create(:post) }
let!(:tag) { create(:tag, primary_name: 'alpha', category: :general) }
let!(:external) { create(:external_tag, name: 'beta') }
let(:point) { '2025-01-01T00:00:00Z' }
let(:base) do
{ resource: 'posts', definition: 'history', mode: 'details',
execution_at: point, timezone: 'UTC', locale: 'ja' }
end
before do
TagVersion.create!(tag:, version_no: 1, event_type: 'create', name: 'alpha',
category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'alpha', script_code: 'Jpan', aliases: [] } },
created_at: Time.utc(2023))
NicoTagVersion.create!(external_tag: external, version_no: 1,
event_type: 'create', name: 'beta', linked_tags: '', created_at: Time.utc(2023))
snapshots = [
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
{ external_tag_id: external.id, name: 'beta', sections: [] }]
[1, 2, 3].each do |number|
PostVersion.create!(post: record, version_no: number,
event_type: number == 1 ? 'create' : 'update', url: record.url,
title: "snapshot #{ number }", tags: '', parent_post_ids: '',
tags_json: snapshots.first(number == 1 ? 2 : (number == 2 ? 1 : 0)),
created_by_user: number == 3 ? nil : actor,
created_at: Time.utc(2024, 1, 31, 15) + (number - 1).hours)
end
end
def query(extra = {})
get '/counts/query', params: base.merge(extra)
expect(response).to have_http_status(:ok)
response.parsed_body
end
def axis(field, value, type: 'string', grain: nil, root: 'conditions')
result = { root => '', "#{ root }.0.field" => field,
"#{ root }.0.value_type" => type }
result["#{ root }.0.operator"] = 'axis_eq' if root == 'conditions'
result["#{ root }.0.value"] = value unless type == 'null'
result["#{ root }.0.grain"] = grain if grain
result
end
def versions(result) = result.fetch('versions').map { _1.fetch('version_no') }
it 'separates latest and history and defaults to stable detail ordering and limit 100' do
history = query
expect(versions(history)).to eq([1, 2, 3])
expect(history).to include('limit' => 100, 'count' => 3, 'output_count' => 3,
'visible_output_count' => 3, 'excluded_count' => 0)
expect(history['versions'].map { _1['latest'] }).to eq([false, false, true])
expect(versions(query(definition: 'latest'))).to eq([3])
expect(versions(query(limit: 1, page: 2))).to eq([2])
expect(versions(query(sort: 'version_no:desc', limit: 1))).to eq([3])
end
it 'counts regular pivot rows separately from totals and from underlying records' do
total = query(mode: 'summary', rows: '', cols: '')
expect(total).to include('count' => 3, 'output_count' => 1, 'group_count' => 1)
expect(total['cells']).to eq([{ 'values' => [], 'count' => 3 }])
pivot = query(mode: 'summary', rows: 'event_type', cols: '', sort: '', limit: 1)
expect(pivot).to include('group_count' => 2, 'visible_output_count' => 2)
expect(pivot['row_headers'].count { _1['kind'] == 'data' }).to eq(1)
expect(pivot['row_headers']).to include(hash_including('kind' => 'grand_total'))
expect(query(mode: 'summary', rows: '', cols: 'event_type', sort: '')['output_count']).to eq(1)
end
it 'preserves timezone, NULL and legacy drill semantics and permits condition removal' do
month = axis('created_at', '2024-02', grain: 'month')
expect(versions(query(month.merge(timezone: 'Asia/Tokyo')))).to eq([1, 2, 3])
expect(versions(query(month))).to eq([])
old = axis('created_at', '2024-02', grain: 'month', root: 'drill')
expect(versions(query(old.merge(timezone: 'Asia/Tokyo')))).to eq([1, 2, 3])
expect(versions(query(old.merge(month).merge(timezone: 'Asia/Tokyo')))).to eq([1, 2, 3])
expect(versions(query(axis('user', nil, type: 'null')))).to eq([3])
expect(versions(query(axis('changed_field', 'title')))).to eq([2, 3])
expect(versions(query)).to eq([1, 2, 3])
end
it 'evaluates latest before filtering the version set' do
predicate = { conditions: '', 'conditions.0.field' => 'latest',
'conditions.0.operator' => 'eq', 'conditions.0.value' => 'false' }
expect(versions(query(predicate))).to eq([1, 2])
expect(versions(query(predicate.merge('conditions.0.value' => 'true')))).to eq([3])
end
it 'keeps ANY, ALL and NONE distinct without multiplying parent records' do
values = { conditions: '', 'conditions.0.field' => 'tags',
'conditions.0.values' => '1', condition_values: '',
'condition_values.0.condition' => '0',
'condition_values.0.value_type' => 'string',
'condition_values.0.value' => "internal:#{ tag.id }",
'condition_values.1.condition' => '0',
'condition_values.1.value_type' => 'string',
'condition_values.1.value' => "external:#{ external.id }" }
{ 'any' => [1, 2], 'all' => [1], 'none' => [3] }.each do |operator, expected|
expect(versions(query(values.merge('conditions.0.operator' => operator)))).to eq(expected)
end
result = query(axis('tags', "internal:#{ tag.id }"))
expect(result['versions'].first['tags'].length).to eq(2)
end
it 'distinguishes IN, NOT IN, an empty selection and an unspecified selection' do
predicate = { conditions: '', 'conditions.0.field' => 'version_no',
'conditions.0.operator' => 'in', 'conditions.0.values' => '1',
condition_values: '', 'condition_values.0.condition' => '0',
'condition_values.0.value_type' => 'number',
'condition_values.0.value' => '1' }
expect(versions(query(predicate))).to eq([1])
expect(versions(query(predicate.merge('conditions.0.operator' => 'not_in')))).to eq([2, 3])
empty = predicate.reject { |key, _value| key.to_s.start_with?('condition_values.') }
expect(versions(query(empty))).to be_empty
expect(versions(query(empty.except('conditions.0.values')))).to eq([1, 2, 3])
end
it 'canonicalises unreferenced tags while preserving partial stable references' do
reference = { tag_refs: '', 'tag_refs.0.name' => 'alpha',
'tag_refs.0.tag_id' => tag.id.to_s }
expect(versions(query(tags: 'alpha'))).to eq([1, 2])
expect(versions(query(reference.merge(tags: 'alpha nico:beta')))).to eq([1])
expect(versions(query(reference.merge(tags: 'nico:beta alpha')))).to eq([1])
expect(versions(query(reference.merge(tags: 'alpha not:nico:beta')))).to eq([2])
expect(versions(query(reference.merge(tags: 'alpha nico:beta', match: 'any')))).to eq([1, 2])
expect(versions(query(tags: 'not:alpha'))).to eq([3])
get '/counts/query', params: base.merge(reference).merge(tags: 'different')
expect(response).to have_http_status(:bad_request)
end
it 'rejects malformed structured conditions and axes without accepting arbitrary SQL' do
[
{ rows: '[{"field":"user"}]' }, { rows: 'user user' },
{ rows: 'user:month' }, { rows: 'password' }, { limit: 101 }, { limit: 0 },
{ page: '1x' }, { conditions: 'eyJmaWVsZCI6InVzZXIifQ==' },
{ conditions: '', 'conditions.1.field' => 'user' },
axis('password', '1'), axis('changed_field', 'title').merge(definition: 'latest'),
axis('user', 'yes', type: 'boolean'),
{ conditions: '', 'conditions.0.field' => 'title',
'conditions.0.operator' => 'eq', 'conditions.0.value' => 'x' * 8193 }].each do |invalid|
get '/counts/query', params: base.merge(invalid)
expect(response).to have_http_status(:bad_request), invalid.inspect
end
end
it 'rejects duplicate URL keys and JSON query payloads at the HTTP boundary' do
['resource=posts&limit=20&limit=50', 'resource=posts&headers=%5B%22Id.%22%5D',
'resource=posts&limit%5B%5D=20'].each do |query_string|
get "/counts/query?#{ query_string }"
expect(response).to have_http_status(:bad_request)
end
end
it 'reports column overflow separately from malformed input' do
101.times do |index|
PostVersion.create!(post: record, version_no: index + 4,
event_type: 'update', url: record.url, title: "column #{ index }",
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
end
get '/counts/query', params: base.merge(mode: 'summary', rows: '', cols: 'title', sort: '')
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq('error' => 'too_many_columns', 'max_columns' => 100)
end
it 'defines all six resources with filterable output fields and shared detail defaults' do
locale
CountsResourceDefinition::DEFINITIONS.each_key do |resource|
get "/counts/definitions/#{ resource }", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
body = response.parsed_body
filters = body['filter_fields'].map { _1['key'] }
walk = lambda do |fields, prefix|
fields.each do |field|
key = [prefix, field['key']].compact.join('.')
expect(filters).to include(key)
walk.call(field.fetch('children', []), key)
end
end
walk.call(body['output_fields'], nil)
defaults = body['defaults'].select { _1['mode'] == 'details' }
expect(defaults.map { _1['definition'] }).to eq(['latest', 'history'])
expect(defaults.first['params']).to eq(defaults.last['params'])
settings = defaults.first['params'].to_h { [_1['key'], _1['value']] }
expect(settings['sort']).to eq('resource_id:asc version_no:asc')
if resource == 'posts'
expect(settings['children'].split).to eq(['tags.name_ain', 'parents.id'])
end
keys = body['name_columns'].map { _1['key'] }
languages = Language.order(:created_at, :code).pluck(:code)
expect(keys).to eq(languages.map { "name_#{ _1 }" } + languages.map { "aliases_#{ _1 }" })
end
end
[nil, :guest, :member, :admin].each do |role|
context "authority #{ role || 'anonymous' }" do
before do
sign_in_as(create(:user, role)) if role
# Equal timestamps deliberately require version ordering, not a date comparison.
['discard', 'restore', 'discard', 'restore'].each_with_index do |event, index|
PostVersion.create!(post: record, version_no: index + 4, event_type: event,
url: record.url, title: event == 'restore' ? 'restored' : 'private snapshot',
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
end
end
it 'keeps every earlier discard boundary private but counts denied records' do
result = query
expected = role == :admin ? (1..7).to_a : [7]
expect(versions(result)).to eq(expected)
expect(result).to include('output_count' => 7,
'visible_output_count' => expected.length, 'excluded_count' => 7 - expected.length)
expect(response.headers['Cache-Control']).to include('no-store')
expect(response.body).not_to include('private snapshot') unless role == :admin
end
it 'applies the same version boundary to the ordinary history endpoint and old values' do
get '/posts/versions', params: { post: record.id, locale: 'ja' }
expect(response).to have_http_status(:ok)
rows = response.parsed_body.fetch('versions')
expect(rows.map { _1['version_no'] }).to eq(role == :admin ? (1..7).to_a.reverse : [7])
unless role == :admin
expect(rows.sole.dig('title', 'prev')).to be_nil
expect(response.body).not_to include('private snapshot')
end
end
it 'does not resolve private condition names even when the result set is empty' do
TagVersion.create!(tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'secret tag', script_code: 'Jpan', aliases: [] } },
created_at: Time.utc(2024, 2))
result = query(axis('tags', "internal:#{ tag.id }").merge(definition: 'latest'))
expect(result['versions']).to be_empty
expected = role == :admin ? { "internal:#{ tag.id }" => 'secret tag' } : {}
expect(result['condition_labels']['tags']).to eq(expected)
get '/counts/definitions/posts/candidates', params: {
field: 'tags', query: 'secret', execution_at: point, locale: 'ja' }
expect(response).to have_http_status(:ok)
expect(response.parsed_body['rows']).to be_empty
end
end
end
end
+168
ファイルの表示
@@ -0,0 +1,168 @@
require 'rails_helper'
require 'tmpdir'
require 'csv'
RSpec.describe 'Counts export contracts' do
include ActiveSupport::Testing::TimeHelpers
let(:payload) do
{ 'resource' => 'posts', 'definition' => 'history', 'mode' => 'details',
'locale' => 'ja', 'execution_at' => '2024-03-01T00:00:00Z',
'columns' => 'title', 'headers' => '["Title"]', 'format' => 'json' }
end
around do |example|
Dir.mktmpdir('counts-export-spec') do |path|
allow(CountsExportDownload).to receive(:directory).and_return(Pathname.new(path))
HistoryAccess.set(user: nil) { example.run }
end
end
context 'serialization after the authorised query' do
let(:rows) { [{ title: "=formula\n\"quoted\"" }, { title: 'second' }] }
let(:page) do
{ count: 2, output_count: 2, visible_output_count: 2, page: 1, limit: 100,
versions: rows, execution_at: payload.fetch('execution_at') }
end
before do
query = CountsTableQuery.new(payload.slice(*CountsTableQuery::KEYS))
# SQL/authority are exercised in request specs; this unit boundary supplies their page.
allow(query).to receive(:each_page) do |before_page:, &block|
before_page.call
block.call(page)
end
allow(CountsTableQuery).to receive(:new).and_return(query)
allow(ApplicationRecord).to receive(:transaction).with(isolation: :repeatable_read).and_yield
end
it 'reports aggregation, actual output progress and regular-row metadata' do
progress = []
phases = []
body = CountsTableExport.new(payload).body(
progress: ->(done, total) { progress << [done, total] },
phase: ->(value) { phases << value }).to_a.join
result = JSON.parse(body)
expect(result['data']).to eq(rows.map(&:stringify_keys))
expect(result['metadata']).to include('logical_count_unit' => 'regular_result_rows',
'aggregate_records_in_logical_count' => false, 'output_count' => 2)
expect(phases).to eq(['aggregating', 'writing'])
expect(progress).to include([0, 2], [1, 2], [2, 2])
expect(progress.last).to eq([2, 2])
end
it 'preserves CSV multiline text and quotes while neutralising spreadsheet formulas' do
body = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
expect(CSV.parse(body.delete_prefix("\uFEFF")))
.to eq([['Title'], ["'=formula\n\"quoted\""], ['second']])
end
it 'completes an isolated worker job with its real processed count and cancellable file' do
CountsExportJobs.pulse
token = CountsExportJobs.start(payload, 'owner', 'ja')
id = CountsExportJobs.authorised_id(token, 'owner')
state = CountsExportJobs.read(id).merge('status' => 'running', 'phase' => 'aggregating')
CountsExportJobs.write(state)
# Invoke the worker entrypoint directly; never fork or start an external worker in specs.
CountsExportJobs.perform(state)
ready = CountsExportJobs.status(token, 'owner')
expect(ready).to include('status' => 'ready', 'processed' => 2, 'total' => 2)
file = CountsExportDownload.find(ready.fetch('token'), 'owner')
expect(JSON.parse(File.read(file.fetch('path')))['data'].length).to eq(2)
CountsExportJobs.status(token, 'owner', cancel: true)
expect(CountsExportDownload.find(ready.fetch('token'), 'owner')).to be_nil
end
it 'marks a generation exception as failed without issuing a download token' do
CountsExportJobs.pulse
token = CountsExportJobs.start(payload, 'owner', 'ja')
id = CountsExportJobs.authorised_id(token, 'owner')
state = CountsExportJobs.read(id).merge('status' => 'running')
CountsExportJobs.write(state)
allow(CountsExportDownload).to receive(:prepare).and_raise(CountsExportDownload::TooLarge)
CountsExportJobs.perform(state)
expect(CountsExportJobs.status(token, 'owner')).to include('status' => 'failed')
expect(CountsExportJobs.status(token, 'owner')).not_to have_key('token')
end
end
it 'retains multilingual names and all inner rows in JSON with one selected display column' do
names = [{ language_code: 'ja', name: 'alpha', script_code: 'Jpan', aliases: [] },
{ language_code: 'ain', name: 'recorded', script_code: 'Kana',
aliases: [{ name: 'alias', script_code: 'Latn' }] }]
rows = [{ id: 1, name_ja: 'alpha', names: }, { id: 2, name_ja: 'beta', names: }]
projection = CountsExportProjection.new(payload.merge('columns' => 'tags',
'headers' => '["Tags"]', 'children' => 'tags.name_ja'),
resource: 'posts', summary: false, tags: false)
result = projection.project({ tags: rows })
expect(result['tags'].length).to eq(2)
expect(result['tags'].map { _1['name_ja'] }).to eq(['alpha', 'beta'])
expect(result['tags'].map { _1['names'] }).to eq([names, names])
end
it 'requires a live worker, enforces queue capacity and binds jobs to their owner' do
expect { CountsExportJobs.start(payload, 'owner', 'ja') }
.to raise_error(CountsExportJobs::WorkerUnavailable)
CountsExportJobs.pulse
token = CountsExportJobs.start(payload, 'owner', 'ja')
expect(CountsExportJobs.status(token, 'other')).to be_nil
expect(CountsExportJobs.status(token, 'owner')).to include(
'status' => 'queued', 'phase' => 'queued', 'processed' => 0, 'total' => nil)
(CountsExportDownload::MAX_CONCURRENT_EXPORTS - 1).times do
CountsExportJobs.start(payload, 'owner', 'ja')
end
expect { CountsExportJobs.start(payload, 'owner', 'ja') }
.to raise_error(CountsExportDownload::Busy)
expect(CountsExportJobs.status(token, 'owner', cancel: true)['status']).to eq('cancelled')
end
it 'fails stale queued/running jobs and preserves actual progress without a fake total' do
CountsExportJobs.pulse
token = CountsExportJobs.start(payload, 'owner', 'ja')
id = CountsExportJobs.authorised_id(token, 'owner')
state = CountsExportJobs.read(id)
state.merge!('status' => 'running', 'phase' => 'aggregating')
CountsExportJobs.write(state)
CountsExportJobs.checkpoint(id, 5, nil, phase: 'writing')
expect(CountsExportJobs.status(token, 'owner')).to include(
'phase' => 'writing', 'processed' => 5, 'total' => nil)
state = CountsExportJobs.read(id)
state['heartbeat_at'] = (Time.current - CountsExportJobs::HEARTBEAT_SECONDS - 1).to_f
CountsExportJobs.write(state)
expect(CountsExportJobs.status(token, 'owner')).to include(
'status' => 'failed', 'error' => 'worker_stalled', 'processed' => 5)
expect { CountsExportJobs.checkpoint(id, 6) }.to raise_error(CountsExportJobs::Cancelled)
end
it 'revokes an already generated download on a new discard and rejects wrong owners' do
exporter = instance_double(CountsExporter, resource: 'posts', format: 'json', body: ['{}'])
token = CountsExportDownload.prepare(exporter, 'owner')
expect(CountsExportDownload.find(token, 'owner')).not_to be_nil
expect(CountsExportDownload.find(token, 'other')).to be_nil
post = create(:post)
PostVersion.create!(post:, version_no: 1, event_type: 'discard', url: post.url,
title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024))
expect(CountsExportDownload.find(token, 'owner')).to be_nil
end
it 'rejects oversized output and removes its partial file' do
stub_const('CountsExportDownload::MAX_FILE_BYTES', 4)
exporter = instance_double(CountsExporter, resource: 'posts', format: 'json', body: ['12345'])
expect { CountsExportDownload.prepare(exporter, 'owner') }
.to raise_error(CountsExportDownload::TooLarge)
expect(Dir.glob(CountsExportDownload.directory.join('counts-*'))).to be_empty
end
it 'expires queued work after worker loss and rejects expired download signatures' do
CountsExportJobs.pulse
token = CountsExportJobs.start(payload, 'owner', 'ja')
travel_to(Time.current + CountsExportJobs::HEARTBEAT_SECONDS + 1) do
expect(CountsExportJobs.status(token, 'owner')).to include(
'status' => 'failed', 'error' => 'worker_unavailable')
end
exporter = instance_double(CountsExporter, resource: 'posts', format: 'json', body: ['{}'])
download = CountsExportDownload.prepare(exporter, 'owner')
travel_to(Time.current + CountsExportDownload::EXPIRY + 1) do
expect(CountsExportDownload.find(download, 'owner')).to be_nil
end
end
end
+33
ファイルの表示
@@ -0,0 +1,33 @@
require 'rails_helper'
RSpec.describe QueryState do
it 'decodes typed axis values and keeps absence distinct from an empty set' do
{ 'boolean' => ['false', false], 'number' => ['7', 7],
'string' => ['a b & c', 'a b & c'], 'null' => ['', nil] }.each do |type, (raw, expected)|
params = { 'conditions' => '', 'conditions.0.field' => 'user',
'conditions.0.operator' => 'axis_eq',
'conditions.0.value_type' => type, 'conditions.0.value' => raw }
expect(described_class.records(params, 'conditions').sole['value']).to eq(expected)
end
expect(described_class.records({}, 'conditions')).to eq([])
expect(described_class.records({ 'conditions' => '' }, 'conditions')).to eq([])
expect(described_class.records({ 'conditions' => '', 'conditions.0.values' => '1' },
'conditions')).to eq([{ 'values' => true }])
end
it 'rejects gaps, unknown keys, JSON, wrong types and oversized lists' do
[
{ 'conditions' => '[]' }, { 'conditions' => '', 'conditions.1.field' => 'user' },
{ 'conditions' => '', 'conditions.0.sql' => 'TRUE' },
{ 'conditions' => '', 'conditions.0.blank' => 'true' },
{ 'conditions' => '', 'conditions.0.value' => ['x'] },
{ 'drill' => '', 'drill.0.field' => 'user', 'drill.0.value_type' => 'object' },
{ 'conditions' => 'x' * 131_073 }].each do |params|
expect { described_class.validate!(params, ['conditions', 'drill']) }
.to raise_error(ArgumentError)
end
['user user', 'user title', ' user', (1..201).to_a.join(' ')].each do |value|
expect { described_class.list(value) }.to raise_error(ArgumentError)
end
end
end
+43 -17
ファイルの表示
@@ -62,14 +62,37 @@ const responseFor = url => {
return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] } return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] }
if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' } if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' }
if (path === '/users/theme_slots') return [] if (path === '/users/theme_slots') return []
if (path === '/counts/posts' || path === '/counts/tags') { if (path.startsWith ('/counts/definitions/')) {
const resource = path.endsWith ('posts') ? 'posts' : 'tags' const resource = path.split ('/')[3]
return { count: 1, page: 1, limit: 20, group_count: 1, const language = locale === 'ain-Kana' ? 'ain' : 'ja'
groups: [{ key: url.searchParams.get ('axis') === 'user' ? 1 : '2024-01', const names = ['ja', 'ain'].map (code => ({ key: `name_${code}`,
name: null, count: 1 }], label: 'counts:localeName', name_locale: { code, language_code: code } }))
versions: resource === 'posts' const columns = resource === 'posts' ? ['resource_id', 'title']
? [{ post_id: 1, version_no: 1, title: { current: `SSR count ${locale}` } }] : ['resource_id', `name_${language}`]
: [{ tag_id: 1, version_no: 1, name: { current: `SSR count ${locale}` } }] } return { fields: [{ key: 'resource_id', type: 'integer', nullable: false,
operators: ['eq', 'ne'] }],
output_fields: [{ key: 'resource_id', label: 'counts:columns.resourceId' },
...(resource === 'posts' ? [{ key: 'title', label: 'counts:columns.postTitle' }] : names)],
name_columns: names, filter_fields: [], column_aliases: [], condition_aliases: [],
axes: { latest: ['resource_id'], history: ['resource_id'] }, grains: ['month'],
defaults: [
{ definition: 'latest', mode: 'summary', params: [
{ key: 'rows', value: '' }, { key: 'cols', value: '' }] },
...['latest', 'history'].map (definition => ({ definition, mode: 'details', params: [
{ key: 'columns', value: columns.join (' ') },
{ key: 'sort', value: 'resource_id:asc version_no:asc' }] }))] }
}
if (path === '/counts/query') {
const header = { values: [], kind: 'data', depth: 0, axis_count: 0, label_key: null }
return { count: 1, page: 1, limit: 100, group_count: 1,
output_count: 1, visible_output_count: 1, excluded_count: 0,
excluded_count_scope: 'current_query', execution_at: date,
rows: [], cols: [], row_headers: [header], column_headers: [header],
column_values: [[]], cells: [{ values: [], count: 1 }], pivot_totals: [],
condition_labels: {}, axis_labels: {}, versions: [{ id: 1, resource_id: 1,
version_no: 1, latest: true, created_at: date, created_by_user: null,
title: 'SSR count', name_ja: 'SSR count', name_ain: 'SSR count',
names: [], aliases: [], parents: [], tags: [] }] }
} }
if (path === '/posts') return { posts: [post (locale)], count: 1 } if (path === '/posts') return { posts: [post (locale)], count: 1 }
if (path === '/gekanator/posts') return { posts: [post (locale)] } if (path === '/gekanator/posts') return { posts: [post (locale)] }
@@ -309,21 +332,24 @@ after (async () => {
if (errors.length) throw new AggregateError (errors, 'SSR teardown failed') if (errors.length) throw new AggregateError (errors, 'SSR teardown failed')
}) })
test ('counts SSR renders history data and retains URL query state', async () => { test ('counts SSR uses query/definitions, noindex and the common site title', async () => {
for (const locale of ['ja', 'ain-Kana']) { for (const locale of ['ja', 'ain-Kana']) {
for (const resource of ['posts', 'tags']) { for (const resource of ['posts', 'tags']) {
const column = resource === 'posts' ? 'title' : 'name' const column = resource === 'posts' ? 'title' : (locale === 'ja' ? 'name_ja' : 'name_ain')
const query = `mode=details&columns=${column}&user_id=4` const query = `definition=history&mode=details&columns=resource_id+${column}&user_id=4`
const result = await renderPage (`/${locale}/counts/${resource}?${query}`, origin, apiURL) const result = await renderPage (`/${locale}/counts/${resource}?${query}`, origin, apiURL)
assert.equal (result.status, 200) assert.equal (result.status, 200)
assert.match (result.html, new RegExp (`SSR count ${locale}`)) assert.match (result.html, /SSR count/)
assert.match (result.html, new RegExp (`/${locale}/${resource}/1`)) assert.match (result.html, new RegExp (`/${locale}/${resource}/1`))
assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/) assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
assert.match (result.html, /aria-expanded="false"/) assert.match (result.head, /<meta[^>]*name="robots"[^>]*content="noindex"/)
if (locale === 'ain-Kana') assert.match (result.head, /<title[^>]*>[^<]*集計[^<]*\|[^<]*<\/title>/)
assert.match (result.html, /lang="ja"[^>]*>集計実行/) assert.ok (requests.some (request => {
assert.ok (requests.some (request => request.url.includes (`/counts/${resource}`) const url = new URL (request.url, origin)
&& request.url.includes ('user_id=4') && request.url.includes (`locale=${locale}`))) return url.pathname === '/counts/query' && url.searchParams.get ('resource') === resource
&& url.searchParams.get ('user_id') === '4'
&& url.searchParams.get ('locale') === locale
}))
} }
} }
}) })
+119
ファイルの表示
@@ -0,0 +1,119 @@
import { fireEvent, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import CountsControls from '@/components/counts/CountsControls'
import CountsFieldTree from '@/components/counts/CountsFieldTree'
import { CountsDefinitionContext } from '@/lib/countsDefinitions'
import { normaliseCountsParams } from '@/lib/counts'
import { readCountState, setCountState, encodeCountState } from '@/lib/queryState'
import { postDefinition } from '@/test/fixtures/counts'
import { renderWithProviders } from '@/test/render'
vi.mock ('@/lib/api', async original => ({
...await original<typeof import ('@/lib/api')> (), apiGet: vi.fn ().mockResolvedValue ([]) }))
const appliedParams = (extra: Record<string, string> = {}) => normaliseCountsParams ('posts',
new URLSearchParams ({ mode: 'details', timezone: 'UTC', ...extra }), postDefinition)
const renderControls = (params: URLSearchParams, labelsParams = params.toString ()) => {
const execute = vi.fn ()
const result = renderWithProviders (
<CountsDefinitionContext.Provider value={postDefinition}>
<CountsControls resource="posts" applied={params} open setOpen={vi.fn ()}
execute={execute} reorder={vi.fn ()} displayJSON={vi.fn ()}
displayRowNumbers={vi.fn ()} displayThumbnail={vi.fn ()}
conditionLabels={{ tags: { 'internal:7': 'Resolved tag' } }}
conditionLabelsParams={labelsParams}/>
</CountsDefinitionContext.Provider>, { route: '/ja/counts/posts' })
return { ...result, execute }
}
const submit = () => fireEvent.submit (
screen.getByRole ('button', { name: '集計実行' }).closest ('form')!)
describe ('Counts filter draft', () => {
it ('uses labels only for the executed condition set', () => {
const params = appliedParams ()
setCountState (params, 'conditions', encodeCountState ('conditions', [
{ field: 'tags', operator: 'axis_eq', value: 'internal:7' }]))
const { execute } = renderControls (params)
expect (screen.getByText ('Resolved tag')).toBeInTheDocument ()
fireEvent.change (screen.getByRole ('textbox', { name: 'Id.' }), { target: { value: '9' } })
expect (screen.queryByText ('Resolved tag')).not.toBeInTheDocument ()
expect (screen.getByText ('…')).toBeInTheDocument ()
expect (screen.queryByText ('internal:7')).not.toBeInTheDocument ()
submit ()
expect (readCountState (execute.mock.lastCall![0], 'conditions', []))
.toEqual ([{ field: 'tags', operator: 'axis_eq', value: 'internal:7' }])
})
it ('removes a visible drill condition from the submitted URL state', () => {
const params = appliedParams ()
setCountState (params, 'conditions', encodeCountState ('conditions', [
{ field: 'created_at', grain: 'month', operator: 'axis_eq', value: '2026-10' }]))
const { execute } = renderControls (params)
expect (screen.getByText (/令和8年10月/)).toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '削除' }))
submit ()
expect (readCountState (execute.mock.lastCall![0], 'conditions', [])).toEqual ([])
})
it ('opens the existing add dialogue without inserting a condition immediately', () => {
const { execute } = renderControls (appliedParams ())
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (screen.getByRole ('dialog')).toBeInTheDocument ()
expect (execute).not.toHaveBeenCalled ()
expect (document.querySelectorAll ('[role="dialog"] select.h-11').length).toBeGreaterThan (0)
expect (screen.queryByText ('pattern.input')).not.toBeInTheDocument ()
})
it ('retains stable references only for surviving search tokens', () => {
const params = appliedParams ({ tags: 'alpha beta' })
setCountState (params, 'tag_refs', encodeCountState ('tag_refs', [
{ name: 'alpha', tag_id: 7 }, { name: 'beta', external_tag_id: 8 }]))
const { execute } = renderControls (params)
const tags = screen.getByDisplayValue ('alpha beta')
fireEvent.change (tags, { target: { value: 'not:beta gamma alpha' } })
submit ()
expect (readCountState (execute.mock.lastCall![0], 'tag_refs', [])).toEqual ([
{ name: 'alpha', tag_id: 7 }, { name: 'not:beta', external_tag_id: 8, negative: true }])
fireEvent.change (tags, { target: { value: 'gamma' } })
submit ()
expect (readCountState (execute.mock.lastCall![0], 'tag_refs', [])).toEqual ([])
expect (execute.mock.lastCall![0].get ('tags')).toBe ('gamma')
expect (screen.queryByText (/internal:7|external:8/)).not.toBeInTheDocument ()
})
})
const FieldTree = () => {
const [params, setParams] = useState (() => appliedParams ({ columns: 'tags',
children: 'tags.name_ja' }))
return <CountsDefinitionContext.Provider value={postDefinition}>
<CountsFieldTree resource="posts" params={params} update={(key, value) => setParams (previous => {
const next = new URLSearchParams (previous)
next.set (key, value)
return next
})}/>
<output data-testid="fields">{params.toString ()}</output>
</CountsDefinitionContext.Provider>
}
it ('derives mixed parent selection and permits selecting all children again after clearing', () => {
renderWithProviders (<FieldTree/>, { route: '/ja/counts/posts' })
const parent = screen.getByRole ('checkbox', { name: 'タグ' })
expect (parent).toHaveAttribute ('aria-checked', 'mixed')
expect (parent).toBeEnabled ()
fireEvent.click (parent)
expect (parent).toBeChecked ()
fireEvent.click (parent)
expect (parent).not.toBeChecked ()
expect (parent).toBeEnabled ()
fireEvent.click (parent)
expect (parent).toBeChecked ()
const params = new URLSearchParams (screen.getByTestId ('fields').textContent ?? '')
expect (params.get ('children')?.split (' ')).toEqual ([
'tags.name_ja', 'tags.name_ain', 'tags.aliases_ja', 'tags.aliases_ain'])
expect (params.get ('columns')).toBe ('tags')
})
+2 -1
ファイルの表示
@@ -72,7 +72,8 @@ const CountsNestedTable = (
{params.get ('nested_json') === '1' ? ( {params.get ('nested_json') === '1' ? (
<pre data-counts-scroll <pre data-counts-scroll
aria-label={title.text} lang={title.language} aria-label={title.text} lang={title.language}
className="max-h-72 w-full overflow-auto whitespace-pre text-xs"> className="max-h-72 w-full overflow-y-auto whitespace-pre-wrap
[overflow-wrap:anywhere] font-sans text-xs">
{JSON.stringify (countsNestedExport (rows, fields, path, params, locale))} {JSON.stringify (countsNestedExport (rows, fields, path, params, locale))}
</pre>) : (columns.length > 0 && rows.length > 0 && ( </pre>) : (columns.length > 0 && rows.length > 0 && (
<div data-counts-scroll <div data-counts-scroll
+105
ファイルの表示
@@ -0,0 +1,105 @@
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import CountsNestedTable from '@/components/counts/CountsNestedTable'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsTableLayout from '@/components/counts/CountsTableLayout'
import { useCountsTableLayout } from '@/lib/countsTableLayout'
import { renderWithProviders } from '@/test/render'
import type { CountField } from '@/lib/counts'
const renderTable = () => renderWithProviders (<CountsTableLayout>
<CountsSelectableTable family="details" columns={['resource_id', 'version_no']}
identity="stable" name={{ text: 'Grid', language: 'en' }}>
<tbody>
<tr><td><span>first</span> <span>cell</span></td><td>second</td></tr>
<tr><td>third</td><td><input aria-label="editor" defaultValue="editable"/></td></tr>
</tbody>
</CountsSelectableTable>
</CountsTableLayout>, { route: '/ja/counts/posts' })
describe ('Counts cell keyboard contract', () => {
it ('moves Home/End on rows and Ctrl+Home/End on columns with a stable Shift anchor', () => {
renderTable ()
const table = screen.getByRole ('grid', { name: 'Grid' })
table.focus ()
fireEvent.keyDown (table, { key: 'End' })
expect (screen.getByText ('second')).toHaveAttribute ('data-counts-selected')
fireEvent.keyDown (table, { key: 'End', ctrlKey: true, shiftKey: true })
expect (screen.getByText ('second')).toHaveAttribute ('data-counts-selected')
expect (screen.getByRole ('textbox').closest ('td')).toHaveAttribute ('data-counts-selected')
expect (screen.getByText ('third')).not.toHaveAttribute ('data-counts-selected')
fireEvent.keyDown (table, { key: 'Home' })
expect (screen.getByText ('third')).toHaveAttribute ('data-counts-selected')
fireEvent.keyDown (table, { key: 'Home', ctrlKey: true })
expect (screen.getByText ('first').closest ('td')).toHaveAttribute ('data-counts-selected')
})
it.each (['Enter', 'Escape']) ('leaves text mode with %s and restores the cell focus', key => {
renderTable ()
const table = screen.getByRole ('grid', { name: 'Grid' })
const cell = screen.getByText ('first').closest ('td')!
table.focus ()
fireEvent.keyDown (table, { key: 'Home' })
fireEvent.keyDown (table, { key: 'Enter' })
expect (cell).toHaveAttribute ('data-counts-text')
expect (window.getSelection ()?.toString ()).toBe ('first cell')
const setData = vi.fn ()
fireEvent.copy (table, { clipboardData: { setData } })
expect (setData).not.toHaveBeenCalled ()
fireEvent.keyDown (table, { key })
expect (cell).not.toHaveAttribute ('data-counts-text')
expect (cell).toHaveAttribute ('data-counts-selected')
expect (window.getSelection ()?.toString ()).toBe ('')
expect (table).toHaveFocus ()
fireEvent.copy (table, { clipboardData: { setData } })
expect (setData).toHaveBeenCalledWith ('text/plain', 'first cell')
expect (setData).toHaveBeenCalledWith ('text/html', expect.stringContaining ('first cell'))
})
it ('does not consume input keyboard events and uses the sum of column widths', () => {
renderTable ()
const input = screen.getByRole ('textbox')
input.focus ()
expect (fireEvent.keyDown (input, { key: 'Enter' })).toBe (true)
expect (input).toHaveFocus ()
expect (screen.getByRole ('grid', { name: 'Grid' })).toHaveStyle ({ width: '176px' })
})
})
const WidthControls = () => {
const layout = useCountsTableLayout ()
return <>
<output>{layout.width ('details', 'title')}</output>
<button onClick={() => layout.fit ('details', 'title', 1000)}>fit</button>
<button onClick={() => layout.resize ('details', 'title', 180)}>resize</button>
</>
}
it ('caps automatic header sizing and preserves manual widths on subsequent fitting', () => {
renderWithProviders (<CountsTableLayout><WidthControls/></CountsTableLayout>)
fireEvent.click (screen.getByRole ('button', { name: 'fit' }))
expect (screen.getByRole ('status')).toHaveTextContent ('640')
fireEvent.click (screen.getByRole ('button', { name: 'resize' }))
fireEvent.click (screen.getByRole ('button', { name: 'fit' }))
expect (screen.getByRole ('status')).toHaveTextContent ('180')
})
it ('wraps nested JSON without truncating, reordering or changing its data', () => {
const fields: CountField[] = [
{ key: 'id', label: 'counts:columns.resourceId' },
{ key: 'name', label: 'counts:columns.name' }]
const rows = [{ id: 1, name: `https://example.invalid/${'a'.repeat (300)}` },
{ id: 2, name: 'line one\nline two "quoted"' }]
const params = new URLSearchParams ({ children: 'tags.id tags.name', nested_json: '1' })
const { container } = renderWithProviders (<CountsNestedTable rows={rows} fields={fields}
path="tags" resource="posts" title={{ text: 'Tags', language: 'en' }}
params={params} update={vi.fn ()}/>, { route: '/ja/counts/posts' })
const pre = container.querySelector ('pre')!
expect (pre.textContent).toBe (JSON.stringify (rows))
expect (pre).toHaveClass ('whitespace-pre-wrap', '[overflow-wrap:anywhere]', 'font-sans')
expect (pre).not.toHaveClass ('truncate', 'whitespace-pre', 'select-none')
})
+146 -28
ファイルの表示
@@ -1,41 +1,159 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { columnsForCounts, countColumns, countsCSV, normaliseCountsParams } from '@/lib/counts' import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams,
countsRequestParams, normaliseCountsParams, selectedCountChildren } from '@/lib/counts'
import { countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
from '@/lib/countsDisplay'
import { countsClipboard } from '@/lib/countsClipboard'
import { createUII18n, translateUIMessage } from '@/lib/i18n' import { createUII18n, translateUIMessage } from '@/lib/i18n'
import { encodeCountState, readCountState, setCountState } from '@/lib/queryState'
import { isCountsPath } from '@/lib/routeLocales'
import { nameColumns, postDefinition } from '@/test/fixtures/counts'
import type { CountsCondition } from '@/lib/countsDefinitions'
describe ('counts exports', () => { describe ('Counts URL contract', () => {
it ('resolves every output and CSV header key in both locales', () => { it ('defaults to latest summary, limit 100, and no axes', () => {
for (const locale of ['ja', 'ain-Kana']) const params = normaliseCountsParams ('posts', new URLSearchParams (), postDefinition)
{ expect (params.get ('definition')).toBe ('latest')
const instance = createUII18n (locale) expect (params.get ('mode')).toBe ('summary')
for (const key of Object.values (countColumns)) expect (params.get ('limit')).toBe ('100')
{ expect (params.get ('rows')).toBe ('')
const translated = translateUIMessage (key, 'counts', locale, { }, instance) expect (params.get ('cols')).toBe ('')
expect (translated.text).not.toBe (key) expect (params.has ('axis')).toBe (false)
expect (translated.text).not.toContain ('counts:') expect (normaliseCountsParams ('posts', params, postDefinition).toString ())
} .toBe (params.toString ())
const fallback = translateUIMessage ('run', 'counts', locale, { }, instance)
expect (fallback).toEqual ({ text: '集計実行', language: 'ja' })
}
}) })
it ('makes the monthly default explicit for the existing API', () => { it.each (['latest', 'history']) ('shares detail defaults for %s', definition => {
expect (normaliseCountsParams ('posts', new URLSearchParams ()).get ('axis')) const params = normaliseCountsParams ('posts', new URLSearchParams ({
.toBe ('month') definition, mode: 'details' }), postDefinition)
expect (params.get ('sort')).toBe ('resource_id:asc version_no:asc')
expect ([...selectedCountChildren (params)]).toEqual (['tags.name_ja'])
expect (countsNestedIsScalar (nameColumns, 'tags', params)).toBe (true)
}) })
it ('escapes quotes, newlines and spreadsheet formulas in UTF-8 CSV', () => { it.each (['20', '50', '100']) ('preserves an explicit limit of %s and empty selections', limit => {
expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']])) const params = normaliseCountsParams ('posts', new URLSearchParams ({
.toBe ('\uFEFF"日本語","a""b","a\nb","\'=1+1"') mode: 'details', limit, sort: '', columns: '', children: '', rows: '', cols: '' }),
postDefinition)
for (const key of ['sort', 'columns', 'children', 'rows', 'cols'])
expect (params.get (key)).toBe ('')
expect (params.get ('limit')).toBe (limit)
}) })
it ('keeps resource-specific columns out of the other resource', () => { it ('normalises saved display keys without broadening script-specific predicates', () => {
expect (columnsForCounts ('posts')).toContain ('added_tags') const params = new URLSearchParams ({ mode: 'details', columns: 'tags',
expect (columnsForCounts ('posts')).not.toContain ('aliases') children: 'tags.name_ain_kana tags.aliases_ja' })
expect (columnsForCounts ('tags')).toContain ('aliases') setCountState (params, 'conditions', encodeCountState ('conditions', [
expect (columnsForCounts ('tags')).not.toContain ('url') { field: 'name_ain_kana', operator: 'eq', value: 'recorded' }]))
expect (columnsForCounts ('posts')).not.toContain ('event_type') const result = normaliseCountsParams ('posts', params, postDefinition)
expect (columnsForCounts ('tags')).not.toContain ('event_type') expect (result.get ('children')).toBe ('tags.name_ain tags.aliases_ja')
expect (result.get ('conditions.0.field')).toBe ('name_ain_kana')
expect (countsNestedIsScalar (nameColumns, 'tags', result)).toBe (false)
})
it ('unifies legacy drill conditions while keeping typed values, grain and execution time', () => {
const params = new URLSearchParams ({ execution_at: '2024-03-01T00:00:00Z',
timezone: 'Asia/Tokyo' })
const drills = [{ field: 'created_at', grain: 'month', value: '2024-02' },
{ field: 'user', value: null }, { field: 'resource_id', value: 7 },
{ field: 'flag', value: false }]
setCountState (params, 'drill', encodeCountState ('drill', drills))
const result = normaliseCountsParams ('posts', params)
expect (readCountState (result, 'conditions', []))
.toEqual (drills.map (entry => ({ ...entry, operator: 'axis_eq' })))
expect ([...result.keys ()].some (key => key === 'drill' || key.startsWith ('drill.')))
.toBe (false)
expect (result.get ('execution_at')).toBe (params.get ('execution_at'))
expect (result.get ('timezone')).toBe ('Asia/Tokyo')
setCountState (result, 'conditions', encodeCountState ('conditions', []))
expect (readCountState (result, 'conditions', [])).toEqual ([])
})
it ('round-trips free text and empty sets without JSON or delimiter splitting', () => {
const conditions: CountsCondition[] = [
{ field: 'title', operator: 'eq', value: 'a & b = c "quoted"' },
{ field: 'category', operator: 'in', input: 'list', values: [] },
{ field: 'version_no', operator: 'in', values: [1, 2] }]
const params = new URLSearchParams ()
setCountState (params, 'conditions', encodeCountState ('conditions', conditions))
expect (params.get ('conditions')).toBe ('')
expect (params.get ('conditions.0.value')).toBe (conditions[0].value)
expect (readCountState (params, 'conditions', [])).toEqual (conditions)
setCountState (params, 'conditions', encodeCountState ('conditions', [conditions[0]]))
expect ([...params.keys ()].some (key => key.startsWith ('condition_values'))).toBe (false)
})
it ('resets definition-dependent state but preserves timezone and row-number preference', () => {
const previous = new URLSearchParams ({ tags: 'alpha', rows: 'user', page: '4',
at: '2024-01-01', execution_at: '2024-02-01', timezone: 'Asia/Tokyo', row_numbers: '0' })
expect (Object.fromEntries (countsDefinitionParams ('history', previous))).toEqual ({
definition: 'history', mode: 'details', timezone: 'Asia/Tokyo', row_numbers: '0' })
})
it ('excludes presentation settings from API identity but retains extraction conditions', () => {
const params = new URLSearchParams ({ definition: 'history', columns: 'title',
children: 'tags.name_ja', order: 'title', nested_json: '1', row_numbers: '1',
resource_id: '7', limit: '50', sort: 'version_no:desc' })
expect (Object.fromEntries (countsRequestParams (params))).toEqual ({
definition: 'history', resource_id: '7', limit: '50', sort: 'version_no:desc' })
}) })
}) })
describe ('Counts logical display and exports', () => {
it ('resolves output labels in both supported locales', () => {
for (const locale of ['ja', 'ain-Kana'])
for (const key of Object.values (countColumns))
{
const message = translateUIMessage (key, 'counts', locale, {}, createUII18n (locale))
expect (message.text).not.toBe (key)
expect (message.text).not.toContain ('counts:')
}
expect (columnsForCounts ('posts')).toContain ('event_type')
expect (columnsForCounts ('tags')).not.toContain ('aliases')
})
it ('distinguishes unresolved, inaccessible and registered condition names', () => {
const axis = { field: 'tags' }
expect (countsConditionValue ('internal:7', axis, 'ja', 'UTC')).toBe ('…')
expect (countsConditionValue ('internal:7', axis, 'ja', 'UTC', { tags: {} })).toBe ('—')
expect (countsConditionValue ('internal:7', axis, 'ja', 'UTC', {
tags: { 'internal:7': 'recorded tag' } })).toBe ('recorded tag')
expect (countsConditionValue (7, { field: 'user' }, 'ja', 'UTC', {
user: { '7': 'actor' } })).toBe ('actor')
expect (countsConditionValue ('title', { field: 'changed_field' }, 'ja', 'UTC'))
.toBe (translateUIMessage ('columns.postTitle', 'counts', 'ja').text)
})
it ('formats calendar grain values without changing their timezone or identifier', () => {
expect (countsConditionValue ('2026', { field: 'created_at', grain: 'year' },
'ja', 'Asia/Tokyo')).toContain ('令和8年')
expect (countsConditionValue ('2026-10', { field: 'created_at', grain: 'month' },
'ja', 'America/New_York')).toContain ('令和8年10月')
})
it ('keeps all inner rows when choosing one display column', () => {
const params = new URLSearchParams ({ children: 'tags.name_ja' })
const rows = [{ name_ja: 'alpha' }, { name_ja: 'beta' }]
expect (countsNestedScalarText (rows, nameColumns, 'tags', params, 'ja')).toBe ('alpha beta')
expect (rows).toEqual ([{ name_ja: 'alpha' }, { name_ja: 'beta' }])
})
it ('escapes CSV and clipboard formulas, quotes, newlines and HTML', () => {
expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']]))
.toBe ('\uFEFF"日本語","a""b","a\nb","\'=1+1"')
const copy = countsClipboard ([['<tag>', 'first\nsecond', '=1+1']])
expect (copy.text).toBe ('<tag>\t"first\nsecond"\t\'=1+1')
expect (copy.html).toContain ('&lt;tag&gt;')
expect (copy.html).toContain ('first<br>second')
expect (copy.html).toContain ('&#39;=1+1')
})
it.each (['/counts', '/counts/posts?q=1', '/ja/counts/tags', '/ain-Kana/counts/materials']) (
'classifies %s as excluded from search and speculative loading', path => {
expect (isCountsPath (path.split ('?')[0])).toBe (true)
})
})
+12
ファイルの表示
@@ -35,6 +35,18 @@ const qc = () => new QueryClient ({
}) })
describe ('prefetchForURL', () => { describe ('prefetchForURL', () => {
it.each (['/counts', '/counts/posts?rows=user', '/ja/counts/tags',
'/ain-Kana/counts/materials?mode=details']) (
'does not prefetch Counts page %s', async path => {
const client = qc ()
const prefetch = vi.spyOn (client, 'prefetchQuery')
const fetch = vi.spyOn (client, 'fetchQuery')
await prefetchForURL (client, path)
expect (prefetch).not.toHaveBeenCalled ()
expect (fetch).not.toHaveBeenCalled ()
expect (client.getQueryCache ().getAll ()).toHaveLength (0)
})
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
+86 -137
ファイルの表示
@@ -1,157 +1,106 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import CountsPage from '@/pages/CountsPage'
import CountsResourcePage from '@/pages/CountsResourcePage' import CountsResourcePage from '@/pages/CountsResourcePage'
import { detailResult, executionAt, postDefinition } from '@/test/fixtures/counts'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import type { CountsResult } from '@/lib/counts' import type { CountsResult } from '@/lib/counts'
const { fetchCounts, fetchTagByName } = vi.hoisted (() => ({ const { fetchCounts, fetchCountsDefinition } = vi.hoisted (() => ({
fetchCounts: vi.fn (), fetchTagByName: vi.fn () })) fetchCounts: vi.fn (), fetchCountsDefinition: vi.fn () }))
vi.mock ('@/lib/counts', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/counts')> (), fetchCounts }))
vi.mock ('@/lib/tags', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/tags')> (), fetchTagByName }))
beforeEach (() => {
fetchCounts.mockReset ()
fetchTagByName.mockReset ()
fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 })
})
const conditions = () => screen.getByRole ('button', { name: '集計条件' })
vi.mock ('@/lib/counts', async original => ({
...await original<typeof import ('@/lib/counts')> (), fetchCounts }))
vi.mock ('@/lib/countsDefinitions', async original => ({
...await original<typeof import ('@/lib/countsDefinitions')> (), fetchCountsDefinition }))
const Location = () => <output data-testid="location">{useLocation ().search}</output>
const currentParams = () => new URLSearchParams (screen.getByTestId ('location').textContent ?? '')
const execute = () => fireEvent.submit ( const execute = () => fireEvent.submit (
screen.getByRole ('button', { name: '集計実行' }).closest ('form')!) screen.getByRole ('button', { name: '集計実行' }).closest ('form')!)
const openConditions = () => fireEvent.click (screen.getByRole ('button', { name: '集計条件' }))
const renderPage = (extra = '') => renderWithProviders (<>
<CountsResourcePage resource="posts"/><Location/>
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
beforeEach (() => {
fetchCounts.mockReset ().mockResolvedValue (detailResult)
fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition)
})
describe ('counts workspace', () => { describe ('Counts execution and URL state', () => {
it ('opens the posts workspace at the root with a resource sidebar', async () => { it ('enriches the initial URL without a second query or removing the page shell', async () => {
renderWithProviders (<CountsPage/>, { route: '/ja/counts' }) renderPage ()
fireEvent.click (screen.getByRole ('button', { name: '集計対象' })) const heading = screen.getByRole ('heading', { name: '広場集計' })
expect (screen.getByRole ('link', { name: '投稿' })) await screen.findByText ('Recorded title')
.toHaveAttribute ('href', '/ja/counts/posts') await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
expect (screen.getByRole ('link', { name: '投稿' })) expect (fetchCounts).toHaveBeenCalledTimes (1)
.toHaveAttribute ('aria-current', 'page') expect (screen.getByRole ('heading', { name: '広場集計' })).toBe (heading)
expect (screen.getByRole ('link', { name: 'タグ' })) expect (screen.getByText ('件数:1 件')).toBeInTheDocument ()
.toHaveAttribute ('href', '/ja/counts/tags') expect (screen.queryByText (/閲覧権限のないレコード/)).not.toBeInTheDocument ()
expect (conditions ()).toHaveAttribute ('aria-expanded', 'false') expect (currentParams ().get ('limit')).toBe ('100')
await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('axis')).toBe ('month'))
}) })
it ('preserves draft filters on collapse without querying until execution', async () => { it ('keeps edits local until execution then commits the server timestamp and filters together',
renderWithProviders (<CountsResourcePage resource="posts"/>, { async () => {
route: '/ja/counts/posts?page=3&mode=details' }) renderPage ()
await screen.findByText ('履歴レコード件数:0') await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
const calls = fetchCounts.mock.calls.length openConditions ()
fireEvent.click (conditions ()) const input = screen.getByRole ('textbox', { name: 'Id.' })
const output = document.querySelector ('[data-counts-output]')! fireEvent.change (input, { target: { value: '7' } })
const filters = document.querySelector ('[data-counts-filters]')! fireEvent.change (screen.getByRole ('combobox', { name: '表示件数' }), {
expect (output.compareDocumentPosition (filters) & Node.DOCUMENT_POSITION_FOLLOWING) target: { value: '20' } })
.toBeTruthy () openConditions ()
fireEvent.change (screen.getByRole ('textbox', { name: '投稿 ID' }), { openConditions ()
target: { value: '7' } }) expect (input).toHaveValue ('7')
fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), { expect (fetchCounts).toHaveBeenCalledTimes (1)
target: { value: '9' } }) expect (currentParams ().has ('resource_id')).toBe (false)
fireEvent.click (conditions ()) let complete!: (result: CountsResult) => void
expect (screen.getByRole ('table')).toBeInTheDocument () fetchCounts.mockImplementationOnce (() => new Promise<CountsResult> (resolve => {
fireEvent.click (conditions ()) complete = resolve
expect (screen.getByRole ('textbox', { name: '投稿 ID' })).toHaveValue ('7') }))
expect (fetchCounts).toHaveBeenCalledTimes (calls) const oldURL = currentParams ().toString ()
execute ()
await waitFor (() => expect (fetchCounts).toHaveBeenCalledTimes (2))
expect (currentParams ().toString ()).toBe (oldURL)
expect (screen.getByRole ('textbox', { name: 'Id.' })).toBe (input)
await act (async () => complete ({ ...detailResult,
executionAt: '2024-03-02T00:00:00.000000Z', limit: 20 }))
await waitFor (() => expect (currentParams ().get ('resource_id')).toBe ('7'))
expect (currentParams ().get ('limit')).toBe ('20')
expect (currentParams ().has ('page')).toBe (false)
expect (currentParams ().get ('execution_at')).toBe ('2024-03-02T00:00:00.000000Z')
expect (fetchCounts).toHaveBeenCalledTimes (2)
})
it ('keeps the filter and identifier after a failed execution', async () => {
renderPage ()
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
openConditions ()
fireEvent.change (screen.getByRole ('textbox', { name: 'Id.' }), {
target: { value: '99' } })
fetchCounts.mockRejectedValueOnce (new Error ('offline'))
execute () execute ()
await waitFor (() => { await screen.findByRole ('alert')
const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams expect (screen.getByRole ('textbox', { name: 'Id.' })).toHaveValue ('99')
expect (params.get ('resource_id')).toBe ('7') expect (currentParams ().has ('resource_id')).toBe (false)
expect (params.get ('user_id')).toBe ('9')
expect (params.has ('page')).toBe (false)
})
const refreshed = fetchCounts.mock.calls.length
execute ()
await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (refreshed))
}) })
it ('applies selected columns only on execution and retains pagination state', async () => { it.each (['initial', 'execution']) ('shows the server column limit on %s failure', async phase => {
fetchCounts.mockResolvedValue ({ count: 21, page: 1, limit: 20, const failure = { isAxiosError: true,
versions: [{ postId: 7, versionNo: 2, url: { current: 'https://example.com' }, response: { status: 422, data: { error: 'too_many_columns', max_columns: 73 } } }
title: { current: 'Snapshot' } }] as CountsResult['versions'] }) if (phase === 'initial') fetchCounts.mockRejectedValueOnce (failure)
renderWithProviders (<CountsResourcePage resource="posts"/>, { renderPage ()
route: '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3' }) if (phase === 'execution')
expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument () {
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
'href', '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3&page=2') fetchCounts.mockRejectedValueOnce (failure)
fireEvent.click (conditions ()) execute ()
expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked () }
fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' })) expect (await screen.findByRole ('alert')).toHaveTextContent ('73')
expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title') expect (screen.getByRole ('alert')).toHaveTextContent ('列数が上限')
execute ()
await waitFor (() => {
expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title,url')
})
})
it ('removes legacy event fields and axes from public query state', async () => {
renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ja/counts/tags?axis=event_type&event_type=update&columns=event_type,name' })
await waitFor (() => {
const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams
expect (params.get ('axis')).toBe ('month')
expect (params.has ('event_type')).toBe (false)
expect (params.get ('columns')).toBe ('name')
})
fireEvent.click (conditions ())
expect (screen.queryByText ('操作種別')).not.toBeInTheDocument ()
expect (screen.getByRole ('combobox', { name: '集計軸' })).toHaveValue ('month')
})
it ('resolves a tag name with the existing lookup before running the query', async () => {
fetchTagByName.mockResolvedValue ({ id: 12 })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts' })
fireEvent.click (conditions ())
fireEvent.change (screen.getByRole ('textbox', { name: 'タグ名または ID' }), {
target: { value: '日本語タグ' } })
execute ()
await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('tag')).toBe ('12'))
expect (fetchTagByName).toHaveBeenCalledWith ('日本語タグ', 'ja')
})
it ('uses Japanese fallback metadata in the Ainu workspace without exposing keys', async () => {
const { container } = renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ain-Kana/counts/tags' })
const title = screen.getByRole ('heading', { name: 'タグの集計・明細' })
expect (title.querySelector ('[lang="ja"]')).toHaveTextContent ('タグの集計・明細')
expect (conditions ().querySelector ('[lang="ja"]')).toBeInTheDocument ()
fireEvent.click (conditions ())
await screen.findByRole ('table')
expect (container.textContent).not.toMatch (/counts:|columns\.|fields\.|axes\./)
})
it ('drills through an actor group while preserving the existing filters', async () => {
fetchCounts.mockResolvedValue ({ count: 3, groupCount: 1, page: 1, limit: 20,
groups: [{ key: 9, name: 'Actor name', count: 3 }] })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?axis=user&resource_id=7' })
const link = await screen.findByRole ('link', { name: 'Actor name' })
const url = new URL (link.getAttribute ('href')!, 'http://localhost')
expect (url.searchParams.get ('resource_id')).toBe ('7')
expect (url.searchParams.get ('user_id')).toBe ('9')
expect (url.searchParams.get ('mode')).toBe ('details')
})
it ('keeps partial-month boundaries when drilling through to details', async () => {
fetchCounts.mockResolvedValue ({ count: 4, groupCount: 1, page: 1, limit: 20,
groups: [{ key: '2024-01', name: null, count: 4 }] })
renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ja/counts/tags?axis=month&from=2024-01-15T00:00Z' })
const link = await screen.findByRole ('link', { name: '2024-01' })
const url = new URL (link.getAttribute ('href')!, 'http://localhost')
expect (url.searchParams.get ('from')).toBe ('2024-01-15T00:00Z')
expect (url.searchParams.get ('before')).toBe ('2024-02-01T00:00Z')
expect (url.searchParams.get ('mode')).toBe ('details')
}) })
}) })
+49
ファイルの表示
@@ -0,0 +1,49 @@
import type { CountField, CountsResult } from '@/lib/counts'
import type { CountsDefinition } from '@/lib/countsDefinitions'
// camelCase transport fixture for the definitions/query endpoints, not the legacy API.
export const nameColumns: CountField[] = [
{ key: 'name_ja', label: 'counts:localeName', labelValues: { language: '日本語' },
nameLocale: { code: 'ja', languageCode: 'ja' } },
{ key: 'name_ain', label: 'counts:localeName', labelValues: { language: '北海道アイヌ語' },
nameLocale: { code: 'ain', languageCode: 'ain' } },
{ key: 'aliases_ja', label: 'counts:localeAliases', labelValues: { language: '日本語' },
nameLocale: { code: 'ja', languageCode: 'ja' }, multipleValues: true },
{ key: 'aliases_ain', label: 'counts:localeAliases', labelValues: { language: '北海道アイヌ語' },
nameLocale: { code: 'ain', languageCode: 'ain' }, multipleValues: true }]
export const postDefinition: CountsDefinition = {
fields: [
{ key: 'resource_id', type: 'integer', nullable: false, operators: ['eq', 'ne'] },
{ key: 'version_no', type: 'integer', nullable: false, operators: ['eq'] },
{ key: 'title', type: 'string', nullable: true, operators: ['eq', 'like'] },
{ key: 'user', type: 'integer', nullable: true, operators: ['eq'] },
{ key: 'created_at', type: 'datetime', nullable: false, operators: ['eq', 'range'] }],
outputFields: [
{ key: 'resource_id', label: 'counts:columns.resourceId' },
{ key: 'version_no', label: 'counts:columns.version' },
{ key: 'latest', label: 'counts:columns.latest' },
{ key: 'title', label: 'counts:columns.postTitle' },
{ key: 'tags', label: 'counts:columns.tags', children: nameColumns }],
nameColumns,
columnAliases: [{ key: 'name_ain_kana', value: 'name_ain' }],
conditionAliases: [{ key: 'name_ain_kana', value: 'name_ain_kana' }],
axes: { latest: ['resource_id', 'created_at', 'user', 'tags'],
history: ['resource_id', 'created_at', 'user', 'tags', 'changed_field'] },
grains: ['year', 'month', 'day'],
defaults: [
{ definition: 'latest', mode: 'summary', params: [
{ key: 'rows', value: '' }, { key: 'cols', value: '' },
{ key: 'conditions', value: '' }] },
...['latest', 'history'].map (definition => ({ definition, mode: 'details', params: [
{ key: 'columns', value: 'resource_id version_no latest title tags' },
{ key: 'children', value: 'tags.name_ja' },
{ key: 'sort', value: 'resource_id:asc version_no:asc' }] }))] }
export const executionAt = '2024-03-01T00:00:00.000000Z'
export const detailResult: CountsResult = {
count: 1, page: 1, limit: 100, outputCount: 1, visibleOutputCount: 1,
excludedCount: 0, excludedCountScope: 'current_query', executionAt,
conditionLabels: {}, rows: [], cols: [], versions: [{
id: 10, resourceId: 7, versionNo: 1, latest: true, createdAt: executionAt,
createdByUser: null, title: 'Recorded title', parents: [], tags: [] }] }