diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..9dadb01 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,5 @@ +module.exports = { + extends: [ + '@nextcloud/eslint-config', + ], +} diff --git a/.github/workflows/lint-eslint.yml b/.github/workflows/lint-eslint.yml new file mode 100644 index 0000000..af11871 --- /dev/null +++ b/.github/workflows/lint-eslint.yml @@ -0,0 +1,100 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Lint eslint + +on: pull_request + +permissions: + contents: read + +concurrency: + group: lint-eslint-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest-low + permissions: + contents: read + pull-requests: read + + outputs: + src: ${{ steps.changes.outputs.src}} + + steps: + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/**' + - 'src/**' + - 'appinfo/info.xml' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.json' + - '.eslintrc.*' + - '.eslintignore' + - '**.js' + - '**.ts' + - '**.vue' + + lint: + runs-on: ubuntu-latest + + needs: changes + if: needs.changes.outputs.src != 'false' + + name: NPM lint + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + fallbackNode: '^24' + fallbackNpm: '^11.3' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + + - name: Install dependencies + env: + CYPRESS_INSTALL_BINARY: 0 + PUPPETEER_SKIP_DOWNLOAD: true + run: npm ci + + - name: Lint + run: npm run lint + + summary: + permissions: + contents: none + runs-on: ubuntu-latest-low + needs: [changes, lint] + + if: always() + + # This is the summary, we just avoid to rename it so that branch protection rules still match + name: eslint + + steps: + - name: Summary status + run: if ${{ needs.changes.outputs.src != 'false' && needs.lint.result != 'success' }}; then exit 1; fi diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml new file mode 100644 index 0000000..336cdd7 --- /dev/null +++ b/.github/workflows/node-test.yml @@ -0,0 +1,104 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Node tests + +on: + pull_request: + push: + branches: + - main + - master + - stable* + +permissions: + contents: read + +concurrency: + group: node-tests-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest-low + permissions: + contents: read + pull-requests: read + + outputs: + src: ${{ steps.changes.outputs.src}} + + steps: + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + continue-on-error: true + with: + filters: | + src: + - '.github/workflows/**' + - 'src/**' + - 'appinfo/info.xml' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.json' + - 'vitest.config.*' + - '**.js' + - '**.ts' + - '**.vue' + + test: + runs-on: ubuntu-latest + + needs: changes + if: needs.changes.outputs.src != 'false' + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + fallbackNode: '^24' + fallbackNpm: '^11.3' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + + - name: Install dependencies & build + env: + CYPRESS_INSTALL_BINARY: 0 + PUPPETEER_SKIP_DOWNLOAD: true + run: | + npm ci + npm run build --if-present + + - name: Test + run: npm run test + + summary: + permissions: + contents: none + runs-on: ubuntu-latest-low + needs: [changes, test] + + if: always() + + name: npm-test-summary + + steps: + - name: Summary status + run: if ${{ needs.changes.outputs.src != 'false' && needs.test.result != 'success' }}; then exit 1; fi diff --git a/package.json b/package.json index 554c371..ef023dd 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,10 @@ "scripts": { "build": "vite --mode production build", "dev": "vite --mode development build", - "watch": "vite --mode development build --watch" + "watch": "vite --mode development build --watch", + "lint": "eslint --ext .js,.vue,.ts src", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@mdi/svg": "^7.3.67", diff --git a/src/__tests__/MindMap.spec.js b/src/__tests__/MindMap.spec.js new file mode 100644 index 0000000..0cae0c0 --- /dev/null +++ b/src/__tests__/MindMap.spec.js @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { shallowMount } from '@vue/test-utils' +import MindMap from '../views/MindMap.vue' + +vi.mock('@nextcloud/l10n', () => ({ getLanguage: () => 'en' })) +vi.mock('@nextcloud/router', () => ({ + generateUrl: (path, params = {}) => + Object.entries(params).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, v ?? ''), + path, + ), +})) +vi.mock('@nextcloud/sharing/public', () => ({ + isPublicShare: vi.fn(() => false), + getSharingToken: () => '', +})) + +// Viewer mixin that supplies the props/methods the component expects +const viewerMixin = { + data() { + return { + fileList: [], + fileid: null, + source: null, + davPath: null, + } + }, + methods: { + doneLoading() {}, + handleWebviewerloaded() {}, + }, +} + +function mountMindMap(dataOverrides = {}) { + return shallowMount(MindMap, { + mixins: [ + { + ...viewerMixin, + data() { + return { ...viewerMixin.data(), ...dataOverrides } + }, + }, + ], + }) +} + +describe('MindMap.vue', () => { + beforeEach(() => { + window.OCA = { FilesMindMap: { setFile: vi.fn() } } + }) + + afterEach(() => { + delete window.OCA + }) + + it('renders an iframe element', () => { + const wrapper = mountMindMap() + expect(wrapper.find('iframe').exists()).toBe(true) + }) + + describe('iframeSrc computed property', () => { + it('uses source when available', () => { + const wrapper = mountMindMap({ + source: '/remote.php/dav/files/user/test.km', + }) + expect(wrapper.vm.iframeSrc).toContain('/remote.php/dav/files/user/test.km') + }) + + it('falls back to davPath when source is null', () => { + const wrapper = mountMindMap({ + source: null, + davPath: '/dav/path/test.km', + }) + expect(wrapper.vm.iframeSrc).toContain('/dav/path/test.km') + }) + }) + + describe('file computed property', () => { + it('returns the file whose fileid matches the current fileid', () => { + const file = { fileid: 7, name: 'test.km' } + const wrapper = mountMindMap({ fileList: [file], fileid: 7 }) + expect(wrapper.vm.file).toBe(file) + }) + + it('returns undefined when no file matches', () => { + const wrapper = mountMindMap({ + fileList: [{ fileid: 1, name: 'other.km' }], + fileid: 99, + }) + expect(wrapper.vm.file).toBeUndefined() + }) + }) + + describe('lifecycle hooks', () => { + it('calls OCA.FilesMindMap.setFile on mount', () => { + mountMindMap({ + fileList: [{ fileid: 3, name: 'test.km' }], + fileid: 3, + }) + expect(window.OCA.FilesMindMap.setFile).toHaveBeenCalled() + }) + + it('removes the webviewerloaded event listener on destroy', () => { + const spy = vi.spyOn(document, 'removeEventListener') + const wrapper = mountMindMap() + wrapper.destroy() + expect(spy).toHaveBeenCalledWith('webviewerloaded', expect.anything()) + spy.mockRestore() + }) + }) +}) diff --git a/src/__tests__/mindmap.spec.js b/src/__tests__/mindmap.spec.js new file mode 100644 index 0000000..d9b8931 --- /dev/null +++ b/src/__tests__/mindmap.spec.js @@ -0,0 +1,442 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)) + +vi.mock('@nextcloud/l10n', () => ({ + translate: (_app, text) => text, +})) + +vi.mock('@nextcloud/router', () => ({ + generateUrl: (path) => `/nc${path}`, +})) + +vi.mock('@nextcloud/dialogs', () => ({ + showMessage: vi.fn(() => ({ hideToast: vi.fn() })), +})) + +vi.mock('@nextcloud/auth', () => ({ + getCurrentUser: vi.fn(() => ({ uid: 'testuser' })), +})) + +vi.mock('@nextcloud/sharing/public', () => ({ + isPublicShare: vi.fn(() => false), +})) + +vi.mock('@nextcloud/event-bus', () => ({ + emit: vi.fn(), +})) + +vi.mock('@mdi/svg/svg/pencil.svg?raw', () => ({ default: '' })) + +vi.mock('@nextcloud/files', () => ({ + DefaultType: { HIDDEN: 'hidden' }, + FileAction: vi.fn().mockImplementation(opts => opts), + addNewFileMenuEntry: vi.fn(), + registerFileAction: vi.fn(), + File: vi.fn(), + Permission: { READ: 1, CREATE: 4, UPDATE: 2, DELETE: 8, SHARE: 16, ALL: 31 }, + getUniqueName: vi.fn(name => name), +})) + +vi.mock('@nextcloud/axios', () => { + const fn = vi.fn() + fn.get = vi.fn() + return { default: fn } +}) + +vi.mock('../plugins/km', () => ({ + default: { name: 'km', mimes: ['application/km'], encode: vi.fn(d => Promise.resolve(d)), decode: vi.fn() }, +})) +vi.mock('../plugins/freemind', () => ({ + default: { name: 'freemind', mimes: ['application/x-freemind'], encode: null, decode: vi.fn() }, +})) +vi.mock('../plugins/xmind', () => ({ + default: { name: 'xmind', mimes: ['application/vnd.xmind.workbook'], encode: null, decode: vi.fn() }, +})) + +import FilesMindMap from '../mindmap.js' +import { showMessage as showToast } from '@nextcloud/dialogs' +import axios from '@nextcloud/axios' + +describe('FilesMindMap', () => { + beforeEach(() => { + FilesMindMap._extensions = [] + FilesMindMap._file = {} + FilesMindMap._currentContext = null + vi.clearAllMocks() + }) + + // ─── Extension management ─────────────────────────────────────────────────── + + describe('registerExtension', () => { + it('registers a single extension object', () => { + const ext = { name: 'test', mimes: ['application/test'] } + FilesMindMap.registerExtension(ext) + expect(FilesMindMap._extensions).toHaveLength(1) + expect(FilesMindMap._extensions[0]).toBe(ext) + }) + + it('registers an array of extensions', () => { + const ext1 = { name: 'a', mimes: ['application/a'] } + const ext2 = { name: 'b', mimes: ['application/b'] } + FilesMindMap.registerExtension([ext1, ext2]) + expect(FilesMindMap._extensions).toHaveLength(2) + }) + }) + + describe('getExtensionByMime', () => { + it('returns the matching extension for a registered mime type', () => { + const ext = { name: 'km', mimes: ['application/km'] } + FilesMindMap.registerExtension(ext) + expect(FilesMindMap.getExtensionByMime('application/km')).toBe(ext) + }) + + it('returns null for an unknown mime type', () => { + expect(FilesMindMap.getExtensionByMime('application/unknown')).toBeNull() + }) + + it('returns null when no extensions are registered', () => { + expect(FilesMindMap.getExtensionByMime('application/km')).toBeNull() + }) + }) + + describe('isSupportedMime', () => { + it('returns true for a registered mime type', () => { + FilesMindMap.registerExtension({ name: 'km', mimes: ['application/km'] }) + expect(FilesMindMap.isSupportedMime('application/km')).toBe(true) + }) + + it('returns false for an unregistered mime type', () => { + expect(FilesMindMap.isSupportedMime('application/pdf')).toBe(false) + }) + }) + + describe('getSupportedMimetypes', () => { + it('returns a flat list of all mimes from all registered extensions', () => { + FilesMindMap.registerExtension([ + { name: 'a', mimes: ['application/a', 'application/a2'] }, + { name: 'b', mimes: ['application/b'] }, + ]) + expect(FilesMindMap.getSupportedMimetypes()).toEqual([ + 'application/a', + 'application/a2', + 'application/b', + ]) + }) + + it('returns an empty array when no extensions are registered', () => { + expect(FilesMindMap.getSupportedMimetypes()).toEqual([]) + }) + }) + + // ─── Notifications ───────────────────────────────────────────────────────── + + describe('showMessage', () => { + it('calls showToast with the message and the default 3 s timeout', () => { + FilesMindMap.showMessage('Hello') + expect(showToast).toHaveBeenCalledWith('Hello', { timeout: 3000 }) + }) + + it('calls showToast with a custom timeout', () => { + FilesMindMap.showMessage('Hello', 5000) + expect(showToast).toHaveBeenCalledWith('Hello', { timeout: 5000 }) + }) + + it('returns the toast object from showToast', () => { + const mockToast = { hideToast: vi.fn() } + showToast.mockReturnValue(mockToast) + expect(FilesMindMap.showMessage('Hello')).toBe(mockToast) + }) + }) + + describe('hideMessage', () => { + it('calls hideToast on the toast object', () => { + const mockToast = { hideToast: vi.fn() } + FilesMindMap.hideMessage(mockToast) + expect(mockToast.hideToast).toHaveBeenCalledOnce() + }) + + it('does not throw when called with null', () => { + expect(() => FilesMindMap.hideMessage(null)).not.toThrow() + }) + + it('does not throw when called with undefined', () => { + expect(() => FilesMindMap.hideMessage(undefined)).not.toThrow() + }) + + it('does not throw when the toast has no hideToast method', () => { + expect(() => FilesMindMap.hideMessage({})).not.toThrow() + }) + }) + + // ─── File state ──────────────────────────────────────────────────────────── + + describe('setFile', () => { + it('sets name, dir, and fullName from file object', () => { + FilesMindMap.setFile({ filename: '/documents/test.km', basename: 'test.km' }) + expect(FilesMindMap._file.name).toBe('test.km') + expect(FilesMindMap._file.dir).toBe('/documents') + expect(FilesMindMap._file.fullName).toBe('/documents/test.km') + }) + + it('sets dir to "/" for top-level files', () => { + FilesMindMap.setFile({ filename: '/test.km', basename: 'test.km' }) + expect(FilesMindMap._file.dir).toBe('/') + }) + + it('sets _currentContext from the resolved dir', () => { + FilesMindMap.setFile({ filename: '/docs/sub/test.km', basename: 'test.km' }) + expect(FilesMindMap._currentContext).toEqual(expect.objectContaining({ dir: '/docs/sub' })) + }) + }) + + // ─── Public share detection ──────────────────────────────────────────────── + + describe('isMindmapPublic', () => { + it('returns false when not on a public share page', async () => { + const { isPublicShare } = await import('@nextcloud/sharing/public') + isPublicShare.mockReturnValue(false) + expect(FilesMindMap.isMindmapPublic()).toBe(false) + }) + + it('returns true when on a public share page with a supported mime type', async () => { + const { isPublicShare } = await import('@nextcloud/sharing/public') + isPublicShare.mockReturnValue(true) + FilesMindMap.registerExtension({ name: 'km', mimes: ['application/km'] }) + + const input = document.createElement('input') + input.id = 'mimetype' + input.value = 'application/km' + document.body.appendChild(input) + try { + expect(FilesMindMap.isMindmapPublic()).toBe(true) + } finally { + document.body.removeChild(input) + } + }) + + it('returns false when on a public share page but mime type is unsupported', async () => { + const { isPublicShare } = await import('@nextcloud/sharing/public') + isPublicShare.mockReturnValue(true) + + const input = document.createElement('input') + input.id = 'mimetype' + input.value = 'application/pdf' + document.body.appendChild(input) + try { + expect(FilesMindMap.isMindmapPublic()).toBe(false) + } finally { + document.body.removeChild(input) + } + }) + }) + + // ─── save() ──────────────────────────────────────────────────────────────── + + describe('save', () => { + it('calls fail immediately when the extension does not support encoding', () => { + FilesMindMap.registerExtension({ + name: 'freemind', + mimes: ['application/x-freemind'], + encode: null, + decode: null, + }) + FilesMindMap._file = { dir: '/docs', name: 'test.mm', mime: 'application/x-freemind' } + + const fail = vi.fn() + FilesMindMap.save('data', vi.fn(), fail) + expect(fail).toHaveBeenCalledWith(expect.stringContaining('Does not support saving')) + }) + + it('sends a PUT request to the savefile URL on the happy path', async () => { + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn().mockResolvedValue('encoded-content'), + decode: null, + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km', mime: 'application/km', mtime: 100 } + axios.mockResolvedValue({ data: { mtime: 200 } }) + + const success = vi.fn() + FilesMindMap.save('input-data', success, vi.fn()) + await flushPromises() + + expect(axios).toHaveBeenCalledWith(expect.objectContaining({ + method: 'PUT', + url: '/nc/apps/files_mindmap/ajax/savefile', + })) + expect(success).toHaveBeenCalledWith('File Saved') + }) + + it('updates _file.mtime from the server response', async () => { + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn().mockResolvedValue('data'), + decode: null, + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km', mime: 'application/km', mtime: 100 } + axios.mockResolvedValue({ data: { mtime: 999 } }) + + FilesMindMap.save('data', vi.fn(), vi.fn()) + await flushPromises() + + expect(FilesMindMap._file.mtime).toBe(999) + }) + + it('calls fail with the server error message on failure', async () => { + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn().mockResolvedValue('data'), + decode: null, + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km', mime: 'application/km', mtime: 100 } + axios.mockRejectedValue({ response: { data: { message: 'Quota exceeded' } } }) + + const fail = vi.fn() + FilesMindMap.save('data', vi.fn(), fail) + await flushPromises() + + expect(fail).toHaveBeenCalledWith('Quota exceeded') + }) + + it('calls fail with generic message when error response has no message', async () => { + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn().mockResolvedValue('data'), + decode: null, + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km', mime: 'application/km', mtime: 100 } + axios.mockRejectedValue(new Error('Network error')) + + const fail = vi.fn() + FilesMindMap.save('data', vi.fn(), fail) + await flushPromises() + + expect(fail).toHaveBeenCalledWith('Save failed') + }) + }) + + // ─── load() ──────────────────────────────────────────────────────────────── + + describe('load', () => { + it('calls success with decoded file contents', async () => { + const decoded = { root: { data: { text: 'Test' } } } + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn(), + decode: vi.fn().mockResolvedValue(decoded), + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km' } + + axios.get.mockResolvedValue({ + data: { + filecontents: btoa('raw-content'), + mime: 'application/km', + writeable: true, + mtime: 9999, + }, + }) + + const success = vi.fn() + FilesMindMap.load(success, vi.fn()) + await flushPromises() + + expect(success).toHaveBeenCalledWith(JSON.stringify(decoded)) + }) + + it('sets _file.mime and _file.mtime from the server response', async () => { + const ext = { + name: 'km', + mimes: ['application/km'], + encode: vi.fn(), + decode: vi.fn().mockResolvedValue({}), + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.km' } + + axios.get.mockResolvedValue({ + data: { + filecontents: btoa('content'), + mime: 'application/km', + writeable: true, + mtime: 42, + }, + }) + + FilesMindMap.load(vi.fn(), vi.fn()) + await flushPromises() + + expect(FilesMindMap._file.mime).toBe('application/km') + expect(FilesMindMap._file.mtime).toBe(42) + }) + + it('calls failure when the HTTP request fails', async () => { + FilesMindMap._file = { dir: '/docs', name: 'test.km' } + axios.get.mockRejectedValue({ + response: { data: { message: 'File not found' } }, + message: 'Request failed', + }) + + const failure = vi.fn() + FilesMindMap.load(vi.fn(), failure) + await flushPromises() + + expect(failure).toHaveBeenCalledWith('File not found') + }) + + it('calls failure for an unsupported mime type', async () => { + FilesMindMap._extensions = [] + FilesMindMap._file = { dir: '/docs', name: 'test.pdf' } + + axios.get.mockResolvedValue({ + data: { + filecontents: btoa('content'), + mime: 'application/pdf', + writeable: true, + mtime: 100, + }, + }) + + const failure = vi.fn() + FilesMindMap.load(vi.fn(), failure) + await flushPromises() + + expect(failure).toHaveBeenCalledWith(expect.stringContaining('Unsupported file type')) + }) + + it('marks supportedWrite as false for read-only extensions', async () => { + const ext = { + name: 'freemind', + mimes: ['application/x-freemind'], + encode: null, + decode: vi.fn().mockResolvedValue('decoded'), + } + FilesMindMap._extensions = [ext] + FilesMindMap._file = { dir: '/docs', name: 'test.mm' } + + axios.get.mockResolvedValue({ + data: { + filecontents: btoa('content'), + mime: 'application/x-freemind', + writeable: true, + mtime: 1, + }, + }) + + FilesMindMap.load(vi.fn(), vi.fn()) + await flushPromises() + + expect(FilesMindMap._file.supportedWrite).toBe(false) + }) + }) +}) diff --git a/src/__tests__/plugins/freemind.spec.js b/src/__tests__/plugins/freemind.spec.js new file mode 100644 index 0000000..916d8f5 --- /dev/null +++ b/src/__tests__/plugins/freemind.spec.js @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import util from '../../util.js' +import freemind from '../../plugins/freemind.js' + +// freemind.toKm references the legacy global FilesMindMap.Util +beforeAll(() => { + global.FilesMindMap = { Util: util } +}) + +describe('freemind plugin', () => { + it('has the correct name', () => { + expect(freemind.name).toBe('freemind') + }) + + it('supports application/x-freemind mime type', () => { + expect(freemind.mimes).toContain('application/x-freemind') + }) + + it('encode is null (read-only format)', () => { + expect(freemind.encode).toBeNull() + }) + + describe('markerMap', () => { + it('maps full-1 to priority 1', () => { + expect(freemind.markerMap['full-1']).toEqual(['priority', 1]) + }) + + it('maps full-8 to priority 8', () => { + expect(freemind.markerMap['full-8']).toEqual(['priority', 8]) + }) + }) + + describe('processTopic', () => { + it('extracts TEXT as data.text', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Hello' }, obj) + expect(obj.data.text).toBe('Hello') + }) + + it('extracts hyperlink from LINK attribute', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Link', LINK: 'https://example.com' }, obj) + expect(obj.data.hyperlink).toBe('https://example.com') + }) + + it('extracts a single priority marker', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Prio', icon: { BUILTIN: 'full-3' } }, obj) + expect(obj.data.priority).toBe(3) + }) + + it('extracts multiple markers from an array', () => { + const obj = {} + freemind.processTopic({ + TEXT: 'Multi', + icon: [{ BUILTIN: 'full-1' }, { BUILTIN: 'full-2' }], + }, obj) + expect(obj.data.priority).toBe(2) // last one wins + }) + + it('ignores unknown marker values', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Unknown', icon: { BUILTIN: 'unknown-marker' } }, obj) + expect(obj.data.priority).toBeUndefined() + }) + + it('processes a single child node', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Parent', node: { TEXT: 'Child' } }, obj) + expect(obj.children).toHaveLength(1) + expect(obj.children[0].data.text).toBe('Child') + }) + + it('processes multiple child nodes', () => { + const obj = {} + freemind.processTopic({ + TEXT: 'Parent', + node: [{ TEXT: 'Child1' }, { TEXT: 'Child2' }], + }, obj) + expect(obj.children).toHaveLength(2) + expect(obj.children[0].data.text).toBe('Child1') + expect(obj.children[1].data.text).toBe('Child2') + }) + + it('produces no children property when topic has no children', () => { + const obj = {} + freemind.processTopic({ TEXT: 'Leaf' }, obj) + expect(obj.children).toBeUndefined() + }) + }) + + describe('decode', () => { + it('decodes a FreeMind XML string into a km-compatible tree', async () => { + const xml = `` + const result = await freemind.decode(xml) + expect(result.data.text).toBe('Root') + expect(result.children).toHaveLength(1) + expect(result.children[0].data.text).toBe('Child') + }) + + it('rejects on malformed input', async () => { + await expect(freemind.decode(null)).rejects.toBeDefined() + }) + }) +}) diff --git a/src/__tests__/plugins/km.spec.js b/src/__tests__/plugins/km.spec.js new file mode 100644 index 0000000..1390ed5 --- /dev/null +++ b/src/__tests__/plugins/km.spec.js @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import km from '../../plugins/km.js' + +describe('km plugin', () => { + it('has the correct name', () => { + expect(km.name).toBe('km') + }) + + it('supports application/km mime type', () => { + expect(km.mimes).toContain('application/km') + }) + + describe('encode', () => { + it('resolves with the same data unchanged', async () => { + const data = '{"root":{"data":{"text":"Test"}}}' + await expect(km.encode(data)).resolves.toBe(data) + }) + + it('resolves with empty string', async () => { + await expect(km.encode('')).resolves.toBe('') + }) + }) + + describe('decode', () => { + it('parses a valid JSON string and resolves with the object', async () => { + const obj = { root: { data: { text: 'Test' } } } + const result = await km.decode(JSON.stringify(obj)) + expect(result).toEqual(obj) + }) + + it('resolves with the raw string when JSON is invalid', async () => { + const data = 'not-valid-json' + await expect(km.decode(data)).resolves.toBe(data) + }) + + it('resolves with empty string', async () => { + await expect(km.decode('')).resolves.toBe('') + }) + }) +}) diff --git a/src/__tests__/plugins/xmind.spec.js b/src/__tests__/plugins/xmind.spec.js new file mode 100644 index 0000000..2a0059c --- /dev/null +++ b/src/__tests__/plugins/xmind.spec.js @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import xmind from '../../plugins/xmind.js' + +describe('xmind plugin', () => { + it('has the correct name', () => { + expect(xmind.name).toBe('xmind') + }) + + it('supports application/vnd.xmind.workbook mime type', () => { + expect(xmind.mimes).toContain('application/vnd.xmind.workbook') + }) + + it('encode is null (read-only format)', () => { + expect(xmind.encode).toBeNull() + }) + + describe('markerMap', () => { + it('maps priority-1 to priority 1', () => { + expect(xmind.markerMap['priority-1']).toEqual(['priority', 1]) + }) + + it('maps priority-8 to priority 8', () => { + expect(xmind.markerMap['priority-8']).toEqual(['priority', 8]) + }) + + it('maps task-done to progress 9', () => { + expect(xmind.markerMap['task-done']).toEqual(['progress', 9]) + }) + + it('maps task-half to progress 5', () => { + expect(xmind.markerMap['task-half']).toEqual(['progress', 5]) + }) + }) + + describe('processTopic', () => { + it('extracts title as data.text', () => { + const obj = {} + xmind.processTopic({ title: 'My Topic' }, obj) + expect(obj.data.text).toBe('My Topic') + }) + + it('extracts hyperlink from xlink:href', () => { + const obj = {} + xmind.processTopic({ title: 'Link', 'xlink:href': 'https://example.com' }, obj) + expect(obj.data.hyperlink).toBe('https://example.com') + }) + + it('extracts a single marker', () => { + const obj = {} + xmind.processTopic({ + title: 'Prio', + marker_refs: { marker_ref: { marker_id: 'priority-2' } }, + }, obj) + expect(obj.data.priority).toBe(2) + }) + + it('extracts multiple markers from an array', () => { + const obj = {} + xmind.processTopic({ + title: 'Multi', + marker_refs: { + marker_ref: [ + { marker_id: 'priority-3' }, + { marker_id: 'task-done' }, + ], + }, + }, obj) + expect(obj.data.priority).toBe(3) + expect(obj.data.progress).toBe(9) + }) + + it('ignores unknown markers', () => { + const obj = {} + xmind.processTopic({ + title: 'Unknown', + marker_refs: { marker_ref: { marker_id: 'unknown-marker' } }, + }, obj) + expect(obj.data.priority).toBeUndefined() + }) + + it('processes multiple child topics', () => { + const obj = {} + xmind.processTopic({ + title: 'Root', + children: { + topics: { + topic: [{ title: 'Child1' }, { title: 'Child2' }], + }, + }, + }, obj) + expect(obj.children).toHaveLength(2) + expect(obj.children[0].data.text).toBe('Child1') + expect(obj.children[1].data.text).toBe('Child2') + }) + + it('produces no children property for leaf topics', () => { + const obj = {} + xmind.processTopic({ title: 'Leaf' }, obj) + expect(obj.children).toBeUndefined() + }) + }) + + describe('readDocument', () => { + it('rejects when content.xml is missing in the zip', async () => { + await expect(xmind.readDocument('not a zip')).rejects.toBeDefined() + }) + }) +}) diff --git a/src/__tests__/util.spec.js b/src/__tests__/util.spec.js new file mode 100644 index 0000000..e8cff23 --- /dev/null +++ b/src/__tests__/util.spec.js @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import util from '../util.js' + +describe('util.jsVar', () => { + it('replaces hyphens with underscores', () => { + expect(util.jsVar('foo-bar-baz')).toBe('foo_bar_baz') + }) + + it('returns empty string for null', () => { + expect(util.jsVar(null)).toBe('') + }) + + it('returns empty string for undefined', () => { + expect(util.jsVar(undefined)).toBe('') + }) + + it('leaves strings without hyphens unchanged', () => { + expect(util.jsVar('foobar')).toBe('foobar') + }) +}) + +describe('util.toArray', () => { + it('wraps a non-array value in an array', () => { + expect(util.toArray('foo')).toEqual(['foo']) + expect(util.toArray(42)).toEqual([42]) + }) + + it('wraps an object in an array', () => { + const obj = { a: 1 } + expect(util.toArray(obj)).toEqual([obj]) + }) + + it('returns arrays unchanged', () => { + expect(util.toArray([1, 2, 3])).toEqual([1, 2, 3]) + }) + + it('returns empty arrays unchanged', () => { + expect(util.toArray([])).toEqual([]) + }) +}) + +describe('util.base64Encode / util.base64Decode', () => { + it('round-trips ASCII text', () => { + const text = 'Hello, World!' + expect(util.base64Decode(util.base64Encode(text))).toBe(text) + }) + + it('round-trips unicode text', () => { + const text = 'Héllo Wörld — こんにちは' + expect(util.base64Decode(util.base64Encode(text))).toBe(text) + }) + + it('base64Encode returns a non-empty string', () => { + expect(util.base64Encode('test')).toBeTruthy() + }) + + it('base64Decode handles plain btoa-encoded ASCII', () => { + expect(util.base64Decode(btoa('hello'))).toBe('hello') + }) +}) + +describe('util.xml2json', () => { + it('parses element attributes', () => { + const xml = '' + const result = util.xml2json(xml) + expect(result.id).toBe('42') + expect(result.name).toBe('test') + }) + + it('parses nested child elements', () => { + const xml = '' + const result = util.xml2json(xml) + expect(result.child).toBeDefined() + expect(result.child.TEXT).toBe('hello') + }) + + it('parses multiple children of the same tag as an array', () => { + const xml = '' + const result = util.xml2json(xml) + expect(Array.isArray(result.node)).toBe(true) + expect(result.node).toHaveLength(2) + }) + + it('returns defined result for a minimal document', () => { + const xml = '' + const result = util.xml2json(xml) + expect(result).toBeDefined() + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..66ba211 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue2' + +export default defineConfig({ + plugins: [vue()], + test: { + environment: 'jsdom', + globals: true, + server: { + deps: { + // cancelable-promise is CJS; inline it so Vite handles interop + inline: ['cancelable-promise'], + }, + }, + }, +})