test: add frontend tests

This commit is contained in:
2026-01-05 17:56:33 +02:00
parent d6d6ae8acc
commit 23b9a8ec5e
15 changed files with 1576 additions and 12 deletions

View File

@@ -1,2 +1,2 @@
templates/
scaffolds/
gen/

266
README.md
View File

@@ -36,6 +36,7 @@ Once you have it cloned on your machine:
- [Tips & gotchas](#tips--gotchas)
- [GitHub Workflows](#github-workflows)
- [Project layout](#project-layout)
- [Testing](#testing)
- [Release Please (automated versioning & releases)](#release-please-automated-versioning--releases)
- [Resources](#resources)
@@ -296,6 +297,271 @@ them to `.github/workflows/`** so Actions start running automatically.
└─ rename-template.sh # One-time renamer script for template cloning
```
## Testing
### Frontend Testing
This template includes a complete frontend testing setup using [Vitest](https://vitest.dev/) and
[Vue Test Utils](https://test-utils.vuejs.org/).
#### Running tests
```bash
# Run tests in watch mode (recommended during development)
pnpm test
# Run tests once (useful for CI)
pnpm test:run
```
#### Test file structure
Test files are placed next to the files they test, using the `.test.ts` suffix:
```
src/
├─ utils/
│ ├─ string.ts # Utility functions
│ └─ string.test.ts # Tests for string.ts
├─ components/
│ ├─ StatusBadge.vue # Vue component
│ └─ StatusBadge.test.ts # Tests for StatusBadge.vue
```
#### Writing tests
##### Pure TypeScript/utility functions
For utility functions, use the standard `describe`/`it`/`expect` pattern:
```typescript
import { describe, expect, it } from 'vitest'
import { myFunction } from './myModule'
describe('myFunction', () => {
it('handles normal input', () => {
expect(myFunction('hello')).toBe('HELLO')
})
it('handles null input', () => {
expect(myFunction(null)).toBe('')
})
})
```
##### Vue components
For Vue components, you'll need to mock Nextcloud dependencies. Here's a typical pattern:
```typescript
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import MyComponent from './MyComponent.vue'
// Mock @nextcloud/l10n
vi.mock('@nextcloud/l10n', () => ({
t: (app: string, text: string, vars?: Record<string, unknown>) => {
if (vars) {
return Object.entries(vars).reduce(
(acc, [key, value]) => acc.replace(`{${key}}`, String(value)),
text,
)
}
return text
},
n: (app: string, singular: string, plural: string, count: number) => {
return count === 1 ? singular : plural
},
}))
// Mock Nextcloud Vue components
vi.mock('@nextcloud/vue/components/NcButton', () => ({
default: {
name: 'NcButton',
template:
'<button :disabled="disabled" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
props: ['variant', 'disabled', 'ariaLabel', 'title'],
},
}))
// Mock icon components
vi.mock('@icons/Check.vue', () => ({
default: { name: 'CheckIcon', template: '<span />', props: ['size'] },
}))
describe('MyComponent', () => {
it('renders with props', () => {
const wrapper = mount(MyComponent, {
props: { title: 'Hello' },
})
expect(wrapper.text()).toContain('Hello')
})
it('emits events', async () => {
const wrapper = mount(MyComponent, {
props: { clickable: true },
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
it('computes values correctly', () => {
const wrapper = mount(MyComponent, {
props: { count: 5 },
})
// Access computed properties via wrapper.vm
expect((wrapper.vm as InstanceType<typeof MyComponent>).doubleCount).toBe(10)
})
})
```
#### Tips
- **Test file location**: Place test files next to the files they test (e.g., `Component.test.ts`
next to `Component.vue`)
- **TypeScript errors**: You may see "Cannot find module './Component.vue'" errors in test files.
These can be ignored as Vitest handles Vue files correctly at runtime
- **Mocking**: Keep mocks minimal - only mock what's necessary for the test to run
- **happy-dom**: This template uses happy-dom instead of jsdom for faster test execution. Note that
happy-dom preserves hex colors (e.g., `#ff5500`) rather than converting to RGB
- **Globals**: The vitest config enables globals, so you don't need to import `describe`, `it`,
`expect` in every file (though explicit imports are recommended for clarity)
#### Resources
- [Vitest documentation](https://vitest.dev/)
- [Vue Test Utils documentation](https://test-utils.vuejs.org/)
- [Testing Vue 3 components](https://test-utils.vuejs.org/guide/)
### Backend Testing (PHP)
This template uses [PHPUnit](https://phpunit.de/) for PHP unit testing, integrated with the
Nextcloud testing framework.
#### Running PHP tests
There are two ways to run PHP tests:
**Option 1: Docker (recommended)**
```bash
make test-docker
```
This automatically finds a running Nextcloud container and runs tests inside it. Works with
[nextcloud-docker-dev](https://github.com/juliushaertl/nextcloud-docker-dev) and similar setups.
**Option 2: Local Nextcloud installation**
```bash
# Set NEXTCLOUD_ROOT to your Nextcloud server path
NEXTCLOUD_ROOT=~/path/to/nextcloud make test
# Or set it in the Makefile (line 47) for convenience
make test
```
#### Test file structure
PHP tests live in the `tests/` directory:
```
tests/
├─ unit/
│ └─ Controller/
│ └─ ApiTest.php # Unit tests for ApiController
├─ bootstrap.php # Test bootstrap (loads Nextcloud environment)
├─ phpunit.xml # PHPUnit config for local testing
└─ phpunit.docker.xml # PHPUnit config for Docker testing
```
#### Writing PHP tests
Here's an example test showing how to mock Nextcloud dependencies:
```php
<?php
declare(strict_types=1);
namespace Controller;
use OCA\YourApp\AppInfo\Application;
use OCA\YourApp\Controller\ApiController;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\IRequest;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ApiTest extends TestCase {
private ApiController $controller;
/** @var IRequest&MockObject */
private IRequest $request;
/** @var IAppConfig&MockObject */
private IAppConfig $config;
/** @var IL10N&MockObject */
private IL10N $l10n;
protected function setUp(): void {
$this->request = $this->createMock(IRequest::class);
$this->config = $this->createMock(IAppConfig::class);
$this->l10n = $this->createMock(IL10N::class);
// Mock translation to return the input string
$this->l10n->method('t')
->willReturnCallback(function ($text, $params = []) {
if (empty($params)) {
return $text;
}
return vsprintf($text, $params);
});
$this->controller = new ApiController(
Application::APP_ID,
$this->request,
$this->config,
$this->l10n
);
}
public function testGetHello(): void {
$this->config->method('getValueString')
->willReturn('');
$resp = $this->controller->getHello()->getData();
$this->assertIsArray($resp);
$this->assertArrayHasKey('message', $resp);
}
public function testPostHello(): void {
$this->config->expects($this->once())
->method('setValueString');
$resp = $this->controller->postHello([
'name' => 'World',
])->getData();
$this->assertStringContainsString('World', $resp['message']);
}
}
```
#### Tips
- **Mocking**: Use `$this->createMock()` for Nextcloud interfaces like `IRequest`, `IAppConfig`,
`IL10N`, etc.
- **Test isolation**: Each test should be independent; use `setUp()` to create fresh mocks
- **Naming convention**: Test files should end with `Test.php` (e.g., `ApiTest.php`)
- **Docker vs local**: Docker testing is more reliable as it uses a fully configured Nextcloud
environment
#### Resources
- [PHPUnit documentation](https://docs.phpunit.de/)
- [Nextcloud app testing guide](https://docs.nextcloud.com/server/latest/developer_manual/digging_deeper/testing.html)
## Release Please (automated versioning & releases)
This template includes **[Release Please](https://github.com/googleapis/release-please)** to

View File

@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Your Name <your@email.com>
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* Unit tests for {{pascalCase name}} component.
*
* Run tests:
* pnpm test # watch mode
* pnpm test:run # single run
*
* See src/components/StatusBadge.test.ts for a complete example.
*/
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
import {{ pascalCase name }} from './{{pascalCase name}}.vue'
// ----------------------------------------------------------------------------
// Mocks - uncomment as needed
// ----------------------------------------------------------------------------
// Mock @nextcloud/l10n (if your component uses t() or n())
// vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
// Mock icon components (adjust path and name as needed)
// vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
describe('{{pascalCase name}}', () => {
// Example: Basic rendering
// it('renders correctly', () => {
// const wrapper = mount({{pascalCase name}})
// expect(wrapper.exists()).toBe(true)
// })
// Example: Testing with props
// it('renders with props', () => {
// const wrapper = mount({{pascalCase name}}, {
// props: { title: 'Hello' },
// })
// expect(wrapper.text()).toContain('Hello')
// })
// Example: Testing CSS classes
// it('applies correct CSS class', () => {
// const wrapper = mount({{pascalCase name}}, {
// props: { variant: 'primary' },
// })
// expect(wrapper.classes()).toContain('is-primary')
// })
// Example: Testing emitted events
// it('emits click event', async () => {
// const wrapper = mount({{pascalCase name}})
// await wrapper.trigger('click')
// expect(wrapper.emitted('click')).toBeTruthy()
// })
// Example: Testing computed properties
// it('computes derived value', () => {
// const wrapper = mount({{pascalCase name}}, {
// props: { count: 5 },
// })
// const vm = wrapper.vm as InstanceType<typeof {{pascalCase name}}>
// expect(vm.doubleCount).toBe(10)
// })
// Example: Testing conditional rendering
// it('shows content when condition is met', () => {
// const wrapper = mount({{pascalCase name}}, {
// props: { showDetails: true },
// })
// expect(wrapper.find('.details').exists()).toBe(true)
// })
// Example: Testing slots
// it('renders slot content', () => {
// const wrapper = mount({{pascalCase name}}, {
// slots: { default: 'Slot content' },
// })
// expect(wrapper.text()).toContain('Slot content')
// })
it.todo('add your tests here')
})

View File

@@ -1,10 +1,14 @@
<!--
SPDX-FileCopyrightText: Your Name <your@email.com>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div>{{ startCase name }}</div>
</template>
<script lang="ts">
import { defineComopnent, type PropType } from 'vue'
// import NcComponentExample from '@nextcloud/vue/dist/Components/NcComponentExample.js'
// import NcComponentExample from '@nextcloud/vue/components/NcComponentExample'
// import IconExample from '@icons/Example.vue'

View File

@@ -4,7 +4,7 @@
<script lang="ts">
import { defineComopnent, type PropType } from 'vue'
// import NcComponentExample from '@nextcloud/vue/dist/Components/NcComponentExample.js'
// import NcComponentExample from '@nextcloud/vue/components/NcComponentExample'
// import IconExample from '@icons/Example.vue'

View File

@@ -13,7 +13,9 @@
"lint": "eslint src",
"format": "eslint --fix src && prettier --write {vite.config.ts,src/,README.md}",
"prepare": "husky",
"gen": "simple-scaffold -c . -k"
"gen": "simple-scaffold -c . -k",
"test": "vitest",
"test:run": "vitest run"
},
"browserslist": [
"extends @nextcloud/browserslist-config"
@@ -34,8 +36,11 @@
"@nextcloud/browserslist-config": "^3.1.2",
"@nextcloud/eslint-config": "^8.4.2",
"@nextcloud/stylelint-config": "^3.1.1",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.2",
"happy-dom": "^20.0.11",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^2.8.8",
@@ -47,6 +52,7 @@
"typescript-eslint": "^8.50.0",
"vite": "^6.4.1",
"vite-plugin-checker": "^0.12.0",
"vitest": "^4.0.16",
"vue-router": "^4.6.4",
"vue-tsc": "^2.2.12"
}

487
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,6 @@ module.exports = () => {
component: {
templates: ['gen/component'],
output: 'src/components',
subDir: false,
},
view: {
templates: ['gen/view'],

View File

@@ -0,0 +1,270 @@
/**
* Example test file demonstrating Vue component testing with mocked dependencies.
*
* This shows how to:
* - Mock @nextcloud/l10n translation functions
* - Mock icon components from vue-material-design-icons
* - Mount components with props
* - Test computed properties via wrapper.vm
* - Test emitted events
* - Test conditional rendering
* - Test CSS classes and inline styles
*/
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createIconMock, nextcloudL10nMock } from '@/test-utils'
import StatusBadge from './StatusBadge.vue'
// Mock @nextcloud/l10n
vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
// Mock icon components
vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
vi.mock('@icons/Alert.vue', () => createIconMock('AlertIcon'))
vi.mock('@icons/ClockOutline.vue', () => createIconMock('ClockIcon'))
describe('StatusBadge', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('renders with required status prop', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success' },
})
expect(wrapper.exists()).toBe(true)
expect(wrapper.classes()).toContain('status-badge')
})
it('renders the label when provided', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success', label: 'Completed' },
})
expect(wrapper.find('.status-label').text()).toBe('Completed')
})
it('renders without label when not provided', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success' },
})
expect(wrapper.find('.status-label').text()).toBe('')
})
})
describe('CSS classes', () => {
it('applies correct class for success status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success' },
})
expect(wrapper.classes()).toContain('status-success')
})
it('applies correct class for warning status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'warning' },
})
expect(wrapper.classes()).toContain('status-warning')
})
it('applies correct class for pending status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'pending' },
})
expect(wrapper.classes()).toContain('status-pending')
})
it('applies correct class for error status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'error' },
})
expect(wrapper.classes()).toContain('status-error')
})
it('applies clickable class when clickable prop is true', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success', clickable: true },
})
expect(wrapper.classes()).toContain('status-clickable')
})
it('does not apply clickable class when clickable prop is false', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success', clickable: false },
})
expect(wrapper.classes()).not.toContain('status-clickable')
})
})
describe('inline styles', () => {
it('applies custom color style when customColor is provided', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success', customColor: '#ff5500' },
})
const style = wrapper.attributes('style')
// Note: happy-dom preserves hex colors instead of converting to RGB
expect(style).toContain('background-color: #ff5500')
})
it('does not apply custom style when customColor is not provided', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success' },
})
const style = wrapper.attributes('style')
expect(style).toBeUndefined()
})
})
describe('icon rendering', () => {
it('renders CheckIcon for success status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success', showIcon: true },
})
expect(wrapper.find('.mock-check-icon').exists()).toBe(true)
})
it('renders AlertIcon for warning status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'warning', showIcon: true },
})
expect(wrapper.find('.mock-alert-icon').exists()).toBe(true)
})
it('renders ClockIcon for pending status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'pending', showIcon: true },
})
expect(wrapper.find('.mock-clock-icon').exists()).toBe(true)
})
it('renders AlertIcon for error status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'error', showIcon: true },
})
expect(wrapper.find('.mock-alert-icon').exists()).toBe(true)
})
it('does not render icon when showIcon is false', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success', showIcon: false },
})
expect(wrapper.find('.mock-check-icon').exists()).toBe(false)
expect(wrapper.find('.mock-alert-icon').exists()).toBe(false)
expect(wrapper.find('.mock-clock-icon').exists()).toBe(false)
})
})
describe('computed properties', () => {
it('computes correct tooltipText for success status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success' },
})
// Access computed property via wrapper.vm
expect((wrapper.vm as InstanceType<typeof StatusBadge>).tooltipText).toBe(
'Completed successfully',
)
})
it('computes correct tooltipText for error status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'error' },
})
expect((wrapper.vm as InstanceType<typeof StatusBadge>).tooltipText).toBe('Failed')
})
it('computes statusClass correctly', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'warning', clickable: true },
})
const statusClass = (wrapper.vm as InstanceType<typeof StatusBadge>).statusClass
expect(statusClass).toEqual({
'status-warning': true,
'status-clickable': true,
})
})
it('computes customStyle as null when no customColor', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success' },
})
expect((wrapper.vm as InstanceType<typeof StatusBadge>).customStyle).toBeNull()
})
it('computes customStyle correctly when customColor is set', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success', customColor: '#123456' },
})
expect((wrapper.vm as InstanceType<typeof StatusBadge>).customStyle).toEqual({
'--status-color': '#123456',
backgroundColor: '#123456',
})
})
})
describe('events', () => {
it('emits click event when clickable and clicked', async () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success', clickable: true },
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('does not emit click event when not clickable', async () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success', clickable: false },
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
it('passes the MouseEvent to the click handler', async () => {
const wrapper = mount(StatusBadge, {
props: { status: 'success', clickable: true },
})
await wrapper.trigger('click')
const emittedEvents = wrapper.emitted('click')
expect(emittedEvents).toBeTruthy()
expect(emittedEvents![0][0]).toBeInstanceOf(MouseEvent)
})
})
describe('title attribute', () => {
it('sets title attribute based on status', () => {
const wrapper = mount(StatusBadge, {
props: { status: 'pending' },
})
expect(wrapper.attributes('title')).toBe('In progress')
})
})
})

View File

@@ -0,0 +1,148 @@
<template>
<span
class="status-badge"
:class="statusClass"
:style="customStyle"
:title="tooltipText"
@click="handleClick"
>
<component :is="iconComponent" v-if="iconComponent" :size="16" class="status-icon" />
<span class="status-label">{{ label }}</span>
</span>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import { t } from '@nextcloud/l10n'
import CheckIcon from '@icons/Check.vue'
import AlertIcon from '@icons/Alert.vue'
import ClockIcon from '@icons/ClockOutline.vue'
export type StatusType = 'success' | 'warning' | 'pending' | 'error'
export default defineComponent({
name: 'StatusBadge',
components: {
CheckIcon,
AlertIcon,
ClockIcon,
},
props: {
status: {
type: String as PropType<StatusType>,
required: true,
validator: (value: string) => ['success', 'warning', 'pending', 'error'].includes(value),
},
label: {
type: String,
default: '',
},
showIcon: {
type: Boolean,
default: true,
},
clickable: {
type: Boolean,
default: false,
},
customColor: {
type: String,
default: null,
},
},
emits: ['click'],
computed: {
statusClass(): Record<string, boolean> {
return {
[`status-${this.status}`]: true,
'status-clickable': this.clickable,
}
},
customStyle(): Record<string, string> | null {
if (!this.customColor) {
return null
}
return {
'--status-color': this.customColor,
backgroundColor: this.customColor,
}
},
tooltipText(): string {
const statusLabels: Record<StatusType, string> = {
success: t('nextcloudapptemplate', 'Completed successfully'),
warning: t('nextcloudapptemplate', 'Completed with warnings'),
pending: t('nextcloudapptemplate', 'In progress'),
error: t('nextcloudapptemplate', 'Failed'),
}
return statusLabels[this.status] || ''
},
iconComponent(): typeof CheckIcon | typeof AlertIcon | typeof ClockIcon | null {
if (!this.showIcon) {
return null
}
const icons: Record<StatusType, typeof CheckIcon | typeof AlertIcon | typeof ClockIcon> = {
success: CheckIcon,
warning: AlertIcon,
pending: ClockIcon,
error: AlertIcon,
}
return icons[this.status]
},
},
methods: {
handleClick(event: MouseEvent): void {
if (this.clickable) {
this.$emit('click', event)
}
},
},
})
</script>
<style scoped lang="scss">
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
&.status-success {
background-color: var(--color-success-light, #e8f5e9);
color: var(--color-success, #4caf50);
}
&.status-warning {
background-color: var(--color-warning-light, #fff3e0);
color: var(--color-warning, #ff9800);
}
&.status-pending {
background-color: var(--color-info-light, #e3f2fd);
color: var(--color-info, #2196f3);
}
&.status-error {
background-color: var(--color-error-light, #ffebee);
color: var(--color-error, #f44336);
}
&.status-clickable {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
.status-icon {
flex-shrink: 0;
}
.status-label {
white-space: nowrap;
}
</style>

60
src/test-utils.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* Test utilities and mock factories for Nextcloud dependencies.
*
* Usage in test files:
*
* import { vi } from 'vitest'
* import { nextcloudL10nMock, createIconMock } from '@/test-utils'
*
* vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
* vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
*/
/**
* Mock implementation for @nextcloud/l10n.
*
* - `t()` returns the text as-is, with variable substitution for {key} patterns
* - `n()` returns singular or plural based on count
*
* @example
* vi.mock('@nextcloud/l10n', () => nextcloudL10nMock)
*/
export const nextcloudL10nMock = {
t: (_app: string, text: string, vars?: Record<string, unknown>) => {
if (vars) {
return Object.entries(vars).reduce(
(acc, [key, value]) => acc.replace(`{${key}}`, String(value)),
text,
)
}
return text
},
n: (_app: string, singular: string, plural: string, count: number) => {
return count === 1 ? singular : plural
},
}
/**
* Create a mock for an icon component from vue-material-design-icons.
*
* @param name - Component name (e.g., 'CheckIcon')
* @param className - Optional CSS class (defaults to 'mock-{kebab-case-name}')
* @returns Mock factory object for vi.mock()
*
* @example
* vi.mock('@icons/Check.vue', () => createIconMock('CheckIcon'))
* // Creates: <span class="mock-check-icon" />
*
* vi.mock('@icons/Alert.vue', () => createIconMock('AlertIcon', 'my-alert'))
* // Creates: <span class="my-alert" />
*/
export function createIconMock(name: string, className?: string) {
const cssClass = className ?? `mock-${name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}`
return {
default: {
name,
template: `<span class="${cssClass}" data-icon="${name}" />`,
props: ['size'],
},
}
}

137
src/utils/string.test.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Example test file demonstrating pure TypeScript/utility function testing.
*
* This shows how to:
* - Use describe/it/expect structure
* - Test various inputs including edge cases
* - Handle null/undefined values
*/
import { describe, expect, it } from 'vitest'
import { capitalize, formatFileSize, truncate } from './string'
describe('truncate', () => {
it('returns empty string for null input', () => {
expect(truncate(null, 10)).toBe('')
})
it('returns empty string for undefined input', () => {
expect(truncate(undefined, 10)).toBe('')
})
it('returns the original string if shorter than maxLength', () => {
expect(truncate('hello', 10)).toBe('hello')
})
it('returns the original string if equal to maxLength', () => {
expect(truncate('hello', 5)).toBe('hello')
})
it('truncates and adds ellipsis when string exceeds maxLength', () => {
expect(truncate('hello world', 8)).toBe('hello...')
})
it('uses custom ellipsis when provided', () => {
expect(truncate('hello world', 8, '…')).toBe('hello w…')
})
it('returns empty string for negative maxLength', () => {
expect(truncate('hello', -1)).toBe('')
})
it('handles zero maxLength', () => {
expect(truncate('hello', 0)).toBe('')
})
it('handles maxLength smaller than ellipsis', () => {
expect(truncate('hello world', 2)).toBe('..')
})
it('handles empty string input', () => {
expect(truncate('', 10)).toBe('')
})
})
describe('capitalize', () => {
it('returns empty string for null input', () => {
expect(capitalize(null)).toBe('')
})
it('returns empty string for undefined input', () => {
expect(capitalize(undefined)).toBe('')
})
it('returns empty string for empty string input', () => {
expect(capitalize('')).toBe('')
})
it('capitalizes a lowercase string', () => {
expect(capitalize('hello')).toBe('Hello')
})
it('keeps already capitalized string unchanged', () => {
expect(capitalize('Hello')).toBe('Hello')
})
it('capitalizes single character', () => {
expect(capitalize('a')).toBe('A')
})
it('handles strings starting with numbers', () => {
expect(capitalize('123abc')).toBe('123abc')
})
it('only capitalizes the first character', () => {
expect(capitalize('hELLO')).toBe('HELLO')
})
})
describe('formatFileSize', () => {
it('returns "0 B" for null input', () => {
expect(formatFileSize(null)).toBe('0 B')
})
it('returns "0 B" for undefined input', () => {
expect(formatFileSize(undefined)).toBe('0 B')
})
it('returns "0 B" for zero bytes', () => {
expect(formatFileSize(0)).toBe('0 B')
})
it('returns "0 B" for negative bytes', () => {
expect(formatFileSize(-100)).toBe('0 B')
})
it('formats bytes correctly', () => {
expect(formatFileSize(500)).toBe('500 B')
})
it('formats kilobytes correctly', () => {
expect(formatFileSize(1024)).toBe('1 KB')
expect(formatFileSize(1536)).toBe('1.5 KB')
})
it('formats megabytes correctly', () => {
expect(formatFileSize(1048576)).toBe('1 MB')
expect(formatFileSize(1572864)).toBe('1.5 MB')
})
it('formats gigabytes correctly', () => {
expect(formatFileSize(1073741824)).toBe('1 GB')
})
it('respects custom decimal places', () => {
expect(formatFileSize(1536, 0)).toBe('2 KB')
expect(formatFileSize(1536, 1)).toBe('1.5 KB')
expect(formatFileSize(1536, 3)).toBe('1.5 KB')
})
it('handles Infinity', () => {
expect(formatFileSize(Infinity)).toBe('0 B')
})
it('handles NaN', () => {
expect(formatFileSize(NaN)).toBe('0 B')
})
})

70
src/utils/string.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Truncates a string to a specified length, adding an ellipsis if truncated.
*
* @param str - The string to truncate
* @param maxLength - Maximum length of the resulting string (including ellipsis)
* @param ellipsis - The ellipsis string to append (default: '...')
* @returns The truncated string
*/
export function truncate(
str: string | null | undefined,
maxLength: number,
ellipsis: string = '...',
): string {
if (str == null) {
return ''
}
if (maxLength < 0) {
return ''
}
if (str.length <= maxLength) {
return str
}
const truncatedLength = maxLength - ellipsis.length
if (truncatedLength <= 0) {
return ellipsis.slice(0, maxLength)
}
return str.slice(0, truncatedLength) + ellipsis
}
/**
* Capitalizes the first letter of a string.
*
* @param str - The string to capitalize
* @returns The string with its first letter capitalized
*/
export function capitalize(str: string | null | undefined): string {
if (str == null || str.length === 0) {
return ''
}
return str.charAt(0).toUpperCase() + str.slice(1)
}
/**
* Formats a number as a human-readable file size.
*
* @param bytes - The number of bytes
* @param decimals - Number of decimal places (default: 2)
* @returns Formatted string like "1.5 KB" or "2.3 MB"
*/
export function formatFileSize(bytes: number | null | undefined, decimals: number = 2): string {
if (bytes == null || bytes < 0 || !Number.isFinite(bytes)) {
return '0 B'
}
if (bytes === 0) {
return '0 B'
}
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const index = Math.min(i, sizes.length - 1)
return `${parseFloat((bytes / Math.pow(k, index)).toFixed(decimals))} ${sizes[index]}`
}

View File

@@ -86,8 +86,9 @@
<table>
<thead>
<tr>
<th style="width: 50%">{{ strings.colMessage }}</th>
<th style="width: 30%">{{ strings.colAt }}</th>
<th style="width: 40%">{{ strings.colMessage }}</th>
<th style="width: 15%">{{ strings.colStatus }}</th>
<th style="width: 25%">{{ strings.colAt }}</th>
<th style="width: 20%">{{ strings.colActions }}</th>
</tr>
</thead>
@@ -96,6 +97,12 @@
<td class="ellipsis">
<span class="mono">{{ hello.message }}</span>
</td>
<td>
<StatusBadge
:status="hello.synced ? 'success' : 'pending'"
:label="hello.synced ? strings.statusSynced : strings.statusLocal"
/>
</td>
<td class="nowrap">
<NcDateTime v-if="hello.at" :timestamp="new Date(hello.at).valueOf()" />
<span v-else class="muted">{{ strings.never }}</span>
@@ -136,6 +143,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import StatusBadge from '@/components/StatusBadge.vue'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
@@ -149,6 +157,7 @@ export default {
NcEmptyContent,
NcLoadingIcon,
NcDateTime,
StatusBadge,
},
data() {
return {
@@ -205,8 +214,11 @@ export default {
emptyDesc: t('nextcloudapptemplate', 'Try adding one using the form above.'),
addExample: t('nextcloudapptemplate', 'Add example'),
colMessage: t('nextcloudapptemplate', 'Message'),
colStatus: t('nextcloudapptemplate', 'Status'),
colAt: t('nextcloudapptemplate', 'Time'),
colActions: t('nextcloudapptemplate', 'Actions'),
statusSynced: t('nextcloudapptemplate', 'Synced'),
statusLocal: t('nextcloudapptemplate', 'Local'),
duplicate: t('nextcloudapptemplate', 'Duplicate'),
remove: t('nextcloudapptemplate', 'Remove'),
clearAll: t('nextcloudapptemplate', 'Clear all'),
@@ -257,6 +269,7 @@ export default {
id: genId(),
message: data.message,
at: data.at ?? null,
synced: true,
})
}
} catch (e) {
@@ -282,7 +295,7 @@ export default {
const data = resp.data
const message = data?.message ?? `Hello, ${name}!`
const at = data?.at ?? new Date().toISOString()
this.hellos.unshift({ id: genId(), message, at })
this.hellos.unshift({ id: genId(), message, at, synced: true })
this.clearForm()
this.formOpen = false
} catch (e) {
@@ -295,7 +308,8 @@ export default {
duplicate(index) {
const src = this.hellos[index]
if (!src) return
this.hellos.splice(index + 1, 0, { ...src, id: genId() })
// Duplicated items are local-only until synced
this.hellos.splice(index + 1, 0, { ...src, id: genId(), synced: false })
},
remove(index) {
@@ -307,10 +321,12 @@ export default {
},
seedOne() {
// Seeded examples are local-only
this.hellos.push({
id: genId(),
message: '👋 Hello example',
at: new Date().toISOString(),
synced: false,
})
},
},

18
vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@icons': path.resolve(__dirname, 'node_modules/vue-material-design-icons'),
},
},
test: {
globals: true,
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{js,ts}'],
},
})