コミットを比較

..

1 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 83cd476325 #386 2026-06-30 12:47:02 +09:00
4個のファイルの変更107行の追加111行の削除
+10 -8
ファイルの表示
@@ -89,15 +89,13 @@ class MaterialsController < ApplicationController
begin begin
Material.transaction do Material.transaction do
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) tag = resolve_material_tag!(tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
material = Material.new(tag:, url:, material = Material.new(tag:, url:,
created_by_user: current_user, created_by_user: current_user,
updated_by_user: current_user) updated_by_user: current_user)
material.file.attach(uploaded_blob) if uploaded_blob material.file.attach(uploaded_blob) if uploaded_blob
material.save! material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
upsert_export_paths!(material) upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :create, MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: current_user) created_by_user: current_user)
@@ -139,10 +137,7 @@ class MaterialsController < ApplicationController
begin begin
Material.transaction do Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) tag = resolve_material_tag!(tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
material.assign_attributes(tag:, url:, updated_by_user: current_user) material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob if uploaded_blob
material.file.attach(uploaded_blob) material.file.attach(uploaded_blob)
@@ -150,6 +145,7 @@ class MaterialsController < ApplicationController
material.file.detach material.file.detach
end end
material.save! material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
upsert_export_paths!(material) upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :update, MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user) created_by_user: current_user)
@@ -240,6 +236,12 @@ class MaterialsController < ApplicationController
nil nil
end end
def resolve_material_tag! tag_name_raw
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
end
def material_index_needs_tag_name? filters def material_index_needs_tag_name? filters
filters[:q].present? || filters[:sort] == 'tag_name' filters[:q].present? || filters[:sort] == 'tag_name'
end end
+89
ファイルの表示
@@ -287,6 +287,25 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png') expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
end end
it 'creates a create tag_version for a newly created material tag' do
expect do
post '/materials', params: {
tag: 'material_create_versioned_tag',
file: dummy_upload(filename: 'created.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:created)
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_create_versioned_tag' })
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_create_versioned_tag')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'snapshots attached file metadata and sha256' do it 'snapshots attached file metadata and sha256' do
post '/materials', params: { post '/materials', params: {
tag: 'material_create_file_version', tag: 'material_create_file_version',
@@ -466,6 +485,73 @@ RSpec.describe 'Materials API', type: :request do
expect(json.dig('tag', 'name')).to eq('material_update_new') expect(json.dig('tag', 'name')).to eq('material_update_new')
end end
it 'creates a create tag_version when update creates a new tag' do
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_versioned_tag',
file: dummy_upload(filename: 'updated.png')
}
end.to change(Tag, :count).by(1)
.and change(TagName, :count).by(1)
.and change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_update_versioned_tag' })
version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_versioned_tag')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'backfills a create tag_version for an existing material tag without history' do
existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'),
category: :material)
expect(existing_tag.tag_versions).to be_empty
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_existing_no_history',
file: dummy_upload(filename: 'updated.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = existing_tag.reload.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_existing_no_history')
expect(version.category).to eq('material')
expect(version.created_by_user).to eq(member_user)
end
it 'backfills a create tag_version for an existing character tag without history' do
existing_tag =
Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'),
category: :character)
expect(existing_tag.tag_versions).to be_empty
expect do
put "/materials/#{ material.id }", params: {
tag: 'material_update_character_no_history',
file: dummy_upload(filename: 'updated.png')
}
end.to change(TagVersion, :count).by(1)
expect(response).to have_http_status(:ok)
version = existing_tag.reload.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create')
expect(version.name).to eq('material_update_character_no_history')
expect(version.category).to eq('character')
expect(version.created_by_user).to eq(member_user)
end
it 'detaches the existing file without purging blob when url replaces file' do it 'detaches the existing file without purging blob when url replaces file' do
old_blob_id = material.file.blob.id old_blob_id = material.file.blob.id
@@ -494,6 +580,7 @@ RSpec.describe 'Materials API', type: :request do
it 'does not increase version for the same snapshot update' do it 'does not increase version for the same snapshot update' do
MaterialVersionRecorder.record!(material:, event_type: :create, MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: member_user) created_by_user: member_user)
TagVersioning.ensure_snapshot!(tag, created_by_user: member_user)
expect do expect do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
@@ -501,6 +588,8 @@ RSpec.describe 'Materials API', type: :request do
} }
end.not_to change(MaterialVersion, :count) end.not_to change(MaterialVersion, :count)
expect(tag.reload.tag_versions.count).to eq(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(material.reload.version_no).to eq(1) expect(material.reload.version_no).to eq(1)
end end
+1 -52
ファイルの表示
@@ -1,10 +1,9 @@
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import { buildPost, import { buildPost,
buildTag,
buildTheatre, buildTheatre,
buildTheatreComment, buildTheatreComment,
buildTheatreInfo, buildTheatreInfo,
@@ -97,9 +96,6 @@ const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
{ route: '/theatres/7' }, { route: '/theatres/7' },
) )
const tagSection = (): HTMLElement =>
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
const mockDefaultApi = () => { const mockDefaultApi = () => {
api.apiGet.mockImplementation ((path: string) => { api.apiGet.mockImplementation ((path: string) => {
switch (path) switch (path)
@@ -249,53 +245,6 @@ describe ('TheatreDetailPage', () => {
expect (postEmbed.seek).not.toHaveBeenCalledWith (0) expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
}) })
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
const parentTag = buildTag ({
id: 11,
name: '親タグ',
category: 'general',
children: [childTag],
})
const duplicateParentTag = buildTag ({
id: 13,
name: '別親タグ',
category: 'general',
children: [childTag],
})
postsApi.fetchPost.mockResolvedValueOnce (buildPost ({
...currentPost,
tags: [parentTag, duplicateParentTag],
}))
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })[0])
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByText ('↳').length).toBeGreaterThan (0)
fireEvent.click (screen.getByRole ('button', { name: '2 列 A 型' }))
fireEvent.click (screen.getAllByRole ('button', { name: '横並び' })[0])
await waitFor (() => {
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '子タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
.toHaveLength (1)
})
})
it ('does not advance host post while video length is unknown', async () => { it ('does not advance host post while video length is unknown', async () => {
api.apiPut.mockImplementation ((path: string) => { api.apiPut.mockImplementation ((path: string) => {
switch (path) switch (path)
+7 -51
ファイルの表示
@@ -102,28 +102,6 @@ const compareTagName = (a: Tag, b: Tag): number =>
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1) a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
const flattenTags = (tags: Tag[]): Tag[] => {
const flattened: Tag[] = []
const seen = new Set<number> ()
const visit = (tag: Tag) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
flattened.push (tag)
for (const child of tag.children ?? [])
visit (child)
}
for (const tag of tags)
visit (tag)
return flattened
}
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => { const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
const grouped: Partial<Record<Category, Tag[]>> = { } const grouped: Partial<Record<Category, Tag[]>> = { }
@@ -140,39 +118,15 @@ const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
} }
const renderReadonlyTagTree = (
tag: Tag,
nestLevel: number,
path: string,
ancestors: Set<number> = new Set<number> (),
): ReactNode[] => {
const key = `${ path }-${ tag.id }`
const nextAncestors = new Set (ancestors)
nextAncestors.add (tag.id)
return [
(
<li key={key} className="text-left leading-tight">
<TagLink tag={tag} nestLevel={nestLevel} withCount={false}/>
</li>),
...((tag.children ?? [])
.filter (child => !(nextAncestors.has (child.id)))
.sort (compareTagName)
.flatMap (child =>
renderReadonlyTagTree (child, nestLevel + 1, key, nextAncestors)))]
}
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{ tags, compact, flow = 'vertical' }) => { { tags, compact, flow = 'vertical' }) => {
const horizontalGrouped = tagsByCategory (flattenTags (tags)) const grouped = tagsByCategory (tags)
const verticalGrouped = tagsByCategory (tags)
if (flow === 'horizontal') if (flow === 'horizontal')
{ {
return ( return (
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}> <ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
{CATEGORIES.flatMap (cat => horizontalGrouped[cat] ?? []).map (tag => ( {CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
<li key={tag.id} className="text-left leading-tight"> <li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/> <TagLink tag={tag} withCount={false}/>
</li>))} </li>))}
@@ -182,7 +136,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{CATEGORIES.map (cat => { {CATEGORIES.map (cat => {
const rows = verticalGrouped[cat] ?? [] const rows = grouped[cat] ?? []
if (rows.length === 0) if (rows.length === 0)
return null return null
@@ -192,8 +146,10 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{CATEGORY_NAMES[cat]} {CATEGORY_NAMES[cat]}
</div> </div>
<ul className={cn ('space-y-1', compact && 'text-sm')}> <ul className={cn ('space-y-1', compact && 'text-sm')}>
{rows.flatMap (tag => {rows.map (tag => (
renderReadonlyTagTree (tag, 0, `cat-${ cat }`))} <li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/>
</li>))}
</ul> </ul>
</div>) </div>)
})} })}