このコミットが含まれているのは:
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'
RSpec.describe 'Counts API', type: :request do
RSpec.describe 'Legacy Counts API', type: :request do
let!(:actor) { create(:user, :member) }
let!(:post) { create(:post) }
let!(:tag) { create(:tag, primary_name: 'counts tag', category: :general) }
@@ -28,16 +28,16 @@ RSpec.describe 'Counts API', type: :request do
context resource do
let(:path) { "/counts/#{ resource }" }
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' } }
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)
summary = response.parsed_body
expect(summary['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')
expect(response.parsed_body['count']).to eq(2)
@@ -45,16 +45,16 @@ RSpec.describe 'Counts API', type: :request do
end
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['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['groups'].sole).to include('key' => '2024-02', 'count' => 1)
end
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(
{ 'key' => nil, 'name' => nil, 'count' => 1 })
get path, params: { resource_id: id, mode: 'details', user_id: 'none' }
@@ -62,7 +62,7 @@ RSpec.describe 'Counts API', type: :request do
end
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' }
expect(response.parsed_body['count']).to eq(1)
end
@@ -88,9 +88,10 @@ RSpec.describe 'Counts API', type: :request do
end
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['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
+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: 'アイヌ' }] }
if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' }
if (path === '/users/theme_slots') return []
if (path === '/counts/posts' || path === '/counts/tags') {
const resource = path.endsWith ('posts') ? 'posts' : 'tags'
return { count: 1, page: 1, limit: 20, group_count: 1,
groups: [{ key: url.searchParams.get ('axis') === 'user' ? 1 : '2024-01',
name: null, count: 1 }],
versions: resource === 'posts'
? [{ post_id: 1, version_no: 1, title: { current: `SSR count ${locale}` } }]
: [{ tag_id: 1, version_no: 1, name: { current: `SSR count ${locale}` } }] }
if (path.startsWith ('/counts/definitions/')) {
const resource = path.split ('/')[3]
const language = locale === 'ain-Kana' ? 'ain' : 'ja'
const names = ['ja', 'ain'].map (code => ({ key: `name_${code}`,
label: 'counts:localeName', name_locale: { code, language_code: code } }))
const columns = resource === 'posts' ? ['resource_id', 'title']
: ['resource_id', `name_${language}`]
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 === '/gekanator/posts') return { posts: [post (locale)] }
@@ -309,21 +332,24 @@ after (async () => {
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 resource of ['posts', 'tags']) {
const column = resource === 'posts' ? 'title' : 'name'
const query = `mode=details&columns=${column}&user_id=4`
const column = resource === 'posts' ? 'title' : (locale === 'ja' ? 'name_ja' : 'name_ain')
const query = `definition=history&mode=details&columns=resource_id+${column}&user_id=4`
const result = await renderPage (`/${locale}/counts/${resource}?${query}`, origin, apiURL)
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.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
assert.match (result.html, /aria-expanded="false"/)
if (locale === 'ain-Kana')
assert.match (result.html, /lang="ja"[^>]*>集計実行/)
assert.ok (requests.some (request => request.url.includes (`/counts/${resource}`)
&& request.url.includes ('user_id=4') && request.url.includes (`locale=${locale}`)))
assert.match (result.head, /<meta[^>]*name="robots"[^>]*content="noindex"/)
assert.match (result.head, /<title[^>]*>[^<]*集計[^<]*\|[^<]*<\/title>/)
assert.ok (requests.some (request => {
const url = new URL (request.url, origin)
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' ? (
<pre data-counts-scroll
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))}
</pre>) : (columns.length > 0 && rows.length > 0 && (
<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 { 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 { 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', () => {
it ('resolves every output and CSV header key in both locales', () => {
for (const locale of ['ja', 'ain-Kana'])
{
const instance = createUII18n (locale)
for (const key of Object.values (countColumns))
{
const translated = translateUIMessage (key, 'counts', locale, { }, instance)
expect (translated.text).not.toBe (key)
expect (translated.text).not.toContain ('counts:')
}
const fallback = translateUIMessage ('run', 'counts', locale, { }, instance)
expect (fallback).toEqual ({ text: '集計実行', language: 'ja' })
}
describe ('Counts URL contract', () => {
it ('defaults to latest summary, limit 100, and no axes', () => {
const params = normaliseCountsParams ('posts', new URLSearchParams (), postDefinition)
expect (params.get ('definition')).toBe ('latest')
expect (params.get ('mode')).toBe ('summary')
expect (params.get ('limit')).toBe ('100')
expect (params.get ('rows')).toBe ('')
expect (params.get ('cols')).toBe ('')
expect (params.has ('axis')).toBe (false)
expect (normaliseCountsParams ('posts', params, postDefinition).toString ())
.toBe (params.toString ())
})
it ('makes the monthly default explicit for the existing API', () => {
expect (normaliseCountsParams ('posts', new URLSearchParams ()).get ('axis'))
.toBe ('month')
it.each (['latest', 'history']) ('shares detail defaults for %s', definition => {
const params = normaliseCountsParams ('posts', new URLSearchParams ({
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', () => {
expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']]))
.toBe ('\uFEFF"日本語","a""b","a\nb","\'=1+1"')
it.each (['20', '50', '100']) ('preserves an explicit limit of %s and empty selections', limit => {
const params = normaliseCountsParams ('posts', new URLSearchParams ({
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', () => {
expect (columnsForCounts ('posts')).toContain ('added_tags')
expect (columnsForCounts ('posts')).not.toContain ('aliases')
expect (columnsForCounts ('tags')).toContain ('aliases')
expect (columnsForCounts ('tags')).not.toContain ('url')
expect (columnsForCounts ('posts')).not.toContain ('event_type')
expect (columnsForCounts ('tags')).not.toContain ('event_type')
it ('normalises saved display keys without broadening script-specific predicates', () => {
const params = new URLSearchParams ({ mode: 'details', columns: 'tags',
children: 'tags.name_ain_kana tags.aliases_ja' })
setCountState (params, 'conditions', encodeCountState ('conditions', [
{ field: 'name_ain_kana', operator: 'eq', value: 'recorded' }]))
const result = normaliseCountsParams ('posts', params, postDefinition)
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', () => {
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 (() => {
vi.clearAllMocks ()
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
+83 -134
ファイルの表示
@@ -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 CountsPage from '@/pages/CountsPage'
import CountsResourcePage from '@/pages/CountsResourcePage'
import { detailResult, executionAt, postDefinition } from '@/test/fixtures/counts'
import { renderWithProviders } from '@/test/render'
import type { CountsResult } from '@/lib/counts'
const { fetchCounts, fetchTagByName } = vi.hoisted (() => ({
fetchCounts: vi.fn (), fetchTagByName: 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: '集計条件' })
const { fetchCounts, fetchCountsDefinition } = vi.hoisted (() => ({
fetchCounts: vi.fn (), fetchCountsDefinition: vi.fn () }))
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 (
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', () => {
it ('opens the posts workspace at the root with a resource sidebar', async () => {
renderWithProviders (<CountsPage/>, { route: '/ja/counts' })
fireEvent.click (screen.getByRole ('button', { name: '集計対象' }))
expect (screen.getByRole ('link', { name: '投稿' }))
.toHaveAttribute ('href', '/ja/counts/posts')
expect (screen.getByRole ('link', { name: '投稿' }))
.toHaveAttribute ('aria-current', 'page')
expect (screen.getByRole ('link', { name: 'タグ' }))
.toHaveAttribute ('href', '/ja/counts/tags')
expect (conditions ()).toHaveAttribute ('aria-expanded', 'false')
await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('axis')).toBe ('month'))
describe ('Counts execution and URL state', () => {
it ('enriches the initial URL without a second query or removing the page shell', async () => {
renderPage ()
const heading = screen.getByRole ('heading', { name: '広場集計' })
await screen.findByText ('Recorded title')
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
expect (fetchCounts).toHaveBeenCalledTimes (1)
expect (screen.getByRole ('heading', { name: '広場集計' })).toBe (heading)
expect (screen.getByText ('件数:1 件')).toBeInTheDocument ()
expect (screen.queryByText (/閲覧権限のないレコード/)).not.toBeInTheDocument ()
expect (currentParams ().get ('limit')).toBe ('100')
})
it ('preserves draft filters on collapse without querying until execution', async () => {
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?page=3&mode=details' })
await screen.findByText ('履歴レコード件数:0')
const calls = fetchCounts.mock.calls.length
fireEvent.click (conditions ())
const output = document.querySelector ('[data-counts-output]')!
const filters = document.querySelector ('[data-counts-filters]')!
expect (output.compareDocumentPosition (filters) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy ()
fireEvent.change (screen.getByRole ('textbox', { name: '投稿 ID' }), {
target: { value: '7' } })
fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), {
target: { value: '9' } })
fireEvent.click (conditions ())
expect (screen.getByRole ('table')).toBeInTheDocument ()
fireEvent.click (conditions ())
expect (screen.getByRole ('textbox', { name: '投稿 ID' })).toHaveValue ('7')
expect (fetchCounts).toHaveBeenCalledTimes (calls)
it ('keeps edits local until execution then commits the server timestamp and filters together',
async () => {
renderPage ()
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
openConditions ()
const input = screen.getByRole ('textbox', { name: 'Id.' })
fireEvent.change (input, { target: { value: '7' } })
fireEvent.change (screen.getByRole ('combobox', { name: '表示件数' }), {
target: { value: '20' } })
openConditions ()
openConditions ()
expect (input).toHaveValue ('7')
expect (fetchCounts).toHaveBeenCalledTimes (1)
expect (currentParams ().has ('resource_id')).toBe (false)
let complete!: (result: CountsResult) => void
fetchCounts.mockImplementationOnce (() => new Promise<CountsResult> (resolve => {
complete = resolve
}))
const oldURL = currentParams ().toString ()
execute ()
await waitFor (() => {
const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams
expect (params.get ('resource_id')).toBe ('7')
expect (params.get ('user_id')).toBe ('9')
expect (params.has ('page')).toBe (false)
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)
})
const refreshed = fetchCounts.mock.calls.length
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 ()
await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (refreshed))
await screen.findByRole ('alert')
expect (screen.getByRole ('textbox', { name: 'Id.' })).toHaveValue ('99')
expect (currentParams ().has ('resource_id')).toBe (false)
})
it ('applies selected columns only on execution and retains pagination state', async () => {
fetchCounts.mockResolvedValue ({ count: 21, page: 1, limit: 20,
versions: [{ postId: 7, versionNo: 2, url: { current: 'https://example.com' },
title: { current: 'Snapshot' } }] as CountsResult['versions'] })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3' })
expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument ()
expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
'href', '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3&page=2')
fireEvent.click (conditions ())
expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked ()
fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' }))
expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title')
it.each (['initial', 'execution']) ('shows the server column limit on %s failure', async phase => {
const failure = { isAxiosError: true,
response: { status: 422, data: { error: 'too_many_columns', max_columns: 73 } } }
if (phase === 'initial') fetchCounts.mockRejectedValueOnce (failure)
renderPage ()
if (phase === 'execution')
{
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
fetchCounts.mockRejectedValueOnce (failure)
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')
}
expect (await screen.findByRole ('alert')).toHaveTextContent ('73')
expect (screen.getByRole ('alert')).toHaveTextContent ('列数が上限')
})
})
+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: [] }] }