2026-09-19 09:45:20 +00:00
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
2026-09-19 08:09:45 +00:00
|
|
|
|
2026-09-19 09:45:20 +00:00
|
|
|
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(),
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-09-19 08:09:45 +00:00
|
|
|
|
|
|
|
|
vi.mock('@nextcloud/files', () => ({
|
2026-09-19 09:45:20 +00:00
|
|
|
getFileActions: mocks.getFileActions,
|
|
|
|
|
getFilesRegistry: mocks.getFilesRegistry,
|
2026-09-19 08:09:45 +00:00
|
|
|
getSidebar: mocks.getSidebar,
|
|
|
|
|
registerFileAction: mocks.registerFileAction,
|
2026-09-19 09:45:20 +00:00
|
|
|
registerSidebarTab: mocks.registerSidebarTab,
|
2026-09-19 08:09:45 +00:00
|
|
|
Permission: { NONE: 0 },
|
|
|
|
|
}))
|
|
|
|
|
|
2026-09-19 09:45:20 +00:00
|
|
|
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,
|
|
|
|
|
}))
|
|
|
|
|
|
2026-09-19 08:09:45 +00:00
|
|
|
vi.mock('@nextcloud/initial-state', () => ({
|
|
|
|
|
loadState: mocks.loadState,
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
import { action } from '../src/main'
|
|
|
|
|
|
2026-09-19 09:45:20 +00:00
|
|
|
const COMMENTS_TAB_ID = 'korean-comments-only'
|
|
|
|
|
|
2026-09-19 08:09:45 +00:00
|
|
|
const node = {
|
|
|
|
|
root: '/files/alice',
|
|
|
|
|
permissions: 1,
|
2026-09-19 09:45:20 +00:00
|
|
|
fileid: 42,
|
|
|
|
|
path: '/report.pdf',
|
|
|
|
|
displayname: 'report.pdf',
|
|
|
|
|
attributes: { 'comments-count': 0 },
|
2026-09-19 08:09:45 +00:00
|
|
|
source: 'https://cloud.example/remote.php/dav/files/alice/report.pdf',
|
2026-09-19 09:45:20 +00:00
|
|
|
update: vi.fn(),
|
2026-09-19 08:09:45 +00:00
|
|
|
}
|
|
|
|
|
const context = {
|
|
|
|
|
nodes: [node],
|
|
|
|
|
folder: {},
|
|
|
|
|
view: {},
|
|
|
|
|
contents: [node],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mocks.open.mockReset()
|
2026-09-19 09:45:20 +00:00
|
|
|
mocks.emit.mockReset()
|
|
|
|
|
mocks.stat.mockReset()
|
|
|
|
|
mocks.resultToNode.mockReset()
|
|
|
|
|
mocks.getClient.mockReturnValue({ stat: mocks.stat })
|
|
|
|
|
node.update.mockReset()
|
2026-09-19 08:09:45 +00:00
|
|
|
mocks.setActiveTab.mockReset()
|
|
|
|
|
mocks.loadState.mockReset()
|
|
|
|
|
mocks.loadState.mockReturnValue(true)
|
|
|
|
|
mocks.getSidebar.mockReturnValue({
|
|
|
|
|
available: true,
|
|
|
|
|
isOpen: false,
|
2026-09-19 09:45:20 +00:00
|
|
|
activeTab: undefined,
|
2026-09-19 08:09:45 +00:00
|
|
|
node: undefined,
|
|
|
|
|
open: mocks.open,
|
|
|
|
|
setActiveTab: mocks.setActiveTab,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-19 09:45:20 +00:00
|
|
|
afterEach(() => {
|
|
|
|
|
vi.unstubAllGlobals()
|
|
|
|
|
vi.useRealTimers()
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-19 08:09:45 +00:00
|
|
|
describe('Korean comments file action', () => {
|
2026-09-19 09:45:20 +00:00
|
|
|
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', () => {
|
2026-09-19 08:09:45 +00:00
|
|
|
expect(mocks.registerFileAction).toHaveBeenCalledWith(action)
|
2026-09-19 09:45:20 +00:00
|
|
|
expect(action.displayName(context as never)).toBe('')
|
|
|
|
|
expect(action.iconSvgInline(context as never)).toContain('data-comment-state="empty"')
|
2026-09-19 08:47:34 +00:00
|
|
|
expect(action.inline?.(context as never)).toBe(true)
|
2026-09-19 08:09:45 +00:00
|
|
|
})
|
|
|
|
|
|
2026-09-19 09:45:20 +00:00
|
|
|
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"')
|
2026-09-19 08:09:45 +00:00
|
|
|
})
|
|
|
|
|
|
2026-09-19 09:45:20 +00:00
|
|
|
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 () => {
|
2026-09-19 08:09:45 +00:00
|
|
|
mocks.getSidebar.mockReturnValue({
|
|
|
|
|
available: true,
|
|
|
|
|
isOpen: true,
|
2026-09-19 09:45:20 +00:00
|
|
|
activeTab: COMMENTS_TAB_ID,
|
2026-09-19 08:09:45 +00:00
|
|
|
node,
|
|
|
|
|
open: mocks.open,
|
|
|
|
|
setActiveTab: mocks.setActiveTab,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(await action.exec(context as never)).toBeNull()
|
2026-09-19 09:45:20 +00:00
|
|
|
expect(mocks.setActiveTab).toHaveBeenCalledWith(COMMENTS_TAB_ID)
|
2026-09-19 08:09:45 +00:00
|
|
|
expect(mocks.open).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-19 09:45:20 +00:00
|
|
|
it('uses the comments-only tab even when Activity integration is disabled', async () => {
|
|
|
|
|
mocks.loadState.mockReturnValue(false)
|
2026-09-19 08:09:45 +00:00
|
|
|
|
2026-09-19 09:45:20 +00:00
|
|
|
expect(action.enabled?.(context as never)).toBe(true)
|
2026-09-19 08:09:45 +00:00
|
|
|
expect(await action.exec(context as never)).toBeNull()
|
2026-09-19 09:45:20 +00:00
|
|
|
expect(mocks.open).toHaveBeenCalledWith(node, COMMENTS_TAB_ID)
|
2026-09-19 08:09:45 +00:00
|
|
|
})
|
|
|
|
|
|
2026-09-19 09:45:20 +00:00
|
|
|
it('hides when the Comments app is unavailable', () => {
|
|
|
|
|
mocks.loadState.mockReturnValue(null)
|
2026-09-19 08:09:45 +00:00
|
|
|
|
|
|
|
|
expect(action.enabled?.(context as never)).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|