mirror of
https://github.com/codex-team/editor.js
synced 2026-03-16 23:55:49 +01:00
test: add missing test coverage
This commit is contained in:
parent
408b160e39
commit
181e73f3c9
50 changed files with 5335 additions and 26 deletions
|
|
@ -12,6 +12,7 @@ VERY IMPORTANT: When encountering ANY problem in the code—such as TypeScript e
|
|||
- **Investigate root causes**: Use tools like debugging, logging, or code searches to understand why the problem occurs before fixing it.
|
||||
- **Align with existing rules**: Follow related policies such as the Fix TypeScript Errors Policy (adapt for other languages), ESLint configurations, and accessibility guidelines.
|
||||
- **Test the fix**: After fixing, verify with tests, linting runs (e.g., `yarn lint:fix`), or manual checks to ensure the problem is truly resolved without introducing new issues.
|
||||
- **Terminal commands**: if you run a command in the terminal make sure to set timeout so the command is not being executed indefinitely
|
||||
|
||||
## When to Apply
|
||||
- During any code editing, reviewing, or generation task.
|
||||
|
|
|
|||
23
.windsurf/rules/do-not-modify-configs.mdc
Normal file
23
.windsurf/rules/do-not-modify-configs.mdc
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Rule: DO NOT MODIFY configuration files unless explicitly instructed
|
||||
|
||||
## Description
|
||||
You MUST **never modify any configuration files** (such as `vite.config.ts`, `tsconfig.json`, `.eslintrc`, `package.json`, `.env`, etc.) **unless explicitly told to do so** in the current request or accompanying instructions.
|
||||
|
||||
## Examples
|
||||
|
||||
✅ **Allowed**
|
||||
- Editing TypeScript source files, tests, or component code.
|
||||
- Updating imports, logic, or styles within non-config files.
|
||||
- Adding configuration changes **only when explicitly requested** (e.g., “Add a new alias in `vite.config.ts`”).
|
||||
|
||||
❌ **Not Allowed**
|
||||
- Modifying or creating any config files without explicit instruction.
|
||||
- Automatically adding dependencies or changing build/test settings.
|
||||
- Altering environment variables or global project settings without being told to.
|
||||
|
||||
## Enforcement
|
||||
If you believe a configuration change might be required, **ask for confirmation first** before proceeding.
|
||||
24
.windsurf/rules/fix-problems.mdc
Normal file
24
.windsurf/rules/fix-problems.mdc
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Fix Problems Policy
|
||||
|
||||
## Core Principle
|
||||
VERY IMPORTANT: When encountering ANY problem in the code—such as TypeScript errors, linting issues, runtime bugs, accessibility violations, or performance problems—you or any other problem MUST find a proper way to fix it. Do NOT silence, suppress, or avoid the problem using workarounds like `// @ts-ignore`, `any` types, or ignoring linter warnings.
|
||||
|
||||
## Preferred Approaches
|
||||
- **Refactor for correctness**: Resolve issues by improving the code structure, using precise types, type guards, proper error handling, and best practices.
|
||||
- **Investigate root causes**: Use tools like debugging, logging, or code searches to understand why the problem occurs before fixing it.
|
||||
- **Align with existing rules**: Follow related policies such as the Fix TypeScript Errors Policy (adapt for other languages), ESLint configurations, and accessibility guidelines.
|
||||
- **Test the fix**: After fixing, verify with tests, linting runs (e.g., `yarn lint:fix`), or manual checks to ensure the problem is truly resolved without introducing new issues.
|
||||
|
||||
## When to Apply
|
||||
- During any code editing, reviewing, or generation task.
|
||||
- Proactively scan for and fix problems in affected files using available tools (e.g., read_lints, grep, codebase_search).
|
||||
- If a problem persists after reasonable efforts, document it clearly and suggest next steps rather than suppressing it.
|
||||
- **Terminal commands**: if you run a command in the terminal make sure to set timeout so the command is not being executed indefinitely
|
||||
|
||||
## Notes
|
||||
- This policy promotes robust, high-quality code that is easier to maintain and less prone to future issues.
|
||||
- If unsure how to fix a problem, use tools to gather more information or break it into smaller, solvable parts rather than bypassing it.
|
||||
119
.windsurf/rules/src/frontend/accessibility.mdc
Normal file
119
.windsurf/rules/src/frontend/accessibility.mdc
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
description: Enforce accessibility best practices so all users can use the application
|
||||
---
|
||||
|
||||
### Accessibility guidance (must follow)
|
||||
|
||||
- Semantics first
|
||||
- Prefer semantic HTML (`button`, `a`, `nav`, `main`, `header`, `footer`, `ul/ol/li`, `table/th/td`) over generic `div`/`span`.
|
||||
- Use `button` for actions and `a`/`Link` for navigation. Do not use click handlers on non-interactive elements. If unavoidable, add `role="button"`, `tabIndex={0}`, and keyboard handlers for Enter/Space.
|
||||
|
||||
- Keyboard support
|
||||
- All interactive controls must be reachable via Tab and operable via keyboard.
|
||||
- Do not remove focus outlines. If customizing, ensure visible `:focus-visible` styles with sufficient contrast.
|
||||
- Preserve a logical tab order; avoid `tabIndex` > 0.
|
||||
|
||||
- Focus management
|
||||
- On opening modals/drawers/popovers: move focus inside, trap focus, and restore focus to the trigger on close.
|
||||
- Provide a skip link to main content (e.g., `href="#main"`) and landmark roles (`<main>`, `<nav>`, `<header>`, `<footer>`).
|
||||
|
||||
- Images and media
|
||||
- Every `img` must have an appropriate `alt`. If decorative, use `alt=""` and `aria-hidden="true"`.
|
||||
- Provide captions/subtitles for video/audio when applicable.
|
||||
- For lazy-loaded images with skeletons, mark skeletons `aria-hidden="true"` and set container `aria-busy` while loading.
|
||||
|
||||
- Forms
|
||||
- Inputs require visible labels bound via `<label htmlFor>` or `aria-label`/`aria-labelledby`.
|
||||
- Indicate errors with `aria-invalid` and associate helper/error text via `aria-describedby`.
|
||||
|
||||
- Live updates and async content
|
||||
- For dynamic status (loading/completion), use `aria-live="polite"` (or `assertive` if critical).
|
||||
- Spinners should have `aria-label` or be hidden (`aria-hidden="true"`) with a separate live region announcing status.
|
||||
|
||||
- Headings and structure
|
||||
- Maintain a logical heading hierarchy without skipping levels.
|
||||
- Use list semantics for collections.
|
||||
|
||||
- Color and contrast
|
||||
- Ensure WCAG 2.1 AA contrast: 4.5:1 for normal text and 3:1 for large or bold text and UI components, including focus and hover states. When placing text over images, add an overlay or background.
|
||||
- Do not convey information by color alone; add icons/text.
|
||||
|
||||
- Motion and reduced motion
|
||||
- Respect `prefers-reduced-motion: reduce`. Disable or simplify non-essential animations.
|
||||
- In React animations, gate effects with `gsap.matchMedia('(prefers-reduced-motion: no-preference)')` and provide a reduced-motion path.
|
||||
- Example usage exists in [AnnouncementsFeedContent.tsx](mdc:src/frontend/src/features/AnnouncementsFeed/ui/AnnouncementsFeedContent.tsx).
|
||||
- Vanilla CSS example using the `prefers-reduced-motion` media query:
|
||||
|
||||
```css
|
||||
/* Default animations */
|
||||
.card {
|
||||
transition: transform 300ms ease, opacity 300ms ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* Reduced motion: remove transforms and long transitions */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition-duration: 0.01ms !important; /* effectively no transition */
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.card:hover {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Tables and data
|
||||
- Use `<th scope>` for headers, provide captions when helpful. Avoid layout tables.
|
||||
|
||||
- Testing
|
||||
- Prefer `@testing-library` queries by role/name (`getByRole`, `getByLabelText`) to reflect real accessibility.
|
||||
|
||||
### React implementation tips
|
||||
|
||||
- Announce route changes by updating `document.title` and placing page content in a `<main id="main">` region.
|
||||
- When building composite widgets (tabs, accordions), follow the relevant ARIA patterns (roles, `aria-selected`, `aria-controls`) only when semantics are not achievable with native elements.
|
||||
- For card components that wrap links, ensure the entire card is a single focusable link (as with `Link`) and include descriptive link text or `aria-label` if needed.
|
||||
|
||||
### Code patterns
|
||||
|
||||
```tsx
|
||||
// Accessible button vs. link
|
||||
<button type="button" onClick={handleAction}>Do action</button>
|
||||
<Link to="/path">Go to details</Link>
|
||||
|
||||
// Custom interactive element (only if you cannot use <button>)
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
const isEnter = e.key === 'Enter' || e.code === 'Enter';
|
||||
const isSpace = e.key === ' ' || e.key === 'Spacebar' || e.code === 'Space';
|
||||
|
||||
if (isEnter) {
|
||||
onClick();
|
||||
}
|
||||
if (isSpace) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
const isSpace = e.key === ' ' || e.key === 'Spacebar' || e.code === 'Space';
|
||||
if (isSpace) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
onClick={onClick}
|
||||
/>
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Use ARIA to enhance semantics, not replace them. Avoid redundant roles on native elements.
|
||||
- If a component is purely decorative (e.g., background clouds), set `aria-hidden="true"` and remove from the tab order.
|
||||
27
.windsurf/rules/src/frontend/code-style-eslint.mdc
Normal file
27
.windsurf/rules/src/frontend/code-style-eslint.mdc
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
title: Frontend ESLint Code Style
|
||||
alwaysApply: true
|
||||
description: Defer all code style decisions to the project's ESLint configuration; do not invent new style rules
|
||||
---
|
||||
|
||||
### Code Style Source of Truth
|
||||
|
||||
- **Always defer to ESLint configuration** for any code style, formatting, or lint rules.
|
||||
- **Do not create or enforce custom style rules** beyond what ESLint (and its plugins) already defines in this repo.
|
||||
|
||||
### Where the rules live
|
||||
|
||||
- Frontend config: [eslint.config.js](mdc:src/frontend/eslint.config.js)
|
||||
- AppShell config: [.eslintrc.js](mdc:src/Dodo.KnowledgeBase.Web/appshell/.eslintrc.js)
|
||||
|
||||
### How to apply
|
||||
|
||||
- When unsure about style (imports order, quote style, indentation, prop ordering, hooks rules, etc.), consult the ESLint configs above and follow them as-is.
|
||||
- Prefer using the repo scripts to validate/fix:
|
||||
- `yarn lint`
|
||||
- `yarn lint:fix`
|
||||
|
||||
### Notes
|
||||
|
||||
- If ESLint and Prettier interact, follow the ESLint-integrated Prettier setup from the configs.
|
||||
- For styles-in-JS (e.g., styled-components), follow any ESLint plugin guidance present; do not invent property ordering rules.
|
||||
22
.windsurf/rules/src/frontend/fix-typescript-errors.mdc
Normal file
22
.windsurf/rules/src/frontend/fix-typescript-errors.mdc
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
globs: *.ts,*.tsx
|
||||
description: Enforce fixing TypeScript errors by improving code quality, not suppressing them
|
||||
---
|
||||
|
||||
# Fix TypeScript Errors Policy
|
||||
|
||||
- **Core Principle**: Always resolve TypeScript errors by refactoring code to be type-safe, rather than suppressing them with `any`, `// @ts-ignore`, or similar workarounds.
|
||||
- **Preferred Approaches**:
|
||||
- Use precise types, type guards, discriminated unions, and proper narrowing to eliminate errors.
|
||||
- Avoid the non-null assertion operator (`!`) and `any` types as per project guidelines.
|
||||
- Refactor functions, components, and logic to align with TypeScript's type system.
|
||||
- **When to Apply**:
|
||||
- For any TypeScript files (`.ts`, `.tsx`), prioritize fixing errors during edits.
|
||||
- After making changes, run `yarn lint:fix` or similar commands to ensure compliance.
|
||||
- **Alignment with Existing Rules**:
|
||||
- This reinforces the ESLint Fix-First Policy: Fix issues flagged by TypeScript/ESLint by improving code, not silencing linters.
|
||||
- Ensure accessibility and best practices are maintained while resolving types.
|
||||
- **Notes**:
|
||||
- If a TypeScript error persists after reasonable refactoring, consult the ESLint configuration or seek clarification on intended behavior, but do not suppress it locally.
|
||||
- Promote code that is both type-safe and adheres to React/JS best practices.
|
||||
69
.windsurf/rules/src/frontend/frontend-simplicity.mdc
Normal file
69
.windsurf/rules/src/frontend/frontend-simplicity.mdc
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
globs: "*.ts","*.tsx","*.js","*.jsx","src/frontend/**"
|
||||
description: "Frontend development principle: Keep solutions simple and avoid overengineering"
|
||||
---
|
||||
|
||||
# Frontend Simplicity Principle
|
||||
|
||||
When working on frontend tasks, prioritize simple, straightforward solutions over complex implementations.
|
||||
|
||||
## Guidelines
|
||||
|
||||
### Keep it simple
|
||||
- **Prefer basic approaches**: Choose standard patterns over custom abstractions unless there's a clear benefit
|
||||
- **Avoid premature optimization**: Don't add complexity for performance gains that haven't been measured
|
||||
- **Use existing libraries**: Leverage well-established libraries rather than building custom solutions
|
||||
|
||||
### Component design
|
||||
- **Single responsibility**: Components should do one thing well
|
||||
- **Avoid deep nesting**: Keep component trees shallow and manageable
|
||||
- **Prefer composition over inheritance**: Use composition patterns for reusable behavior
|
||||
|
||||
### State management
|
||||
- **Local state first**: Use local component state before reaching for global state management
|
||||
- **Simple patterns**: Prefer useState/useReducer over complex state machines unless necessary
|
||||
- **Avoid over-abstraction**: Don't create unnecessary abstractions for simple state logic
|
||||
|
||||
### Code organization
|
||||
- **Clear naming**: Use descriptive names that explain the purpose
|
||||
- **Minimal files**: Avoid splitting simple features across multiple files
|
||||
- **Straightforward logic**: Write code that's easy to follow and debug
|
||||
|
||||
### When complexity is justified
|
||||
Only add complexity when:
|
||||
- It solves a measured performance problem
|
||||
- It significantly improves user experience
|
||||
- It enables critical functionality
|
||||
- The team agrees it's necessary
|
||||
|
||||
## Examples
|
||||
|
||||
```tsx
|
||||
// ✅ Simple and clear
|
||||
const UserProfile = ({ user }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{user.name}</h2>
|
||||
{isEditing ? (
|
||||
<EditForm onSave={() => setIsEditing(false)} />
|
||||
) : (
|
||||
<button onClick={() => setIsEditing(true)}>Edit</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ❌ Overcomplicated
|
||||
const UserProfile = ({ user }) => {
|
||||
const [state, dispatch] = useReducer(profileReducer, initialProfileState);
|
||||
const editingContext = useContext(EditingContext);
|
||||
const formManager = useFormManager();
|
||||
|
||||
// Complex logic that could be simplified...
|
||||
};
|
||||
```
|
||||
|
||||
Remember: Code is read more than it's written. Choose the solution that future developers can understand quickly.
|
||||
24
.windsurf/rules/src/frontend/lint-fix-policy.mdc
Normal file
24
.windsurf/rules/src/frontend/lint-fix-policy.mdc
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
description: Policy for handling ESLint issues by preferring autofix with yarn lint:fix
|
||||
---
|
||||
|
||||
# Lint Fix Policy
|
||||
|
||||
When encountering ANY ESLint problem:
|
||||
|
||||
## Core Steps
|
||||
1. **ALWAYS try autofix first**: Run `yarn lint:fix` (or the equivalent command for the subproject) to automatically resolve the issue.
|
||||
- For frontend: From the workspace root, run `cd packages/frontend && yarn lint:fix`
|
||||
- If targeting specific files: `cd packages/frontend && yarn eslint "path/to/file.tsx" --fix`
|
||||
|
||||
2. **ONLY manual fix if autofix fails**: If `yarn lint:fix` does not resolve the issue, manually edit the code to comply with ESLint rules.
|
||||
- Defer to the ESLint configuration as the source of truth: [eslint.config.js](mdc:eslint.config.js) for frontend.
|
||||
- Do not invent custom style rules; follow ESLint and integrated Prettier setups exactly.
|
||||
- After manual fixes, re-run `yarn lint` to verify resolution.
|
||||
|
||||
## Notes
|
||||
- Prefer `yarn lint:fix` over ad-hoc formatting to ensure consistency.
|
||||
- If ESLint interacts with Prettier, let ESLint enforce the rules.
|
||||
- For uncertainty, consult ESLint configs before manual changes.
|
||||
- Proactively use this during code edits, reviews, or generations to maintain high-quality code.
|
||||
37
.windsurf/rules/test/e2e-best-practices.mdc
Normal file
37
.windsurf/rules/test/e2e-best-practices.mdc
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
globs: tests/**/*.spec.ts,tests/**/*.ts
|
||||
description: Playwright end-to-end testing patterns and expectations
|
||||
---
|
||||
# Playwright E2E Tests
|
||||
|
||||
- **Use shared fixtures**
|
||||
- Import `test`/`expect` from `[fixtures.ts](mdc:tests/Dodo.KnowledgeBase.Ui/fixtures/fixtures.ts)` so page objects, API helpers, and auth utilities stay consistent.
|
||||
- Prefer the `storedCookies` fixture when a test needs an authenticated context to avoid duplicate login work.
|
||||
|
||||
- **Lean on helpers and page objects**
|
||||
- Reuse the page-object classes in `[pages/](mdc:tests/Dodo.KnowledgeBase.Ui/pages)` for interactions; add new methods there instead of ad-hoc selectors in specs.
|
||||
- Wrap navigation, assertions, and Allure metadata with `test.step` via `[utils/helpers.ts](mdc:tests/Dodo.KnowledgeBase.Ui/utils/helpers.ts)` for richer reporting.
|
||||
|
||||
- **Prefer resilient, accessible locators**
|
||||
- Target elements by role, label, or text when possible (e.g., `page.getByRole('button', { name: '...' })`) before falling back to CSS/XPath.
|
||||
- Mirror the app’s accessibility requirements—favor semantic selectors over brittle DOM structure hooks.
|
||||
|
||||
- **Keep tests focused and deterministic**
|
||||
- Scope each spec to a single feature/flow; move common setup into `test.beforeEach` blocks using helpers.
|
||||
|
||||
- **Leverage configuration**
|
||||
- Align new suites with existing Playwright projects defined in `[playwright.config.ts](mdc:playwright.config.ts)`; extend `testMatch` rather than spinning up new configs.
|
||||
- Respect shared `use` options (locale, screenshots, traces) to keep reports uniform.
|
||||
|
||||
- **AVOID using mocks unless it's necessary**
|
||||
- When writing tests prefer actual data instead of using mocks to test actual behavior.
|
||||
|
||||
- **Do not @allure.id to tests**
|
||||
- Adding @allure.id is handled on the user's side DO NOT add it yourself.
|
||||
|
||||
- **Document Allure hierarchy**
|
||||
- Call `Helpers.addAllureHierarchy` at suite setup (see `[auth-tests.spec.ts](mdc:tests/Dodo.KnowledgeBase.Ui/auth-tests.spec.ts)`) so new tests appear correctly in TestOps.
|
||||
|
||||
- **Running locally**
|
||||
- Follow the workflow in `[README.md](mdc:README.md#L51)` (`yarn --cwd src/frontend serve` + `yarn e2e:ui`) when validating new specs.
|
||||
Binary file not shown.
|
|
@ -48,7 +48,8 @@
|
|||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/node": "^18.15.11",
|
||||
"@vitest/ui": "^1.0.0",
|
||||
"@vitest/coverage-v8": "^1.6.1",
|
||||
"@vitest/ui": "^1.6.1",
|
||||
"core-js": "3.30.0",
|
||||
"eslint": "^8.37.0",
|
||||
"eslint-config-codex": "^1.7.1",
|
||||
|
|
@ -71,7 +72,7 @@
|
|||
"typescript": "5.0.3",
|
||||
"vite": "^4.2.1",
|
||||
"vite-plugin-css-injected-by-js": "^3.1.0",
|
||||
"vitest": "^1.0.0"
|
||||
"vitest": "^1.6.1"
|
||||
},
|
||||
"collective": {
|
||||
"type": "opencollective",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ declare global {
|
|||
*
|
||||
* @param centerIfNeeded - true, if the element should be aligned so it is centered within the visible area of the scrollable ancestor.
|
||||
*/
|
||||
scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void;
|
||||
scrollIntoViewIfNeeded?(centerIfNeeded?: boolean): void;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ export default class Notifier {
|
|||
*
|
||||
* @returns {Promise<CodexNotifierModule>} loaded notifier module
|
||||
*/
|
||||
private async loadNotifierModule(): Promise<CodexNotifierModule> {
|
||||
private loadNotifierModule(): Promise<CodexNotifierModule> {
|
||||
if (this.notifierModule !== null) {
|
||||
return this.notifierModule;
|
||||
return Promise.resolve(this.notifierModule);
|
||||
}
|
||||
|
||||
if (this.loadingPromise === null) {
|
||||
|
|
|
|||
|
|
@ -8,21 +8,38 @@
|
|||
*/
|
||||
export default class PromiseQueue {
|
||||
/**
|
||||
* Queue of promises to be executed
|
||||
* Tail promise representing the queued operations chain
|
||||
*/
|
||||
public completed = Promise.resolve();
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Stored failure that should be propagated to consumers
|
||||
*/
|
||||
private failure: unknown;
|
||||
|
||||
/**
|
||||
* Expose completion promise that rejects if any queued task failed
|
||||
*/
|
||||
public get completed(): Promise<void> {
|
||||
return this.failure ? Promise.reject(this.failure) : this.tail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new promise to queue
|
||||
*
|
||||
* @param operation - promise should be added to queue
|
||||
*/
|
||||
public add(operation: (value: void) => void | PromiseLike<void>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.completed = this.completed
|
||||
.then(operation)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
public add(operation: () => void | PromiseLike<void>): Promise<void> {
|
||||
if (this.failure) {
|
||||
return Promise.reject(this.failure);
|
||||
}
|
||||
|
||||
const task = this.tail.then(() => operation());
|
||||
|
||||
this.tail = task.catch((error) => {
|
||||
this.failure = error;
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,14 @@ export const resolveAliases = <ObjectType extends object>(
|
|||
const propertyString = String(property);
|
||||
const aliasedProperty = aliases[propertyString];
|
||||
|
||||
if (aliasedProperty !== undefined) {
|
||||
result[aliasedProperty] = obj[propertyKey];
|
||||
} else {
|
||||
if (aliasedProperty === undefined) {
|
||||
result[propertyKey] = obj[propertyKey];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(aliasedProperty in obj)) {
|
||||
result[aliasedProperty] = obj[propertyKey];
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
70
test/unit/components/block-tunes/block-tune-delete.test.ts
Normal file
70
test/unit/components/block-tunes/block-tune-delete.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { IconCross } from '@codexteam/icons';
|
||||
import type { Mock } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DeleteTune from '../../../../src/components/block-tunes/block-tune-delete';
|
||||
import type { API } from '../../../../types';
|
||||
import type { MenuConfig } from '../../../../types/tools/menu-config';
|
||||
|
||||
type BlocksMocks = {
|
||||
delete: Mock<[], void>;
|
||||
};
|
||||
|
||||
type I18nMocks = {
|
||||
t: Mock<[string], string>;
|
||||
};
|
||||
|
||||
const createApiMocks = (): { api: API; blocks: BlocksMocks; i18n: I18nMocks } => {
|
||||
const blocks: BlocksMocks = {
|
||||
delete: vi.fn<[], void>(),
|
||||
};
|
||||
|
||||
const i18n: I18nMocks = {
|
||||
t: vi.fn<[string], string>().mockImplementation((text) => text),
|
||||
};
|
||||
|
||||
return {
|
||||
api: {
|
||||
blocks: blocks as unknown as API['blocks'],
|
||||
i18n: i18n as unknown as API['i18n'],
|
||||
} as API,
|
||||
blocks,
|
||||
i18n,
|
||||
};
|
||||
};
|
||||
|
||||
describe('DeleteTune', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders block tune config with translated labels and confirmation handler', () => {
|
||||
const { api, i18n } = createApiMocks();
|
||||
const tune = new DeleteTune({ api });
|
||||
const handleClickSpy = vi.spyOn(tune, 'handleClick').mockImplementation(() => {});
|
||||
|
||||
type MenuConfigWithConfirmation = Extract<MenuConfig, { confirmation: unknown }>;
|
||||
|
||||
const config = tune.render() as MenuConfigWithConfirmation;
|
||||
|
||||
expect(i18n.t).toHaveBeenNthCalledWith(1, 'Delete');
|
||||
expect(i18n.t).toHaveBeenNthCalledWith(2, 'Click to delete');
|
||||
expect(config.icon).toBe(IconCross);
|
||||
expect(config.title).toBe('Delete');
|
||||
expect(config.name).toBe('delete');
|
||||
expect(config.confirmation?.title).toBe('Click to delete');
|
||||
|
||||
config.confirmation?.onActivate?.(config);
|
||||
|
||||
expect(handleClickSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deletes current block when handler is triggered', () => {
|
||||
const { api, blocks } = createApiMocks();
|
||||
const tune = new DeleteTune({ api });
|
||||
|
||||
tune.handleClick();
|
||||
|
||||
expect(blocks.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
355
test/unit/components/block/block.test.ts
Normal file
355
test/unit/components/block/block.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach, type Mock } from 'vitest';
|
||||
|
||||
import Block from '../../../../src/components/block';
|
||||
import ToolsCollection from '../../../../src/components/tools/collection';
|
||||
import type BlockToolAdapter from '../../../../src/components/tools/block';
|
||||
import type BlockTuneAdapter from '../../../../src/components/tools/tune';
|
||||
import type ApiModules from '../../../../src/components/modules/api';
|
||||
import { PopoverItemType } from '@/types/utils/popover/popover-item-type';
|
||||
import type { BlockToolData } from '@/types';
|
||||
import type { BlockTuneData } from '@/types/block-tunes/block-tune-data';
|
||||
import EventsDispatcher from '../../../../src/components/utils/events';
|
||||
import type { EditorEventMap } from '../../../../src/components/events';
|
||||
import { FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet } from '../../../../src/components/events';
|
||||
import SelectionUtils from '../../../../src/components/selection';
|
||||
|
||||
interface MockToolInstance {
|
||||
render: Mock<[], HTMLElement>;
|
||||
save: Mock<[HTMLElement], Promise<BlockToolData>>;
|
||||
validate: Mock<[BlockToolData], Promise<boolean>>;
|
||||
renderSettings?: () => unknown;
|
||||
merge?: (data: BlockToolData) => void | Promise<void>;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
interface TuneFactoryResult {
|
||||
name: string;
|
||||
adapter: BlockTuneAdapter;
|
||||
instance: {
|
||||
render: Mock<[], HTMLElement | { title: string }>;
|
||||
wrap: Mock<[HTMLElement], HTMLElement>;
|
||||
save: Mock<[], BlockTuneData>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateBlockOptions {
|
||||
toolOverrides?: Partial<MockToolInstance>;
|
||||
renderSettings?: () => unknown;
|
||||
data?: BlockToolData;
|
||||
tunes?: TuneFactoryResult[];
|
||||
tunesData?: Record<string, BlockTuneData>;
|
||||
eventBus?: EventsDispatcher<EditorEventMap>;
|
||||
}
|
||||
|
||||
interface CreateBlockResult {
|
||||
block: Block;
|
||||
toolInstance: MockToolInstance;
|
||||
toolAdapter: BlockToolAdapter;
|
||||
tunes: TuneFactoryResult[];
|
||||
renderElement: HTMLElement;
|
||||
}
|
||||
|
||||
const requestIdleCallbackMock = vi.fn<[IdleRequestCallback], number>((callback) => {
|
||||
callback({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => 1,
|
||||
});
|
||||
|
||||
return 1;
|
||||
});
|
||||
|
||||
const cancelIdleCallbackMock = vi.fn<[number], void>();
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'requestIdleCallback', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: requestIdleCallbackMock,
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'cancelIdleCallback', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: cancelIdleCallbackMock,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
requestIdleCallbackMock.mockClear();
|
||||
cancelIdleCallbackMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createTuneAdapter = (name: string, {
|
||||
isInternal = false,
|
||||
renderReturn,
|
||||
saveReturn,
|
||||
}: {
|
||||
isInternal?: boolean;
|
||||
renderReturn?: HTMLElement | { title: string };
|
||||
saveReturn?: BlockTuneData;
|
||||
} = {}): TuneFactoryResult => {
|
||||
const instance = {
|
||||
render: vi.fn<[], HTMLElement | { title: string }>(() => renderReturn ?? { title: `${name}-action` }),
|
||||
wrap: vi.fn<[HTMLElement], HTMLElement>((node) => node),
|
||||
save: vi.fn<[], BlockTuneData>(() => saveReturn ?? { [`${name}Enabled`]: true }),
|
||||
};
|
||||
|
||||
const adapter = {
|
||||
name,
|
||||
isInternal,
|
||||
create: vi.fn(() => instance),
|
||||
} as unknown as BlockTuneAdapter;
|
||||
|
||||
return {
|
||||
name,
|
||||
adapter,
|
||||
instance,
|
||||
};
|
||||
};
|
||||
|
||||
const createBlock = (options: CreateBlockOptions = {}): CreateBlockResult => {
|
||||
const renderElement = document.createElement('div');
|
||||
|
||||
renderElement.setAttribute('contenteditable', 'true');
|
||||
|
||||
const toolInstance: MockToolInstance = {
|
||||
render: options.toolOverrides?.render ?? vi.fn<[], HTMLElement>(() => renderElement),
|
||||
save: options.toolOverrides?.save
|
||||
?? vi.fn<[HTMLElement], Promise<BlockToolData>>(async () => ({ text: 'saved' } as BlockToolData)),
|
||||
validate: options.toolOverrides?.validate ?? vi.fn<[BlockToolData], Promise<boolean>>(async () => true),
|
||||
renderSettings: options.renderSettings ?? options.toolOverrides?.renderSettings,
|
||||
merge: options.toolOverrides?.merge,
|
||||
destroy: options.toolOverrides?.destroy,
|
||||
};
|
||||
|
||||
const tunes = options.tunes ?? [];
|
||||
const tunesCollection = new ToolsCollection<BlockTuneAdapter>(
|
||||
tunes.map(({ adapter }) => [adapter.name, adapter] as [ string, BlockTuneAdapter ])
|
||||
);
|
||||
|
||||
const toolAdapter = {
|
||||
name: 'paragraph',
|
||||
settings: { config: { placeholder: 'Test' } },
|
||||
create: vi.fn(() => toolInstance as unknown as object),
|
||||
tunes: tunesCollection,
|
||||
sanitizeConfig: {},
|
||||
inlineTools: new ToolsCollection(),
|
||||
conversionConfig: undefined,
|
||||
} as unknown as BlockToolAdapter;
|
||||
|
||||
const block = new Block({
|
||||
id: 'test-block',
|
||||
data: options.data ?? {},
|
||||
tool: toolAdapter,
|
||||
readOnly: false,
|
||||
tunesData: options.tunesData ?? {},
|
||||
api: {} as ApiModules,
|
||||
}, options.eventBus);
|
||||
|
||||
return {
|
||||
block,
|
||||
toolInstance,
|
||||
toolAdapter,
|
||||
tunes,
|
||||
renderElement,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Block', () => {
|
||||
describe('call', () => {
|
||||
it('invokes tool method when present', () => {
|
||||
const { block, toolInstance } = createBlock();
|
||||
const customMethod = vi.fn();
|
||||
|
||||
(toolInstance as unknown as { custom: typeof customMethod }).custom = customMethod;
|
||||
|
||||
block.call('custom', { foo: 'bar' });
|
||||
|
||||
expect(customMethod).toHaveBeenCalledWith({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('skips invocation for missing methods', () => {
|
||||
const { block } = createBlock();
|
||||
|
||||
expect(() => {
|
||||
block.call('unknown');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeWith', () => {
|
||||
it('throws when tool does not support merging', async () => {
|
||||
const { block } = createBlock();
|
||||
|
||||
await expect(block.mergeWith({} as BlockToolData)).rejects.toThrow('does not support merging');
|
||||
});
|
||||
|
||||
it('delegates merge to tool when supported', async () => {
|
||||
const merge = vi.fn();
|
||||
const { block } = createBlock({
|
||||
toolOverrides: {
|
||||
merge,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = { text: 'merge me' } as BlockToolData;
|
||||
|
||||
await block.mergeWith(payload);
|
||||
|
||||
expect(merge).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('collects tool data and tunes data including unavailable ones', async () => {
|
||||
const userTune = createTuneAdapter('userTune');
|
||||
const internalTune = createTuneAdapter('internalTune', { isInternal: true });
|
||||
const { block, toolInstance } = createBlock({
|
||||
tunes: [userTune, internalTune],
|
||||
tunesData: {
|
||||
missingTune: { collapsed: true },
|
||||
},
|
||||
});
|
||||
|
||||
const performanceSpy = vi.spyOn(window.performance, 'now');
|
||||
|
||||
performanceSpy.mockReturnValueOnce(100);
|
||||
performanceSpy.mockReturnValueOnce(160);
|
||||
|
||||
const result = await block.save();
|
||||
|
||||
expect(toolInstance.save).toHaveBeenCalledWith(block.pluginsContent);
|
||||
expect(result).toMatchObject({
|
||||
id: 'test-block',
|
||||
tool: 'paragraph',
|
||||
data: { text: 'saved' },
|
||||
tunes: {
|
||||
userTune: { userTuneEnabled: true },
|
||||
internalTune: { internalTuneEnabled: true },
|
||||
missingTune: { collapsed: true },
|
||||
},
|
||||
time: 60,
|
||||
});
|
||||
|
||||
performanceSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTunes', () => {
|
||||
it('splits tool-specific and common tunes including html entries', () => {
|
||||
const htmlButton = document.createElement('button');
|
||||
const userTune = createTuneAdapter('userTune');
|
||||
const internalTune = createTuneAdapter('internalTune', {
|
||||
isInternal: true,
|
||||
renderReturn: htmlButton,
|
||||
});
|
||||
|
||||
const renderSettings = (): Array<{ title: string }> => [ { title: 'Tool Action' } ];
|
||||
const { block } = createBlock({
|
||||
tunes: [userTune, internalTune],
|
||||
renderSettings,
|
||||
});
|
||||
|
||||
const tunes = block.getTunes();
|
||||
|
||||
expect(tunes.toolTunes).toEqual([ { title: 'Tool Action' } ]);
|
||||
|
||||
expect(tunes.commonTunes).toEqual([
|
||||
expect.objectContaining({ title: 'userTune-action' }),
|
||||
expect.objectContaining({
|
||||
type: PopoverItemType.Html,
|
||||
element: htmlButton,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inputs handling', () => {
|
||||
it('caches inputs until cache is dropped', () => {
|
||||
const { block } = createBlock();
|
||||
const content = block.pluginsContent;
|
||||
const cachedInputs = block.inputs;
|
||||
const firstInput = document.createElement('div');
|
||||
|
||||
firstInput.setAttribute('contenteditable', 'true');
|
||||
content.appendChild(firstInput);
|
||||
|
||||
expect(block.inputs).toBe(cachedInputs);
|
||||
|
||||
(block as unknown as { dropInputsCache: () => void }).dropInputsCache();
|
||||
|
||||
const refreshedInputs = block.inputs;
|
||||
|
||||
expect(refreshedInputs).not.toBe(cachedInputs);
|
||||
expect(refreshedInputs).toContain(firstInput);
|
||||
|
||||
const secondInput = document.createElement('div');
|
||||
|
||||
secondInput.setAttribute('contenteditable', 'true');
|
||||
content.appendChild(secondInput);
|
||||
|
||||
expect(block.inputs).toBe(refreshedInputs);
|
||||
|
||||
(block as unknown as { dropInputsCache: () => void }).dropInputsCache();
|
||||
|
||||
const refreshedInputsAgain = block.inputs;
|
||||
|
||||
expect(refreshedInputsAgain).not.toBe(refreshedInputs);
|
||||
expect(refreshedInputsAgain).toContain(firstInput);
|
||||
expect(refreshedInputsAgain).toContain(secondInput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('block state helpers', () => {
|
||||
it('detects empty state based on text and media content', () => {
|
||||
const { block } = createBlock();
|
||||
|
||||
block.pluginsContent.textContent = '';
|
||||
expect(block.isEmpty).toBe(true);
|
||||
|
||||
block.pluginsContent.textContent = 'filled';
|
||||
expect(block.isEmpty).toBe(false);
|
||||
|
||||
block.pluginsContent.textContent = '';
|
||||
const image = document.createElement('img');
|
||||
|
||||
block.holder.appendChild(image);
|
||||
expect(block.hasMedia).toBe(true);
|
||||
expect(block.isEmpty).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles selection class and emits fake cursor events', () => {
|
||||
const eventBus = new EventsDispatcher<EditorEventMap>();
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit');
|
||||
const { block } = createBlock({ eventBus });
|
||||
|
||||
const isRangeInsideContainerSpy = vi.spyOn(SelectionUtils, 'isRangeInsideContainer').mockReturnValue(true);
|
||||
const isFakeCursorInsideContainerSpy = vi.spyOn(SelectionUtils, 'isFakeCursorInsideContainer').mockReturnValue(true);
|
||||
const addFakeCursorSpy = vi.spyOn(SelectionUtils, 'addFakeCursor').mockImplementation(() => {});
|
||||
const removeFakeCursorSpy = vi.spyOn(SelectionUtils, 'removeFakeCursor').mockImplementation(() => {});
|
||||
|
||||
block.selected = true;
|
||||
|
||||
expect(block.holder.classList.contains(Block.CSS.selected)).toBe(true);
|
||||
expect(emitSpy).toHaveBeenCalledWith(FakeCursorAboutToBeToggled, { state: true });
|
||||
expect(emitSpy).toHaveBeenCalledWith(FakeCursorHaveBeenSet, { state: true });
|
||||
|
||||
block.selected = false;
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(FakeCursorAboutToBeToggled, { state: false });
|
||||
expect(emitSpy).toHaveBeenCalledWith(FakeCursorHaveBeenSet, { state: false });
|
||||
expect(addFakeCursorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(removeFakeCursorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
isRangeInsideContainerSpy.mockRestore();
|
||||
isFakeCursorInsideContainerSpy.mockRestore();
|
||||
addFakeCursorSpy.mockRestore();
|
||||
removeFakeCursorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { FakeCursorAboutToBeToggled } from '../../../../src/components/events/FakeCursorAboutToBeToggled';
|
||||
import type { FakeCursorAboutToBeToggledPayload } from '../../../../src/components/events/FakeCursorAboutToBeToggled';
|
||||
import type { EditorEventMap } from '../../../../src/components/events';
|
||||
import EventsDispatcher from '../../../../src/components/utils/events';
|
||||
|
||||
describe('FakeCursorAboutToBeToggled event', () => {
|
||||
it('exposes the stable event name used across the editor', () => {
|
||||
expect(FakeCursorAboutToBeToggled).toBe('fake cursor is about to be toggled');
|
||||
});
|
||||
|
||||
it('delivers payloads through EventsDispatcher listeners', () => {
|
||||
const dispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const listener = vi.fn();
|
||||
|
||||
dispatcher.on(FakeCursorAboutToBeToggled, listener);
|
||||
|
||||
const payload: FakeCursorAboutToBeToggledPayload = { state: true };
|
||||
|
||||
dispatcher.emit(FakeCursorAboutToBeToggled, payload);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
});
|
||||
33
test/unit/components/events/block-settings.test.ts
Normal file
33
test/unit/components/events/block-settings.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
|
||||
|
||||
import { BlockSettingsClosed, type BlockSettingsClosedPayload } from '../../../../src/components/events/BlockSettingsClosed';
|
||||
import type { EditorEventMap } from '../../../../src/components/events';
|
||||
import EventsDispatcher from '../../../../src/components/utils/events';
|
||||
|
||||
describe('BlockSettingsClosed event', () => {
|
||||
it('uses stable event name', () => {
|
||||
expect(BlockSettingsClosed).toBe('block-settings-closed');
|
||||
});
|
||||
|
||||
it('does not require payload data', () => {
|
||||
const acceptsPayload = (payload: BlockSettingsClosedPayload): BlockSettingsClosedPayload => payload;
|
||||
const payload: BlockSettingsClosedPayload = {};
|
||||
|
||||
expect(acceptsPayload(payload)).toEqual({});
|
||||
});
|
||||
|
||||
it('is registered in EditorEventMap with matching payload', () => {
|
||||
expectTypeOf<EditorEventMap[typeof BlockSettingsClosed]>().toEqualTypeOf<BlockSettingsClosedPayload>();
|
||||
});
|
||||
|
||||
it('can be emitted through EventsDispatcher without payload', () => {
|
||||
const dispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const handler = vi.fn();
|
||||
|
||||
dispatcher.on(BlockSettingsClosed, handler);
|
||||
dispatcher.emit(BlockSettingsClosed);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
394
test/unit/components/flipper.test.ts
Normal file
394
test/unit/components/flipper.test.ts
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest';
|
||||
import Flipper from '../../../src/components/flipper';
|
||||
|
||||
const focusedClass = 'is-focused';
|
||||
|
||||
type KeydownOptions = {
|
||||
shiftKey?: boolean;
|
||||
target?: HTMLElement;
|
||||
};
|
||||
|
||||
const createItems = (count = 3): HTMLElement[] => {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const button = document.createElement('button');
|
||||
|
||||
button.textContent = `Item ${index + 1}`;
|
||||
document.body.appendChild(button);
|
||||
|
||||
return button;
|
||||
});
|
||||
};
|
||||
|
||||
const createKeyboardEvent = (
|
||||
key: string,
|
||||
options: KeydownOptions = {}
|
||||
): KeyboardEvent => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
shiftKey: options.shiftKey ?? false,
|
||||
});
|
||||
|
||||
const target = options.target ?? document.body;
|
||||
|
||||
Object.defineProperty(event, 'target', {
|
||||
configurable: true,
|
||||
get: () => target,
|
||||
});
|
||||
|
||||
Object.defineProperty(event, 'currentTarget', {
|
||||
configurable: true,
|
||||
get: () => target,
|
||||
});
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
scrollIntoViewIfNeeded?: (centerIfNeeded?: boolean) => void;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Flipper', () => {
|
||||
const originalScrollIntoViewIfNeeded = HTMLElement.prototype.scrollIntoViewIfNeeded;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoViewIfNeeded', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalScrollIntoViewIfNeeded) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoViewIfNeeded', {
|
||||
configurable: true,
|
||||
value: originalScrollIntoViewIfNeeded,
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- restoring prototype to original state
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoViewIfNeeded', {
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('activates with provided items and cursor position and registers keydown listeners', () => {
|
||||
const items = createItems();
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
});
|
||||
const docAddSpy = vi.spyOn(document, 'addEventListener');
|
||||
const winAddSpy = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
flipper.activate(items, 1);
|
||||
|
||||
expect(docAddSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
|
||||
expect(winAddSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
|
||||
expect(items[1].classList.contains(focusedClass)).toBe(true);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
|
||||
it('deactivates by removing listeners and clearing focused state', () => {
|
||||
const items = createItems();
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
});
|
||||
const docRemoveSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const winRemoveSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
flipper.activate();
|
||||
flipper.flipRight();
|
||||
|
||||
expect(flipper.hasFocus()).toBe(true);
|
||||
|
||||
flipper.deactivate();
|
||||
|
||||
expect(docRemoveSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
|
||||
expect(winRemoveSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
|
||||
expect(flipper.hasFocus()).toBe(false);
|
||||
});
|
||||
|
||||
it('handles arrow navigation and runs flip callbacks', () => {
|
||||
const items = createItems();
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
});
|
||||
const onFlipSpy = vi.fn();
|
||||
|
||||
flipper.onFlip(onFlipSpy);
|
||||
flipper.activate();
|
||||
|
||||
const event = createKeyboardEvent('ArrowRight');
|
||||
|
||||
flipper.handleExternalKeydown(event);
|
||||
|
||||
expect(onFlipSpy).toHaveBeenCalledTimes(1);
|
||||
expect(items[0].classList.contains(focusedClass)).toBe(true);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
|
||||
it('pressing Enter on a focused item triggers click and activate callback', () => {
|
||||
const items = createItems();
|
||||
const clickSpy = vi.fn();
|
||||
const activateSpy = vi.fn();
|
||||
|
||||
items[0].addEventListener('click', clickSpy);
|
||||
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
activateCallback: activateSpy,
|
||||
});
|
||||
|
||||
flipper.activate();
|
||||
flipper.focusItem(0);
|
||||
|
||||
const event = createKeyboardEvent('Enter');
|
||||
|
||||
flipper.handleExternalKeydown(event);
|
||||
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
expect(activateSpy).toHaveBeenCalledWith(items[0]);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
|
||||
it('can toggle handling for contenteditable targets', () => {
|
||||
const items = createItems();
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
});
|
||||
|
||||
flipper.activate();
|
||||
|
||||
const editable = document.createElement('div');
|
||||
|
||||
editable.contentEditable = 'true';
|
||||
Object.defineProperty(editable, 'isContentEditable', {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
document.body.appendChild(editable);
|
||||
|
||||
const initialEvent = createKeyboardEvent('ArrowDown', {
|
||||
target: editable,
|
||||
});
|
||||
|
||||
flipper.handleExternalKeydown(initialEvent);
|
||||
|
||||
expect(flipper.hasFocus()).toBe(false);
|
||||
|
||||
flipper.setHandleContentEditableTargets(true);
|
||||
|
||||
const secondEvent = createKeyboardEvent('ArrowDown', {
|
||||
target: editable,
|
||||
});
|
||||
|
||||
flipper.handleExternalKeydown(secondEvent);
|
||||
|
||||
expect(flipper.hasFocus()).toBe(true);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
|
||||
it('respects native input opt-in for Tab handling', () => {
|
||||
const items = createItems();
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
|
||||
document.body.appendChild(input);
|
||||
|
||||
flipper.activate();
|
||||
|
||||
const initialTabEvent = createKeyboardEvent('Tab', {
|
||||
target: input,
|
||||
});
|
||||
|
||||
flipper.handleExternalKeydown(initialTabEvent);
|
||||
|
||||
expect(flipper.hasFocus()).toBe(false);
|
||||
|
||||
input.dataset.flipperTabTarget = 'true';
|
||||
|
||||
const secondTabEvent = createKeyboardEvent('Tab', {
|
||||
target: input,
|
||||
});
|
||||
|
||||
flipper.handleExternalKeydown(secondTabEvent);
|
||||
|
||||
expect(flipper.hasFocus()).toBe(true);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
|
||||
it('provides accurate activation state via getter', () => {
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
});
|
||||
|
||||
expect(flipper.isActivated).toBe(false);
|
||||
flipper.activate(createItems());
|
||||
expect(flipper.isActivated).toBe(true);
|
||||
flipper.deactivate();
|
||||
expect(flipper.isActivated).toBe(false);
|
||||
});
|
||||
|
||||
it('focusFirst focuses the first item and scrolls it into view', () => {
|
||||
const items = createItems(2);
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
});
|
||||
const scrollSpy = vi.spyOn(items[0], 'scrollIntoViewIfNeeded');
|
||||
|
||||
flipper.activate();
|
||||
flipper.focusFirst();
|
||||
|
||||
expect(items[0].classList.contains(focusedClass)).toBe(true);
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
|
||||
it('focusItem drops cursor for negative index', () => {
|
||||
const items = createItems();
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
});
|
||||
|
||||
flipper.activate();
|
||||
flipper.focusItem(1);
|
||||
expect(items[1].classList.contains(focusedClass)).toBe(true);
|
||||
|
||||
flipper.focusItem(-1);
|
||||
expect(Array.from(items).some(item => item.classList.contains(focusedClass))).toBe(false);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
|
||||
it('skips the first Tab movement after presetting initial focus', () => {
|
||||
const items = createItems();
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
});
|
||||
|
||||
flipper.activate();
|
||||
flipper.focusItem(0);
|
||||
expect(items[0].classList.contains(focusedClass)).toBe(true);
|
||||
|
||||
const firstTabEvent = createKeyboardEvent('Tab');
|
||||
|
||||
flipper.handleExternalKeydown(firstTabEvent);
|
||||
expect(items[0].classList.contains(focusedClass)).toBe(true);
|
||||
|
||||
const secondTabEvent = createKeyboardEvent('Tab');
|
||||
|
||||
flipper.handleExternalKeydown(secondTabEvent);
|
||||
expect(items[1].classList.contains(focusedClass)).toBe(true);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
|
||||
it('removes onFlip callbacks when requested', () => {
|
||||
const items = createItems();
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
});
|
||||
const callback = vi.fn();
|
||||
|
||||
flipper.onFlip(callback);
|
||||
flipper.activate();
|
||||
|
||||
const initialEvent = createKeyboardEvent('ArrowRight');
|
||||
|
||||
flipper.handleExternalKeydown(initialEvent);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
flipper.removeOnFlip(callback);
|
||||
const secondEvent = createKeyboardEvent('ArrowRight');
|
||||
|
||||
flipper.handleExternalKeydown(secondEvent);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
|
||||
it('handleExternalKeydown delegates handling logic', () => {
|
||||
const items = createItems();
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
});
|
||||
|
||||
flipper.activate();
|
||||
|
||||
const event = createKeyboardEvent('ArrowRight');
|
||||
|
||||
flipper.handleExternalKeydown(event);
|
||||
|
||||
expect(items[0].classList.contains(focusedClass)).toBe(true);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
|
||||
it('skips inline tool inputs unless explicitly allowed', () => {
|
||||
const items = createItems();
|
||||
const flipper = new Flipper({
|
||||
focusedItemClass: focusedClass,
|
||||
items,
|
||||
});
|
||||
const inlineToolInputWrapper = document.createElement('div');
|
||||
const inlineInput = document.createElement('input');
|
||||
|
||||
inlineToolInputWrapper.dataset.linkToolInputOpened = 'true';
|
||||
inlineToolInputWrapper.appendChild(inlineInput);
|
||||
document.body.appendChild(inlineToolInputWrapper);
|
||||
|
||||
flipper.activate();
|
||||
|
||||
const initialEvent = createKeyboardEvent('ArrowDown', {
|
||||
target: inlineInput,
|
||||
});
|
||||
|
||||
flipper.handleExternalKeydown(initialEvent);
|
||||
expect(flipper.hasFocus()).toBe(false);
|
||||
|
||||
inlineToolInputWrapper.removeAttribute('data-link-tool-input-opened');
|
||||
inlineInput.dataset.flipperTabTarget = 'true';
|
||||
const secondEvent = createKeyboardEvent('Tab', {
|
||||
target: inlineInput,
|
||||
});
|
||||
|
||||
flipper.handleExternalKeydown(secondEvent);
|
||||
expect(flipper.hasFocus()).toBe(true);
|
||||
|
||||
flipper.deactivate();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
80
test/unit/components/i18n/index.test.ts
Normal file
80
test/unit/components/i18n/index.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import I18n from '../../../../src/components/i18n';
|
||||
import defaultDictionary from '../../../../src/components/i18n/locales/en/messages.json';
|
||||
import type { I18nDictionary } from '../../../../types/configs';
|
||||
|
||||
const createDictionary = (): I18nDictionary => ({
|
||||
ui: {
|
||||
toolbar: {
|
||||
toolbox: {
|
||||
Add: 'Ajouter',
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
link: {
|
||||
'Add a link': 'Ajouter un lien',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const alternativeDictionary: I18nDictionary = {
|
||||
tools: {
|
||||
link: {
|
||||
'Add a link': 'Lien secondaire',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('I18n', () => {
|
||||
beforeEach(() => {
|
||||
I18n.setDictionary(defaultDictionary as I18nDictionary);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
I18n.setDictionary(defaultDictionary as I18nDictionary);
|
||||
});
|
||||
|
||||
it('translates internal namespaces via ui()', () => {
|
||||
const dictionary = createDictionary();
|
||||
|
||||
I18n.setDictionary(dictionary);
|
||||
|
||||
expect(I18n.ui('ui.toolbar.toolbox', 'Add')).toBe('Ajouter');
|
||||
});
|
||||
|
||||
it('translates external namespaces via t()', () => {
|
||||
const dictionary = createDictionary();
|
||||
|
||||
I18n.setDictionary(dictionary);
|
||||
|
||||
expect(I18n.t('tools.link', 'Add a link')).toBe('Ajouter un lien');
|
||||
});
|
||||
|
||||
it('returns the original key when namespace is missing', () => {
|
||||
const dictionary = createDictionary();
|
||||
|
||||
I18n.setDictionary(dictionary);
|
||||
|
||||
expect(I18n.t('missing.namespace', 'Fallback text')).toBe('Fallback text');
|
||||
});
|
||||
|
||||
it('returns the original key when translation is missing inside namespace', () => {
|
||||
const dictionary = createDictionary();
|
||||
|
||||
I18n.setDictionary(dictionary);
|
||||
|
||||
expect(I18n.t('tools.link', 'Missing label')).toBe('Missing label');
|
||||
});
|
||||
|
||||
it('allows overriding dictionary via setDictionary()', () => {
|
||||
const firstDictionary = createDictionary();
|
||||
|
||||
I18n.setDictionary(firstDictionary);
|
||||
expect(I18n.t('tools.link', 'Add a link')).toBe('Ajouter un lien');
|
||||
|
||||
I18n.setDictionary(alternativeDictionary);
|
||||
expect(I18n.t('tools.link', 'Add a link')).toBe('Lien secondaire');
|
||||
});
|
||||
});
|
||||
239
test/unit/components/inline-tools/inline-tool-convert.test.ts
Normal file
239
test/unit/components/inline-tools/inline-tool-convert.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { IconReplace } from '@codexteam/icons';
|
||||
|
||||
import ConvertInlineTool from '../../../../src/components/inline-tools/inline-tool-convert';
|
||||
import SelectionUtils from '../../../../src/components/selection';
|
||||
import * as Utils from '../../../../src/components/utils';
|
||||
import * as BlocksUtils from '../../../../src/components/utils/blocks';
|
||||
import I18nInternal from '../../../../src/components/i18n';
|
||||
import { I18nInternalNS } from '../../../../src/components/i18n/namespace-internal';
|
||||
import type { API } from '../../../../types';
|
||||
import type BlockToolAdapter from '../../../../src/components/tools/block';
|
||||
|
||||
type MenuConfigWithChildren = {
|
||||
icon?: string;
|
||||
name?: string;
|
||||
children?: {
|
||||
searchable?: boolean;
|
||||
items?: Array<{
|
||||
title?: string;
|
||||
onActivate?: () => Promise<void> | void;
|
||||
}>;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
const createSelectionMock = (anchorNode: Node): Selection => {
|
||||
return { anchorNode } as unknown as Selection;
|
||||
};
|
||||
|
||||
const createTool = (): {
|
||||
tool: ConvertInlineTool;
|
||||
blocksAPI: { getBlockByElement: ReturnType<typeof vi.fn>; convert: ReturnType<typeof vi.fn> };
|
||||
selectionAPI: {
|
||||
setFakeBackground: ReturnType<typeof vi.fn>;
|
||||
save: ReturnType<typeof vi.fn>;
|
||||
restore: ReturnType<typeof vi.fn>;
|
||||
removeFakeBackground: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
toolsAPI: { getBlockTools: ReturnType<typeof vi.fn> };
|
||||
caretAPI: { setToBlock: ReturnType<typeof vi.fn> };
|
||||
} => {
|
||||
const blocksAPI = {
|
||||
getBlockByElement: vi.fn(),
|
||||
convert: vi.fn(),
|
||||
};
|
||||
|
||||
const selectionAPI = {
|
||||
setFakeBackground: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
removeFakeBackground: vi.fn(),
|
||||
};
|
||||
|
||||
const toolsAPI = {
|
||||
getBlockTools: vi.fn(),
|
||||
};
|
||||
|
||||
const caretAPI = {
|
||||
setToBlock: vi.fn(),
|
||||
};
|
||||
|
||||
const api = {
|
||||
blocks: blocksAPI,
|
||||
selection: selectionAPI,
|
||||
tools: toolsAPI,
|
||||
caret: caretAPI,
|
||||
i18n: {},
|
||||
} as unknown as API;
|
||||
|
||||
return {
|
||||
tool: new ConvertInlineTool({ api }),
|
||||
blocksAPI,
|
||||
selectionAPI,
|
||||
toolsAPI,
|
||||
caretAPI,
|
||||
};
|
||||
};
|
||||
|
||||
describe('ConvertInlineTool', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exposes inline metadata', () => {
|
||||
expect(ConvertInlineTool.isInline).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty config when selection is missing', async () => {
|
||||
const { tool } = createTool();
|
||||
|
||||
vi.spyOn(SelectionUtils, 'get').mockReturnValue(null);
|
||||
|
||||
await expect(tool.render()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty config when current block cannot be resolved', async () => {
|
||||
const { tool, blocksAPI } = createTool();
|
||||
const anchorNode = document.createElement('div');
|
||||
|
||||
vi.spyOn(SelectionUtils, 'get').mockReturnValue(createSelectionMock(anchorNode));
|
||||
blocksAPI.getBlockByElement.mockReturnValue(undefined);
|
||||
|
||||
await expect(tool.render()).resolves.toEqual([]);
|
||||
expect(blocksAPI.getBlockByElement).toHaveBeenCalledWith(anchorNode);
|
||||
});
|
||||
|
||||
it('returns empty config when no convertible tools found', async () => {
|
||||
const { tool, blocksAPI, toolsAPI } = createTool();
|
||||
const anchorNode = document.createElement('div');
|
||||
const currentBlock = {
|
||||
id: 'block-1',
|
||||
name: 'paragraph',
|
||||
getActiveToolboxEntry: vi.fn(),
|
||||
};
|
||||
|
||||
vi.spyOn(SelectionUtils, 'get').mockReturnValue(createSelectionMock(anchorNode));
|
||||
blocksAPI.getBlockByElement.mockReturnValue(currentBlock);
|
||||
toolsAPI.getBlockTools.mockReturnValue([]);
|
||||
vi.spyOn(BlocksUtils, 'getConvertibleToolsForBlock').mockResolvedValue([]);
|
||||
|
||||
await expect(tool.render()).resolves.toEqual([]);
|
||||
expect(BlocksUtils.getConvertibleToolsForBlock).toHaveBeenCalledWith(currentBlock, []);
|
||||
});
|
||||
|
||||
it('builds menu config and handles desktop-only selection behavior', async () => {
|
||||
const { tool, blocksAPI, toolsAPI, selectionAPI, caretAPI } = createTool();
|
||||
const anchorNode = document.createElement('div');
|
||||
const currentBlock = {
|
||||
id: 'block-1',
|
||||
name: 'paragraph',
|
||||
getActiveToolboxEntry: vi.fn().mockResolvedValue({ icon: '<svg>current</svg>' }),
|
||||
};
|
||||
const toolboxItem = {
|
||||
title: 'Heading',
|
||||
icon: '<svg>H</svg>',
|
||||
data: {
|
||||
level: 2,
|
||||
},
|
||||
};
|
||||
const convertibleTool = {
|
||||
name: 'header',
|
||||
toolbox: [
|
||||
toolboxItem,
|
||||
],
|
||||
} as unknown as BlockToolAdapter;
|
||||
const convertedBlock = {
|
||||
id: 'converted',
|
||||
};
|
||||
|
||||
vi.spyOn(SelectionUtils, 'get').mockReturnValue(createSelectionMock(anchorNode));
|
||||
blocksAPI.getBlockByElement.mockReturnValue(currentBlock);
|
||||
toolsAPI.getBlockTools.mockReturnValue([ convertibleTool ]);
|
||||
vi.spyOn(BlocksUtils, 'getConvertibleToolsForBlock').mockResolvedValue([ convertibleTool ]);
|
||||
vi.spyOn(Utils, 'isMobileScreen').mockReturnValue(false);
|
||||
const translateSpy = vi.spyOn(I18nInternal, 't').mockImplementation(() => 'Heading translated');
|
||||
|
||||
vi.spyOn(I18nInternal, 'ui').mockImplementation(() => 'Convert to');
|
||||
blocksAPI.convert.mockResolvedValue(convertedBlock);
|
||||
|
||||
const config = await tool.render();
|
||||
|
||||
expect(Array.isArray(config)).toBe(false);
|
||||
|
||||
const menuConfig = config as MenuConfigWithChildren;
|
||||
|
||||
expect(menuConfig).toMatchObject({
|
||||
icon: '<svg>current</svg>',
|
||||
name: 'convert-to',
|
||||
});
|
||||
|
||||
const children = menuConfig.children;
|
||||
|
||||
expect(children?.searchable).toBe(true);
|
||||
expect(children?.items).toHaveLength(1);
|
||||
|
||||
const items = children?.items ?? [];
|
||||
const firstItem = items[0];
|
||||
|
||||
expect(firstItem?.title).toBe('Heading translated');
|
||||
expect(translateSpy).toHaveBeenCalledWith(I18nInternalNS.toolNames, 'Heading');
|
||||
|
||||
children?.onOpen?.();
|
||||
expect(selectionAPI.setFakeBackground).toHaveBeenCalled();
|
||||
expect(selectionAPI.save).toHaveBeenCalled();
|
||||
|
||||
children?.onClose?.();
|
||||
expect(selectionAPI.restore).toHaveBeenCalled();
|
||||
expect(selectionAPI.removeFakeBackground).toHaveBeenCalled();
|
||||
|
||||
await firstItem?.onActivate?.();
|
||||
expect(blocksAPI.convert).toHaveBeenCalledWith(currentBlock.id, convertibleTool.name, toolboxItem.data);
|
||||
expect(caretAPI.setToBlock).toHaveBeenCalledWith(convertedBlock, 'end');
|
||||
});
|
||||
|
||||
it('falls back to default icon and skips fake selection on mobile', async () => {
|
||||
const { tool, blocksAPI, toolsAPI, selectionAPI } = createTool();
|
||||
const anchorNode = document.createElement('div');
|
||||
const currentBlock = {
|
||||
id: 'block-2',
|
||||
name: 'paragraph',
|
||||
getActiveToolboxEntry: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const toolboxItem = {
|
||||
title: 'Paragraph',
|
||||
icon: '<svg>P</svg>',
|
||||
};
|
||||
const convertibleTool = {
|
||||
name: 'paragraph',
|
||||
toolbox: [ toolboxItem ],
|
||||
} as unknown as BlockToolAdapter;
|
||||
|
||||
vi.spyOn(SelectionUtils, 'get').mockReturnValue(createSelectionMock(anchorNode));
|
||||
blocksAPI.getBlockByElement.mockReturnValue(currentBlock);
|
||||
toolsAPI.getBlockTools.mockReturnValue([ convertibleTool ]);
|
||||
vi.spyOn(BlocksUtils, 'getConvertibleToolsForBlock').mockResolvedValue([ convertibleTool ]);
|
||||
vi.spyOn(Utils, 'isMobileScreen').mockReturnValue(true);
|
||||
vi.spyOn(I18nInternal, 't').mockImplementation(() => 'Paragraph');
|
||||
vi.spyOn(I18nInternal, 'ui').mockImplementation(() => 'Convert to');
|
||||
|
||||
const config = await tool.render();
|
||||
|
||||
expect(Array.isArray(config)).toBe(false);
|
||||
|
||||
const menuConfig = config as MenuConfigWithChildren;
|
||||
|
||||
expect(menuConfig.icon).toBe(IconReplace);
|
||||
expect(menuConfig.children?.searchable).toBe(false);
|
||||
|
||||
menuConfig.children?.onOpen?.();
|
||||
menuConfig.children?.onClose?.();
|
||||
|
||||
expect(selectionAPI.setFakeBackground).not.toHaveBeenCalled();
|
||||
expect(selectionAPI.save).not.toHaveBeenCalled();
|
||||
expect(selectionAPI.restore).not.toHaveBeenCalled();
|
||||
expect(selectionAPI.removeFakeBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
293
test/unit/components/inline-tools/inline-tool-link.test.ts
Normal file
293
test/unit/components/inline-tools/inline-tool-link.test.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { IconLink, IconUnlink } from '@codexteam/icons';
|
||||
|
||||
import LinkInlineTool from '../../../../src/components/inline-tools/inline-tool-link';
|
||||
import type SelectionUtils from '../../../../src/components/selection';
|
||||
import type { API } from '../../../../types';
|
||||
|
||||
type SelectionMock = Pick<SelectionUtils,
|
||||
'setFakeBackground' |
|
||||
'save' |
|
||||
'restore' |
|
||||
'removeFakeBackground' |
|
||||
'expandToTag' |
|
||||
'clearSaved' |
|
||||
'collapseToEnd'> & {
|
||||
isFakeBackgroundEnabled: boolean;
|
||||
findParentTag: Mock<[tagName: string, className?: string, searchDepth?: number], HTMLElement | null>;
|
||||
};
|
||||
|
||||
const setDocumentCommand = (implementation: Document['execCommand']): void => {
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: implementation,
|
||||
});
|
||||
};
|
||||
|
||||
const createSelectionMock = (): SelectionMock => {
|
||||
return {
|
||||
setFakeBackground: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
removeFakeBackground: vi.fn(),
|
||||
findParentTag: vi.fn<[string, string?, number?], HTMLElement | null>(() => null),
|
||||
expandToTag: vi.fn(),
|
||||
clearSaved: vi.fn(),
|
||||
collapseToEnd: vi.fn(),
|
||||
isFakeBackgroundEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
type ToolSetup = {
|
||||
tool: LinkInlineTool;
|
||||
toolbar: { close: ReturnType<typeof vi.fn> };
|
||||
inlineToolbar: { close: ReturnType<typeof vi.fn> };
|
||||
notifier: { show: ReturnType<typeof vi.fn> };
|
||||
selection: SelectionMock;
|
||||
};
|
||||
|
||||
const createTool = (): ToolSetup => {
|
||||
const toolbar = { close: vi.fn() };
|
||||
const inlineToolbar = { close: vi.fn() };
|
||||
const notifier = { show: vi.fn() };
|
||||
const i18n = { t: vi.fn((phrase: string) => phrase) };
|
||||
|
||||
const api = {
|
||||
toolbar,
|
||||
inlineToolbar,
|
||||
notifier,
|
||||
i18n,
|
||||
} as unknown as API;
|
||||
|
||||
const tool = new LinkInlineTool({ api });
|
||||
const selection = createSelectionMock();
|
||||
|
||||
(tool as unknown as { selection: SelectionMock }).selection = selection;
|
||||
|
||||
return {
|
||||
tool,
|
||||
toolbar,
|
||||
inlineToolbar,
|
||||
notifier,
|
||||
selection,
|
||||
};
|
||||
};
|
||||
|
||||
const createKeyboardEventWithKeyCode = (keyCode: number): KeyboardEvent => {
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
|
||||
Object.defineProperty(event, 'keyCode', {
|
||||
configurable: true,
|
||||
value: keyCode,
|
||||
});
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
type KeyboardEventStub = Pick<KeyboardEvent,
|
||||
'preventDefault' |
|
||||
'stopPropagation' |
|
||||
'stopImmediatePropagation'> & {
|
||||
preventDefault: ReturnType<typeof vi.fn>;
|
||||
stopPropagation: ReturnType<typeof vi.fn>;
|
||||
stopImmediatePropagation: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const createEnterEventStubs = (): KeyboardEventStub => {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
stopImmediatePropagation: vi.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes HTML string by parsing and re-serializing it.
|
||||
* This ensures consistent comparison when browsers serialize SVG differently.
|
||||
*
|
||||
* @param html - The HTML string to normalize
|
||||
* @returns The normalized HTML string
|
||||
*/
|
||||
const normalizeHTML = (html: string): string => {
|
||||
const temp = document.createElement('div');
|
||||
|
||||
temp.innerHTML = html;
|
||||
|
||||
return temp.innerHTML;
|
||||
};
|
||||
|
||||
const expectButtonIcon = (button: HTMLElement, iconHTML: string): void => {
|
||||
expect(normalizeHTML(button.innerHTML)).toBe(normalizeHTML(iconHTML));
|
||||
};
|
||||
|
||||
describe('LinkInlineTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
setDocumentCommand(vi.fn());
|
||||
});
|
||||
|
||||
it('exposes inline metadata and shortcut', () => {
|
||||
expect(LinkInlineTool.isInline).toBe(true);
|
||||
expect(LinkInlineTool.title).toBe('Link');
|
||||
expect(LinkInlineTool.sanitize).toEqual({
|
||||
a: {
|
||||
href: true,
|
||||
target: '_blank',
|
||||
rel: 'nofollow',
|
||||
},
|
||||
});
|
||||
|
||||
const { tool } = createTool();
|
||||
|
||||
expect(tool.shortcut).toBe('CMD+K');
|
||||
});
|
||||
|
||||
it('renders toolbar button with initial state persisted in data attributes', () => {
|
||||
const { tool } = createTool();
|
||||
|
||||
const button = tool.render() as HTMLButtonElement;
|
||||
|
||||
expect(button).toBeInstanceOf(HTMLButtonElement);
|
||||
expect(button.type).toBe('button');
|
||||
expect(button.classList.contains('ce-inline-tool')).toBe(true);
|
||||
expect(button.classList.contains('ce-inline-tool--link')).toBe(true);
|
||||
expect(button.getAttribute('data-link-tool-active')).toBe('false');
|
||||
expect(button.getAttribute('data-link-tool-unlink')).toBe('false');
|
||||
expectButtonIcon(button, IconLink);
|
||||
});
|
||||
|
||||
it('renders actions input and invokes enter handler when Enter key is pressed', () => {
|
||||
const { tool } = createTool();
|
||||
const enterSpy = vi.spyOn(tool as unknown as { enterPressed(event: KeyboardEvent): void }, 'enterPressed');
|
||||
|
||||
const input = tool.renderActions() as HTMLInputElement;
|
||||
|
||||
expect(input.placeholder).toBe('Add a link');
|
||||
expect(input.classList.contains('ce-inline-tool-input')).toBe(true);
|
||||
expect(input.getAttribute('data-link-tool-input-opened')).toBe('false');
|
||||
|
||||
const event = createKeyboardEventWithKeyCode(13);
|
||||
|
||||
input.dispatchEvent(event);
|
||||
|
||||
expect(enterSpy).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('activates unlink state when selection already contains anchor', () => {
|
||||
const { tool, selection } = createTool();
|
||||
const button = tool.render() as HTMLButtonElement;
|
||||
const input = tool.renderActions() as HTMLInputElement;
|
||||
const openActionsSpy = vi.spyOn(tool as unknown as { openActions(needFocus?: boolean): void }, 'openActions');
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.setAttribute('href', 'https://codex.so');
|
||||
selection.findParentTag.mockReturnValue(anchor);
|
||||
|
||||
const result = tool.checkState();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expectButtonIcon(button, IconUnlink);
|
||||
expect(button.classList.contains('ce-inline-tool--active')).toBe(true);
|
||||
expect(button.getAttribute('data-link-tool-unlink')).toBe('true');
|
||||
expect(input.value).toBe('https://codex.so');
|
||||
expect(openActionsSpy).toHaveBeenCalled();
|
||||
expect(selection.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deactivates button when selection leaves anchor', () => {
|
||||
const { tool, selection } = createTool();
|
||||
const button = tool.render() as HTMLButtonElement;
|
||||
|
||||
button.classList.add('ce-inline-tool--active');
|
||||
tool.renderActions();
|
||||
selection.findParentTag.mockReturnValue(null);
|
||||
|
||||
const result = tool.checkState();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expectButtonIcon(button, IconLink);
|
||||
expect(button.classList.contains('ce-inline-tool--active')).toBe(false);
|
||||
expect(button.getAttribute('data-link-tool-unlink')).toBe('false');
|
||||
});
|
||||
|
||||
it('removes link when input is submitted empty', () => {
|
||||
const { tool, selection } = createTool();
|
||||
const input = tool.renderActions() as HTMLInputElement;
|
||||
const unlinkSpy = vi.spyOn(tool as unknown as { unlink(): void }, 'unlink');
|
||||
const closeActionsSpy = vi.spyOn(tool as unknown as { closeActions(clearSavedSelection?: boolean): void }, 'closeActions');
|
||||
|
||||
input.value = ' ';
|
||||
|
||||
const event = createEnterEventStubs();
|
||||
|
||||
(tool as unknown as { enterPressed(event: KeyboardEvent): void }).enterPressed(event as unknown as KeyboardEvent);
|
||||
|
||||
expect(selection.restore).toHaveBeenCalled();
|
||||
expect(unlinkSpy).toHaveBeenCalled();
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(closeActionsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows notifier when URL validation fails', () => {
|
||||
const { tool, notifier } = createTool();
|
||||
const input = tool.renderActions() as HTMLInputElement;
|
||||
const insertLinkSpy = vi.spyOn(tool as unknown as { insertLink(link: string): void }, 'insertLink');
|
||||
|
||||
input.value = 'https://codex .so';
|
||||
|
||||
(tool as unknown as { enterPressed(event: KeyboardEvent): void }).enterPressed(createEnterEventStubs() as unknown as KeyboardEvent);
|
||||
|
||||
expect(notifier.show).toHaveBeenCalledWith({
|
||||
message: 'Pasted link is not valid.',
|
||||
style: 'error',
|
||||
});
|
||||
expect(insertLinkSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inserts prepared link and collapses selection when URL is valid', () => {
|
||||
const { tool, selection, inlineToolbar } = createTool();
|
||||
const input = tool.renderActions() as HTMLInputElement;
|
||||
const insertLinkSpy = vi.spyOn(tool as unknown as { insertLink(link: string): void }, 'insertLink');
|
||||
const removeFakeBackgroundSpy = selection.removeFakeBackground as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
input.value = 'example.com';
|
||||
|
||||
(tool as unknown as { enterPressed(event: KeyboardEvent): void }).enterPressed(createEnterEventStubs() as unknown as KeyboardEvent);
|
||||
|
||||
expect(selection.restore).toHaveBeenCalled();
|
||||
expect(removeFakeBackgroundSpy).toHaveBeenCalled();
|
||||
expect(insertLinkSpy).toHaveBeenCalledWith('http://example.com');
|
||||
expect(selection.collapseToEnd).toHaveBeenCalled();
|
||||
expect(inlineToolbar.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds missing protocol only when needed', () => {
|
||||
const { tool } = createTool();
|
||||
const addProtocol = tool as unknown as { addProtocol(link: string): string };
|
||||
|
||||
expect(addProtocol.addProtocol('https://codex.so')).toBe('https://codex.so');
|
||||
expect(addProtocol.addProtocol('codex.so')).toBe('http://codex.so');
|
||||
expect(addProtocol.addProtocol('/internal')).toBe('/internal');
|
||||
expect(addProtocol.addProtocol('#hash')).toBe('#hash');
|
||||
expect(addProtocol.addProtocol('//cdn.codex.so')).toBe('//cdn.codex.so');
|
||||
});
|
||||
|
||||
it('delegates to document.execCommand when inserting and removing links', () => {
|
||||
const execSpy = vi.fn();
|
||||
|
||||
setDocumentCommand(execSpy as Document['execCommand']);
|
||||
|
||||
const { tool } = createTool();
|
||||
|
||||
(tool as unknown as { insertLink(link: string): void }).insertLink('https://codex.so');
|
||||
expect(execSpy).toHaveBeenCalledWith('createLink', false, 'https://codex.so');
|
||||
|
||||
execSpy.mockClear();
|
||||
|
||||
(tool as unknown as { unlink(): void }).unlink();
|
||||
expect(execSpy).toHaveBeenCalledWith('unlink');
|
||||
});
|
||||
});
|
||||
136
test/unit/components/module-base.test.ts
Normal file
136
test/unit/components/module-base.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/* eslint-disable jsdoc/require-jsdoc */
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import Module from '../../../src/components/__module';
|
||||
import EventsDispatcher from '../../../src/components/utils/events';
|
||||
|
||||
import type { ModuleConfig } from '../../../src/types-internal/module-config';
|
||||
import type { EditorConfig } from '../../../types';
|
||||
import type { EditorEventMap } from '../../../src/components/events';
|
||||
import type { EditorModules } from '../../../src/types-internal/editor-modules';
|
||||
import type Listeners from '../../../src/components/utils/listeners';
|
||||
|
||||
const createModuleConfig = (configOverrides?: Partial<EditorConfig>): ModuleConfig => {
|
||||
const defaultConfig = {
|
||||
i18n: {
|
||||
direction: 'ltr',
|
||||
},
|
||||
} as EditorConfig;
|
||||
|
||||
const mergedConfig = {
|
||||
...defaultConfig,
|
||||
...(configOverrides ?? {}),
|
||||
i18n: {
|
||||
...defaultConfig.i18n,
|
||||
...(configOverrides?.i18n ?? {}),
|
||||
},
|
||||
} as EditorConfig;
|
||||
|
||||
return {
|
||||
config: mergedConfig,
|
||||
eventsDispatcher: new EventsDispatcher<EditorEventMap>(),
|
||||
};
|
||||
};
|
||||
|
||||
class ConcreteModule extends Module<{
|
||||
primary?: HTMLElement;
|
||||
secondary?: HTMLElement;
|
||||
misc?: HTMLElement;
|
||||
}> {
|
||||
public exposeReadOnlyListeners(): Module['readOnlyMutableListeners'] {
|
||||
return this.readOnlyMutableListeners;
|
||||
}
|
||||
|
||||
public overrideListeners(listeners: Pick<Listeners, 'on' | 'offById'>): void {
|
||||
this.listeners = listeners as Listeners;
|
||||
}
|
||||
|
||||
public editorState(): EditorModules {
|
||||
return this.Editor;
|
||||
}
|
||||
|
||||
public isRightToLeft(): boolean {
|
||||
return this.isRtl;
|
||||
}
|
||||
}
|
||||
|
||||
const createConcreteModule = (configOverrides?: Partial<EditorConfig>): ConcreteModule => {
|
||||
return new ConcreteModule(createModuleConfig(configOverrides));
|
||||
};
|
||||
|
||||
describe('Module base class', () => {
|
||||
it('throws when attempting to instantiate directly', () => {
|
||||
const createModuleInstance = (): Module =>
|
||||
new Module({
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher: new EventsDispatcher<EditorEventMap>(),
|
||||
});
|
||||
|
||||
expect(createModuleInstance).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('accepts state setter to store Editor modules instance', () => {
|
||||
const moduleInstance = createConcreteModule();
|
||||
const editorModules = { blocks: {} } as unknown as EditorModules;
|
||||
|
||||
moduleInstance.state = editorModules;
|
||||
|
||||
expect(moduleInstance.editorState()).toBe(editorModules);
|
||||
});
|
||||
|
||||
it('removes memorized HTMLElements via removeAllNodes()', () => {
|
||||
const moduleInstance = createConcreteModule();
|
||||
const first = document.createElement('div');
|
||||
const second = document.createElement('span');
|
||||
const firstRemoveSpy = vi.spyOn(first, 'remove');
|
||||
const secondRemoveSpy = vi.spyOn(second, 'remove');
|
||||
const mockObject = { remove: vi.fn() };
|
||||
|
||||
moduleInstance.nodes.primary = first;
|
||||
moduleInstance.nodes.secondary = second;
|
||||
moduleInstance.nodes.misc = mockObject as unknown as HTMLElement;
|
||||
|
||||
moduleInstance.removeAllNodes();
|
||||
|
||||
expect(firstRemoveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(secondRemoveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockObject.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tracks read-only mutable listeners and clears them on demand', () => {
|
||||
const moduleInstance = createConcreteModule();
|
||||
const listeners = {
|
||||
on: vi.fn(),
|
||||
offById: vi.fn(),
|
||||
};
|
||||
|
||||
listeners.on.mockReturnValueOnce('listener-1').mockReturnValueOnce(undefined);
|
||||
|
||||
moduleInstance.overrideListeners(listeners as unknown as Listeners);
|
||||
|
||||
const handler = vi.fn();
|
||||
const element = document.createElement('button');
|
||||
const { on, clearAll } = moduleInstance.exposeReadOnlyListeners();
|
||||
|
||||
on(element, 'click', handler);
|
||||
on(element, 'mouseover', handler);
|
||||
|
||||
clearAll();
|
||||
clearAll();
|
||||
|
||||
expect(listeners.on).toHaveBeenCalledTimes(2);
|
||||
expect(listeners.offById).toHaveBeenCalledTimes(1);
|
||||
expect(listeners.offById).toHaveBeenCalledWith('listener-1');
|
||||
});
|
||||
|
||||
it('detects RTL direction based on config', () => {
|
||||
const rtlModule = createConcreteModule({
|
||||
i18n: {
|
||||
direction: 'rtl',
|
||||
},
|
||||
});
|
||||
|
||||
expect(rtlModule.isRightToLeft()).toBe(true);
|
||||
expect(createConcreteModule().isRightToLeft()).toBe(false);
|
||||
});
|
||||
});
|
||||
218
test/unit/components/modules/api/caret.test.ts
Normal file
218
test/unit/components/modules/api/caret.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import CaretAPI from '../../../../../src/components/modules/api/caret';
|
||||
import EventsDispatcher from '../../../../../src/components/utils/events';
|
||||
import type { ModuleConfig } from '../../../../../src/types-internal/module-config';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
import type { EditorModules } from '../../../../../src/types-internal/editor-modules';
|
||||
import type { EditorEventMap } from '../../../../../src/components/events';
|
||||
import type { BlockAPI } from '../../../../../types/api';
|
||||
|
||||
const resolveBlockMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../../../../src/components/utils/api', () => ({
|
||||
resolveBlock: resolveBlockMock,
|
||||
}));
|
||||
|
||||
type BlockManagerStub = {
|
||||
firstBlock?: BlockAPI;
|
||||
lastBlock?: BlockAPI;
|
||||
previousBlock?: BlockAPI;
|
||||
nextBlock?: BlockAPI;
|
||||
};
|
||||
|
||||
type CaretModuleStub = {
|
||||
positions: {
|
||||
DEFAULT: string;
|
||||
START: string;
|
||||
END: string;
|
||||
};
|
||||
setToBlock: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type EditorStub = {
|
||||
BlockManager: BlockManagerStub;
|
||||
Caret: CaretModuleStub;
|
||||
};
|
||||
|
||||
const createBlock = (id: string): BlockAPI => ({
|
||||
id,
|
||||
} as BlockAPI);
|
||||
|
||||
const createCaretApi = (): { caretApi: CaretAPI; editor: EditorStub } => {
|
||||
const eventsDispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const moduleConfig: ModuleConfig = {
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher,
|
||||
};
|
||||
|
||||
const caretApi = new CaretAPI(moduleConfig);
|
||||
|
||||
const editor: EditorStub = {
|
||||
BlockManager: {},
|
||||
Caret: {
|
||||
positions: {
|
||||
DEFAULT: 'default',
|
||||
START: 'start',
|
||||
END: 'end',
|
||||
},
|
||||
setToBlock: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
caretApi.state = editor as unknown as EditorModules;
|
||||
|
||||
return { caretApi,
|
||||
editor };
|
||||
};
|
||||
|
||||
describe('CaretAPI', () => {
|
||||
beforeEach(() => {
|
||||
resolveBlockMock.mockReset();
|
||||
});
|
||||
|
||||
it('exposes caret helpers via methods getter', () => {
|
||||
const { caretApi } = createCaretApi();
|
||||
|
||||
expect(caretApi.methods).toEqual(expect.objectContaining({
|
||||
setToFirstBlock: expect.any(Function),
|
||||
setToLastBlock: expect.any(Function),
|
||||
setToPreviousBlock: expect.any(Function),
|
||||
setToNextBlock: expect.any(Function),
|
||||
setToBlock: expect.any(Function),
|
||||
focus: expect.any(Function),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('setToFirstBlock', () => {
|
||||
it('returns false when there is no first block', () => {
|
||||
const { caretApi } = createCaretApi();
|
||||
|
||||
expect(caretApi.methods.setToFirstBlock()).toBe(false);
|
||||
});
|
||||
|
||||
it('moves caret to the first block using provided params', () => {
|
||||
const { caretApi, editor } = createCaretApi();
|
||||
const block = createBlock('first');
|
||||
|
||||
editor.BlockManager.firstBlock = block;
|
||||
|
||||
const result = caretApi.methods.setToFirstBlock('start', 3);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(editor.Caret.setToBlock).toHaveBeenCalledWith(block, 'start', 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setToLastBlock', () => {
|
||||
it('returns false when last block is missing', () => {
|
||||
const { caretApi } = createCaretApi();
|
||||
|
||||
expect(caretApi.methods.setToLastBlock()).toBe(false);
|
||||
});
|
||||
|
||||
it('moves caret to the last block', () => {
|
||||
const { caretApi, editor } = createCaretApi();
|
||||
const block = createBlock('last');
|
||||
|
||||
editor.BlockManager.lastBlock = block;
|
||||
|
||||
const result = caretApi.methods.setToLastBlock('end');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(editor.Caret.setToBlock).toHaveBeenCalledWith(block, 'end', 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setToPreviousBlock', () => {
|
||||
it('returns false when previous block does not exist', () => {
|
||||
const { caretApi } = createCaretApi();
|
||||
|
||||
expect(caretApi.methods.setToPreviousBlock()).toBe(false);
|
||||
});
|
||||
|
||||
it('moves caret to the previous block', () => {
|
||||
const { caretApi, editor } = createCaretApi();
|
||||
const block = createBlock('previous');
|
||||
|
||||
editor.BlockManager.previousBlock = block;
|
||||
|
||||
const result = caretApi.methods.setToPreviousBlock('start', 1);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(editor.Caret.setToBlock).toHaveBeenCalledWith(block, 'start', 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setToNextBlock', () => {
|
||||
it('returns false when next block does not exist', () => {
|
||||
const { caretApi } = createCaretApi();
|
||||
|
||||
expect(caretApi.methods.setToNextBlock()).toBe(false);
|
||||
});
|
||||
|
||||
it('moves caret to the next block', () => {
|
||||
const { caretApi, editor } = createCaretApi();
|
||||
const block = createBlock('next');
|
||||
|
||||
editor.BlockManager.nextBlock = block;
|
||||
|
||||
const result = caretApi.methods.setToNextBlock('default', 2);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(editor.Caret.setToBlock).toHaveBeenCalledWith(block, 'default', 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setToBlock', () => {
|
||||
it('returns false when block cannot be resolved', () => {
|
||||
const { caretApi, editor } = createCaretApi();
|
||||
|
||||
resolveBlockMock.mockReturnValueOnce(undefined);
|
||||
|
||||
const result = caretApi.methods.setToBlock('missing');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(resolveBlockMock).toHaveBeenCalledWith('missing', editor);
|
||||
expect(editor.Caret.setToBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates caret placement to resolved block', () => {
|
||||
const { caretApi, editor } = createCaretApi();
|
||||
const resolvedBlock = createBlock('resolved');
|
||||
|
||||
resolveBlockMock.mockReturnValueOnce(resolvedBlock);
|
||||
|
||||
const result = caretApi.methods.setToBlock('any', 'default', 4);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(resolveBlockMock).toHaveBeenCalledWith('any', editor);
|
||||
expect(editor.Caret.setToBlock).toHaveBeenCalledWith(resolvedBlock, 'default', 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus', () => {
|
||||
it('focuses the first block when atEnd is false', () => {
|
||||
const { caretApi, editor } = createCaretApi();
|
||||
const firstBlock = createBlock('first');
|
||||
|
||||
editor.BlockManager.firstBlock = firstBlock;
|
||||
|
||||
const result = caretApi.methods.focus();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(editor.Caret.setToBlock).toHaveBeenCalledWith(firstBlock, editor.Caret.positions.START, 0);
|
||||
});
|
||||
|
||||
it('focuses the last block when atEnd is true', () => {
|
||||
const { caretApi, editor } = createCaretApi();
|
||||
const lastBlock = createBlock('last');
|
||||
|
||||
editor.BlockManager.lastBlock = lastBlock;
|
||||
|
||||
const result = caretApi.methods.focus(true);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(editor.Caret.setToBlock).toHaveBeenCalledWith(lastBlock, editor.Caret.positions.END, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
78
test/unit/components/modules/api/events.test.ts
Normal file
78
test/unit/components/modules/api/events.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import EventsAPI from '../../../../../src/components/modules/api/events';
|
||||
import EventsDispatcher from '../../../../../src/components/utils/events';
|
||||
import { BlockChanged } from '../../../../../src/components/events/BlockChanged';
|
||||
|
||||
import type { ModuleConfig } from '../../../../../src/types-internal/module-config';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
import type { EditorEventMap } from '../../../../../src/components/events';
|
||||
import type { BlockChangedPayload } from '../../../../../src/components/events/BlockChanged';
|
||||
import type { BlockMutationEvent } from '../../../../../types/events/block';
|
||||
|
||||
const createEventsApi = (): {
|
||||
eventsApi: EventsAPI;
|
||||
eventsDispatcher: EventsDispatcher<EditorEventMap>;
|
||||
} => {
|
||||
const eventsDispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const moduleConfig: ModuleConfig = {
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher,
|
||||
};
|
||||
|
||||
return {
|
||||
eventsApi: new EventsAPI(moduleConfig),
|
||||
eventsDispatcher,
|
||||
};
|
||||
};
|
||||
|
||||
describe('EventsAPI', () => {
|
||||
const payload: BlockChangedPayload = {
|
||||
event: {} as BlockMutationEvent,
|
||||
};
|
||||
|
||||
it('exposes emit/on/off wrappers via methods getter', () => {
|
||||
const { eventsApi } = createEventsApi();
|
||||
const emitSpy = vi.spyOn(eventsApi, 'emit').mockImplementation(() => undefined);
|
||||
const onSpy = vi.spyOn(eventsApi, 'on').mockImplementation(() => undefined);
|
||||
const offSpy = vi.spyOn(eventsApi, 'off').mockImplementation(() => undefined);
|
||||
const handler = vi.fn() as (data?: unknown) => void;
|
||||
|
||||
eventsApi.methods.emit(BlockChanged, payload);
|
||||
eventsApi.methods.on(BlockChanged, handler);
|
||||
eventsApi.methods.off(BlockChanged, handler);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(BlockChanged, payload);
|
||||
expect(onSpy).toHaveBeenCalledWith(BlockChanged, handler);
|
||||
expect(offSpy).toHaveBeenCalledWith(BlockChanged, handler);
|
||||
});
|
||||
|
||||
it('subscribes to events via dispatcher on()', () => {
|
||||
const { eventsApi, eventsDispatcher } = createEventsApi();
|
||||
const dispatcherSpy = vi.spyOn(eventsDispatcher, 'on');
|
||||
const handler = vi.fn() as (data?: unknown) => void;
|
||||
|
||||
eventsApi.on(BlockChanged, handler);
|
||||
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith(BlockChanged, handler);
|
||||
});
|
||||
|
||||
it('emits events via dispatcher emit()', () => {
|
||||
const { eventsApi, eventsDispatcher } = createEventsApi();
|
||||
const dispatcherSpy = vi.spyOn(eventsDispatcher, 'emit');
|
||||
|
||||
eventsApi.emit(BlockChanged, payload);
|
||||
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith(BlockChanged, payload);
|
||||
});
|
||||
|
||||
it('unsubscribes from events via dispatcher off()', () => {
|
||||
const { eventsApi, eventsDispatcher } = createEventsApi();
|
||||
const dispatcherSpy = vi.spyOn(eventsDispatcher, 'off');
|
||||
const handler = vi.fn() as (data?: unknown) => void;
|
||||
|
||||
eventsApi.off(BlockChanged, handler);
|
||||
|
||||
expect(dispatcherSpy).toHaveBeenCalledWith(BlockChanged, handler);
|
||||
});
|
||||
});
|
||||
67
test/unit/components/modules/api/inlineToolbar.test.ts
Normal file
67
test/unit/components/modules/api/inlineToolbar.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import InlineToolbarAPI from '../../../../../src/components/modules/api/inlineToolbar';
|
||||
import EventsDispatcher from '../../../../../src/components/utils/events';
|
||||
import type { ModuleConfig } from '../../../../../src/types-internal/module-config';
|
||||
import type { EditorModules } from '../../../../../src/types-internal/editor-modules';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
import type { EditorEventMap } from '../../../../../src/components/events';
|
||||
|
||||
type InlineToolbarEditorMock = {
|
||||
InlineToolbar: {
|
||||
tryToShow: ReturnType<typeof vi.fn<[], Promise<void>>>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe('InlineToolbarAPI', () => {
|
||||
let inlineToolbarApi: InlineToolbarAPI;
|
||||
let editorMock: InlineToolbarEditorMock;
|
||||
|
||||
const createInlineToolbarApi = (overrides?: Partial<InlineToolbarEditorMock>): void => {
|
||||
const eventsDispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const moduleConfig: ModuleConfig = {
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher,
|
||||
};
|
||||
|
||||
inlineToolbarApi = new InlineToolbarAPI(moduleConfig);
|
||||
editorMock = {
|
||||
InlineToolbar: {
|
||||
tryToShow: vi.fn<[], Promise<void>>(() => Promise.resolve()),
|
||||
close: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
inlineToolbarApi.state = editorMock as unknown as EditorModules;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createInlineToolbarApi();
|
||||
});
|
||||
|
||||
it('exposes inline toolbar controls via methods getter', () => {
|
||||
const openSpy = vi.spyOn(inlineToolbarApi, 'open').mockImplementation(() => {});
|
||||
const closeSpy = vi.spyOn(inlineToolbarApi, 'close').mockImplementation(() => {});
|
||||
|
||||
const { open, close } = inlineToolbarApi.methods;
|
||||
|
||||
open();
|
||||
close();
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens inline toolbar through Editor module', () => {
|
||||
inlineToolbarApi.open();
|
||||
|
||||
expect(editorMock.InlineToolbar.tryToShow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes inline toolbar through Editor module', () => {
|
||||
inlineToolbarApi.close();
|
||||
|
||||
expect(editorMock.InlineToolbar.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
89
test/unit/components/modules/api/listeners.test.ts
Normal file
89
test/unit/components/modules/api/listeners.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
|
||||
|
||||
import ListenersAPI from '../../../../../src/components/modules/api/listeners';
|
||||
import EventsDispatcher from '../../../../../src/components/utils/events';
|
||||
|
||||
import type { EditorEventMap } from '../../../../../src/components/events';
|
||||
import type { ModuleConfig } from '../../../../../src/types-internal/module-config';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
|
||||
type ListenersMock = {
|
||||
on: MockInstance<[HTMLElement, string, () => void, boolean?], string | undefined>;
|
||||
off: MockInstance<[Element, string, () => void, boolean?], void>;
|
||||
offById: MockInstance<[string], void>;
|
||||
};
|
||||
|
||||
const createListenersApi = (): ListenersAPI => {
|
||||
const eventsDispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const moduleConfig: ModuleConfig = {
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher,
|
||||
};
|
||||
|
||||
return new ListenersAPI(moduleConfig);
|
||||
};
|
||||
|
||||
describe('ListenersAPI', () => {
|
||||
let listenersApi: ListenersAPI;
|
||||
let listenersMock: ListenersMock;
|
||||
|
||||
beforeEach(() => {
|
||||
listenersApi = createListenersApi();
|
||||
|
||||
listenersMock = {
|
||||
on: vi.fn<[HTMLElement, string, () => void, boolean?], string | undefined>(),
|
||||
off: vi.fn<[Element, string, () => void, boolean?], void>(),
|
||||
offById: vi.fn<[string], void>(),
|
||||
};
|
||||
|
||||
(listenersApi as unknown as { listeners: ListenersMock }).listeners = listenersMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exposes bound methods via the methods getter', () => {
|
||||
const onSpy = vi.spyOn(listenersApi, 'on');
|
||||
const offSpy = vi.spyOn(listenersApi, 'off');
|
||||
const offByIdSpy = vi.spyOn(listenersApi, 'offById');
|
||||
const methods = listenersApi.methods;
|
||||
const element = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
|
||||
methods.on(element, 'click', handler, true);
|
||||
methods.off(element, 'click', handler, false);
|
||||
methods.offById('listener-id');
|
||||
|
||||
expect(onSpy).toHaveBeenCalledWith(element, 'click', handler, true);
|
||||
expect(offSpy).toHaveBeenCalledWith(element, 'click', handler, false);
|
||||
expect(offByIdSpy).toHaveBeenCalledWith('listener-id');
|
||||
});
|
||||
|
||||
it('registers DOM listeners via the listeners utility', () => {
|
||||
const element = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
|
||||
listenersMock.on.mockReturnValueOnce('listener-id');
|
||||
|
||||
const id = listenersApi.on(element, 'scroll', handler, true);
|
||||
|
||||
expect(listenersMock.on).toHaveBeenCalledWith(element, 'scroll', handler, true);
|
||||
expect(id).toBe('listener-id');
|
||||
});
|
||||
|
||||
it('removes DOM listeners via the listeners utility', () => {
|
||||
const element = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
|
||||
listenersApi.off(element, 'keydown', handler, false);
|
||||
|
||||
expect(listenersMock.off).toHaveBeenCalledWith(element, 'keydown', handler, false);
|
||||
});
|
||||
|
||||
it('removes DOM listeners by id via the listeners utility', () => {
|
||||
listenersApi.offById('listener-id');
|
||||
|
||||
expect(listenersMock.offById).toHaveBeenCalledWith('listener-id');
|
||||
});
|
||||
});
|
||||
92
test/unit/components/modules/api/readonly.test.ts
Normal file
92
test/unit/components/modules/api/readonly.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ReadOnlyAPI from '../../../../../src/components/modules/api/readonly';
|
||||
import EventsDispatcher from '../../../../../src/components/utils/events';
|
||||
|
||||
import type { ModuleConfig } from '../../../../../src/types-internal/module-config';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
import type { EditorEventMap } from '../../../../../src/components/events';
|
||||
import type { EditorModules } from '../../../../../src/types-internal/editor-modules';
|
||||
|
||||
type ReadOnlyModuleStub = {
|
||||
toggle: ReturnType<typeof vi.fn<[boolean | undefined], Promise<boolean>>>;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
type EditorStub = {
|
||||
ReadOnly: ReadOnlyModuleStub;
|
||||
};
|
||||
|
||||
const createReadOnlyApi = (overrides: Partial<ReadOnlyModuleStub> = {}): {
|
||||
readOnlyApi: ReadOnlyAPI;
|
||||
editor: EditorStub;
|
||||
} => {
|
||||
const moduleConfig: ModuleConfig = {
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher: new EventsDispatcher<EditorEventMap>(),
|
||||
};
|
||||
|
||||
const readOnlyApi = new ReadOnlyAPI(moduleConfig);
|
||||
|
||||
const defaultReadOnlyModule: ReadOnlyModuleStub = {
|
||||
toggle: vi.fn<[boolean | undefined], Promise<boolean>>().mockResolvedValue(false),
|
||||
isEnabled: false,
|
||||
};
|
||||
|
||||
const editor: EditorStub = {
|
||||
ReadOnly: {
|
||||
...defaultReadOnlyModule,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
|
||||
readOnlyApi.state = editor as unknown as EditorModules;
|
||||
|
||||
return {
|
||||
readOnlyApi,
|
||||
editor,
|
||||
};
|
||||
};
|
||||
|
||||
describe('ReadOnlyAPI', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exposes toggle via the methods getter', async () => {
|
||||
const { readOnlyApi } = createReadOnlyApi();
|
||||
const toggleSpy = vi.spyOn(readOnlyApi, 'toggle').mockResolvedValue(true);
|
||||
|
||||
await expect(readOnlyApi.methods.toggle(true)).resolves.toBe(true);
|
||||
expect(toggleSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('reflects current state via the methods getter', () => {
|
||||
const { readOnlyApi, editor } = createReadOnlyApi();
|
||||
|
||||
expect(readOnlyApi.methods.isEnabled).toBe(false);
|
||||
|
||||
editor.ReadOnly.isEnabled = true;
|
||||
|
||||
expect(readOnlyApi.methods.isEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('delegates toggle calls to the Editor module', async () => {
|
||||
const { readOnlyApi, editor } = createReadOnlyApi();
|
||||
|
||||
editor.ReadOnly.toggle.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(readOnlyApi.toggle(true)).resolves.toBe(true);
|
||||
expect(editor.ReadOnly.toggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('reads isEnabled from the Editor module', () => {
|
||||
const { readOnlyApi, editor } = createReadOnlyApi();
|
||||
|
||||
editor.ReadOnly.isEnabled = true;
|
||||
expect(readOnlyApi.isEnabled).toBe(true);
|
||||
|
||||
editor.ReadOnly.isEnabled = false;
|
||||
expect(readOnlyApi.isEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
48
test/unit/components/modules/api/sanitizer.test.ts
Normal file
48
test/unit/components/modules/api/sanitizer.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import EventsDispatcher from '../../../../../src/components/utils/events';
|
||||
import SanitizerAPI from '../../../../../src/components/modules/api/sanitizer';
|
||||
import * as sanitizerUtils from '../../../../../src/components/utils/sanitizer';
|
||||
import type { ModuleConfig } from '../../../../../src/types-internal/module-config';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
import type { EditorEventMap } from '../../../../../src/components/events';
|
||||
import type { SanitizerConfig } from '../../../../../types/configs';
|
||||
|
||||
const createSanitizerApi = (): SanitizerAPI => {
|
||||
const eventsDispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const moduleConfig: ModuleConfig = {
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher,
|
||||
};
|
||||
|
||||
return new SanitizerAPI(moduleConfig);
|
||||
};
|
||||
|
||||
describe('SanitizerAPI', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exposes clean method via methods getter', () => {
|
||||
const sanitizerApi = createSanitizerApi();
|
||||
const cleanSpy = vi.spyOn(sanitizerApi, 'clean').mockReturnValue('cleaned');
|
||||
const config = { strong: {} } as SanitizerConfig;
|
||||
const taint = '<strong>text</strong>';
|
||||
|
||||
const result = sanitizerApi.methods.clean(taint, config);
|
||||
|
||||
expect(cleanSpy).toHaveBeenCalledWith(taint, config);
|
||||
expect(result).toBe('cleaned');
|
||||
});
|
||||
|
||||
it('delegates clean implementation to sanitizer utils', () => {
|
||||
const sanitizerApi = createSanitizerApi();
|
||||
const cleanSpy = vi.spyOn(sanitizerUtils, 'clean').mockReturnValue('utility-result');
|
||||
const config = { strong: {} } as SanitizerConfig;
|
||||
const taint = '<strong>text</strong>';
|
||||
|
||||
const result = sanitizerApi.clean(taint, config);
|
||||
|
||||
expect(cleanSpy).toHaveBeenCalledWith(taint, config);
|
||||
expect(result).toBe('utility-result');
|
||||
});
|
||||
});
|
||||
88
test/unit/components/modules/api/selection.test.ts
Normal file
88
test/unit/components/modules/api/selection.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import SelectionAPI from '../../../../../src/components/modules/api/selection';
|
||||
import type SelectionUtils from '../../../../../src/components/selection';
|
||||
import EventsDispatcher from '../../../../../src/components/utils/events';
|
||||
|
||||
import type { ModuleConfig } from '../../../../../src/types-internal/module-config';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
import type { EditorEventMap } from '../../../../../src/components/events';
|
||||
|
||||
const createSelectionApi = (): SelectionAPI => {
|
||||
const eventsDispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const moduleConfig: ModuleConfig = {
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher,
|
||||
};
|
||||
|
||||
return new SelectionAPI(moduleConfig);
|
||||
};
|
||||
|
||||
describe('SelectionAPI', () => {
|
||||
let selectionApi: SelectionAPI;
|
||||
const selectionUtilsFor = (api: SelectionAPI): SelectionUtils => {
|
||||
return (api as unknown as { selectionUtils: SelectionUtils }).selectionUtils;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
selectionApi = createSelectionApi();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('methods getter', () => {
|
||||
it('exposes findParentTag and expandToTag wrappers', () => {
|
||||
const findParentSpy = vi.spyOn(selectionApi, 'findParentTag');
|
||||
const expandSpy = vi.spyOn(selectionApi, 'expandToTag');
|
||||
|
||||
const element = document.createElement('span');
|
||||
|
||||
selectionApi.methods.findParentTag('SPAN', 'highlight');
|
||||
selectionApi.methods.expandToTag(element);
|
||||
|
||||
expect(findParentSpy).toHaveBeenCalledWith('SPAN', 'highlight');
|
||||
expect(expandSpy).toHaveBeenCalledWith(element);
|
||||
});
|
||||
|
||||
it('exposes SelectionUtils passthrough methods', () => {
|
||||
const utilsInstance = selectionUtilsFor(selectionApi);
|
||||
const saveSpy = vi.spyOn(utilsInstance, 'save');
|
||||
const restoreSpy = vi.spyOn(utilsInstance, 'restore');
|
||||
const setFakeBackgroundSpy = vi.spyOn(utilsInstance, 'setFakeBackground');
|
||||
const removeFakeBackgroundSpy = vi.spyOn(utilsInstance, 'removeFakeBackground');
|
||||
|
||||
selectionApi.methods.save();
|
||||
selectionApi.methods.restore();
|
||||
selectionApi.methods.setFakeBackground();
|
||||
selectionApi.methods.removeFakeBackground();
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(restoreSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setFakeBackgroundSpy).toHaveBeenCalledTimes(1);
|
||||
expect(removeFakeBackgroundSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('delegates findParentTag to SelectionUtils instance', () => {
|
||||
const expectedElement = document.createElement('p');
|
||||
const findParentSpy = vi
|
||||
.spyOn(selectionUtilsFor(selectionApi), 'findParentTag')
|
||||
.mockReturnValue(expectedElement);
|
||||
|
||||
const result = selectionApi.findParentTag('P', 'cls');
|
||||
|
||||
expect(findParentSpy).toHaveBeenCalledWith('P', 'cls');
|
||||
expect(result).toBe(expectedElement);
|
||||
});
|
||||
|
||||
it('delegates expandToTag to SelectionUtils instance', () => {
|
||||
const element = document.createElement('div');
|
||||
const expandSpy = vi.spyOn(selectionUtilsFor(selectionApi), 'expandToTag');
|
||||
|
||||
selectionApi.expandToTag(element);
|
||||
|
||||
expect(expandSpy).toHaveBeenCalledWith(element);
|
||||
});
|
||||
});
|
||||
58
test/unit/components/modules/api/tools.test.ts
Normal file
58
test/unit/components/modules/api/tools.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import ToolsAPI from '../../../../../src/components/modules/api/tools';
|
||||
import EventsDispatcher from '../../../../../src/components/utils/events';
|
||||
|
||||
import type { ModuleConfig } from '../../../../../src/types-internal/module-config';
|
||||
import type { EditorModules } from '../../../../../src/types-internal/editor-modules';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
import type { EditorEventMap } from '../../../../../src/components/events';
|
||||
import type BlockToolAdapter from '../../../../../src/components/tools/block';
|
||||
|
||||
type CreateToolsApiResult = {
|
||||
toolsApi: ToolsAPI;
|
||||
blockTools: Map<string, BlockToolAdapter>;
|
||||
};
|
||||
|
||||
const createToolsApi = (
|
||||
blockToolsEntries: Array<[string, BlockToolAdapter]> = []
|
||||
): CreateToolsApiResult => {
|
||||
const eventsDispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const moduleConfig: ModuleConfig = {
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher,
|
||||
};
|
||||
|
||||
const toolsApi = new ToolsAPI(moduleConfig);
|
||||
const blockTools = new Map(blockToolsEntries);
|
||||
|
||||
toolsApi.state = {
|
||||
Tools: {
|
||||
blockTools,
|
||||
},
|
||||
} as unknown as EditorModules;
|
||||
|
||||
return {
|
||||
toolsApi,
|
||||
blockTools,
|
||||
};
|
||||
};
|
||||
|
||||
describe('ToolsAPI', () => {
|
||||
it('exposes getBlockTools via methods getter', () => {
|
||||
const toolA = { name: 'toolA' } as unknown as BlockToolAdapter;
|
||||
const toolB = { name: 'toolB' } as unknown as BlockToolAdapter;
|
||||
const { toolsApi } = createToolsApi([
|
||||
['toolA', toolA],
|
||||
['toolB', toolB],
|
||||
]);
|
||||
|
||||
expect(toolsApi.methods.getBlockTools()).toEqual([toolA, toolB]);
|
||||
});
|
||||
|
||||
it('returns empty list when no block tools are registered', () => {
|
||||
const { toolsApi } = createToolsApi();
|
||||
|
||||
expect(toolsApi.methods.getBlockTools()).toEqual([]);
|
||||
});
|
||||
});
|
||||
93
test/unit/components/modules/api/tooltip.test.ts
Normal file
93
test/unit/components/modules/api/tooltip.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import TooltipAPI from '../../../../../src/components/modules/api/tooltip';
|
||||
import EventsDispatcher from '../../../../../src/components/utils/events';
|
||||
import type { ModuleConfig } from '../../../../../src/types-internal/module-config';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
import type { EditorModules } from '../../../../../src/types-internal/editor-modules';
|
||||
import type { EditorEventMap } from '../../../../../src/components/events';
|
||||
|
||||
const { showMock, hideMock, onHoverMock } = vi.hoisted(() => ({
|
||||
showMock: vi.fn(),
|
||||
hideMock: vi.fn(),
|
||||
onHoverMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../../src/components/utils/tooltip', () => ({
|
||||
show: showMock,
|
||||
hide: hideMock,
|
||||
onHover: onHoverMock,
|
||||
}));
|
||||
|
||||
const createTooltipApi = (): TooltipAPI => {
|
||||
const eventsDispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const moduleConfig: ModuleConfig = {
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher,
|
||||
};
|
||||
|
||||
const tooltipApi = new TooltipAPI(moduleConfig);
|
||||
|
||||
tooltipApi.state = {} as EditorModules;
|
||||
|
||||
return tooltipApi;
|
||||
};
|
||||
|
||||
describe('TooltipAPI', () => {
|
||||
let tooltipApi: TooltipAPI;
|
||||
let element: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
tooltipApi = createTooltipApi();
|
||||
element = document.createElement('div');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
showMock.mockReset();
|
||||
hideMock.mockReset();
|
||||
onHoverMock.mockReset();
|
||||
});
|
||||
|
||||
it('exposes show, hide and onHover helpers through methods getter', () => {
|
||||
const { show, hide, onHover } = tooltipApi.methods;
|
||||
const options = { placement: 'top' };
|
||||
|
||||
expect(show).toEqual(expect.any(Function));
|
||||
expect(hide).toEqual(expect.any(Function));
|
||||
expect(onHover).toEqual(expect.any(Function));
|
||||
|
||||
show(element, 'text', options);
|
||||
hide();
|
||||
onHover(element, 'hover', options);
|
||||
|
||||
expect(showMock).toHaveBeenCalledWith(element, 'text', options);
|
||||
expect(hideMock).toHaveBeenCalledWith();
|
||||
expect(onHoverMock).toHaveBeenCalledWith(element, 'hover', options);
|
||||
});
|
||||
|
||||
it('delegates show() calls to tooltip utility', () => {
|
||||
const options = { delay: 100 };
|
||||
|
||||
tooltipApi.show(element, 'content', options);
|
||||
|
||||
expect(showMock).toHaveBeenCalledTimes(1);
|
||||
expect(showMock).toHaveBeenCalledWith(element, 'content', options);
|
||||
});
|
||||
|
||||
it('delegates hide() calls to tooltip utility', () => {
|
||||
tooltipApi.hide();
|
||||
|
||||
expect(hideMock).toHaveBeenCalledTimes(1);
|
||||
expect(hideMock).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('delegates onHover() calls to tooltip utility', () => {
|
||||
const contentNode = document.createElement('span');
|
||||
const options = { delay: 50 };
|
||||
|
||||
tooltipApi.onHover(element, contentNode, options);
|
||||
|
||||
expect(onHoverMock).toHaveBeenCalledTimes(1);
|
||||
expect(onHoverMock).toHaveBeenCalledWith(element, contentNode, options);
|
||||
});
|
||||
});
|
||||
91
test/unit/components/modules/api/ui.test.ts
Normal file
91
test/unit/components/modules/api/ui.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import UiAPI from '../../../../../src/components/modules/api/ui';
|
||||
import EventsDispatcher from '../../../../../src/components/utils/events';
|
||||
|
||||
import type { ModuleConfig } from '../../../../../src/types-internal/module-config';
|
||||
import type { EditorModules } from '../../../../../src/types-internal/editor-modules';
|
||||
import type { EditorEventMap } from '../../../../../src/components/events';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
|
||||
type EditorStub = {
|
||||
UI: {
|
||||
nodes: {
|
||||
wrapper: HTMLElement;
|
||||
redactor: HTMLElement;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const createUiApi = (): {
|
||||
uiApi: UiAPI;
|
||||
editor: EditorStub;
|
||||
wrapper: HTMLDivElement;
|
||||
redactor: HTMLDivElement;
|
||||
} => {
|
||||
const eventsDispatcher = new EventsDispatcher<EditorEventMap>();
|
||||
const moduleConfig: ModuleConfig = {
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher,
|
||||
};
|
||||
|
||||
const uiApi = new UiAPI(moduleConfig);
|
||||
const wrapper = document.createElement('div');
|
||||
const redactor = document.createElement('div');
|
||||
|
||||
const editor: EditorStub = {
|
||||
UI: {
|
||||
nodes: {
|
||||
wrapper,
|
||||
redactor,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
uiApi.state = editor as unknown as EditorModules;
|
||||
|
||||
return {
|
||||
uiApi,
|
||||
editor,
|
||||
wrapper,
|
||||
redactor,
|
||||
};
|
||||
};
|
||||
|
||||
describe('UiAPI', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('exposes editor wrapper and redactor nodes via methods getter', () => {
|
||||
const { uiApi, wrapper, redactor } = createUiApi();
|
||||
|
||||
const nodes = uiApi.methods.nodes;
|
||||
|
||||
expect(nodes.wrapper).toBe(wrapper);
|
||||
expect(nodes.redactor).toBe(redactor);
|
||||
});
|
||||
|
||||
it('reflects the latest Editor UI nodes each time methods are accessed', () => {
|
||||
const { uiApi, editor, wrapper } = createUiApi();
|
||||
|
||||
const initialNodes = uiApi.methods.nodes;
|
||||
|
||||
expect(initialNodes.wrapper).toBe(wrapper);
|
||||
|
||||
const freshWrapper = document.createElement('section');
|
||||
const freshRedactor = document.createElement('article');
|
||||
|
||||
editor.UI.nodes.wrapper = freshWrapper;
|
||||
editor.UI.nodes.redactor = freshRedactor;
|
||||
|
||||
const updatedNodes = uiApi.methods.nodes;
|
||||
|
||||
expect(updatedNodes.wrapper).toBe(freshWrapper);
|
||||
expect(updatedNodes.redactor).toBe(freshRedactor);
|
||||
});
|
||||
});
|
||||
653
test/unit/components/modules/toolbar/blockSettings.test.ts
Normal file
653
test/unit/components/modules/toolbar/blockSettings.test.ts
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
|
||||
import BlockSettings from '../../../../../src/components/modules/toolbar/blockSettings';
|
||||
import type Block from '../../../../../src/components/block';
|
||||
import type { EditorModules } from '../../../../../src/types-internal/editor-modules';
|
||||
import type { EditorConfig } from '../../../../../types';
|
||||
import type { MenuConfigItem } from '../../../../../types/tools';
|
||||
import { PopoverItemType, PopoverDesktop, PopoverMobile } from '../../../../../src/components/utils/popover';
|
||||
import type { PopoverItemParams } from '../../../../../types/utils/popover/popover-item';
|
||||
import SelectionUtils from '../../../../../src/components/selection';
|
||||
|
||||
type PopoverMock = {
|
||||
on: Mock<[string, () => void], void>;
|
||||
off: Mock<[string, () => void], void>;
|
||||
destroy: Mock<[], void>;
|
||||
getElement: Mock<[], HTMLDivElement>;
|
||||
show: Mock<[], void>;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
const popoverInstances: PopoverMock[] = [];
|
||||
|
||||
const buildPopoverMock = (): PopoverMock => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
return {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
getElement: vi.fn(() => element),
|
||||
show: vi.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
const getLastPopover = (): PopoverMock | undefined => popoverInstances.at(-1);
|
||||
|
||||
vi.mock('../../../../../src/components/utils/popover', () => {
|
||||
const createPopover = (params: unknown): PopoverMock => {
|
||||
const instance = buildPopoverMock();
|
||||
|
||||
instance.params = params;
|
||||
popoverInstances.push(instance);
|
||||
|
||||
return instance;
|
||||
};
|
||||
|
||||
return {
|
||||
PopoverDesktop: vi.fn(createPopover),
|
||||
PopoverMobile: vi.fn(createPopover),
|
||||
PopoverItemType: {
|
||||
Default: 'default',
|
||||
Separator: 'separator',
|
||||
Html: 'html',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type FlipperMock = {
|
||||
focusItem: Mock<[number], void>;
|
||||
setHandleContentEditableTargets: Mock<[boolean], void>;
|
||||
handleExternalKeydown: Mock<[KeyboardEvent], void>;
|
||||
};
|
||||
|
||||
const flipperInstances: FlipperMock[] = [];
|
||||
|
||||
vi.mock('../../../../../src/components/flipper', () => ({
|
||||
default: vi.fn().mockImplementation(() => {
|
||||
const instance: FlipperMock = {
|
||||
focusItem: vi.fn(),
|
||||
setHandleContentEditableTargets: vi.fn(),
|
||||
handleExternalKeydown: vi.fn(),
|
||||
};
|
||||
|
||||
flipperInstances.push(instance);
|
||||
|
||||
return instance;
|
||||
}),
|
||||
}));
|
||||
|
||||
const { getConvertibleToolsForBlockMock } = vi.hoisted(() => ({
|
||||
getConvertibleToolsForBlockMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../../src/components/utils/blocks', () => ({
|
||||
getConvertibleToolsForBlock: getConvertibleToolsForBlockMock,
|
||||
}));
|
||||
|
||||
const { isMobileScreenMock } = vi.hoisted(() => ({
|
||||
isMobileScreenMock: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../../src/components/utils', async () => {
|
||||
const actual = await vi.importActual('../../../../../src/components/utils');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
isMobileScreen: isMobileScreenMock,
|
||||
keyCodes: {
|
||||
TAB: 9,
|
||||
UP: 38,
|
||||
DOWN: 40,
|
||||
ENTER: 13,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../../src/components/utils/popover/components/popover-item', () => ({
|
||||
css: {
|
||||
focused: 'focused',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/types/utils/popover/popover-event', () => ({
|
||||
PopoverEvent: {
|
||||
Closed: 'closed',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@codexteam/icons', () => ({
|
||||
IconReplace: '<svg data-icon="replace" />',
|
||||
}));
|
||||
|
||||
vi.mock('../../../../../src/components/i18n', () => ({
|
||||
default: {
|
||||
ui: vi.fn((_ns: string, key: string) => key),
|
||||
t: vi.fn((_ns: string, key: string) => key),
|
||||
},
|
||||
}));
|
||||
|
||||
const { domModuleMock } = vi.hoisted(() => {
|
||||
const makeDomNodeMock = vi.fn((tag: string, className?: string | string[]) => {
|
||||
const node = document.createElement(tag);
|
||||
|
||||
if (Array.isArray(className)) {
|
||||
node.className = className.join(' ');
|
||||
} else if (className) {
|
||||
node.className = className;
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
return {
|
||||
domModuleMock: {
|
||||
default: {
|
||||
make: makeDomNodeMock,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../../src/components/dom', () => domModuleMock);
|
||||
|
||||
type EventsDispatcherMock = {
|
||||
on: Mock<[unknown, () => void], void>;
|
||||
off: Mock<[unknown, () => void], void>;
|
||||
emit: Mock<[unknown], void>;
|
||||
};
|
||||
|
||||
const createBlock = (): Block => ({
|
||||
getTunes: vi.fn(() => ({
|
||||
toolTunes: [],
|
||||
commonTunes: [],
|
||||
})),
|
||||
holder: document.createElement('div'),
|
||||
pluginsContent: document.createElement('div'),
|
||||
} as unknown as Block);
|
||||
|
||||
type EditorMock = {
|
||||
BlockSelection: {
|
||||
selectBlock: Mock<[Block], void>;
|
||||
clearCache: Mock<[], void>;
|
||||
unselectBlock: Mock<[Block], void>;
|
||||
};
|
||||
BlockManager: {
|
||||
currentBlock?: Block;
|
||||
convert: Mock<[Block, string, unknown?], Promise<Block>>;
|
||||
};
|
||||
CrossBlockSelection: {
|
||||
isCrossBlockSelectionStarted: boolean;
|
||||
clear: Mock<[Event?], void>;
|
||||
};
|
||||
API: {
|
||||
methods: {
|
||||
ui: {
|
||||
nodes: {
|
||||
redactor: HTMLElement;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
Tools: {
|
||||
blockTools: Map<string, { name: string; toolbox?: Array<{ icon?: string; title?: string; data?: unknown }> }>;
|
||||
inlineTools: Map<never, never>;
|
||||
blockTunes: Map<never, never>;
|
||||
externalTools: Map<never, never>;
|
||||
internalTools: Map<never, never>;
|
||||
};
|
||||
Caret: {
|
||||
positions: {
|
||||
START: string;
|
||||
END: string;
|
||||
DEFAULT: string;
|
||||
};
|
||||
setToBlock: Mock<[Block, string], void>;
|
||||
};
|
||||
Toolbar: {
|
||||
close: Mock<[], void>;
|
||||
};
|
||||
};
|
||||
|
||||
const createEditorMock = (): EditorMock => {
|
||||
const redactor = document.createElement('div');
|
||||
const blockSelection = {
|
||||
selectBlock: vi.fn(),
|
||||
clearCache: vi.fn(),
|
||||
unselectBlock: vi.fn(),
|
||||
};
|
||||
const blockManager = {
|
||||
currentBlock: undefined as Block | undefined,
|
||||
convert: vi.fn<[Block, string, unknown?], Promise<Block>>(async () => createBlock()),
|
||||
};
|
||||
const crossBlockSelection = {
|
||||
isCrossBlockSelectionStarted: false,
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const tools = {
|
||||
blockTools: new Map<string, { name: string; toolbox?: Array<{ icon?: string; title?: string; data?: unknown }> }>(),
|
||||
inlineTools: new Map<never, never>(),
|
||||
blockTunes: new Map<never, never>(),
|
||||
externalTools: new Map<never, never>(),
|
||||
internalTools: new Map<never, never>(),
|
||||
};
|
||||
const caret = {
|
||||
positions: {
|
||||
START: 'start',
|
||||
END: 'end',
|
||||
DEFAULT: 'default',
|
||||
},
|
||||
setToBlock: vi.fn(),
|
||||
};
|
||||
const toolbar = {
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
BlockSelection: blockSelection,
|
||||
BlockManager: blockManager,
|
||||
CrossBlockSelection: crossBlockSelection,
|
||||
API: {
|
||||
methods: {
|
||||
ui: {
|
||||
nodes: {
|
||||
redactor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Tools: tools,
|
||||
Caret: caret,
|
||||
Toolbar: toolbar,
|
||||
} satisfies EditorMock;
|
||||
};
|
||||
|
||||
describe('BlockSettings', () => {
|
||||
let blockSettings: BlockSettings;
|
||||
let editorMock: EditorMock;
|
||||
let eventsDispatcher: EventsDispatcherMock;
|
||||
|
||||
beforeEach(() => {
|
||||
popoverInstances.length = 0;
|
||||
flipperInstances.length = 0;
|
||||
getConvertibleToolsForBlockMock.mockReset();
|
||||
isMobileScreenMock.mockClear();
|
||||
|
||||
eventsDispatcher = {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
blockSettings = new BlockSettings({
|
||||
config: {} as EditorConfig,
|
||||
eventsDispatcher: eventsDispatcher as unknown as typeof blockSettings['eventsDispatcher'],
|
||||
});
|
||||
|
||||
editorMock = createEditorMock();
|
||||
blockSettings.state = editorMock as unknown as EditorModules;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates wrapper and subscribes to layout toggles on make', () => {
|
||||
blockSettings.make();
|
||||
|
||||
const element = blockSettings.getElement();
|
||||
|
||||
expect(element).toBeInstanceOf(HTMLElement);
|
||||
expect(element?.classList.contains('ce-settings')).toBe(true);
|
||||
expect(element?.dataset.cy).toBe('block-tunes');
|
||||
expect(eventsDispatcher.on).toHaveBeenCalledWith(expect.anything(), blockSettings.close);
|
||||
});
|
||||
|
||||
it('does nothing when open is called without a block', async () => {
|
||||
await blockSettings.open();
|
||||
|
||||
expect(blockSettings.opened).toBe(false);
|
||||
expect(eventsDispatcher.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens block settings near provided block', async () => {
|
||||
blockSettings.make();
|
||||
|
||||
const block = createBlock();
|
||||
|
||||
editorMock.BlockManager.currentBlock = block;
|
||||
|
||||
const selectionStub = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
clearSaved: vi.fn(),
|
||||
};
|
||||
|
||||
(blockSettings as unknown as { selection: typeof selectionStub }).selection = selectionStub;
|
||||
|
||||
const addEventListenerSpy = vi.spyOn(block.pluginsContent as HTMLElement, 'addEventListener');
|
||||
const getTunesItemsSpy = vi.spyOn(blockSettings as unknown as {
|
||||
getTunesItems: (b: Block, common: MenuConfigItem[], tool?: MenuConfigItem[]) => Promise<PopoverItemParams[]>;
|
||||
}, 'getTunesItems').mockResolvedValue([
|
||||
{
|
||||
name: 'duplicate',
|
||||
title: 'Duplicate',
|
||||
} as PopoverItemParams,
|
||||
]);
|
||||
|
||||
await blockSettings.open(block);
|
||||
|
||||
expect(blockSettings.opened).toBe(true);
|
||||
expect(selectionStub.save).toHaveBeenCalledTimes(1);
|
||||
expect(editorMock.BlockSelection.selectBlock).toHaveBeenCalledWith(block);
|
||||
expect(eventsDispatcher.emit).toHaveBeenCalledWith(blockSettings.events.opened);
|
||||
|
||||
const popover = getLastPopover();
|
||||
|
||||
expect(popover?.show).toHaveBeenCalledTimes(1);
|
||||
expect(popover?.on).toHaveBeenCalledWith('closed', expect.any(Function));
|
||||
|
||||
const popoverElement = popover?.getElement();
|
||||
|
||||
expect(popoverElement && blockSettings.getElement()?.contains(popoverElement)).toBe(true);
|
||||
expect(flipperInstances[0]?.focusItem).toHaveBeenCalledWith(0);
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
|
||||
|
||||
getTunesItemsSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back to current block and instantiates mobile popover without focusing flipper', async () => {
|
||||
blockSettings.make();
|
||||
|
||||
const block = createBlock();
|
||||
|
||||
editorMock.BlockManager.currentBlock = block;
|
||||
|
||||
const selectionStub = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
clearSaved: vi.fn(),
|
||||
};
|
||||
|
||||
(blockSettings as unknown as { selection: typeof selectionStub }).selection = selectionStub;
|
||||
|
||||
const getTunesItemsSpy = vi.spyOn(blockSettings as unknown as {
|
||||
getTunesItems: (b: Block, common: MenuConfigItem[], tool?: MenuConfigItem[]) => Promise<PopoverItemParams[]>;
|
||||
}, 'getTunesItems').mockResolvedValue([]);
|
||||
|
||||
isMobileScreenMock.mockReturnValueOnce(true);
|
||||
|
||||
await blockSettings.open();
|
||||
|
||||
expect(PopoverMobile).toHaveBeenCalledTimes(1);
|
||||
expect(PopoverDesktop).not.toHaveBeenCalled();
|
||||
expect(selectionStub.save).toHaveBeenCalledTimes(1);
|
||||
expect(flipperInstances[0]?.focusItem).not.toHaveBeenCalled();
|
||||
|
||||
const params = getLastPopover()?.params as { flipper?: unknown } | undefined;
|
||||
|
||||
expect(params?.flipper).toBeUndefined();
|
||||
|
||||
getTunesItemsSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('restores selection and tears down popover on close', () => {
|
||||
blockSettings.make();
|
||||
|
||||
const block = createBlock();
|
||||
|
||||
editorMock.BlockManager.currentBlock = block;
|
||||
|
||||
const selectionStub = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
clearSaved: vi.fn(),
|
||||
};
|
||||
|
||||
(blockSettings as unknown as { selection: typeof selectionStub }).selection = selectionStub;
|
||||
|
||||
const popoverElement = document.createElement('div');
|
||||
const popover = {
|
||||
off: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
getElement: vi.fn(() => popoverElement),
|
||||
};
|
||||
|
||||
Object.assign(blockSettings as unknown as { opened: boolean; popover: typeof popover }, {
|
||||
opened: true,
|
||||
popover,
|
||||
});
|
||||
|
||||
const detachSpy = vi.spyOn(blockSettings as unknown as { detachFlipperKeydownListener: () => void }, 'detachFlipperKeydownListener');
|
||||
const selectionAtEditorSpy = vi.spyOn(SelectionUtils, 'isAtEditor', 'get').mockReturnValue(false);
|
||||
const removeSpy = vi.spyOn(popoverElement, 'remove');
|
||||
|
||||
blockSettings.close();
|
||||
|
||||
expect(blockSettings.opened).toBe(false);
|
||||
expect(selectionStub.restore).toHaveBeenCalledTimes(1);
|
||||
expect(selectionStub.clearSaved).toHaveBeenCalledTimes(1);
|
||||
expect(editorMock.BlockSelection.unselectBlock).toHaveBeenCalledWith(block);
|
||||
expect(eventsDispatcher.emit).toHaveBeenCalledWith(blockSettings.events.closed);
|
||||
expect(popover.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(popover.off).toHaveBeenCalledWith('closed', expect.any(Function));
|
||||
expect(removeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(detachSpy).toHaveBeenCalledTimes(1);
|
||||
expect((blockSettings as unknown as { popover: unknown }).popover).toBeNull();
|
||||
|
||||
selectionAtEditorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not try to restore selection when caret is already inside editor UI', () => {
|
||||
blockSettings.make();
|
||||
|
||||
const block = createBlock();
|
||||
|
||||
editorMock.BlockManager.currentBlock = block;
|
||||
|
||||
const selectionStub = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
clearSaved: vi.fn(),
|
||||
};
|
||||
|
||||
(blockSettings as unknown as { selection: typeof selectionStub }).selection = selectionStub;
|
||||
|
||||
Object.assign(blockSettings as unknown as { opened: boolean; popover: null }, {
|
||||
opened: true,
|
||||
popover: null,
|
||||
});
|
||||
|
||||
const selectionAtEditorSpy = vi.spyOn(SelectionUtils, 'isAtEditor', 'get').mockReturnValue(true);
|
||||
|
||||
blockSettings.close();
|
||||
|
||||
expect(selectionStub.restore).not.toHaveBeenCalled();
|
||||
expect(selectionStub.clearSaved).toHaveBeenCalledTimes(1);
|
||||
|
||||
selectionAtEditorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('resolves tune aliases including nested children', () => {
|
||||
const resolveTuneAliases = (blockSettings as unknown as {
|
||||
resolveTuneAliases: (item: MenuConfigItem) => PopoverItemParams;
|
||||
}).resolveTuneAliases.bind(blockSettings);
|
||||
|
||||
const item: MenuConfigItem = {
|
||||
name: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
confirmation: {
|
||||
label: 'Confirm',
|
||||
onActivate: vi.fn(),
|
||||
},
|
||||
children: {
|
||||
items: [
|
||||
{
|
||||
name: 'child',
|
||||
label: 'Child label',
|
||||
onActivate: vi.fn(),
|
||||
} as MenuConfigItem,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveTuneAliases(item);
|
||||
|
||||
if ('title' in resolved) {
|
||||
expect(resolved.title).toBe('Duplicate');
|
||||
}
|
||||
if ('confirmation' in resolved && resolved.confirmation && 'title' in resolved.confirmation) {
|
||||
expect(resolved.confirmation.title).toBe('Confirm');
|
||||
}
|
||||
if ('children' in resolved && resolved.children?.items?.[0] && 'title' in resolved.children.items[0]) {
|
||||
expect(resolved.children.items[0].title).toBe('Child label');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns separator items unchanged when resolving aliases', () => {
|
||||
const resolveTuneAliases = (blockSettings as unknown as {
|
||||
resolveTuneAliases: (item: MenuConfigItem) => PopoverItemParams;
|
||||
}).resolveTuneAliases.bind(blockSettings);
|
||||
|
||||
const separatorItem: MenuConfigItem = {
|
||||
type: PopoverItemType.Separator,
|
||||
} as MenuConfigItem;
|
||||
|
||||
expect(resolveTuneAliases(separatorItem)).toBe(separatorItem);
|
||||
});
|
||||
|
||||
it('merges tool tunes, convert-to menu and common tunes', async () => {
|
||||
const block = createBlock();
|
||||
|
||||
editorMock.Tools.blockTools = new Map([
|
||||
['paragraph', { name: 'paragraph' } ],
|
||||
]);
|
||||
|
||||
const toolTunes: MenuConfigItem[] = [
|
||||
{
|
||||
name: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
onActivate: vi.fn(),
|
||||
},
|
||||
];
|
||||
const commonTunes: MenuConfigItem[] = [
|
||||
{
|
||||
name: 'delete',
|
||||
title: 'Delete',
|
||||
onActivate: vi.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
getConvertibleToolsForBlockMock.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'header',
|
||||
toolbox: [
|
||||
{
|
||||
icon: '<svg />',
|
||||
title: 'Header',
|
||||
data: { level: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const items = await (blockSettings as unknown as {
|
||||
getTunesItems: (b: Block, common: MenuConfigItem[], tool?: MenuConfigItem[]) => Promise<PopoverItemParams[]>;
|
||||
}).getTunesItems(block, commonTunes, toolTunes);
|
||||
|
||||
if ('title' in items[0]) {
|
||||
expect(items[0].title).toBe('Duplicate');
|
||||
}
|
||||
expect(items[1].type).toBe(PopoverItemType.Separator);
|
||||
|
||||
const convertTo = items.find((item): item is PopoverItemParams & { name?: string; children?: { items?: PopoverItemParams[] } } => 'name' in item && item.name === 'convert-to');
|
||||
|
||||
if (convertTo && 'children' in convertTo) {
|
||||
expect(convertTo.children?.items).toHaveLength(1);
|
||||
}
|
||||
|
||||
const lastItem = items.at(-1);
|
||||
|
||||
if (lastItem && 'name' in lastItem) {
|
||||
expect(lastItem.name).toBe('delete');
|
||||
}
|
||||
expect(getConvertibleToolsForBlockMock).toHaveBeenCalledWith(block, Array.from(editorMock.Tools.blockTools.values()));
|
||||
});
|
||||
|
||||
it('returns only common tunes when there are no tool-specific or convertible tunes', async () => {
|
||||
const block = createBlock();
|
||||
const commonTunes: MenuConfigItem[] = [
|
||||
{
|
||||
name: 'move-up',
|
||||
title: 'Move up',
|
||||
onActivate: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'move-down',
|
||||
title: 'Move down',
|
||||
onActivate: vi.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
getConvertibleToolsForBlockMock.mockResolvedValueOnce([]);
|
||||
|
||||
const resolveSpy = vi.spyOn(blockSettings as unknown as { resolveTuneAliases: (item: MenuConfigItem) => PopoverItemParams }, 'resolveTuneAliases');
|
||||
|
||||
const items = await (blockSettings as unknown as {
|
||||
getTunesItems: (b: Block, common: MenuConfigItem[], tool?: MenuConfigItem[]) => Promise<PopoverItemParams[]>;
|
||||
}).getTunesItems(block, commonTunes);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.every((item) => item.type !== PopoverItemType.Separator)).toBe(true);
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(commonTunes.length);
|
||||
|
||||
resolveSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('forwards popover close event to block settings close', () => {
|
||||
const closeSpy = vi.spyOn(blockSettings, 'close');
|
||||
|
||||
(blockSettings as unknown as { onPopoverClose: () => void }).onPopoverClose();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('attaches and detaches flipper keydown listeners around block content', () => {
|
||||
const block = createBlock();
|
||||
const addSpy = vi.spyOn(block.pluginsContent as HTMLElement, 'addEventListener');
|
||||
const removeSpy = vi.spyOn(block.pluginsContent as HTMLElement, 'removeEventListener');
|
||||
|
||||
(blockSettings as unknown as { attachFlipperKeydownListener: (b: Block) => void }).attachFlipperKeydownListener(block);
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
|
||||
expect(flipperInstances[0].setHandleContentEditableTargets).toHaveBeenCalledWith(true);
|
||||
|
||||
(blockSettings as unknown as { detachFlipperKeydownListener: () => void }).detachFlipperKeydownListener();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
|
||||
expect(flipperInstances[0].setHandleContentEditableTargets).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('skips attaching flipper keydown listener when plugins content is not an element', () => {
|
||||
const block = {
|
||||
pluginsContent: null,
|
||||
} as unknown as Block;
|
||||
|
||||
(blockSettings as unknown as { attachFlipperKeydownListener: (b: Block) => void }).attachFlipperKeydownListener(block);
|
||||
|
||||
expect(flipperInstances[0].setHandleContentEditableTargets).toHaveBeenCalledWith(false);
|
||||
expect((blockSettings as unknown as { flipperKeydownSource: HTMLElement | null }).flipperKeydownSource).toBeNull();
|
||||
});
|
||||
|
||||
it('cleans up listeners and nodes on destroy', () => {
|
||||
const detachSpy = vi.spyOn(blockSettings as unknown as { detachFlipperKeydownListener: () => void }, 'detachFlipperKeydownListener');
|
||||
const removeNodesSpy = vi.spyOn(blockSettings, 'removeAllNodes');
|
||||
const listenersDestroySpy = vi.spyOn((blockSettings as unknown as { listeners: { destroy: () => void } }).listeners, 'destroy');
|
||||
|
||||
blockSettings.destroy();
|
||||
|
||||
expect(detachSpy).toHaveBeenCalledTimes(1);
|
||||
expect(removeNodesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(listenersDestroySpy).toHaveBeenCalledTimes(1);
|
||||
expect(eventsDispatcher.off).toHaveBeenCalledWith(expect.anything(), blockSettings.close);
|
||||
});
|
||||
});
|
||||
321
test/unit/components/tools/factory.test.ts
Normal file
321
test/unit/components/tools/factory.test.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { EditorConfig } from '@/types';
|
||||
import type { ToolConstructable, ToolSettings } from '@/types/tools';
|
||||
import ToolsFactory from '../../../../src/components/tools/factory';
|
||||
import {
|
||||
InternalInlineToolSettings,
|
||||
InternalTuneSettings,
|
||||
type ToolOptions
|
||||
} from '../../../../src/components/tools/base';
|
||||
import type ApiModule from '../../../../src/components/modules/api';
|
||||
import BlockToolAdapter from '../../../../src/components/tools/block';
|
||||
|
||||
type ToolAdapterOptions = {
|
||||
name: string;
|
||||
constructable: ToolConstructable;
|
||||
config: ToolOptions;
|
||||
api: unknown;
|
||||
isDefault: boolean;
|
||||
defaultPlaceholder?: string | false;
|
||||
isInternal: boolean;
|
||||
};
|
||||
|
||||
type ToolAdapterMockInstance = { options: ToolAdapterOptions };
|
||||
|
||||
type ToolAdapterMockControl = {
|
||||
instances: ToolAdapterMockInstance[];
|
||||
reset(): void;
|
||||
};
|
||||
|
||||
const createMockControl = (): ToolAdapterMockControl => {
|
||||
const control: ToolAdapterMockControl = {
|
||||
instances: [],
|
||||
reset() {
|
||||
control.instances = [];
|
||||
},
|
||||
};
|
||||
|
||||
return control;
|
||||
};
|
||||
|
||||
const inlineAdapterMockControl = createMockControl();
|
||||
const blockAdapterMockControl = createMockControl();
|
||||
const tuneAdapterMockControl = createMockControl();
|
||||
|
||||
vi.mock('../../../../src/components/tools/inline', () => {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
class InlineToolAdapterMockImpl {
|
||||
public static instances: ToolAdapterMockInstance[] = [];
|
||||
|
||||
public options: ToolAdapterOptions;
|
||||
|
||||
/**
|
||||
* @param options - bundle passed by the factory under test
|
||||
*/
|
||||
constructor(options: ToolAdapterOptions) {
|
||||
this.options = options;
|
||||
InlineToolAdapterMockImpl.instances.push(this);
|
||||
inlineAdapterMockControl.instances.push(this);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public static reset(): void {
|
||||
InlineToolAdapterMockImpl.instances = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
default: InlineToolAdapterMockImpl,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/components/tools/block', () => {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
class BlockToolAdapterMockImpl {
|
||||
public static instances: ToolAdapterMockInstance[] = [];
|
||||
|
||||
public options: ToolAdapterOptions;
|
||||
|
||||
/**
|
||||
* @param options - bundle passed by the factory under test
|
||||
*/
|
||||
constructor(options: ToolAdapterOptions) {
|
||||
this.options = options;
|
||||
BlockToolAdapterMockImpl.instances.push(this);
|
||||
blockAdapterMockControl.instances.push(this);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public static reset(): void {
|
||||
BlockToolAdapterMockImpl.instances = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
default: BlockToolAdapterMockImpl,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/components/tools/tune', () => {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
class BlockTuneAdapterMockImpl {
|
||||
public static instances: ToolAdapterMockInstance[] = [];
|
||||
|
||||
public options: ToolAdapterOptions;
|
||||
|
||||
/**
|
||||
* @param options - bundle passed by the factory under test
|
||||
*/
|
||||
constructor(options: ToolAdapterOptions) {
|
||||
this.options = options;
|
||||
BlockTuneAdapterMockImpl.instances.push(this);
|
||||
tuneAdapterMockControl.instances.push(this);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public static reset(): void {
|
||||
BlockTuneAdapterMockImpl.instances = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
default: BlockTuneAdapterMockImpl,
|
||||
};
|
||||
});
|
||||
|
||||
type ToolConfigEntry = ToolSettings & { isInternal?: boolean };
|
||||
|
||||
type ApiStub = {
|
||||
api: ApiModule;
|
||||
getMethodsForTool: ReturnType<typeof vi.fn>;
|
||||
methods: object;
|
||||
};
|
||||
|
||||
const baseEditorConfig: EditorConfig = {
|
||||
tools: {},
|
||||
defaultBlock: 'paragraph',
|
||||
placeholder: 'Type a text',
|
||||
};
|
||||
|
||||
const createApiStub = (): ApiStub => {
|
||||
const methods = { name: 'methods' };
|
||||
const getMethodsForTool = vi.fn().mockReturnValue(methods);
|
||||
|
||||
return {
|
||||
api: { getMethodsForTool } as unknown as ApiModule,
|
||||
getMethodsForTool,
|
||||
methods,
|
||||
};
|
||||
};
|
||||
|
||||
const createConstructable = (
|
||||
overrides: Record<string, unknown> = {}
|
||||
): ToolConstructable & Record<string, unknown> => {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
class Constructable {}
|
||||
|
||||
Object.assign(Constructable, overrides);
|
||||
|
||||
return Constructable as unknown as ToolConstructable & Record<string, unknown>;
|
||||
};
|
||||
|
||||
const createToolConfig = (overrides: Partial<ToolConfigEntry> = {}): ToolConfigEntry => {
|
||||
return {
|
||||
class: createConstructable(),
|
||||
config: {
|
||||
config: {},
|
||||
},
|
||||
...overrides,
|
||||
} as ToolConfigEntry;
|
||||
};
|
||||
|
||||
const createFactory = (
|
||||
tools: Record<string, ToolConfigEntry>,
|
||||
editorConfigOverrides: Partial<EditorConfig> = {},
|
||||
apiStub: ApiStub = createApiStub()
|
||||
): { factory: ToolsFactory; apiStub: ApiStub } => {
|
||||
const editorConfig: EditorConfig = {
|
||||
...baseEditorConfig,
|
||||
...editorConfigOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
factory: new ToolsFactory(tools, editorConfig, apiStub.api),
|
||||
apiStub,
|
||||
};
|
||||
};
|
||||
|
||||
describe('ToolsFactory', () => {
|
||||
beforeEach(() => {
|
||||
inlineAdapterMockControl.reset();
|
||||
blockAdapterMockControl.reset();
|
||||
tuneAdapterMockControl.reset();
|
||||
});
|
||||
|
||||
it('throws when tool config does not provide a class', () => {
|
||||
const toolName = 'brokenTool';
|
||||
const toolsConfig = {
|
||||
[toolName]: {
|
||||
config: {
|
||||
config: {},
|
||||
},
|
||||
} as ToolConfigEntry,
|
||||
};
|
||||
const { factory } = createFactory(toolsConfig);
|
||||
|
||||
expect(() => factory.get(toolName)).toThrowError('Tool "brokenTool" does not provide a class.');
|
||||
});
|
||||
|
||||
it('returns an inline adapter when tool is marked as inline', () => {
|
||||
const toolName = 'inlineTool';
|
||||
const inlineConstructable = createConstructable({
|
||||
[InternalInlineToolSettings.IsInline]: true,
|
||||
});
|
||||
const toolsConfig = {
|
||||
[toolName]: createToolConfig({
|
||||
class: inlineConstructable,
|
||||
}),
|
||||
};
|
||||
const { factory } = createFactory(toolsConfig);
|
||||
|
||||
factory.get(toolName);
|
||||
expect(inlineAdapterMockControl.instances).toHaveLength(1);
|
||||
expect(blockAdapterMockControl.instances).toHaveLength(0);
|
||||
expect(tuneAdapterMockControl.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns a block tune adapter when tool is marked as tune', () => {
|
||||
const toolName = 'tuneTool';
|
||||
const tuneConstructable = createConstructable({
|
||||
[InternalTuneSettings.IsTune]: true,
|
||||
});
|
||||
const toolsConfig = {
|
||||
[toolName]: createToolConfig({
|
||||
class: tuneConstructable,
|
||||
}),
|
||||
};
|
||||
const { factory, apiStub } = createFactory(toolsConfig);
|
||||
|
||||
factory.get(toolName);
|
||||
expect(apiStub.getMethodsForTool).toHaveBeenCalledWith(toolName, true);
|
||||
expect(tuneAdapterMockControl.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a block adapter when tool is not inline or tune', () => {
|
||||
const toolName = 'blockTool';
|
||||
const toolsConfig = {
|
||||
[toolName]: createToolConfig(),
|
||||
};
|
||||
const { factory, apiStub } = createFactory(toolsConfig);
|
||||
|
||||
factory.get(toolName);
|
||||
expect(apiStub.getMethodsForTool).toHaveBeenCalledWith(toolName, false);
|
||||
expect(blockAdapterMockControl.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes full configuration bundle to the adapter constructor', () => {
|
||||
const toolName = 'detailedTool';
|
||||
const constructable = createConstructable();
|
||||
const toolsConfig = {
|
||||
[toolName]: createToolConfig({
|
||||
class: constructable,
|
||||
shortcut: 'CMD+J',
|
||||
inlineToolbar: [ 'link' ],
|
||||
tunes: [ 'anchor' ],
|
||||
toolbox: {
|
||||
title: 'Custom',
|
||||
},
|
||||
isInternal: true,
|
||||
}),
|
||||
};
|
||||
const placeholder = 'Type here';
|
||||
const apiStub = createApiStub();
|
||||
const { factory } = createFactory(
|
||||
toolsConfig,
|
||||
{
|
||||
defaultBlock: toolName,
|
||||
placeholder,
|
||||
},
|
||||
apiStub
|
||||
);
|
||||
|
||||
const tool = factory.get(toolName);
|
||||
const instantiatedOptions = blockAdapterMockControl.instances.at(-1)?.options;
|
||||
|
||||
expect(tool).toBeInstanceOf(BlockToolAdapter);
|
||||
expect(instantiatedOptions).toBeDefined();
|
||||
expect(instantiatedOptions).toMatchObject({
|
||||
name: toolName,
|
||||
constructable,
|
||||
config: {
|
||||
shortcut: 'CMD+J',
|
||||
inlineToolbar: [ 'link' ],
|
||||
tunes: [ 'anchor' ],
|
||||
toolbox: {
|
||||
title: 'Custom',
|
||||
},
|
||||
config: {
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
api: apiStub.methods,
|
||||
isDefault: true,
|
||||
defaultPlaceholder: placeholder,
|
||||
isInternal: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
68
test/unit/components/utils/hint.test.ts
Normal file
68
test/unit/components/utils/hint.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/components/utils/popover/components/hint/hint.css', () => ({}));
|
||||
|
||||
import { Hint } from '../../../../src/components/utils/popover/components/hint';
|
||||
import { css } from '../../../../src/components/utils/popover/components/hint/hint.const';
|
||||
|
||||
describe('Hint component', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('creates root element with title and start alignment by default', () => {
|
||||
const hint = new Hint({
|
||||
title: 'Primary action',
|
||||
});
|
||||
|
||||
const root = hint.getElement();
|
||||
const title = root.querySelector(`.${css.title}`);
|
||||
const description = root.querySelector(`.${css.description}`);
|
||||
|
||||
expect(root.classList.contains(css.root)).toBe(true);
|
||||
expect(root.classList.contains(css.alignedStart)).toBe(true);
|
||||
expect(root.classList.contains(css.alignedCenter)).toBe(false);
|
||||
expect(title?.textContent).toBe('Primary action');
|
||||
expect(description).toBeNull();
|
||||
});
|
||||
|
||||
it('renders description when provided', () => {
|
||||
const hint = new Hint({
|
||||
title: 'Primary action',
|
||||
description: 'Explains what the action does',
|
||||
});
|
||||
|
||||
const root = hint.getElement();
|
||||
const description = root.querySelector(`.${css.description}`);
|
||||
|
||||
expect(description).not.toBeNull();
|
||||
expect(description?.textContent).toBe('Explains what the action does');
|
||||
});
|
||||
|
||||
it('applies center alignment class when alignment="center" is passed', () => {
|
||||
const hint = new Hint({
|
||||
title: 'Center aligned',
|
||||
alignment: 'center',
|
||||
});
|
||||
|
||||
const root = hint.getElement();
|
||||
|
||||
expect(root.classList.contains(css.alignedCenter)).toBe(true);
|
||||
expect(root.classList.contains(css.alignedStart)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the same root element instance from getElement', () => {
|
||||
const hint = new Hint({
|
||||
title: 'Stable instance',
|
||||
});
|
||||
|
||||
const firstCall = hint.getElement();
|
||||
const secondCall = hint.getElement();
|
||||
|
||||
expect(secondCall).toBe(firstCall);
|
||||
});
|
||||
});
|
||||
72
test/unit/components/utils/keyboard.test.ts
Normal file
72
test/unit/components/utils/keyboard.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { getKeyboardKeyForCode } from '../../../../src/components/utils/keyboard';
|
||||
|
||||
describe('keyboard utils', () => {
|
||||
const CODE = 'Slash';
|
||||
const FALLBACK = '/';
|
||||
const navigatorHolder = globalThis as typeof globalThis & { navigator?: Navigator | undefined };
|
||||
|
||||
type LayoutMap = { get: (code: string) => string | undefined };
|
||||
type KeyboardLike = { getLayoutMap: () => Promise<LayoutMap> };
|
||||
const originalNavigator = navigatorHolder.navigator;
|
||||
const setNavigator = (value: Navigator | undefined): void => {
|
||||
Object.defineProperty(navigatorHolder, 'navigator', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
setNavigator(originalNavigator);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns fallback when Keyboard API is missing', async () => {
|
||||
setNavigator({} as Navigator);
|
||||
|
||||
const result = await getKeyboardKeyForCode(CODE, FALLBACK);
|
||||
|
||||
expect(result).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('returns actual layout key when available', async () => {
|
||||
const layoutKey = '-';
|
||||
const map: LayoutMap = { get: vi.fn().mockReturnValue(layoutKey) };
|
||||
const keyboard: KeyboardLike = { getLayoutMap: vi.fn().mockResolvedValue(map) };
|
||||
|
||||
setNavigator({ keyboard } as unknown as Navigator);
|
||||
|
||||
const result = await getKeyboardKeyForCode(CODE, FALLBACK);
|
||||
|
||||
expect(keyboard.getLayoutMap).toHaveBeenCalledTimes(1);
|
||||
expect(map.get).toHaveBeenCalledWith(CODE);
|
||||
expect(result).toBe(layoutKey);
|
||||
});
|
||||
|
||||
it('returns fallback when layout map does not contain the key', async () => {
|
||||
const map: LayoutMap = { get: vi.fn().mockReturnValue(undefined) };
|
||||
const keyboard: KeyboardLike = { getLayoutMap: vi.fn().mockResolvedValue(map) };
|
||||
|
||||
setNavigator({ keyboard } as unknown as Navigator);
|
||||
|
||||
const result = await getKeyboardKeyForCode(CODE, FALLBACK);
|
||||
|
||||
expect(map.get).toHaveBeenCalledWith(CODE);
|
||||
expect(result).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('returns fallback and logs error when layout map retrieval fails', async () => {
|
||||
const error = new Error('Keyboard API failed');
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const keyboard: KeyboardLike = { getLayoutMap: vi.fn().mockRejectedValue(error) };
|
||||
|
||||
setNavigator({ keyboard } as unknown as Navigator);
|
||||
|
||||
const result = await getKeyboardKeyForCode(CODE, FALLBACK);
|
||||
|
||||
expect(result).toBe(FALLBACK);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
186
test/unit/components/utils/notifier.test.ts
Normal file
186
test/unit/components/utils/notifier.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { NotifierOptions } from 'codex-notifier';
|
||||
|
||||
import Notifier from '../../../../src/components/utils/notifier';
|
||||
|
||||
type ShowMock = ReturnType<typeof vi.fn>;
|
||||
|
||||
type CodexNotifierModule = {
|
||||
show: ShowMock;
|
||||
};
|
||||
|
||||
type NotifierInternals = {
|
||||
loadNotifierModule: () => Promise<CodexNotifierModule>;
|
||||
getNotifierModule: () => CodexNotifierModule | null;
|
||||
setNotifierModule: (module: CodexNotifierModule | null) => void;
|
||||
getLoadingPromise: () => Promise<CodexNotifierModule> | null;
|
||||
setLoadingPromise: (promise: Promise<CodexNotifierModule> | null) => void;
|
||||
};
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const showSpy = vi.fn();
|
||||
const moduleExports: Record<string, unknown> = {};
|
||||
|
||||
const overwriteModuleExports = (exports: unknown): void => {
|
||||
for (const key of Object.keys(moduleExports)) {
|
||||
delete moduleExports[key];
|
||||
}
|
||||
|
||||
if (typeof exports === 'object' && exports !== null) {
|
||||
Object.assign(moduleExports, exports as Record<string, unknown>);
|
||||
}
|
||||
};
|
||||
|
||||
const setDefaultExports = (): void => {
|
||||
overwriteModuleExports({
|
||||
default: {
|
||||
show: showSpy,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
setDefaultExports();
|
||||
|
||||
return {
|
||||
showSpy,
|
||||
getModuleExports: () => moduleExports,
|
||||
setModuleExports: (exports: unknown) => {
|
||||
overwriteModuleExports(exports);
|
||||
},
|
||||
resetModuleExports: () => {
|
||||
setDefaultExports();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { showSpy, getModuleExports, setModuleExports, resetModuleExports } = hoisted;
|
||||
|
||||
vi.mock('codex-notifier', () => getModuleExports());
|
||||
|
||||
const exposeInternals = (notifier: Notifier): NotifierInternals => {
|
||||
const loadModule = (Reflect.get(notifier as object, 'loadNotifierModule') as () => Promise<CodexNotifierModule>).bind(notifier);
|
||||
|
||||
return {
|
||||
loadNotifierModule: loadModule,
|
||||
getNotifierModule: () => Reflect.get(notifier as object, 'notifierModule') as CodexNotifierModule | null,
|
||||
setNotifierModule: (module) => {
|
||||
Reflect.set(notifier as object, 'notifierModule', module);
|
||||
},
|
||||
getLoadingPromise: () => Reflect.get(notifier as object, 'loadingPromise') as Promise<CodexNotifierModule> | null,
|
||||
setLoadingPromise: (promise) => {
|
||||
Reflect.set(notifier as object, 'loadingPromise', promise);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createNotifierWithInternals = (): { notifier: Notifier; internals: NotifierInternals } => {
|
||||
const notifier = new Notifier();
|
||||
|
||||
return {
|
||||
notifier,
|
||||
internals: exposeInternals(notifier),
|
||||
};
|
||||
};
|
||||
|
||||
describe('Notifier utility', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetModuleExports();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('loadNotifierModule', () => {
|
||||
it('loads codex-notifier lazily and caches the resolved module', async () => {
|
||||
const { internals } = createNotifierWithInternals();
|
||||
const moduleInstance: CodexNotifierModule = { show: showSpy };
|
||||
|
||||
setModuleExports({ default: moduleInstance });
|
||||
|
||||
const loadedModule = await internals.loadNotifierModule();
|
||||
|
||||
expect(loadedModule).toBe(moduleInstance);
|
||||
expect(internals.getNotifierModule()).toBe(moduleInstance);
|
||||
});
|
||||
|
||||
it('returns cached module when it is already available', async () => {
|
||||
const { internals } = createNotifierWithInternals();
|
||||
const cachedModule: CodexNotifierModule = { show: vi.fn() };
|
||||
|
||||
internals.setNotifierModule(cachedModule);
|
||||
|
||||
setModuleExports({ default: { show: showSpy } });
|
||||
|
||||
const loadedModule = await internals.loadNotifierModule();
|
||||
|
||||
expect(loadedModule).toBe(cachedModule);
|
||||
expect(internals.getNotifierModule()).toBe(cachedModule);
|
||||
});
|
||||
|
||||
it('reuses the same promise while loading is in progress', async () => {
|
||||
const { internals } = createNotifierWithInternals();
|
||||
const moduleInstance: CodexNotifierModule = { show: showSpy };
|
||||
|
||||
setModuleExports({ default: moduleInstance });
|
||||
|
||||
const firstPromise = internals.loadNotifierModule();
|
||||
const secondPromise = internals.loadNotifierModule();
|
||||
|
||||
expect(secondPromise).toBe(firstPromise);
|
||||
await expect(firstPromise).resolves.toBe(moduleInstance);
|
||||
});
|
||||
|
||||
it('rejects when module does not expose show and resets loading promise', async () => {
|
||||
const { internals } = createNotifierWithInternals();
|
||||
|
||||
setModuleExports({ default: {} });
|
||||
|
||||
await expect(internals.loadNotifierModule()).rejects.toThrow('codex-notifier module does not expose a "show" method.');
|
||||
expect(internals.getLoadingPromise()).toBeNull();
|
||||
expect(internals.getNotifierModule()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('show', () => {
|
||||
it('delegates the call to codex-notifier show once loaded', async () => {
|
||||
const { notifier, internals } = createNotifierWithInternals();
|
||||
const options = { message: 'Hello' } as NotifierOptions;
|
||||
const moduleInstance: CodexNotifierModule = { show: showSpy };
|
||||
|
||||
setModuleExports({ default: moduleInstance });
|
||||
|
||||
notifier.show(options);
|
||||
|
||||
const loadingPromise = internals.getLoadingPromise();
|
||||
|
||||
expect(loadingPromise).not.toBeNull();
|
||||
await loadingPromise;
|
||||
|
||||
expect(showSpy).toHaveBeenCalledTimes(1);
|
||||
expect(showSpy).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
it('logs an error when loading codex-notifier fails', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const { notifier, internals } = createNotifierWithInternals();
|
||||
|
||||
setModuleExports({ default: {} });
|
||||
|
||||
notifier.show({ message: 'Oops' } as NotifierOptions);
|
||||
|
||||
const loadingPromise = internals.getLoadingPromise();
|
||||
|
||||
expect(loadingPromise).not.toBeNull();
|
||||
await expect(loadingPromise).rejects.toThrow('codex-notifier module does not expose a "show" method.');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message, errorInstance] = consoleErrorSpy.mock.calls[0];
|
||||
|
||||
expect(message).toBe('[Editor.js] Failed to display notification. Reason:');
|
||||
expect(errorInstance).toBeInstanceOf(Error);
|
||||
expect((errorInstance as Error).message).toBe('codex-notifier module does not expose a "show" method.');
|
||||
});
|
||||
});
|
||||
});
|
||||
127
test/unit/components/utils/popover-header.test.ts
Normal file
127
test/unit/components/utils/popover-header.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PopoverHeader } from '../../../../src/components/utils/popover/components/popover-header';
|
||||
import Listeners from '../../../../src/components/utils/listeners';
|
||||
import { css } from '../../../../src/components/utils/popover/components/popover-header/popover-header.const';
|
||||
|
||||
const { iconMarkup } = vi.hoisted(() => ({
|
||||
iconMarkup: '<svg data-testid="chevron"></svg>',
|
||||
}));
|
||||
|
||||
vi.mock('@codexteam/icons', () => ({
|
||||
IconChevronLeft: iconMarkup,
|
||||
}));
|
||||
|
||||
const innerTextPolyfill = vi.hoisted(() => {
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText');
|
||||
|
||||
const apply = (): void => {
|
||||
if (originalDescriptor !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, 'innerText', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.textContent ?? '';
|
||||
},
|
||||
set(this: HTMLElement, value: string) {
|
||||
this.textContent = value;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const restore = (): void => {
|
||||
if (originalDescriptor !== undefined) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'innerText', originalDescriptor);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
delete (HTMLElement.prototype as { innerText?: string }).innerText;
|
||||
};
|
||||
|
||||
return { apply,
|
||||
restore };
|
||||
});
|
||||
|
||||
describe('PopoverHeader', () => {
|
||||
beforeAll(() => {
|
||||
innerTextPolyfill.apply();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
innerTextPolyfill.restore();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createHeader = (overrides: Partial<ConstructorParameters<typeof PopoverHeader>[0]> = {}): PopoverHeader => {
|
||||
return new PopoverHeader({
|
||||
text: 'Nested level',
|
||||
onBackButtonClick: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
it('renders root, back button, and text nodes with expected content and classes', () => {
|
||||
const header = createHeader({ text: 'Section title' });
|
||||
const root = header.getElement();
|
||||
|
||||
expect(root).not.toBeNull();
|
||||
expect(root?.classList.contains(css.root)).toBe(true);
|
||||
|
||||
const backButton = root?.querySelector('button');
|
||||
const textElement = root?.querySelector(`.${css.text}`);
|
||||
|
||||
expect(backButton).not.toBeNull();
|
||||
expect(backButton?.classList.contains(css.backButton)).toBe(true);
|
||||
expect(backButton?.innerHTML).toBe(iconMarkup);
|
||||
expect(root?.firstChild).toBe(backButton);
|
||||
|
||||
expect(textElement).not.toBeNull();
|
||||
expect(textElement?.textContent).toBe('Section title');
|
||||
expect(root?.lastChild).toBe(textElement);
|
||||
});
|
||||
|
||||
it('returns the same root element via getElement', () => {
|
||||
const header = createHeader();
|
||||
const element = header.getElement();
|
||||
|
||||
expect(element).not.toBeNull();
|
||||
expect(header.getElement()).toBe(element);
|
||||
});
|
||||
|
||||
it('invokes provided back button handler on click', () => {
|
||||
const handler = vi.fn();
|
||||
const header = createHeader({ onBackButtonClick: handler });
|
||||
const backButton = header.getElement()?.querySelector('button');
|
||||
|
||||
backButton?.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes root element from DOM and destroys listeners on destroy', () => {
|
||||
const destroySpy = vi.spyOn(Listeners.prototype, 'destroy');
|
||||
const header = createHeader();
|
||||
const root = header.getElement();
|
||||
|
||||
if (root === null) {
|
||||
throw new Error('PopoverHeader root element was not created');
|
||||
}
|
||||
|
||||
document.body.appendChild(root);
|
||||
|
||||
header.destroy();
|
||||
|
||||
expect(document.body.contains(root)).toBe(false);
|
||||
expect(destroySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
63
test/unit/components/utils/resolve-aliases.test.ts
Normal file
63
test/unit/components/utils/resolve-aliases.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveAliases } from '../../../../src/components/utils/resolve-aliases';
|
||||
|
||||
type MenuItem = {
|
||||
title?: string;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
describe('resolveAliases', () => {
|
||||
it('maps alias value to the target property and omits the alias key', () => {
|
||||
const item: MenuItem = { label: 'Alias title' };
|
||||
|
||||
const resolved = resolveAliases(item, { label: 'title' });
|
||||
|
||||
expect(resolved).not.toBe(item);
|
||||
expect(resolved.title).toBe('Alias title');
|
||||
expect(resolved.label).toBeUndefined();
|
||||
expect(item).toEqual({ label: 'Alias title' });
|
||||
});
|
||||
|
||||
it('does not override explicitly set target property', () => {
|
||||
const item: MenuItem = {
|
||||
title: 'Preferred',
|
||||
label: 'Fallback',
|
||||
};
|
||||
|
||||
const resolved = resolveAliases(item, { label: 'title' });
|
||||
|
||||
expect(resolved.title).toBe('Preferred');
|
||||
expect(resolved.label).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves multiple aliases while keeping other properties intact', () => {
|
||||
const item: MenuItem = {
|
||||
label: 'Title alias',
|
||||
caption: 'Tooltip alias',
|
||||
description: 'Keep me',
|
||||
};
|
||||
|
||||
const resolved = resolveAliases(item, {
|
||||
label: 'title',
|
||||
caption: 'tooltip',
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
title: 'Title alias',
|
||||
tooltip: 'Tooltip alias',
|
||||
description: 'Keep me',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores alias entries that are absent on the object', () => {
|
||||
const item: MenuItem = { description: 'Only field' };
|
||||
|
||||
const resolved = resolveAliases(item, { label: 'title' });
|
||||
|
||||
expect(resolved).toEqual({ description: 'Only field' });
|
||||
});
|
||||
});
|
||||
60
test/unit/components/utils/tools.test.ts
Normal file
60
test/unit/components/utils/tools.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isToolConvertable } from '../../../../src/components/utils/tools';
|
||||
import type BlockToolAdapter from '../../../../src/components/tools/block';
|
||||
|
||||
/**
|
||||
* Unit tests for tools.ts utility functions
|
||||
*/
|
||||
describe('tools utilities', () => {
|
||||
describe('isToolConvertable', () => {
|
||||
it('returns false when tool has no conversion config', () => {
|
||||
const tool = {
|
||||
conversionConfig: undefined,
|
||||
} as unknown as BlockToolAdapter;
|
||||
|
||||
expect(isToolConvertable(tool, 'export')).toBe(false);
|
||||
expect(isToolConvertable(tool, 'import')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when conversion config direction is a string', () => {
|
||||
const tool = {
|
||||
conversionConfig: {
|
||||
export: 'text',
|
||||
},
|
||||
} as unknown as BlockToolAdapter;
|
||||
|
||||
expect(isToolConvertable(tool, 'export')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when conversion config direction is a function', () => {
|
||||
const tool = {
|
||||
conversionConfig: {
|
||||
import: (_value: string) => ({ text: _value }),
|
||||
},
|
||||
} as unknown as BlockToolAdapter;
|
||||
|
||||
expect(isToolConvertable(tool, 'import')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when conversion config is missing the requested direction', () => {
|
||||
const tool = {
|
||||
conversionConfig: {
|
||||
export: 'text',
|
||||
},
|
||||
} as unknown as BlockToolAdapter;
|
||||
|
||||
expect(isToolConvertable(tool, 'import')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when conversion prop is not a string or function', () => {
|
||||
const tool = {
|
||||
conversionConfig: {
|
||||
import: 42,
|
||||
},
|
||||
} as unknown as BlockToolAdapter;
|
||||
|
||||
expect(isToolConvertable(tool, 'import')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
156
test/unit/tools/block-tune-adapter.test.ts
Normal file
156
test/unit/tools/block-tune-adapter.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import BlockTuneAdapter from '../../../src/components/tools/tune';
|
||||
import { UserSettings } from '../../../src/components/tools/base';
|
||||
import { ToolType } from '@/types/tools/adapters/tool-type';
|
||||
import type { ToolOptions } from '../../../src/components/tools/base';
|
||||
import type { API, BlockAPI, ToolConfig } from '@/types';
|
||||
import type { BlockTuneConstructable } from '@/types/block-tunes/block-tune';
|
||||
import type { BlockTuneData } from '@/types/block-tunes/block-tune-data';
|
||||
|
||||
type BlockTuneCtorPayload = {
|
||||
api: API;
|
||||
config?: ToolConfig;
|
||||
block: BlockAPI;
|
||||
data: BlockTuneData;
|
||||
};
|
||||
|
||||
interface ConstructableDouble {
|
||||
constructable: BlockTuneConstructable;
|
||||
classRef: new (payload: BlockTuneCtorPayload) => unknown;
|
||||
calls: BlockTuneCtorPayload[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lightweight BlockTune stub that records constructor payloads.
|
||||
*/
|
||||
const createConstructableDouble = (): ConstructableDouble => {
|
||||
const calls: BlockTuneCtorPayload[] = [];
|
||||
|
||||
/**
|
||||
* Minimal tune implementation used for adapter instantiation tests.
|
||||
*/
|
||||
class TestBlockTune {
|
||||
public static isTune = true;
|
||||
|
||||
/**
|
||||
* @param payload constructor arguments captured for assertions
|
||||
*/
|
||||
constructor(payload: BlockTuneCtorPayload) {
|
||||
calls.push(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public render(): HTMLElement {
|
||||
return document.createElement('div');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
constructable: TestBlockTune as unknown as BlockTuneConstructable,
|
||||
classRef: TestBlockTune,
|
||||
calls,
|
||||
};
|
||||
};
|
||||
|
||||
type AdapterOverrides = Partial<{
|
||||
constructable: BlockTuneConstructable;
|
||||
config: ToolOptions;
|
||||
api: API;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
isInternal: boolean;
|
||||
defaultPlaceholder: string | false;
|
||||
}>;
|
||||
|
||||
const setupAdapter = (overrides: AdapterOverrides = {}): {
|
||||
adapter: BlockTuneAdapter;
|
||||
constructableDouble: ConstructableDouble;
|
||||
} => {
|
||||
const constructableDouble = createConstructableDouble();
|
||||
|
||||
const adapter = new BlockTuneAdapter({
|
||||
name: overrides.name ?? 'testTune',
|
||||
constructable: overrides.constructable ?? constructableDouble.constructable,
|
||||
config: overrides.config ?? {
|
||||
[UserSettings.Config]: {
|
||||
option: 'value',
|
||||
},
|
||||
},
|
||||
api: overrides.api ?? {} as API,
|
||||
isDefault: overrides.isDefault ?? false,
|
||||
isInternal: overrides.isInternal ?? false,
|
||||
defaultPlaceholder: overrides.defaultPlaceholder,
|
||||
});
|
||||
|
||||
return {
|
||||
adapter,
|
||||
constructableDouble,
|
||||
};
|
||||
};
|
||||
|
||||
describe('BlockTuneAdapter', () => {
|
||||
const createBlock = (): BlockAPI => ({
|
||||
id: 'block-id',
|
||||
}) as BlockAPI;
|
||||
|
||||
const createData = (): BlockTuneData => ({
|
||||
alignment: 'center',
|
||||
}) as BlockTuneData;
|
||||
|
||||
it('reports tune type metadata', () => {
|
||||
const { adapter } = setupAdapter();
|
||||
|
||||
expect(adapter.type).toBe(ToolType.Tune);
|
||||
expect(adapter.isTune()).toBe(true);
|
||||
expect(adapter.isBlock()).toBe(false);
|
||||
expect(adapter.isInline()).toBe(false);
|
||||
});
|
||||
|
||||
it('instantiates tune constructable with expected payload', () => {
|
||||
const api = {
|
||||
i18n: {
|
||||
t: (phrase: string) => phrase,
|
||||
},
|
||||
} as unknown as API;
|
||||
const config: ToolOptions = {
|
||||
[UserSettings.Config]: {
|
||||
appearance: 'compact',
|
||||
},
|
||||
};
|
||||
const { adapter, constructableDouble } = setupAdapter({ api,
|
||||
config });
|
||||
const block = createBlock();
|
||||
const data = createData();
|
||||
|
||||
const instance = adapter.create(data, block);
|
||||
|
||||
expect(constructableDouble.calls).toHaveLength(1);
|
||||
const payload = constructableDouble.calls[0];
|
||||
|
||||
expect(payload.api).toBe(api);
|
||||
expect(payload.block).toBe(block);
|
||||
expect(payload.data).toBe(data);
|
||||
expect(payload.config).toBe(adapter.settings);
|
||||
expect(instance).toBeInstanceOf(constructableDouble.classRef);
|
||||
});
|
||||
|
||||
it('propagates default placeholder into settings when tune is default', () => {
|
||||
const config = {
|
||||
[UserSettings.Shortcut]: 'CMD+T',
|
||||
} as ToolOptions;
|
||||
const defaultPlaceholder = 'Toggle alignment';
|
||||
const { adapter, constructableDouble } = setupAdapter({
|
||||
config,
|
||||
isDefault: true,
|
||||
defaultPlaceholder,
|
||||
});
|
||||
|
||||
const instance = adapter.create(createData(), createBlock());
|
||||
const payload = constructableDouble.calls[0];
|
||||
|
||||
expect(instance).toBeInstanceOf(constructableDouble.classRef);
|
||||
expect(payload.config).toStrictEqual({ placeholder: defaultPlaceholder });
|
||||
});
|
||||
});
|
||||
115
test/unit/tools/stub.test.ts
Normal file
115
test/unit/tools/stub.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MockInstance } from 'vitest';
|
||||
import type { API, BlockAPI } from '@/types';
|
||||
import type { BlockToolConstructorOptions } from '@/types/tools/block-tool';
|
||||
import Stub, { type StubData } from '../../../src/tools/stub';
|
||||
|
||||
interface CreateStubOptions {
|
||||
data?: Partial<StubData>;
|
||||
translator?: MockInstance<[string], string>;
|
||||
}
|
||||
|
||||
const createStub = (
|
||||
options: CreateStubOptions = {}
|
||||
): {
|
||||
stub: Stub;
|
||||
translator: MockInstance<[string], string>;
|
||||
data: StubData;
|
||||
savedData: StubData['savedData'];
|
||||
} => {
|
||||
const translator = options.translator ?? vi.fn<[string], string>((key) => `translated:${key}`);
|
||||
const savedData = options.data?.savedData ?? {
|
||||
type: 'missing-tool',
|
||||
data: { payload: true },
|
||||
};
|
||||
const stubData: StubData = {
|
||||
title: 'Unavailable tool',
|
||||
savedData,
|
||||
...options.data,
|
||||
};
|
||||
|
||||
const stub = new Stub({
|
||||
data: stubData,
|
||||
api: {
|
||||
i18n: {
|
||||
t: translator,
|
||||
},
|
||||
} as unknown as API,
|
||||
block: {} as BlockAPI,
|
||||
readOnly: false,
|
||||
} as BlockToolConstructorOptions<StubData>);
|
||||
|
||||
return {
|
||||
stub,
|
||||
translator,
|
||||
data: stubData,
|
||||
savedData,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Stub tool', () => {
|
||||
it('exposes read-only capability flag', () => {
|
||||
expect(Stub.isReadOnlySupported).toBe(true);
|
||||
});
|
||||
|
||||
it('renders provided title along with translated subtitle', () => {
|
||||
const translator = vi.fn((key: string) => `t:${key}`);
|
||||
const { stub } = createStub({
|
||||
data: {
|
||||
title: 'Broken block',
|
||||
},
|
||||
translator,
|
||||
});
|
||||
|
||||
const element = stub.render();
|
||||
const titleEl = element.querySelector('.ce-stub__title');
|
||||
const subtitleEl = element.querySelector('.ce-stub__subtitle');
|
||||
|
||||
expect(element.classList.contains('ce-stub')).toBe(true);
|
||||
expect(titleEl?.textContent).toBe('Broken block');
|
||||
expect(subtitleEl?.textContent).toBe('t:The block can not be displayed correctly.');
|
||||
expect(translator).toHaveBeenCalledTimes(1);
|
||||
expect(translator).toHaveBeenCalledWith('The block can not be displayed correctly.');
|
||||
});
|
||||
|
||||
it('falls back to translated error title when data title is missing', () => {
|
||||
const translator = vi.fn((key: string) => `t:${key}`);
|
||||
const { stub } = createStub({
|
||||
data: {
|
||||
title: '',
|
||||
},
|
||||
translator,
|
||||
});
|
||||
|
||||
const element = stub.render();
|
||||
const titleEl = element.querySelector('.ce-stub__title');
|
||||
|
||||
expect(translator).toHaveBeenNthCalledWith(1, 'Error');
|
||||
expect(translator).toHaveBeenNthCalledWith(2, 'The block can not be displayed correctly.');
|
||||
expect(titleEl?.textContent).toBe('t:Error');
|
||||
});
|
||||
|
||||
it('returns the original saved data reference', () => {
|
||||
const savedData = {
|
||||
id: 'block-1',
|
||||
type: 'unsupported',
|
||||
data: { text: 'legacy payload' },
|
||||
};
|
||||
const { stub } = createStub({
|
||||
data: {
|
||||
savedData,
|
||||
},
|
||||
});
|
||||
|
||||
expect(stub.save()).toBe(savedData);
|
||||
});
|
||||
|
||||
it('reuses the same wrapper element between renders', () => {
|
||||
const { stub } = createStub();
|
||||
|
||||
const firstRender = stub.render();
|
||||
const secondRender = stub.render();
|
||||
|
||||
expect(secondRender).toBe(firstRender);
|
||||
});
|
||||
});
|
||||
85
test/unit/utils/api.test.ts
Normal file
85
test/unit/utils/api.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { resolveBlock } from '../../../src/components/utils/api';
|
||||
import type { EditorModules } from '../../../src/types-internal/editor-modules';
|
||||
import type Block from '../../../src/components/block';
|
||||
import type { BlockAPI } from '../../../types/api/block';
|
||||
|
||||
type BlockManagerStub = {
|
||||
getBlockByIndex: ReturnType<typeof vi.fn>;
|
||||
getBlockById: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const createEditor = (): { editor: EditorModules; blockManager: BlockManagerStub } => {
|
||||
const blockManager: BlockManagerStub = {
|
||||
getBlockByIndex: vi.fn(),
|
||||
getBlockById: vi.fn(),
|
||||
};
|
||||
|
||||
const editor = {
|
||||
BlockManager: blockManager,
|
||||
} as unknown as EditorModules;
|
||||
|
||||
return { editor,
|
||||
blockManager };
|
||||
};
|
||||
|
||||
describe('utils/api resolveBlock', () => {
|
||||
it('returns block resolved by index when attribute is a number', () => {
|
||||
const { editor, blockManager } = createEditor();
|
||||
const block = {
|
||||
id: 'by-index',
|
||||
} as Block;
|
||||
|
||||
blockManager.getBlockByIndex.mockReturnValue(block);
|
||||
|
||||
const result = resolveBlock(2, editor);
|
||||
|
||||
expect(blockManager.getBlockByIndex).toHaveBeenCalledWith(2);
|
||||
expect(blockManager.getBlockById).not.toHaveBeenCalled();
|
||||
expect(result).toBe(block);
|
||||
});
|
||||
|
||||
it('returns block resolved by id when attribute is a string', () => {
|
||||
const { editor, blockManager } = createEditor();
|
||||
const block = {
|
||||
id: 'by-id',
|
||||
} as Block;
|
||||
|
||||
blockManager.getBlockById.mockReturnValue(block);
|
||||
|
||||
const result = resolveBlock('block-id', editor);
|
||||
|
||||
expect(blockManager.getBlockById).toHaveBeenCalledWith('block-id');
|
||||
expect(blockManager.getBlockByIndex).not.toHaveBeenCalled();
|
||||
expect(result).toBe(block);
|
||||
});
|
||||
|
||||
it('extracts id from BlockAPI instances and resolves via BlockManager', () => {
|
||||
const { editor, blockManager } = createEditor();
|
||||
const blockApi = {
|
||||
id: 'api-id',
|
||||
} as BlockAPI;
|
||||
const block = {
|
||||
id: 'resolved',
|
||||
} as Block;
|
||||
|
||||
blockManager.getBlockById.mockReturnValue(block);
|
||||
|
||||
const result = resolveBlock(blockApi, editor);
|
||||
|
||||
expect(blockManager.getBlockById).toHaveBeenCalledWith('api-id');
|
||||
expect(blockManager.getBlockByIndex).not.toHaveBeenCalled();
|
||||
expect(result).toBe(block);
|
||||
});
|
||||
|
||||
it('returns undefined when BlockManager fails to resolve numeric attribute', () => {
|
||||
const { editor, blockManager } = createEditor();
|
||||
|
||||
blockManager.getBlockByIndex.mockReturnValue(undefined);
|
||||
|
||||
const result = resolveBlock(99, editor);
|
||||
|
||||
expect(blockManager.getBlockByIndex).toHaveBeenCalledWith(99);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
157
test/unit/utils/popover-item-html.test.ts
Normal file
157
test/unit/utils/popover-item-html.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/components/utils/tooltip', () => ({
|
||||
onHover: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
}));
|
||||
|
||||
import { PopoverItemHtml } from '../../../src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html';
|
||||
import { css } from '../../../src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.const';
|
||||
import { PopoverItemType, type PopoverItemHtmlParams, type PopoverItemRenderParamsMap } from '../../../src/components/utils/popover/components/popover-item';
|
||||
import * as tooltip from '../../../src/components/utils/tooltip';
|
||||
|
||||
type SetupResult = {
|
||||
item: PopoverItemHtml;
|
||||
params: PopoverItemHtmlParams;
|
||||
root: HTMLElement;
|
||||
};
|
||||
|
||||
const createItem = (
|
||||
overrides: Partial<PopoverItemHtmlParams> = {},
|
||||
renderParams?: PopoverItemRenderParamsMap[PopoverItemType.Html]
|
||||
): SetupResult => {
|
||||
const element = overrides.element ?? document.createElement('div');
|
||||
|
||||
const params: PopoverItemHtmlParams = {
|
||||
type: PopoverItemType.Html,
|
||||
element,
|
||||
name: 'custom-html-item',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const item = new PopoverItemHtml(params, renderParams);
|
||||
const root = item.getElement();
|
||||
|
||||
if (!(root instanceof HTMLElement)) {
|
||||
throw new Error('PopoverItemHtml should create root element');
|
||||
}
|
||||
|
||||
return {
|
||||
item,
|
||||
params,
|
||||
root,
|
||||
};
|
||||
};
|
||||
|
||||
describe('PopoverItemHtml', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('wraps provided element into root container and sets dataset name', () => {
|
||||
const customElement = document.createElement('button');
|
||||
|
||||
customElement.textContent = 'Custom content';
|
||||
|
||||
const { root, params } = createItem({ element: customElement,
|
||||
name: 'html-item' });
|
||||
|
||||
expect(root.classList.contains(css.root)).toBe(true);
|
||||
expect(root.dataset.itemName).toBe(params.name);
|
||||
expect(root.contains(customElement)).toBe(true);
|
||||
expect(customElement.parentElement).toBe(root);
|
||||
});
|
||||
|
||||
it('adds hint with provided params when hints are enabled', () => {
|
||||
const hint = {
|
||||
title: 'HTML hint',
|
||||
description: 'Rendered near custom HTML',
|
||||
};
|
||||
|
||||
const renderParams: PopoverItemRenderParamsMap[PopoverItemType.Html] = {
|
||||
hint: {
|
||||
position: 'left',
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const { root } = createItem({ hint }, renderParams);
|
||||
|
||||
expect(tooltip.onHover).toHaveBeenCalledTimes(1);
|
||||
expect(tooltip.onHover).toHaveBeenCalledWith(
|
||||
root,
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({
|
||||
placement: 'left',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to default hint position when render params omit it', () => {
|
||||
const hint = { title: 'Needs default position' };
|
||||
|
||||
const { root } = createItem({ hint });
|
||||
|
||||
expect(tooltip.onHover).toHaveBeenCalledWith(
|
||||
root,
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({
|
||||
placement: 'right',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips hint creation when render params disable it', () => {
|
||||
createItem(
|
||||
{
|
||||
hint: { title: 'Should not render' },
|
||||
},
|
||||
{
|
||||
hint: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(tooltip.onHover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles hidden class on root element', () => {
|
||||
const { item, root } = createItem();
|
||||
|
||||
item.toggleHidden(true);
|
||||
expect(root.classList.contains(css.hidden)).toBe(true);
|
||||
|
||||
item.toggleHidden(false);
|
||||
expect(root.classList.contains(css.hidden)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns focusable controls located inside custom html content', () => {
|
||||
const htmlContent = document.createElement('div');
|
||||
const button = document.createElement('button');
|
||||
const textInput = document.createElement('input');
|
||||
|
||||
textInput.type = 'text';
|
||||
|
||||
const numberInput = document.createElement('input');
|
||||
|
||||
numberInput.type = 'number';
|
||||
|
||||
const colorInput = document.createElement('input');
|
||||
|
||||
colorInput.type = 'color';
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
htmlContent.append(button, textInput, numberInput, colorInput, textarea);
|
||||
|
||||
const { item } = createItem({ element: htmlContent });
|
||||
|
||||
expect(item.getControls()).toEqual([button, textInput, numberInput, textarea]);
|
||||
});
|
||||
});
|
||||
35
test/unit/utils/popover-item-separator.test.ts
Normal file
35
test/unit/utils/popover-item-separator.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { PopoverItemSeparator } from '../../../src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator';
|
||||
import { css } from '../../../src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.const';
|
||||
|
||||
describe('PopoverItemSeparator', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('creates expected DOM structure on instantiation', () => {
|
||||
const separator = new PopoverItemSeparator();
|
||||
const element = separator.getElement();
|
||||
|
||||
expect(element).toBeInstanceOf(HTMLElement);
|
||||
expect(element.classList.contains(css.container)).toBe(true);
|
||||
expect(element.childElementCount).toBe(1);
|
||||
|
||||
const line = element.firstElementChild as HTMLElement | null;
|
||||
|
||||
expect(line).not.toBeNull();
|
||||
expect(line?.classList.contains(css.line)).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles hidden class on the root element', () => {
|
||||
const separator = new PopoverItemSeparator();
|
||||
const element = separator.getElement();
|
||||
|
||||
separator.toggleHidden(true);
|
||||
expect(element.classList.contains(css.hidden)).toBe(true);
|
||||
|
||||
separator.toggleHidden(false);
|
||||
expect(element.classList.contains(css.hidden)).toBe(false);
|
||||
});
|
||||
});
|
||||
82
test/unit/utils/popover-states-history.test.ts
Normal file
82
test/unit/utils/popover-states-history.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
import type { PopoverItemParams } from '@/types/utils/popover/popover-item';
|
||||
import { PopoverStatesHistory } from '../../../src/components/utils/popover/utils/popover-states-history';
|
||||
|
||||
const createItem = (title: string): PopoverItemParams => ({
|
||||
title,
|
||||
onActivate: vi.fn(),
|
||||
});
|
||||
|
||||
describe('PopoverStatesHistory', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns default title and items when no states were pushed', () => {
|
||||
const history = new PopoverStatesHistory();
|
||||
|
||||
expect(history.currentTitle).toBe('');
|
||||
expect(history.currentItems).toEqual([]);
|
||||
expect(history.pop()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pushes states in LIFO order and exposes current title/items', () => {
|
||||
const history = new PopoverStatesHistory();
|
||||
const rootItems = [ createItem('Root item') ];
|
||||
const nestedItems = [ createItem('Nested item') ];
|
||||
|
||||
history.push({
|
||||
title: 'Root',
|
||||
items: rootItems,
|
||||
});
|
||||
|
||||
expect(history.currentTitle).toBe('Root');
|
||||
expect(history.currentItems).toBe(rootItems);
|
||||
|
||||
history.push({
|
||||
title: 'Nested',
|
||||
items: nestedItems,
|
||||
});
|
||||
|
||||
expect(history.currentTitle).toBe('Nested');
|
||||
expect(history.currentItems).toBe(nestedItems);
|
||||
|
||||
const popped = history.pop();
|
||||
|
||||
expect(popped).toEqual({
|
||||
title: 'Nested',
|
||||
items: nestedItems,
|
||||
});
|
||||
expect(history.currentTitle).toBe('Root');
|
||||
expect(history.currentItems).toBe(rootItems);
|
||||
});
|
||||
|
||||
it('reset keeps the earliest state and removes all newer ones', () => {
|
||||
const history = new PopoverStatesHistory();
|
||||
const rootItems = [ createItem('Root item') ];
|
||||
const nestedItems = [ createItem('Nested item') ];
|
||||
const anotherNestedItems = [ createItem('Another nested item') ];
|
||||
|
||||
history.push({
|
||||
title: 'Root',
|
||||
items: rootItems,
|
||||
});
|
||||
history.push({
|
||||
title: 'Nested',
|
||||
items: nestedItems,
|
||||
});
|
||||
history.push({
|
||||
title: 'Another nested',
|
||||
items: anotherNestedItems,
|
||||
});
|
||||
|
||||
const popSpy = vi.spyOn(history, 'pop');
|
||||
|
||||
history.reset();
|
||||
|
||||
expect(popSpy).toHaveBeenCalledTimes(2);
|
||||
expect(history.currentTitle).toBe('Root');
|
||||
expect(history.currentItems).toBe(rootItems);
|
||||
});
|
||||
});
|
||||
61
test/unit/utils/promise-queue.test.ts
Normal file
61
test/unit/utils/promise-queue.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import PromiseQueue from '../../../src/components/utils/promise-queue';
|
||||
|
||||
describe('PromiseQueue', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('executes queued operations sequentially', async () => {
|
||||
const queue = new PromiseQueue();
|
||||
const order: string[] = [];
|
||||
const createTask = (label: string, delay: number) => () => new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
order.push(label);
|
||||
resolve();
|
||||
}, delay);
|
||||
});
|
||||
|
||||
const firstPromise = queue.add(createTask('first', 20));
|
||||
const secondPromise = queue.add(createTask('second', 0));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await firstPromise;
|
||||
await secondPromise;
|
||||
await expect(queue.completed).resolves.toBeUndefined();
|
||||
|
||||
expect(order).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('resolves the promise returned by add after operation finishes', async () => {
|
||||
const queue = new PromiseQueue();
|
||||
const operation = vi.fn(() => Promise.resolve());
|
||||
|
||||
const promise = queue.add(operation);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
await expect(queue.completed).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('propagates errors from queued operations and stops chain', async () => {
|
||||
const queue = new PromiseQueue();
|
||||
const error = new Error('fail');
|
||||
const succeedingTask = vi.fn();
|
||||
|
||||
await expect(queue.add(() => {
|
||||
throw error;
|
||||
})).rejects.toThrow(error);
|
||||
|
||||
await expect(queue.completed).rejects.toThrow(error);
|
||||
|
||||
await expect(queue.add(succeedingTask)).rejects.toThrow(error);
|
||||
expect(succeedingTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
172
yarn.lock
172
yarn.lock
|
|
@ -5,6 +5,16 @@ __metadata:
|
|||
version: 8
|
||||
cacheKey: 10c0
|
||||
|
||||
"@ampproject/remapping@npm:^2.2.1":
|
||||
version: 2.3.0
|
||||
resolution: "@ampproject/remapping@npm:2.3.0"
|
||||
dependencies:
|
||||
"@jridgewell/gen-mapping": "npm:^0.3.5"
|
||||
"@jridgewell/trace-mapping": "npm:^0.3.24"
|
||||
checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@asamuzakjp/css-color@npm:^3.2.0":
|
||||
version: 3.2.0
|
||||
resolution: "@asamuzakjp/css-color@npm:3.2.0"
|
||||
|
|
@ -65,7 +75,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/parser@npm:^7.6.0, @babel/parser@npm:^7.9.6":
|
||||
"@babel/parser@npm:^7.25.4, @babel/parser@npm:^7.6.0, @babel/parser@npm:^7.9.6":
|
||||
version: 7.28.5
|
||||
resolution: "@babel/parser@npm:7.28.5"
|
||||
dependencies:
|
||||
|
|
@ -91,7 +101,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/types@npm:^7.28.5, @babel/types@npm:^7.6.1, @babel/types@npm:^7.9.6":
|
||||
"@babel/types@npm:^7.25.4, @babel/types@npm:^7.28.5, @babel/types@npm:^7.6.1, @babel/types@npm:^7.9.6":
|
||||
version: 7.28.5
|
||||
resolution: "@babel/types@npm:7.28.5"
|
||||
dependencies:
|
||||
|
|
@ -101,6 +111,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@bcoe/v8-coverage@npm:^0.2.3":
|
||||
version: 0.2.3
|
||||
resolution: "@bcoe/v8-coverage@npm:0.2.3"
|
||||
checksum: 10c0/6b80ae4cb3db53f486da2dc63b6e190a74c8c3cca16bb2733f234a0b6a9382b09b146488ae08e2b22cf00f6c83e20f3e040a2f7894f05c045c946d6a090b1d52
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codexteam/icons@npm:0.3.2":
|
||||
version: 0.3.2
|
||||
resolution: "@codexteam/icons@npm:0.3.2"
|
||||
|
|
@ -629,7 +646,8 @@ __metadata:
|
|||
"@playwright/test": "npm:^1.56.1"
|
||||
"@types/lodash": "npm:^4.17.20"
|
||||
"@types/node": "npm:^18.15.11"
|
||||
"@vitest/ui": "npm:^1.0.0"
|
||||
"@vitest/coverage-v8": "npm:^1.6.1"
|
||||
"@vitest/ui": "npm:^1.6.1"
|
||||
codex-notifier: "npm:^1.1.2"
|
||||
core-js: "npm:3.30.0"
|
||||
eslint: "npm:^8.37.0"
|
||||
|
|
@ -654,7 +672,7 @@ __metadata:
|
|||
typescript: "npm:5.0.3"
|
||||
vite: "npm:^4.2.1"
|
||||
vite-plugin-css-injected-by-js: "npm:^3.1.0"
|
||||
vitest: "npm:^1.0.0"
|
||||
vitest: "npm:^1.6.1"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
|
|
@ -1133,6 +1151,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@istanbuljs/schema@npm:^0.1.2":
|
||||
version: 0.1.3
|
||||
resolution: "@istanbuljs/schema@npm:0.1.3"
|
||||
checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jest/schemas@npm:^29.6.3":
|
||||
version: 29.6.3
|
||||
resolution: "@jest/schemas@npm:29.6.3"
|
||||
|
|
@ -1142,13 +1167,40 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jridgewell/sourcemap-codec@npm:^1.5.5":
|
||||
"@jridgewell/gen-mapping@npm:^0.3.5":
|
||||
version: 0.3.13
|
||||
resolution: "@jridgewell/gen-mapping@npm:0.3.13"
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec": "npm:^1.5.0"
|
||||
"@jridgewell/trace-mapping": "npm:^0.3.24"
|
||||
checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jridgewell/resolve-uri@npm:^3.1.0":
|
||||
version: 3.1.2
|
||||
resolution: "@jridgewell/resolve-uri@npm:3.1.2"
|
||||
checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5":
|
||||
version: 1.5.5
|
||||
resolution: "@jridgewell/sourcemap-codec@npm:1.5.5"
|
||||
checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24":
|
||||
version: 0.3.31
|
||||
resolution: "@jridgewell/trace-mapping@npm:0.3.31"
|
||||
dependencies:
|
||||
"@jridgewell/resolve-uri": "npm:^3.1.0"
|
||||
"@jridgewell/sourcemap-codec": "npm:^1.4.14"
|
||||
checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jscpd/core@npm:4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "@jscpd/core@npm:4.0.1"
|
||||
|
|
@ -1642,6 +1694,29 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/coverage-v8@npm:^1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@vitest/coverage-v8@npm:1.6.1"
|
||||
dependencies:
|
||||
"@ampproject/remapping": "npm:^2.2.1"
|
||||
"@bcoe/v8-coverage": "npm:^0.2.3"
|
||||
debug: "npm:^4.3.4"
|
||||
istanbul-lib-coverage: "npm:^3.2.2"
|
||||
istanbul-lib-report: "npm:^3.0.1"
|
||||
istanbul-lib-source-maps: "npm:^5.0.4"
|
||||
istanbul-reports: "npm:^3.1.6"
|
||||
magic-string: "npm:^0.30.5"
|
||||
magicast: "npm:^0.3.3"
|
||||
picocolors: "npm:^1.0.0"
|
||||
std-env: "npm:^3.5.0"
|
||||
strip-literal: "npm:^2.0.0"
|
||||
test-exclude: "npm:^6.0.0"
|
||||
peerDependencies:
|
||||
vitest: 1.6.1
|
||||
checksum: 10c0/2e88903e6487d3ddfcffcb12fdf3796d8e30f3c0db6ae3bbc8670652c9b8c890202bdb9bdc057a288ff8948e11e25bbd2d42f231cb6674fa2c826fc07377b5fc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/expect@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@vitest/expect@npm:1.6.1"
|
||||
|
|
@ -1684,7 +1759,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitest/ui@npm:^1.0.0":
|
||||
"@vitest/ui@npm:^1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@vitest/ui@npm:1.6.1"
|
||||
dependencies:
|
||||
|
|
@ -2632,7 +2707,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"debug@npm:4, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5":
|
||||
"debug@npm:4, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5":
|
||||
version: 4.4.3
|
||||
resolution: "debug@npm:4.4.3"
|
||||
dependencies:
|
||||
|
|
@ -4048,7 +4123,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"glob@npm:^7.1.1, glob@npm:^7.1.3":
|
||||
"glob@npm:^7.1.1, glob@npm:^7.1.3, glob@npm:^7.1.4":
|
||||
version: 7.2.3
|
||||
resolution: "glob@npm:7.2.3"
|
||||
dependencies:
|
||||
|
|
@ -4253,6 +4328,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"html-escaper@npm:^2.0.0":
|
||||
version: 2.0.2
|
||||
resolution: "html-escaper@npm:2.0.2"
|
||||
checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"html-janitor@npm:^2.0.4":
|
||||
version: 2.0.4
|
||||
resolution: "html-janitor@npm:2.0.4"
|
||||
|
|
@ -4756,6 +4838,45 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2":
|
||||
version: 3.2.2
|
||||
resolution: "istanbul-lib-coverage@npm:3.2.2"
|
||||
checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "istanbul-lib-report@npm:3.0.1"
|
||||
dependencies:
|
||||
istanbul-lib-coverage: "npm:^3.0.0"
|
||||
make-dir: "npm:^4.0.0"
|
||||
supports-color: "npm:^7.1.0"
|
||||
checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"istanbul-lib-source-maps@npm:^5.0.4":
|
||||
version: 5.0.6
|
||||
resolution: "istanbul-lib-source-maps@npm:5.0.6"
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping": "npm:^0.3.23"
|
||||
debug: "npm:^4.1.1"
|
||||
istanbul-lib-coverage: "npm:^3.0.0"
|
||||
checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"istanbul-reports@npm:^3.1.6":
|
||||
version: 3.2.0
|
||||
resolution: "istanbul-reports@npm:3.2.0"
|
||||
dependencies:
|
||||
html-escaper: "npm:^2.0.0"
|
||||
istanbul-lib-report: "npm:^3.0.0"
|
||||
checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jackspeak@npm:^3.1.2":
|
||||
version: 3.4.3
|
||||
resolution: "jackspeak@npm:3.4.3"
|
||||
|
|
@ -5084,6 +5205,17 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magicast@npm:^0.3.3":
|
||||
version: 0.3.5
|
||||
resolution: "magicast@npm:0.3.5"
|
||||
dependencies:
|
||||
"@babel/parser": "npm:^7.25.4"
|
||||
"@babel/types": "npm:^7.25.4"
|
||||
source-map-js: "npm:^1.2.0"
|
||||
checksum: 10c0/a6cacc0a848af84f03e3f5bda7b0de75e4d0aa9ddce5517fd23ed0f31b5ddd51b2d0ff0b7e09b51f7de0f4053c7a1107117edda6b0732dca3e9e39e6c5a68c64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"make-dir@npm:^2.0.0, make-dir@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "make-dir@npm:2.1.0"
|
||||
|
|
@ -5094,6 +5226,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"make-dir@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "make-dir@npm:4.0.0"
|
||||
dependencies:
|
||||
semver: "npm:^7.5.3"
|
||||
checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"make-fetch-happen@npm:^14.0.3":
|
||||
version: 14.0.3
|
||||
resolution: "make-fetch-happen@npm:14.0.3"
|
||||
|
|
@ -7205,7 +7346,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.1":
|
||||
"source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1":
|
||||
version: 1.2.1
|
||||
resolution: "source-map-js@npm:1.2.1"
|
||||
checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf
|
||||
|
|
@ -7622,6 +7763,17 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"test-exclude@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "test-exclude@npm:6.0.0"
|
||||
dependencies:
|
||||
"@istanbuljs/schema": "npm:^0.1.2"
|
||||
glob: "npm:^7.1.4"
|
||||
minimatch: "npm:^3.0.4"
|
||||
checksum: 10c0/019d33d81adff3f9f1bfcff18125fb2d3c65564f437d9be539270ee74b994986abb8260c7c2ce90e8f30162178b09dbbce33c6389273afac4f36069c48521f57
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"text-table@npm:^0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "text-table@npm:0.2.0"
|
||||
|
|
@ -8138,7 +8290,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vitest@npm:^1.0.0":
|
||||
"vitest@npm:^1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "vitest@npm:1.6.1"
|
||||
dependencies:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue