From d7b136c1984931c0e5ab5f68a02a36d3920aaafc Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 26 Jun 2026 01:20:30 +0900 Subject: [PATCH] #306 --- backend/app/controllers/tags_controller.rb | 12 +- .../app/services/material_sync_importer.rb | 16 +- ...60623000000_enhance_material_management.rb | 12 +- backend/spec/requests/materials_spec.rb | 128 +++++++++------- .../services/material_sync_importer_spec.rb | 128 ++++++++++++++++ frontend/src/components/ui/input.tsx | 38 ++--- frontend/src/components/ui/switch.tsx | 52 +++---- .../materials/MaterialDetailPage.test.tsx | 30 +++- .../pages/materials/MaterialListPage.test.tsx | 145 +++++++++++++----- 9 files changed, 407 insertions(+), 154 deletions(-) create mode 100644 backend/spec/services/material_sync_importer_spec.rb diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 43f22f2..f345a02 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -379,12 +379,20 @@ class TagsController < ApplicationController end def visible_root_tag_ids graph - graph[:tags_by_id].filter_map do |tag_id, _attrs| + root_ids = Set.new + + graph[:tags_by_id].each do |tag_id, attrs| next unless visible_root_tag?(tag_id, graph) next unless visible_subtree?(tag_id, graph) - tag_id + if attrs[:deprecated] + collect_visible_child_tag_ids(tag_id, graph, root_ids, Set.new) + else + root_ids << tag_id + end end + + root_ids.to_a end def visible_root_tag? tag_id, graph diff --git a/backend/app/services/material_sync_importer.rb b/backend/app/services/material_sync_importer.rb index ed292ed..34c0357 100644 --- a/backend/app/services/material_sync_importer.rb +++ b/backend/app/services/material_sync_importer.rb @@ -44,6 +44,12 @@ class MaterialSyncImporter :update end uploaded_blob = uploaded_blob! + if @import_block + return Result.new(material: nil, + action: :suppressed, + suppressed: true, + suppression: @import_block) + end Material.transaction do if material.persisted? @@ -129,11 +135,19 @@ class MaterialSyncImporter return nil unless tempfile tempfile.rewind + file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest + @attributes[:file_sha256] = file_sha256 + + @import_block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) + if @import_block + tempfile.rewind + return nil + end + blob = ActiveStorage::Blob.create_and_upload!( io: tempfile, filename: @attributes[:filename], content_type: @attributes[:content_type]) - file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest blob.metadata['sha256'] = file_sha256 if file_sha256.present? blob.save! if blob.changed? tempfile.rewind diff --git a/backend/db/migrate/20260623000000_enhance_material_management.rb b/backend/db/migrate/20260623000000_enhance_material_management.rb index 27f6338..9c0321b 100644 --- a/backend/db/migrate/20260623000000_enhance_material_management.rb +++ b/backend/db/migrate/20260623000000_enhance_material_management.rb @@ -2,9 +2,6 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0] def up change_table :materials, bulk: true do |t| t.integer :version_no, null: false, default: 1 - t.datetime :file_suppressed_at - t.references :file_suppressed_by_user, foreign_key: { to_table: :users } - t.string :file_suppression_reason end change_table :material_versions, bulk: true do |t| @@ -18,8 +15,6 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0] t.bigint :file_byte_size t.string :file_checksum t.string :file_sha256 - t.datetime :file_suppressed_at - t.string :file_suppression_reason end execute <<~SQL.squish @@ -35,7 +30,7 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0] add_index :material_versions, :file_blob_id add_check_constraint :material_versions, - "event_type IN ('create', 'update', 'discard', 'restore', 'suppress')", + "event_type IN ('create', 'update', 'discard', 'restore')", name: 'material_versions_event_type_valid' create_table :material_export_items do |t| @@ -87,12 +82,7 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0] remove_column :material_versions, :file_byte_size remove_column :material_versions, :file_checksum remove_column :material_versions, :file_sha256 - remove_column :material_versions, :file_suppressed_at - remove_column :material_versions, :file_suppression_reason - remove_reference :materials, :file_suppressed_by_user, foreign_key: { to_table: :users } remove_column :materials, :version_no - remove_column :materials, :file_suppressed_at - remove_column :materials, :file_suppression_reason end end diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index 1193ab7..f2a7581 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -64,27 +64,36 @@ RSpec.describe 'Materials API', type: :request do 'name' => 'material_index_b', 'category' => 'material' ) - expect(row['created_by_user']).to include( - 'id' => member_user.id, - 'name' => member_user.name - ) expect(row['content_type']).to eq('image/png') + expect(row['media_kind']).to eq('image') end - it 'filters materials by tag_id' do - get '/materials', params: { tag_id: material_a.tag_id } + it 'filters materials by q' do + get '/materials', params: { q: 'material_index_a' } expect(response).to have_http_status(:ok) expect(json['count']).to eq(1) expect(response_materials.map { |m| m['id'] }).to eq([material_a.id]) end - it 'filters materials by parent_id' do - get '/materials', params: { parent_id: material_a.id } + it 'filters materials by tag_state' do + untagged = + build_material(tag: nil, user: member_user, + file: dummy_upload(filename: 'untagged.png')) + + get '/materials', params: { tag_state: 'untagged' } expect(response).to have_http_status(:ok) expect(json['count']).to eq(1) - expect(response_materials.map { |m| m['id'] }).to eq([material_b.id]) + expect(response_materials.map { |m| m['id'] }).to eq([untagged.id]) + end + + it 'filters materials by media_kind' do + get '/materials', params: { media_kind: 'image' } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(2) + expect(response_materials.map { |m| m['media_kind'] }).to all(eq('image')) end it 'paginates and keeps total count' do @@ -528,9 +537,8 @@ RSpec.describe 'Materials API', type: :request do expect(response.body.b).not_to include('素材/b.png'.b) end - it 'does not include suppressed materials' do - material_b.update!(file_suppressed_at: Time.current, - file_suppression_reason: 'copyright_high_risk') + it 'does not include disabled export items' do + material_b.material_export_items.first.update!(enabled: false) get '/materials/download.zip', params: { profile: 'legacy_drive' } @@ -540,62 +548,78 @@ RSpec.describe 'Materials API', type: :request do end end - describe 'PATCH /materials/:id/suppress_file' do + describe 'GET /materials/versions' do let!(:tag) do - Tag.create!(tag_name: TagName.create!(name: 'material_suppress'), category: :material) + Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material) end let!(:material) do - build_material(tag:, user: member_user, file: dummy_upload(filename: 'suppress.png')) + build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png')) end - it 'allows admin to suppress a file and records a suppress version' do - sign_in_as(admin_user) + it 'returns material versions in reverse chronological order' do + MaterialVersionRecorder.record!(material:, event_type: :create, + created_by_user: member_user) + material.update!(url: 'https://example.com/history') + MaterialVersionRecorder.record!(material:, event_type: :update, + created_by_user: admin_user) + + get '/materials/versions', params: { material_id: material.id } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(2) + versions = json.fetch('versions') + expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) + expect(versions.first).to include( + 'material_id' => material.id, + 'event_type' => 'update', + 'tag_name' => 'material_history', + 'file_filename' => 'history.png', + 'url' => 'https://example.com/history' + ) + expect(versions.first['created_by_user']).to include('id' => admin_user.id) + end + + it 'filters material versions by tag and event_type' do MaterialVersionRecorder.record!(material:, event_type: :create, created_by_user: member_user) - expect do - patch "/materials/#{ material.id }/suppress_file", - params: { reason: 'copyright_high_risk' } - end.to change(MaterialVersion, :count).by(1) + get '/materials/versions', params: { + tag: 'material_history', + event_type: 'create' + } expect(response).to have_http_status(:ok) + expect(json.fetch('versions').map { |v| v['material_id'] }).to include(material.id) + expect(json.fetch('versions').map { |v| v['event_type'] }).to all(eq('create')) + end + end - material.reload - expect(material.file_suppressed_at).to be_present - expect(material.file_suppressed_by_user).to eq(admin_user) - expect(material.file_suppression_reason).to eq('copyright_high_risk') - expect(material.material_versions.order(:version_no).last.event_type).to eq('suppress') - expect(json['file']).to be_nil - expect(json['file_suppressed_at']).to be_present + describe 'material sync suppressions' do + it 'requires member permission for index' do + sign_out + + get '/materials/suppressions' + + expect(response).to have_http_status(:unauthorized) end - it 'purges blob when purge=true is requested' do - sign_in_as(admin_user) - old_blob_id = material.file.blob.id - MaterialVersionRecorder.record!(material:, event_type: :create, - created_by_user: member_user) - - expect do - patch "/materials/#{ material.id }/suppress_file", - params: { reason: 'copyright_takedown', purge: '1' } - end.to have_enqueued_job(ActiveStorage::PurgeJob) - - expect(response).to have_http_status(:ok) - - version = material.material_versions.order(:version_no).last - expect(version.event_type).to eq('suppress') - expect(version.file_blob_id).to eq(old_blob_id) - expect(version.file_filename).to eq('suppress.png') - expect(version.file_sha256).to be_present - end - - it 'rejects member suppression' do + it 'creates source suppression as member' do sign_in_as(member_user) - patch "/materials/#{ material.id }/suppress_file", - params: { reason: 'copyright_high_risk' } + expect do + post '/materials/suppressions', params: { + source_kind: 'google_drive_path_prefix', + drive_path: '伊地知ニジカ/危険素材', + reason: 'copyright_high_risk' + } + end.to change(MaterialSyncSuppression, :count).by(1) - expect(response).to have_http_status(:forbidden) + expect(response).to have_http_status(:created) + expect(json).to include( + 'source_kind' => 'google_drive_path_prefix', + 'drive_path' => '伊地知ニジカ/危険素材', + 'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材' + ) end end diff --git a/backend/spec/services/material_sync_importer_spec.rb b/backend/spec/services/material_sync_importer_spec.rb new file mode 100644 index 0000000..20c69fd --- /dev/null +++ b/backend/spec/services/material_sync_importer_spec.rb @@ -0,0 +1,128 @@ +require 'rails_helper' + +RSpec.describe MaterialSyncImporter do + let(:user) { create(:user, :member) } + let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'sync_tag'), category: :material) } + + def tempfile_for body + Tempfile.new(['material-sync-importer', '.png']).tap do |file| + file.binmode + file.write(body) + file.rewind + end + end + + it 'creates an untagged material from a sync candidate with file_downloader' do + result = + described_class.import!( + source_kind: 'google_drive_file', + source_path: '素材/a.png', + source_file_id: 'drive-file-a', + normalized_source_key: 'google_drive_file:drive-file-a', + filename: 'a.png', + content_type: 'image/png', + file_sha256: Digest::SHA256.hexdigest('sync-body'), + file_downloader: -> { tempfile_for('sync-body') }, + export_path: '素材/a.png', + tag: nil, + url: nil, + created_by_user: user, + updated_by_user: user) + + material = result.material.reload + expect(result.action).to eq(:imported) + expect(material.tag).to be_nil + expect(material.url).to be_nil + expect(material.file).to be_attached + expect(material.file.blob.filename.to_s).to eq('a.png') + expect(material.material_export_items.first.export_path).to eq('素材/a.png') + end + + it 'does not clear a human-assigned tag or URL on existing Drive sync' do + material = + Material.create!(tag:, url: 'https://example.com/human', + source_kind: 'google_drive_file', + source_path: '素材/a.png', + source_file_id: 'drive-file-a', + created_by_user: user, + updated_by_user: user) + material.file.attach( + io: StringIO.new('same-body'), + filename: 'a.png', + content_type: 'image/png') + material.file.blob.metadata['sha256'] = Digest::SHA256.hexdigest('same-body') + material.file.blob.save! + MaterialExportItem.create!(material:, + profile: 'legacy_drive', + export_path: '素材/a.png', + created_by_user: user) + + result = + described_class.import!( + source_kind: 'google_drive_file', + source_path: '素材/a.png', + source_file_id: 'drive-file-a', + filename: 'a.png', + content_type: 'image/png', + file_sha256: Digest::SHA256.hexdigest('same-body'), + export_path: '素材/a.png', + tag: nil, + url: nil, + updated_by_user: user) + + expect(result.action).to eq(:unchanged) + expect(material.reload.tag).to eq(tag) + expect(material.url).to eq('https://example.com/human') + end + + it 'suppresses a Google Drive candidate by relative path prefix' do + MaterialSyncSuppression.create!(source_kind: 'google_drive_path_prefix', + drive_path: '素材/危険', + reason: 'copyright_high_risk', + created_by_user: user) + + result = + described_class.import!( + source_kind: 'google_drive_file', + source_path: '素材/危険/a.png', + source_file_id: 'drive-file-b', + filename: 'a.png', + content_type: 'image/png', + file_sha256: Digest::SHA256.hexdigest('blocked-body'), + file_downloader: -> { tempfile_for('blocked-body') }, + export_path: '素材/危険/a.png', + updated_by_user: user) + + expect(result.action).to eq(:suppressed) + expect(result.suppression.normalized_source_key) + .to eq('google_drive_path_prefix:素材/危険') + expect(Material.find_by(source_file_id: 'drive-file-b')).to be_nil + end + + it 'rechecks import blocks after downloaded file sha256 is known' do + MaterialImportBlock.create!(match_kind: 'sha256', + sha256: Digest::SHA256.hexdigest('blocked-body'), + reason: 'copyright_high_risk', + created_by_user: user) + blob_count = ActiveStorage::Blob.count + + expect do + result = + described_class.import!( + source_kind: 'google_drive_file', + source_path: '素材/blocked.png', + source_file_id: 'drive-file-blocked', + filename: 'blocked.png', + content_type: 'image/png', + file_downloader: -> { tempfile_for('blocked-body') }, + export_path: '素材/blocked.png', + updated_by_user: user) + + expect(result.action).to eq(:suppressed) + expect(result.suppression.reason).to eq('copyright_high_risk') + end.not_to change(Material, :count) + + expect(ActiveStorage::Blob.count).to eq(blob_count) + expect(Material.find_by(source_file_id: 'drive-file-blocked')).to be_nil + end +end diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 1ba33a5..7fa6a01 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,19 +1,19 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( - ) - } ) -Input.displayName = "Input" - -export { Input } +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + ) + }) +Input.displayName = 'Input' + +export { Input } diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx index 49127ff..1b591d8 100644 --- a/frontend/src/components/ui/switch.tsx +++ b/frontend/src/components/ui/switch.tsx @@ -1,26 +1,26 @@ -"use client" - -import * as React from "react" -import * as SwitchPrimitives from "@radix-ui/react-switch" - -import { cn } from "@/lib/utils" - -const Switch = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - )) -Switch.displayName = SwitchPrimitives.Root.displayName - -export { Switch } +'use client' + +import * as React from 'react' +import * as SwitchPrimitives from '@radix-ui/react-switch' + +import { cn } from '@/lib/utils' + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + )) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/frontend/src/pages/materials/MaterialDetailPage.test.tsx b/frontend/src/pages/materials/MaterialDetailPage.test.tsx index bb6fc6b..8a5819a 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.test.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.test.tsx @@ -7,9 +7,9 @@ import { buildMaterial, buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' const api = vi.hoisted (() => ({ - apiGet: vi.fn (), - apiPatch: vi.fn (), - apiPut: vi.fn (), + apiGet: vi.fn (), + apiPut: vi.fn (), + isApiError: vi.fn (), })) const wikiApi = vi.hoisted (() => ({ @@ -35,7 +35,11 @@ const renderPage = () => describe ('MaterialDetailPage', () => { beforeEach (() => { vi.clearAllMocks () - api.apiGet.mockResolvedValue ([]) + api.apiGet.mockImplementation (async (path: string) => + path === '/tags/autocomplete' + ? [] + : buildMaterial ({ id: 8 })) + api.isApiError.mockReturnValue (false) wikiApi.fetchWikiPages.mockResolvedValue ([]) vi.stubGlobal ('fetch', vi.fn (async () => ({ blob: async () => new Blob (['image'], { type: 'image/png' }), @@ -86,4 +90,22 @@ describe ('MaterialDetailPage', () => { expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png') expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' }) }) + + it ('shows not found when material API returns 404', async () => { + api.isApiError.mockReturnValue (true) + api.apiGet.mockRejectedValueOnce ({ response: { status: 404 } }) + + renderPage () + + expect (await screen.findByText ('素材が見つかりませんでした.')).toBeInTheDocument () + }) + + it ('shows an error for non-404 material API failures', async () => { + api.isApiError.mockReturnValue (false) + api.apiGet.mockRejectedValueOnce (new Error ('network error')) + + renderPage () + + expect (await screen.findByText ('素材の取得に失敗しました.')).toBeInTheDocument () + }) }) diff --git a/frontend/src/pages/materials/MaterialListPage.test.tsx b/frontend/src/pages/materials/MaterialListPage.test.tsx index cc10674..4c0992b 100644 --- a/frontend/src/pages/materials/MaterialListPage.test.tsx +++ b/frontend/src/pages/materials/MaterialListPage.test.tsx @@ -1,5 +1,5 @@ -import { screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' import MaterialListPage from '@/pages/materials/MaterialListPage' import { buildMaterial, buildTag } from '@/test/factories' @@ -12,51 +12,118 @@ const api = vi.hoisted (() => ({ vi.mock ('@/lib/api', () => api) describe ('MaterialListPage', () => { - it ('shows the empty selection guide without a tag query', () => { - renderWithProviders (, { route: '/materials' }) - - expect (screen.getByText ('左のリストから照会したいタグを選択してください。')).toBeInTheDocument () - expect (screen.getByRole ('link', { name: '素材を新規追加する' })).toHaveAttribute ( - 'href', - '/materials/new', - ) + beforeEach (() => { + vi.clearAllMocks () + api.apiGet.mockResolvedValue ({ materials: [], count: 0 }) }) - it ('loads materials for a tag query', async () => { - const tag = { - ...buildTag ({ - id: 4, - name: '素材タグ', - category: 'material', - }), - material: buildMaterial ({ id: 8, contentType: 'image/png', file: 'image.png' }), - children: [], - } - api.apiGet.mockResolvedValueOnce (tag) - - renderWithProviders (, { route: '/materials?tag=%E7%B4%A0%E6%9D%90' }) + it ('loads the material index on the initial page', async () => { + renderWithProviders (, { route: '/materials' }) await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( - '/tags/name/%E7%B4%A0%E6%9D%90/materials', + '/materials', + { params: { + tag_state: 'all', + media_kind: 'all', + sort: 'created_at', + direction: 'desc', + page: 1, + limit: 20, + } }, ) }) - expect (await screen.findByRole ('link', { name: '素材タグ' })).toBeInTheDocument () - expect (screen.getByRole ('link', { name: '' })).toHaveAttribute ('href', '/materials/8') - }) - - it ('offers adding a missing non-meme material', async () => { - api.apiGet.mockResolvedValueOnce ({ - ...buildTag ({ name: '未登録', category: 'material' }), - material: null, - children: [], - }) - - renderWithProviders (, { route: '/materials?tag=x' }) - - expect (await screen.findByRole ('link', { name: '追加' })).toHaveAttribute ( + expect (await screen.findByText ('素材はありません.')).toBeInTheDocument () + expect (screen.getByRole ('link', { name: '新規素材を追加' })).toHaveAttribute ( 'href', - '/materials/new?tag=%E6%9C%AA%E7%99%BB%E9%8C%B2', + '/materials/new', + ) + expect (screen.getByRole ('link', { name: '履歴' })).toHaveAttribute ( + 'href', + '/materials/changes', ) }) + + it ('shows materials in the default card view', async () => { + api.apiGet.mockResolvedValueOnce ({ + materials: [ + buildMaterial ({ + id: 8, + tag: buildTag ({ name: '素材タグ', category: 'material' }), + thumbnail: 'thumb.png', + mediaKind: 'image', + contentType: 'image/png', + }), + ], + count: 1, + }) + + renderWithProviders (, { route: '/materials' }) + + expect ((await screen.findByText ('素材タグ')).closest ('a')) + .toHaveAttribute ('href', '/materials/8') + expect (screen.getByText (/画像 \/ 令和8年1月2日/)).toBeInTheDocument () + }) + + it ('submits list filters as material index params', async () => { + renderWithProviders (, { route: '/materials' }) + + fireEvent.change (screen.getByPlaceholderText ('タグ名 / URL / ファイル名'), { + target: { value: '虹夏' }, + }) + const selects = screen.getAllByRole ('combobox') + fireEvent.change (selects[0], { + target: { value: 'untagged' }, + }) + fireEvent.change (selects[1], { + target: { value: 'image' }, + }) + fireEvent.click (screen.getByRole ('button', { name: '検索' })) + + await waitFor (() => { + expect (api.apiGet).toHaveBeenCalledWith ( + '/materials', + expect.objectContaining ({ params: expect.objectContaining ({ + q: '虹夏', + tag_state: 'untagged', + media_kind: 'image', + }) }), + ) + }) + }) + + it ('keeps a route back to the material search top for untagged materials', async () => { + renderWithProviders ( + , + { route: '/materials?tag_state=untagged&material_filter=any' }, + ) + + expect (await screen.findByRole ( + 'link', + { name: '素材検索トップへ戻る' }, + )).toHaveAttribute ('href', '/materials?material_filter=any') + }) + + it ('uses list view for detailed material rows', async () => { + api.apiGet.mockResolvedValueOnce ({ + materials: [ + buildMaterial ({ + id: 9, + tag: buildTag ({ name: '詳細素材', category: 'material' }), + mediaKind: 'file_other', + fileByteSize: 2048, + }), + ], + count: 1, + }) + + renderWithProviders (, { route: '/materials?view=list' }) + + expect (await screen.findByRole ('link', { name: '詳細素材' })).toHaveAttribute ( + 'href', + '/materials/9', + ) + expect (screen.getByText ('サイズ:')).toBeInTheDocument () + expect (screen.getByText ('2.0 KB')).toBeInTheDocument () + }) })