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) => ''), 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(() => ''), 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() class FakeElement { style: Record = {} 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() class FakeElement { style: Record = {} 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 } 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 } 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 } 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): 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): 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) }) })