Files

393 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const nativeUnreadAction = {
id: 'comments-unread',
displayName: vi.fn((_context?: unknown) => 'Comment'),
title: vi.fn((_context?: unknown) => 'Comment'),
iconSvgInline: vi.fn((_context?: unknown) => '<svg data-native="true"/>'),
enabled: vi.fn(({ nodes }) => Number(nodes[0]?.attributes?.['comments-unread']) > 0),
exec: vi.fn(),
inline: vi.fn(() => true),
}
return {
emit: vi.fn(),
getSidebar: vi.fn(),
getClient: vi.fn(),
getDavNameSpaces: vi.fn(() => 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"'),
getDavProperties: vi.fn(() => '<oc:comments-count />'),
getFileActions: vi.fn(() => [nativeUnreadAction]),
getFilesRegistry: vi.fn(() => ({ addEventListener: vi.fn() })),
nativeUnreadAction,
resultToNode: vi.fn(),
stat: vi.fn(),
registerDavProperty: vi.fn(),
registerFileAction: vi.fn(),
registerSidebarTab: vi.fn(),
loadState: vi.fn(),
open: vi.fn(),
setActiveTab: vi.fn(),
}
})
vi.mock('@nextcloud/files', () => ({
getFileActions: mocks.getFileActions,
getFilesRegistry: mocks.getFilesRegistry,
getSidebar: mocks.getSidebar,
registerFileAction: mocks.registerFileAction,
registerSidebarTab: mocks.registerSidebarTab,
Permission: { NONE: 0 },
}))
vi.mock('@nextcloud/files/dav', () => ({
getClient: mocks.getClient,
getDavNameSpaces: mocks.getDavNameSpaces,
getDavProperties: mocks.getDavProperties,
registerDavProperty: mocks.registerDavProperty,
resultToNode: mocks.resultToNode,
}))
vi.mock('@nextcloud/event-bus', () => ({
emit: mocks.emit,
}))
vi.mock('@nextcloud/initial-state', () => ({
loadState: mocks.loadState,
}))
import { action } from '../src/main'
const COMMENTS_TAB_ID = 'korean-comments-only'
const node = {
root: '/files/alice',
permissions: 1,
fileid: 42,
path: '/report.pdf',
displayname: 'report.pdf',
attributes: { 'comments-count': 0 },
source: 'https://cloud.example/remote.php/dav/files/alice/report.pdf',
update: vi.fn(),
}
const context = {
nodes: [node],
folder: {},
view: {},
contents: [node],
}
beforeEach(() => {
mocks.open.mockReset()
mocks.emit.mockReset()
mocks.stat.mockReset()
mocks.resultToNode.mockReset()
mocks.getClient.mockReturnValue({ stat: mocks.stat })
node.update.mockReset()
mocks.setActiveTab.mockReset()
mocks.loadState.mockReset()
mocks.loadState.mockReturnValue(true)
mocks.getSidebar.mockReturnValue({
available: true,
isOpen: false,
activeTab: undefined,
node: undefined,
open: mocks.open,
setActiveTab: mocks.setActiveTab,
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
describe('Korean comments file action', () => {
it('registers the comment count property and comments-only sidebar tab', () => {
expect(mocks.registerDavProperty).toHaveBeenCalledWith('oc:comments-count')
expect(mocks.registerSidebarTab).toHaveBeenCalledWith(expect.objectContaining({
id: COMMENTS_TAB_ID,
displayName: '댓글',
}))
})
it('mounts the native Comments view in the comments-only tab', async () => {
const mounted: Array<{ resourceType: string, resourceId: number }> = []
const definitions = new Map<string, CustomElementConstructor>()
class FakeElement {
style: Record<string, string> = {}
isConnected = true
append() {}
replaceChildren() {}
}
class FakeCommentsView {
constructor(resourceType: string, options: { props: { resourceId: number } }) {
mounted.push({ resourceType, resourceId: options.props.resourceId })
}
$unmount() {}
update() {}
}
vi.stubGlobal('HTMLElement', FakeElement)
vi.stubGlobal('document', {
createElement: () => ({ className: '' }),
})
vi.stubGlobal('window', {
OCA: { Comments: { View: FakeCommentsView } },
customElements: {
get: (name: string) => definitions.get(name),
define: (name: string, definition: CustomElementConstructor) => definitions.set(name, definition),
},
clearTimeout,
setTimeout,
})
const tab = mocks.registerSidebarTab.mock.calls[0][0]
await tab.onInit()
const Element = definitions.get('korean_comments_action-comments-tab')!
const element = new Element() as unknown as { node: typeof node, connectedCallback(): void }
element.node = node
element.connectedCallback()
await Promise.resolve()
expect(mounted).toEqual([{ resourceType: 'files', resourceId: 42 }])
})
it('retains separate count refreshes when switching files during debounce', async () => {
vi.useFakeTimers()
let mutationCallback: (() => void) | undefined
const definitions = new Map<string, CustomElementConstructor>()
class FakeElement {
style: Record<string, string> = {}
isConnected = true
append() {}
replaceChildren() {}
}
class FakeMutationObserver {
constructor(callback: () => void) {
mutationCallback = callback
}
observe() {}
disconnect() {}
}
class FakeCommentsView {
$unmount() {}
update() {}
}
vi.stubGlobal('HTMLElement', FakeElement)
vi.stubGlobal('MutationObserver', FakeMutationObserver)
vi.stubGlobal('document', {
createElement: () => ({ className: '' }),
})
vi.stubGlobal('window', {
OCA: { Comments: { View: FakeCommentsView } },
customElements: {
get: (name: string) => definitions.get(name),
define: (name: string, definition: CustomElementConstructor) => definitions.set(name, definition),
},
clearTimeout,
setTimeout,
})
mocks.stat.mockResolvedValue({ data: {} })
mocks.resultToNode.mockReturnValue({ attributes: { 'comments-count': 0 } })
const tab = mocks.registerSidebarTab.mock.calls[0][0]
await tab.onInit()
const Element = definitions.get('korean_comments_action-comments-tab')!
const element = new Element() as unknown as { node: typeof node, connectedCallback(): void }
element.node = node
element.connectedCallback()
await Promise.resolve()
vi.clearAllTimers()
mocks.stat.mockClear()
mutationCallback!()
const secondNode = {
...node,
fileid: 43,
path: '/second.pdf',
displayname: 'second.pdf',
source: 'https://cloud.example/remote.php/dav/files/alice/second.pdf',
update: vi.fn(),
}
element.node = secondNode
await Promise.resolve()
await vi.advanceTimersByTimeAsync(200)
const paths = mocks.stat.mock.calls.map(([path]) => path)
expect(paths).toContain('/files/alice/report.pdf')
expect(paths).toContain('/files/alice/second.pdf')
})
it('shows only an empty square icon when the file has no comments', () => {
expect(mocks.registerFileAction).toHaveBeenCalledWith(action)
expect(action.displayName(context as never)).toBe('')
expect(action.iconSvgInline(context as never)).toContain('data-comment-state="empty"')
expect(action.inline?.(context as never)).toBe(true)
})
it('shows a square with dots when the file has comments', () => {
const withComments = {
...context,
nodes: [{ ...node, attributes: { 'comments-count': 2 } }],
}
expect(action.iconSvgInline(withComments as never)).toContain('data-comment-state="has-comments"')
})
it('refreshes the row icon state after native comment mutations', async () => {
const module = await import('../src/main') as unknown as {
refreshCommentCount(target: typeof node): Promise<void>
}
mocks.stat.mockResolvedValue({ data: {} })
mocks.resultToNode.mockReturnValue({ attributes: { 'comments-count': 1 } })
expect(module.refreshCommentCount).toBeTypeOf('function')
await module.refreshCommentCount(node)
expect(mocks.stat).toHaveBeenCalledWith('/files/alice/report.pdf', expect.any(Object))
expect(node.update).toHaveBeenCalledWith({ 'comments-count': 1 })
expect(mocks.emit).toHaveBeenCalledWith('files:node:updated', node)
})
it('does not emit a node update when the comment count is unchanged', async () => {
const module = await import('../src/main') as unknown as {
refreshCommentCount(target: typeof node): Promise<void>
}
const unchangedNode = {
...node,
attributes: { 'comments-count': 1 },
update: vi.fn(),
}
mocks.stat.mockResolvedValue({ data: {} })
mocks.resultToNode.mockReturnValue({ attributes: { 'comments-count': 1 } })
await module.refreshCommentCount(unchangedNode)
expect(unchangedNode.update).not.toHaveBeenCalled()
expect(mocks.emit).not.toHaveBeenCalled()
})
it('ignores an older count refresh that finishes after a newer request', async () => {
const module = await import('../src/main') as unknown as {
refreshCommentCount(target: typeof node): Promise<void>
}
let resolveFirst!: (value: unknown) => void
let resolveSecond!: (value: unknown) => void
const first = new Promise((resolve) => { resolveFirst = resolve })
const second = new Promise((resolve) => { resolveSecond = resolve })
mocks.stat.mockReturnValueOnce(first).mockReturnValueOnce(second)
mocks.resultToNode.mockImplementation((data: { count: number }) => ({
attributes: { 'comments-count': data.count },
}))
const older = module.refreshCommentCount(node)
const newer = module.refreshCommentCount(node)
resolveSecond({ data: { count: 2 } })
await newer
resolveFirst({ data: { count: 1 } })
await older
expect(node.update).toHaveBeenCalledTimes(1)
expect(node.update).toHaveBeenCalledWith({ 'comments-count': 2 })
})
it('reuses the native unread slot so only one comment icon is shown', async () => {
const unreadContext = {
...context,
nodes: [{ ...node, attributes: { 'comments-count': 2, 'comments-unread': 1 } }],
}
expect(action.enabled?.(unreadContext as never)).toBe(false)
expect(mocks.nativeUnreadAction.displayName(unreadContext as never)).toBe('')
expect(mocks.nativeUnreadAction.iconSvgInline(unreadContext as never)).toContain('data-comment-state="has-comments"')
expect(await mocks.nativeUnreadAction.exec(unreadContext as never)).toBeNull()
expect(mocks.open).toHaveBeenCalledWith(unreadContext.nodes[0], COMMENTS_TAB_ID)
})
it('refuses to adopt a native unread action without its eligibility predicate', async () => {
const module = await import('../src/main') as unknown as {
adoptNativeUnreadAction(candidate: Record<string, unknown>): boolean
}
const incompleteAction = {
id: 'comments-unread',
displayName: vi.fn(),
iconSvgInline: vi.fn(),
exec: vi.fn(),
}
expect(module.adoptNativeUnreadAction).toBeTypeOf('function')
expect(module.adoptNativeUnreadAction(incompleteAction)).toBe(false)
expect(incompleteAction.exec).not.toBe(action.exec)
})
it('keeps an adopted native action disabled when its predicate returns undefined', async () => {
const module = await import('../src/main') as unknown as {
adoptNativeUnreadAction(candidate: Record<string, unknown>): boolean
}
const malformedAction = {
id: 'comments-unread',
displayName: vi.fn(),
iconSvgInline: vi.fn(),
exec: vi.fn(),
enabled: vi.fn((_context?: unknown) => undefined),
}
expect(module.adoptNativeUnreadAction(malformedAction)).toBe(true)
expect(malformedAction.enabled(context as never)).toBe(false)
})
it('keeps the adopted unread action hidden for unsupported contexts', () => {
const multiple = { ...context, nodes: [node, node] }
const noPermission = {
...context,
nodes: [{ ...node, permissions: 0, attributes: { 'comments-count': 2, 'comments-unread': 1 } }],
}
expect(mocks.nativeUnreadAction.enabled(multiple as never)).toBe(false)
expect(mocks.nativeUnreadAction.enabled(noPermission as never)).toBe(false)
})
it('rejects execution for unsupported contexts instead of falling back to Activity', async () => {
const multiple = { ...context, nodes: [node, node] }
expect(await action.exec(multiple as never)).toBe(false)
expect(mocks.open).not.toHaveBeenCalled()
expect(mocks.setActiveTab).not.toHaveBeenCalled()
})
it('opens the dedicated comments-only tab instead of Activity', async () => {
expect(action.enabled?.(context as never)).toBe(true)
expect(await action.exec(context as never)).toBeNull()
expect(mocks.open).toHaveBeenCalledWith(node, COMMENTS_TAB_ID)
expect(mocks.open).not.toHaveBeenCalledWith(node, 'activity')
})
it('keeps the dedicated comments-only tab active when clicked again', async () => {
mocks.getSidebar.mockReturnValue({
available: true,
isOpen: true,
activeTab: COMMENTS_TAB_ID,
node,
open: mocks.open,
setActiveTab: mocks.setActiveTab,
})
expect(await action.exec(context as never)).toBeNull()
expect(mocks.setActiveTab).toHaveBeenCalledWith(COMMENTS_TAB_ID)
expect(mocks.open).not.toHaveBeenCalled()
})
it('uses the comments-only tab even when Activity integration is disabled', async () => {
mocks.loadState.mockReturnValue(false)
expect(action.enabled?.(context as never)).toBe(true)
expect(await action.exec(context as never)).toBeNull()
expect(mocks.open).toHaveBeenCalledWith(node, COMMENTS_TAB_ID)
})
it('hides when the Comments app is unavailable', () => {
mocks.loadState.mockReturnValue(null)
expect(action.enabled?.(context as never)).toBe(false)
})
})