mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-20 01:31:42 +02:00
Merge branch 'upstream' into concedo_experimental
# Conflicts: # ci/run.sh # docs/backend/SYCL.md # docs/ops.md # docs/ops/SYCL.csv # examples/sycl/start-svr.sh # examples/sycl/test.sh # examples/sycl/win-start-svr.bat # examples/sycl/win-test.bat # ggml/CMakeLists.txt # ggml/src/ggml-sycl/common.hpp # ggml/src/ggml-sycl/element_wise.cpp # ggml/src/ggml-sycl/fattn-vec.hpp # ggml/src/ggml-sycl/ggml-sycl.cpp # ggml/src/ggml-sycl/presets.hpp # ggml/src/ggml-sycl/set_rows.cpp # ggml/src/ggml-sycl/ssm_conv.cpp # scripts/sync-ggml.last # tests/test-backend-ops.cpp # tests/test-model-resolution.cpp # tests/test-mtmd-c-api.c
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
// Guards the newline contract of the chat-form contenteditable: browsers
|
||||
// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
|
||||
// serialization must fold those back into `\n` so the emitted value never
|
||||
// diverges from what is on screen.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
|
||||
|
||||
const SOURCE = 'see [docs](file:///a/b) here';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
|
||||
return el;
|
||||
}
|
||||
|
||||
function fireInput(root: HTMLElement) {
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function setCaret(node: Node, offset: number) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) throw new Error('no selection');
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'second line';
|
||||
root.appendChild(div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
|
||||
});
|
||||
|
||||
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const first = document.createElement('div');
|
||||
while (root.firstChild) first.appendChild(root.firstChild);
|
||||
const second = document.createElement('div');
|
||||
second.textContent = 'second line';
|
||||
root.appendChild(first);
|
||||
root.appendChild(second);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
|
||||
});
|
||||
|
||||
it('serializes a <br> as a newline', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'here' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
root.appendChild(document.createElement('br'));
|
||||
root.appendChild(document.createTextNode('second line'));
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('here\nsecond line');
|
||||
});
|
||||
|
||||
it('ignores a trailing <br> (browser caret placeholder)', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
root.appendChild(document.createElement('br'));
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('serializes one newline per empty-line <div><br></div>', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createElement('br'));
|
||||
root.appendChild(div);
|
||||
}
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('abc\n\n');
|
||||
});
|
||||
|
||||
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createElement('br'));
|
||||
root.replaceChildren(div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(root.dataset.empty).toBe('true');
|
||||
});
|
||||
|
||||
it('maps the caret across block boundaries in both directions', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc\ndef' });
|
||||
await tick();
|
||||
|
||||
// Rebuild into the Chromium block shape; the source is unchanged,
|
||||
// so no re-render fires.
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'def';
|
||||
root.replaceChildren(document.createTextNode('abc'), div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc\ndef');
|
||||
|
||||
const divText = div.firstChild;
|
||||
if (!divText) throw new Error('div text missing');
|
||||
|
||||
setCaret(divText, 2);
|
||||
expect(screen.component.getCaretOffset()).toBe(6);
|
||||
|
||||
screen.component.setCaretOffset(6);
|
||||
const selection = window.getSelection();
|
||||
expect(selection?.anchorNode).toBe(divText);
|
||||
expect(selection?.anchorOffset).toBe(2);
|
||||
|
||||
// The boundary newline itself: offset 3 is the end of "abc", offset
|
||||
// 4 the start of the "def" line.
|
||||
screen.component.setCaretOffset(4);
|
||||
expect(window.getSelection()?.anchorNode).toBe(divText);
|
||||
expect(window.getSelection()?.anchorOffset).toBe(0);
|
||||
|
||||
screen.component.setCaretOffset(3);
|
||||
expect(window.getSelection()?.anchorNode).toBe(root.firstChild);
|
||||
expect(window.getSelection()?.anchorOffset).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// Guards the editing-key contract of the chat-form contenteditable:
|
||||
// undo/redo is replayed from source snapshots (the token rebuilds destroy
|
||||
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
|
||||
// keyboard trap), matching the plain textarea.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
|
||||
|
||||
const SOURCE = 'see [docs](file:///a/b)';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
|
||||
return el;
|
||||
}
|
||||
|
||||
function type(root: HTMLElement, text: string, inputType = 'insertText') {
|
||||
root.appendChild(document.createTextNode(text));
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
|
||||
}
|
||||
|
||||
function keydown(root: HTMLElement, init: KeyboardEventInit) {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
root.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('ChatFormContenteditable undo/redo', () => {
|
||||
it('undoes and redoes an edit across a badge-containing buffer', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
type(root, ' more');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
|
||||
const undoEvent = keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
expect(undoEvent.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe(SOURCE);
|
||||
|
||||
const redoEvent = keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||
await tick();
|
||||
expect(redoEvent.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
});
|
||||
|
||||
it('redoes with Ctrl+Y as well', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
type(root, ' more');
|
||||
await tick();
|
||||
keydown(root, { key: 'z', metaKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(SOURCE);
|
||||
|
||||
keydown(root, { key: 'y', ctrlKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
});
|
||||
|
||||
it('coalesces a typing burst into one undo step', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
type(root, 'd');
|
||||
type(root, 'e');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcde');
|
||||
|
||||
keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('keeps a newline as its own undo step', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
type(root, 'd');
|
||||
type(root, '\n', 'insertLineBreak');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcd\n');
|
||||
|
||||
keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcd');
|
||||
|
||||
keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('is a no-op when there is nothing to undo', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const event = keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('abandons the redo branch after a fresh edit', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
type(root, 'd');
|
||||
await tick();
|
||||
keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
|
||||
type(root, 'e');
|
||||
await tick();
|
||||
keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abce');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormContenteditable Tab key', () => {
|
||||
it('does not trap Tab (focus can leave the editable)', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const event = keydown(root, { key: 'Tab' });
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,701 @@
|
||||
// Guards the clipboard contract of the chat-form contenteditable:
|
||||
// copy/cut expose the markdown SOURCE of the selection (each badge
|
||||
// contributes its full `[name](file://...)` link) and pasting such
|
||||
// markdown re-renders the badges.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { tick } from 'svelte';
|
||||
import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
|
||||
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
|
||||
|
||||
const SOURCE = 'hello [docs](file:///a/b) world';
|
||||
const BADGE_SELECTOR = '[data-mention-badge="true"]';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
|
||||
return el;
|
||||
}
|
||||
|
||||
function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
|
||||
const range = document.createRange();
|
||||
place(range, root);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) throw new Error('no selection');
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
|
||||
const data = new DataTransfer();
|
||||
if (text) data.setData('text/plain', text);
|
||||
const event = new ClipboardEvent(type, { clipboardData: data, bubbles: true, cancelable: true });
|
||||
return { event, data };
|
||||
}
|
||||
|
||||
describe('ChatFormContenteditable clipboard', () => {
|
||||
it('copy exposes the markdown source of the selection', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
|
||||
const { event, data } = clipboardEvent('copy');
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(data.getData('text/plain')).toBe(SOURCE);
|
||||
});
|
||||
|
||||
it('cut exposes the markdown source and removes the slice', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
setSelection(root, (range) => {
|
||||
const badge = root.querySelector(BADGE_SELECTOR);
|
||||
if (!badge) throw new Error('badge not rendered');
|
||||
range.setStartBefore(badge);
|
||||
range.setEndAfter(badge);
|
||||
});
|
||||
|
||||
const { event, data } = clipboardEvent('cut');
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(data.getData('text/plain')).toBe('[docs](file:///a/b)');
|
||||
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
|
||||
expect(root.textContent).toBe('hello world');
|
||||
});
|
||||
|
||||
it('paste of markdown mention links re-renders badges', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
|
||||
const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
|
||||
root.dispatchEvent(event);
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
const badge = root.querySelector(BADGE_SELECTOR);
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge!.getAttribute('data-mention-name')).toBe('docs');
|
||||
expect(root.textContent).toContain('world');
|
||||
});
|
||||
|
||||
it('paste without mention links keeps the DOM untouched', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
const firstChild = root.firstChild;
|
||||
|
||||
const { event } = clipboardEvent('paste', 'plain text');
|
||||
root.dispatchEvent(event);
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
|
||||
// no rebuild: the live text node is the same instance
|
||||
expect(root.firstChild).toBe(firstChild);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormContenteditable code spans', () => {
|
||||
it('renders inline code from the initial value', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: 'run `npm test` now' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="inline"]');
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('`npm test`');
|
||||
});
|
||||
|
||||
it('renders a fenced code block with a language', async () => {
|
||||
const source = 'before\n```js\nconst a = 1;\n```\nafter';
|
||||
const { container } = render(ChatFormContenteditable, { value: source });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="block"]');
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
|
||||
});
|
||||
|
||||
it('copy exposes the markdown source of a selection spanning code', async () => {
|
||||
const source = 'run `npm test` now';
|
||||
const { container } = render(ChatFormContenteditable, { value: source });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
|
||||
const { event, data } = clipboardEvent('copy');
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(data.getData('text/plain')).toBe(source);
|
||||
});
|
||||
|
||||
it('paste of a code span renders the styled element', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: 'run ' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
|
||||
const { event } = clipboardEvent('paste', '`npm test` now');
|
||||
root.dispatchEvent(event);
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
const code = root.querySelector('code[data-code-token="inline"]');
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('`npm test`');
|
||||
expect(root.textContent).toContain('now');
|
||||
});
|
||||
|
||||
it('highlights a fenced block content and stays byte-exact', async () => {
|
||||
const source = '```js\nconst a = 1;\n```';
|
||||
const { container } = render(ChatFormContenteditable, { value: source });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="block"]');
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
|
||||
expect(code!.textContent).toBe(source);
|
||||
});
|
||||
|
||||
it('does not highlight inline code', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: 'run `const` now' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
expect(root.querySelector('[class*="hljs-"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
||||
const BLOCK_SELECTOR = 'code[data-code-token="block"]';
|
||||
|
||||
function blockIn(root: HTMLElement): HTMLElement {
|
||||
const el = root.querySelector(BLOCK_SELECTOR);
|
||||
if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
|
||||
return el;
|
||||
}
|
||||
|
||||
// Caret at the very start/end of the block's text (across highlight spans)
|
||||
function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
|
||||
const code = blockIn(root);
|
||||
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
|
||||
let target: Node | null = null;
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
target = where === 'start' ? (target ?? n) : n;
|
||||
}
|
||||
if (!target) throw new Error('no text inside code block');
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
|
||||
range.collapse(true);
|
||||
});
|
||||
}
|
||||
|
||||
function caretContainer(): Node {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) throw new Error('no selection');
|
||||
return selection.getRangeAt(0).startContainer;
|
||||
}
|
||||
|
||||
it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
// no permanent empty line above a leading block
|
||||
expect(root.firstChild).toBe(blockIn(root));
|
||||
expect(root.lastChild?.nodeName).toBe('BR');
|
||||
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
const { event, data } = clipboardEvent('copy');
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
|
||||
});
|
||||
|
||||
it('escapes a trailing code block with ArrowDown and types after it', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
|
||||
// the DOM holds no separator newline (it would render as a
|
||||
// phantom empty line); serialization synthesizes it so the
|
||||
// markdown source keeps the text below the block
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||
// the stale trailing hatch is removed once real text follows the block
|
||||
expect(root.lastChild?.nodeName).not.toBe('BR');
|
||||
});
|
||||
|
||||
it('escapes a leading code block with ArrowUp and types before it', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||
// the transient hatch line exists while the caret sits on it
|
||||
expect(root.firstChild?.nodeName).toBe('BR');
|
||||
|
||||
await userEvent.keyboard('y');
|
||||
await tick();
|
||||
|
||||
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
|
||||
expect(root.textContent).toBe('y' + BLOCK_SOURCE);
|
||||
expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE);
|
||||
// the typed text consumed the hatch
|
||||
expect(root.firstChild?.nodeName).not.toBe('BR');
|
||||
});
|
||||
|
||||
it('escapes a leading code block with ArrowLeft from its first character', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
await userEvent.keyboard('{ArrowLeft}');
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||
expect(root.firstChild?.nodeName).toBe('BR');
|
||||
});
|
||||
|
||||
it('removes the transient leading hatch when the caret moves back into the block', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(root.firstChild?.nodeName).toBe('BR');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(true);
|
||||
expect(root.firstChild).toBe(blockIn(root));
|
||||
});
|
||||
|
||||
it('extends the selection out of the block with Shift+ArrowDown', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
|
||||
|
||||
const selection = window.getSelection();
|
||||
expect(selection).not.toBeNull();
|
||||
expect(selection!.isCollapsed).toBe(false);
|
||||
expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
|
||||
});
|
||||
|
||||
it('line-separates text typed right after the closing fence', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
// no arrow keys: the caret sits at the block's end edge, where the
|
||||
// post-rebuild restore lands it, and the typed text renders on the
|
||||
// line below the block
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
// the text stays on the caret's line in the DOM (no phantom empty
|
||||
// line); the source gets the separator newline
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||
});
|
||||
|
||||
it('does not double the newline when Shift+Enter already added one', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||
});
|
||||
|
||||
it('moves a caret stuck before the inserted newline onto the new line', async () => {
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
|
||||
// post-break DOM some browsers produce: the inserted newline plus
|
||||
// the artificial trailing one, with the caret stuck BEFORE the
|
||||
// inserted one (visually at the end of the old line)
|
||||
root.appendChild(document.createTextNode('\n'));
|
||||
root.appendChild(document.createTextNode('\n'));
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(root.childNodes[2], 0);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true }));
|
||||
await tick();
|
||||
|
||||
const selection = window.getSelection();
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||
});
|
||||
|
||||
it('appends the artificial trailing newline when the browser did not add one', async () => {
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
|
||||
// post-break DOM some browsers produce: a lone trailing \n (or a
|
||||
// <br> the hatch sync strips). Collapsed by the renderer, so the
|
||||
// caret looks stuck on the old line and the next typed character
|
||||
// would consume the newline.
|
||||
root.appendChild(document.createTextNode('\n'));
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(root.childNodes[2], 1);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true }));
|
||||
await tick();
|
||||
|
||||
const selection = window.getSelection();
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||
});
|
||||
|
||||
it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
const text = root.childNodes[1];
|
||||
range.setStart(text, (text.textContent ?? '').length);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||
await tick();
|
||||
|
||||
const selection = window.getSelection();
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||
|
||||
// the next typed character lands on the new line
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx');
|
||||
});
|
||||
|
||||
it('lets Backspace at the text start move into the block without a source fight', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await userEvent.keyboard('create');
|
||||
await tick();
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
|
||||
|
||||
// Backspace at the start of the text line: the separator newline
|
||||
// is structural (synthesized while text follows the block), so
|
||||
// the caret just moves to the block's edge - nothing is re-added
|
||||
await userEvent.keyboard('{Home}');
|
||||
await userEvent.keyboard('{Backspace}');
|
||||
await tick();
|
||||
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
|
||||
expect(caretContainer()).toBe(root);
|
||||
});
|
||||
|
||||
it('lets forward Delete eat the text after a block normally', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await userEvent.keyboard('create');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Home}');
|
||||
await userEvent.keyboard('{Delete}');
|
||||
await tick();
|
||||
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate');
|
||||
});
|
||||
|
||||
it('renders text after a block without a phantom empty line', async () => {
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE + '\nhello'
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
|
||||
});
|
||||
|
||||
it('keeps an intentional blank line after a block out of the separator', async () => {
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE + '\n\nhello'
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
|
||||
});
|
||||
|
||||
it('re-highlights while typing inside a block and keeps the caret', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
|
||||
// caret at the start of the block content (after the opening fence)
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
const code = blockIn(root);
|
||||
expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
|
||||
expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
|
||||
expect(code.querySelector('.hljs-number')).not.toBeNull();
|
||||
|
||||
const selection = window.getSelection();
|
||||
expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormContenteditable Enter in code blocks', () => {
|
||||
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
||||
|
||||
it('adds a line instead of submitting on plain Enter inside a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE,
|
||||
onKeydown
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
|
||||
// caret at the start of the block content (after the opening fence)
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
// consumed locally: the parent's submit handler never sees it
|
||||
expect(onKeydown).not.toHaveBeenCalled();
|
||||
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
|
||||
|
||||
const code = root.querySelector('code[data-code-token="block"]');
|
||||
const selection = window.getSelection();
|
||||
expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
|
||||
});
|
||||
|
||||
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: '```js\nconst a = 1;',
|
||||
onKeydown
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
|
||||
// caret at the start of the block content (after the opening fence)
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onKeydown).not.toHaveBeenCalled();
|
||||
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;');
|
||||
});
|
||||
|
||||
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE + '\nafter',
|
||||
onKeydown
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||
expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards plain Enter on the trailing hatch line after a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE,
|
||||
onKeydown
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
// root-level caret between the block and its trailing br hatch
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(root, 1);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE,
|
||||
onKeydown
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Control>}{Enter}{/Control}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'Enter', ctrlKey: true })
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE);
|
||||
});
|
||||
|
||||
it('forwards Enter inside an inline code span', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: 'run `npm test` now',
|
||||
onKeydown
|
||||
});
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
root.focus();
|
||||
const code = root.querySelector('code[data-code-token="inline"]')!;
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(code.firstChild!, 3);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// Guards the Enter-key contract of the chat form against the
|
||||
// fenced-code-block flow: while the caret sits inside a fenced
|
||||
// block region - closed, or still OPEN while the user is typing
|
||||
// one - plain Enter adds a line instead of submitting the message.
|
||||
// The textarea path is covered here end-to-end (the contenteditable
|
||||
// consumes the same case locally; see chat-form-contenteditable).
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { tick } from 'svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
|
||||
|
||||
function textareaIn(container: HTMLElement): HTMLTextAreaElement {
|
||||
const el = container.querySelector('textarea');
|
||||
if (!(el instanceof HTMLTextAreaElement)) throw new Error('textarea not rendered');
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('ChatForm Enter in code blocks', () => {
|
||||
beforeEach(() => {
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, true);
|
||||
});
|
||||
|
||||
it('adds a line after a still-open fence instead of submitting', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(textarea.value).toBe('```\n');
|
||||
});
|
||||
|
||||
it('keeps adding lines while the block stays open', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```js');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await userEvent.keyboard('const a = 1;');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(textarea.value).toBe('```js\nconst a = 1;\n');
|
||||
});
|
||||
|
||||
it('submits when the caret is before the opening fence', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```');
|
||||
await tick();
|
||||
|
||||
textarea.setSelectionRange(0, 0);
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Enter outside a code block', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('hello');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Ctrl+Enter even inside a code block', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Control>}{Enter}{/Control}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Guards the @-mention picker's file_glob_search gate: when the server
|
||||
// does not expose the tool (started without --tools) or the user disabled
|
||||
// it, the picker still opens but explains why instead of firing searches
|
||||
// that would only fail with "Search failed".
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import type { OpenAIToolDefinition } from '$lib/types';
|
||||
|
||||
const FILE_SEARCH_DEF: OpenAIToolDefinition = {
|
||||
type: 'function',
|
||||
function: { name: BuiltInTool.FILE_GLOB_SEARCH, description: '', parameters: {} }
|
||||
};
|
||||
|
||||
const FILE_SEARCH_KEY = `builtin:${BuiltInTool.FILE_GLOB_SEARCH}`;
|
||||
|
||||
// The store keeps its builtin tool list private; tests inject it through
|
||||
// the reactive field so the derived gates recompute.
|
||||
function setBuiltinTools(defs: OpenAIToolDefinition[]) {
|
||||
(toolsStore as unknown as { _builtinTools: OpenAIToolDefinition[] })._builtinTools = defs;
|
||||
}
|
||||
|
||||
function renderPicker() {
|
||||
return render(ChatFormMentionPicker, {
|
||||
isOpen: true,
|
||||
query: 'main',
|
||||
onClose: () => {},
|
||||
onSelect: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setBuiltinTools([]);
|
||||
toolsStore.setToolEnabled(FILE_SEARCH_KEY, true);
|
||||
localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('ChatFormMentionPicker file_glob_search gate', () => {
|
||||
it('explains that file search is unavailable when the server has no tools', async () => {
|
||||
setBuiltinTools([]);
|
||||
renderPicker();
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'File search is unavailable on this server (started without --tools)'
|
||||
);
|
||||
});
|
||||
|
||||
it('explains that file search must be enabled when the user disabled it', async () => {
|
||||
setBuiltinTools([FILE_SEARCH_DEF]);
|
||||
toolsStore.setToolEnabled(FILE_SEARCH_KEY, false);
|
||||
renderPicker();
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Guards the slash-command dispatch contract: commands dispatch only on
|
||||
// explicit selection (Enter/click in the picker), never mid-typing.
|
||||
// Typing `/model is broken` is prose until the command is picked - the
|
||||
// buffer must survive; only an actual selection consumes the token.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormPickersHarness from './components/ChatFormPickersHarness.svelte';
|
||||
|
||||
describe('slash command dispatch', () => {
|
||||
it('does not dispatch or clear the buffer when a space follows the name', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
expect(screen.component.getValue()).toBe('/model is broken');
|
||||
expect(screen.component.getCalls()).not.toContain('openModelSelector');
|
||||
expect(screen.component.getCalls().some((c) => c.startsWith('setValue:'))).toBe(false);
|
||||
expect(pickers.isCommandPickerOpen).toBe(true);
|
||||
expect(pickers.commandQuery).toBe('model');
|
||||
});
|
||||
|
||||
it('dispatches /model on explicit selection and consumes the token', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const model = pickers.availableCommands.find((c) => c.name === 'model');
|
||||
if (!model) throw new Error('model command missing');
|
||||
pickers.handleCommandSelect(model);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(screen.component.getCalls()).toContain('openModelSelector');
|
||||
expect(pickers.isCommandPickerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('seeds the prompt picker search from the token args on selection', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/prompt weather');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
expect(pickers.isPromptPickerOpen).toBe(false);
|
||||
|
||||
const prompt = pickers.availableCommands.find((c) => c.name === 'prompt');
|
||||
if (!prompt) throw new Error('prompt command missing');
|
||||
pickers.handleCommandSelect(prompt);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(pickers.isPromptPickerOpen).toBe(true);
|
||||
expect(pickers.promptSearchQuery).toBe('weather');
|
||||
});
|
||||
|
||||
it('normalizes a partial /cwd token on selection and keeps it in the buffer', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cw docs');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
|
||||
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
|
||||
expect(pickers.workingDirectoryQuery).toBe('docs');
|
||||
expect(screen.component.getValue()).toBe('/cwd docs');
|
||||
});
|
||||
|
||||
it('syncs the /cwd token into the picker search while the picker is open', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
screen.component.type('/cwd docs');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cwd docs/sub');
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
|
||||
expect(pickers.workingDirectoryQuery).toBe('docs/sub');
|
||||
expect(pickers.isCommandPickerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('abandons the /cwd picker when the token is edited away from /cwd', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
screen.component.type('/cwd docs');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cwdd docs');
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let { value: initial = '' }: Props = $props();
|
||||
|
||||
let value = $state(untrack(() => initial));
|
||||
let inputRef: ChatFormContenteditable | undefined = $state(undefined);
|
||||
|
||||
export function getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getCaretOffset() {
|
||||
return inputRef?.getCaretOffset();
|
||||
}
|
||||
|
||||
export function setCaretOffset(offset: number) {
|
||||
inputRef?.setCaretOffset(offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChatFormContenteditable bind:this={inputRef} bind:value />
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
useChatFormPickers,
|
||||
type UseChatFormPickersReturn
|
||||
} from '$lib/hooks/use-chat-form-pickers.svelte';
|
||||
|
||||
let value = $state('');
|
||||
let caretOffset = $state(0);
|
||||
const calls: string[] = [];
|
||||
|
||||
const pickers = useChatFormPickers({
|
||||
getValue: () => value,
|
||||
setValue: (v) => {
|
||||
value = v;
|
||||
calls.push(`setValue:${v}`);
|
||||
},
|
||||
getCaretOffset: () => caretOffset,
|
||||
setCaretOffset: (o) => {
|
||||
caretOffset = o;
|
||||
},
|
||||
focusInput: () => {},
|
||||
getShowModelSelector: () => true,
|
||||
hasPrompts: () => true,
|
||||
hasBuiltinTools: () => true,
|
||||
getCwd: () => null,
|
||||
getServerHome: () => null,
|
||||
openModelSelector: () => {
|
||||
calls.push('openModelSelector');
|
||||
},
|
||||
getPickersRef: () => undefined
|
||||
});
|
||||
|
||||
// Simulate the user typing: update the buffer and run the input flow.
|
||||
export function type(text: string) {
|
||||
value = text;
|
||||
caretOffset = text.length;
|
||||
pickers.handleInput();
|
||||
}
|
||||
|
||||
export function getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getCalls() {
|
||||
return calls;
|
||||
}
|
||||
|
||||
export function getPickers(): UseChatFormPickersReturn {
|
||||
return pickers;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
|
||||
|
||||
let { onSubmit }: { onSubmit?: () => void } = $props();
|
||||
|
||||
let value = $state('');
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<ChatForm bind:value {onSubmit} />
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const items: Item[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: String(i),
|
||||
label: `item ${i}`
|
||||
}));
|
||||
|
||||
let open = $state(false);
|
||||
let scrollTrigger = $state(0);
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
export function openPicker() {
|
||||
open = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="height: 5000px;">conversation</div>
|
||||
|
||||
{#if open}
|
||||
<div data-testid="picker-host">
|
||||
<ChatFormPickerList
|
||||
{items}
|
||||
isLoading={false}
|
||||
{selectedIndex}
|
||||
searchQuery=""
|
||||
showSearchInput={false}
|
||||
{scrollTrigger}
|
||||
itemKey={(it) => it.id}
|
||||
>
|
||||
{#snippet item(it, index, isSelected)}
|
||||
<ChatFormPickerListItem dataIndex={index} {isSelected} onclick={() => {}}>
|
||||
{it.label}
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Regression test: opening a chat-form picker must not scroll the
|
||||
// conversation to the top. Root cause: the list's scroll effect fired
|
||||
// scrollIntoView on the initial mount, before the popover was positioned,
|
||||
// so the browser scrolled every scrollable ancestor to reveal the row.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import PickerListScrollHarness from './components/PickerListScrollHarness.svelte';
|
||||
|
||||
describe('ChatFormPickerList mount scroll', () => {
|
||||
it('does not scroll documentElement when the picker mounts', async () => {
|
||||
const screen = render(PickerListScrollHarness);
|
||||
await tick();
|
||||
|
||||
document.documentElement.scrollTop = document.documentElement.scrollHeight;
|
||||
await tick();
|
||||
const before = document.documentElement.scrollTop;
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
screen.component.openPicker();
|
||||
await tick();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await tick();
|
||||
|
||||
const after = document.documentElement.scrollTop;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Guards the legacy render-key migration: `renderUserContentAsMarkdown`
|
||||
// and `renderThinkingAsMarkdown` (opt-INTO markdown) fold into the single
|
||||
// `renderContentAsRawText` setting, with any explicit raw-text preference
|
||||
// winning when the legacy keys disagree. Legacy keys are removed from the
|
||||
// persisted config so they do not stay orphaned in localStorage.
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { settingsStore, config } from '$lib/stores/settings.svelte';
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
|
||||
|
||||
function seedConfig(stored: Record<string, unknown>) {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
|
||||
settingsStore.initialize();
|
||||
}
|
||||
|
||||
function persisted(): Record<string, unknown> {
|
||||
return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
|
||||
}
|
||||
|
||||
describe('renderContentAsRawText migration', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
settingsStore.initialize();
|
||||
});
|
||||
|
||||
it('maps renderUserContentAsMarkdown=false to raw text', () => {
|
||||
seedConfig({ renderUserContentAsMarkdown: false });
|
||||
expect(config().renderContentAsRawText).toBe(true);
|
||||
});
|
||||
|
||||
it('maps renderUserContentAsMarkdown=true to markdown', () => {
|
||||
seedConfig({ renderUserContentAsMarkdown: true });
|
||||
expect(config().renderContentAsRawText).toBe(false);
|
||||
});
|
||||
|
||||
it('maps renderThinkingAsMarkdown=false to raw text', () => {
|
||||
seedConfig({ renderThinkingAsMarkdown: false });
|
||||
expect(config().renderContentAsRawText).toBe(true);
|
||||
});
|
||||
|
||||
it('lets any explicit raw-text preference win when the legacy keys disagree', () => {
|
||||
seedConfig({ renderUserContentAsMarkdown: true, renderThinkingAsMarkdown: false });
|
||||
expect(config().renderContentAsRawText).toBe(true);
|
||||
});
|
||||
|
||||
it('honors the intermediate renderUserContentAsRawText key from the PR branch', () => {
|
||||
seedConfig({ renderUserContentAsRawText: true });
|
||||
expect(config().renderContentAsRawText).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an already-migrated value and cleans up the legacy keys', () => {
|
||||
seedConfig({ renderContentAsRawText: false, renderUserContentAsMarkdown: false });
|
||||
expect(config().renderContentAsRawText).toBe(false);
|
||||
|
||||
const stored = persisted();
|
||||
expect(stored.renderUserContentAsMarkdown).toBeUndefined();
|
||||
expect(stored.renderThinkingAsMarkdown).toBeUndefined();
|
||||
expect(stored.renderUserContentAsRawText).toBeUndefined();
|
||||
});
|
||||
|
||||
it('defaults to markdown when no legacy key exists', () => {
|
||||
seedConfig({});
|
||||
expect(config().renderContentAsRawText).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { highlightCode, trimCodePadding } from '$lib/utils/code';
|
||||
import { highlightCode, splitGluedClosingCodeFences, trimCodePadding } from '$lib/utils/code';
|
||||
|
||||
describe('trimCodePadding', () => {
|
||||
it('removes a single leading newline', () => {
|
||||
@@ -101,3 +101,38 @@ describe('highlightCode', () => {
|
||||
expect(html).toBe('<script>a && b</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitGluedClosingCodeFences', () => {
|
||||
it('splits text glued to a closing fence onto its own line', () => {
|
||||
const input = "```ts\nlet foo = 'bar';\n```create this file on [Desktop](file:///a/b/)";
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(
|
||||
"```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)"
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a well-formed code block untouched', () => {
|
||||
const input = "```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)";
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('leaves content without fences untouched', () => {
|
||||
expect(splitGluedClosingCodeFences('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('keeps nested markdown fences inside a block intact', () => {
|
||||
const input = '```md\n# Example\n```python\nprint(1)\n```\n```';
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('splits every glued closing fence when several blocks are present', () => {
|
||||
const input = '```ts\na\n```first words\n\n```js\nb\n```second words';
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(
|
||||
'```ts\na\n```\nfirst words\n\n```js\nb\n```\nsecond words'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a still-open fence untouched', () => {
|
||||
const input = '```ts\nlet foo = 1;';
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findCommandToken, takeCommandDismissSnapshot } from '$lib/utils';
|
||||
|
||||
describe('findCommandToken', () => {
|
||||
it('returns null when the value does not start with a slash', () => {
|
||||
expect(findCommandToken('hello /prompt')).toBeNull();
|
||||
expect(findCommandToken('')).toBeNull();
|
||||
expect(findCommandToken('prompt')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses a bare slash', () => {
|
||||
expect(findCommandToken('/')).toEqual({ name: '', args: '', end: 1 });
|
||||
});
|
||||
|
||||
it('parses a command name with no args', () => {
|
||||
expect(findCommandToken('/prompt')).toEqual({ name: 'prompt', args: '', end: 7 });
|
||||
});
|
||||
|
||||
it('parses a command name followed by a space', () => {
|
||||
expect(findCommandToken('/prompt ')).toEqual({ name: 'prompt', args: '', end: 8 });
|
||||
});
|
||||
|
||||
it('parses args after the command name', () => {
|
||||
expect(findCommandToken('/prompt rev')).toEqual({ name: 'prompt', args: 'rev', end: 11 });
|
||||
});
|
||||
|
||||
it('parses multi-word args', () => {
|
||||
expect(findCommandToken('/prompt review code ')).toEqual({
|
||||
name: 'prompt',
|
||||
args: ' review code ',
|
||||
end: 22
|
||||
});
|
||||
});
|
||||
|
||||
it('treats the whole run as the name when there is no space', () => {
|
||||
expect(findCommandToken('/promptx')).toEqual({ name: 'promptx', args: '', end: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('takeCommandDismissSnapshot', () => {
|
||||
it('returns null when there is no command token', () => {
|
||||
expect(takeCommandDismissSnapshot('hello')).toBeNull();
|
||||
});
|
||||
|
||||
it('captures the name and args', () => {
|
||||
expect(takeCommandDismissSnapshot('/prompt rev')).toEqual({
|
||||
name: 'prompt',
|
||||
args: 'rev'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils';
|
||||
|
||||
describe('tokenizeContent', () => {
|
||||
it('tokenizes a plain text buffer with no badges', () => {
|
||||
expect(tokenizeContent('hello world')).toEqual([{ kind: 'text', text: 'hello world' }]);
|
||||
});
|
||||
|
||||
it('tokenizes a single badge', () => {
|
||||
expect(tokenizeContent('[docs](file:///a/b)')).toEqual([
|
||||
{ kind: 'badge', name: 'docs', path: '/a/b' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes text around a single badge', () => {
|
||||
expect(tokenizeContent('hello [docs](file:///a/b) world')).toEqual([
|
||||
{ kind: 'text', text: 'hello ' },
|
||||
{ kind: 'badge', name: 'docs', path: '/a/b' },
|
||||
{ kind: 'text', text: ' world' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes adjacent badges as separate tokens', () => {
|
||||
expect(tokenizeContent('[a](file:///x)[b](file:///y)')).toEqual([
|
||||
{ kind: 'badge', name: 'a', path: '/x' },
|
||||
{ kind: 'badge', name: 'b', path: '/y' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves non-file links untouched in the stream', () => {
|
||||
expect(tokenizeContent('see [foo](https://example.com) for details')).toEqual([
|
||||
{ kind: 'text', text: 'see [foo](https://example.com) for details' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('recognizes badges whose path contains spaces (macOS screenshots)', () => {
|
||||
const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
|
||||
const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `;
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
|
||||
{ kind: 'text', text: ' ' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('recognizes badges whose path lives in the macOS temp folder', () => {
|
||||
const path =
|
||||
'/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png';
|
||||
const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `;
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path },
|
||||
{ kind: 'text', text: ' ' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps text around a badge with spaces in the path', () => {
|
||||
const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
|
||||
const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`;
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'text', text: 'see ' },
|
||||
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
|
||||
{ kind: 'text', text: ' done' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => {
|
||||
const path = '/Users/foo/Screenshot (1).png';
|
||||
const source = `[Screenshot (1).png](file://${path}) `;
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'badge', name: 'Screenshot (1).png', path },
|
||||
{ kind: 'text', text: ' ' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('recognizes badges whose folder name is wrapped in parentheses', () => {
|
||||
const path = '/Users/foo/Project (Stuff)/main.rs';
|
||||
const source = `[main.rs](file://${path}) `;
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'badge', name: 'main.rs', path },
|
||||
{ kind: 'text', text: ' ' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('recognizes adjacent badges back-to-back with no separator', () => {
|
||||
const source = '[a](file:///p)[b](file:///q)';
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'badge', name: 'a', path: '/p' },
|
||||
{ kind: 'badge', name: 'b', path: '/q' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes inline code with the backticks included', () => {
|
||||
expect(tokenizeContent('run `npm test` now')).toEqual([
|
||||
{ kind: 'text', text: 'run ' },
|
||||
{ kind: 'inlineCode', text: '`npm test`' },
|
||||
{ kind: 'text', text: ' now' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes a fenced code block without a language', () => {
|
||||
const source = 'before\n```\nconst a = 1;\n```\nafter';
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'text', text: 'before\n' },
|
||||
{ kind: 'codeBlock', text: '```\nconst a = 1;\n```' },
|
||||
{ kind: 'text', text: '\nafter' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes a fenced code block with a language', () => {
|
||||
const source = '```js\nconst a = 1;\n```';
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'codeBlock', text: '```js\nconst a = 1;\n```' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers the fenced block over inline spans at triple backticks', () => {
|
||||
expect(tokenizeContent('```a``` ```b```')).toEqual([
|
||||
{ kind: 'codeBlock', text: '```a```' },
|
||||
{ kind: 'text', text: ' ' },
|
||||
{ kind: 'codeBlock', text: '```b```' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves an unclosed fence as plain text', () => {
|
||||
expect(tokenizeContent('```js\nconst a = 1;')).toEqual([
|
||||
{ kind: 'text', text: '```js\nconst a = 1;' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves an unclosed inline backtick as plain text', () => {
|
||||
expect(tokenizeContent('run `npm test')).toEqual([{ kind: 'text', text: 'run `npm test' }]);
|
||||
});
|
||||
|
||||
it('does not recognize badges inside code spans', () => {
|
||||
expect(tokenizeContent('`[a](file:///p)`')).toEqual([
|
||||
{ kind: 'inlineCode', text: '`[a](file:///p)`' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes badges and code spans side by side', () => {
|
||||
expect(tokenizeContent('[a](file:///p) `x`')).toEqual([
|
||||
{ kind: 'badge', name: 'a', path: '/p' },
|
||||
{ kind: 'text', text: ' ' },
|
||||
{ kind: 'inlineCode', text: '`x`' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsCodeSpan', () => {
|
||||
it('detects inline code', () => {
|
||||
expect(containsCodeSpan('run `npm test` now')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a fenced block with a language', () => {
|
||||
expect(containsCodeSpan('```js\nconst a = 1;\n```')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a fenced block without a language', () => {
|
||||
expect(containsCodeSpan('```\ncode\n```')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unclosed fences and lone backticks', () => {
|
||||
expect(containsCodeSpan('```js\nconst a = 1;')).toBe(false);
|
||||
expect(containsCodeSpan('run `npm test')).toBe(false);
|
||||
expect(containsCodeSpan('``')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores plain text and mention links', () => {
|
||||
expect(containsCodeSpan('hello world')).toBe(false);
|
||||
expect(containsCodeSpan('[a](file:///p)')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOffsetInCodeBlock', () => {
|
||||
const BLOCK = '```js\nconst a = 1;\n```';
|
||||
|
||||
it('is false with no fences in the buffer', () => {
|
||||
expect(isOffsetInCodeBlock('hello world', 5)).toBe(false);
|
||||
expect(isOffsetInCodeBlock('run `npm test` now', 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true right after the opening fence, before any content', () => {
|
||||
expect(isOffsetInCodeBlock('```', 3)).toBe(true);
|
||||
expect(isOffsetInCodeBlock('```js', 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true inside a still-open block while it is being typed', () => {
|
||||
const open = '```js\nconst a = 1;';
|
||||
expect(isOffsetInCodeBlock(open, open.length)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true inside a closed block and false outside it', () => {
|
||||
expect(isOffsetInCodeBlock(BLOCK, 6)).toBe(true);
|
||||
expect(isOffsetInCodeBlock(BLOCK, 0)).toBe(false);
|
||||
expect(isOffsetInCodeBlock(BLOCK, BLOCK.length)).toBe(false);
|
||||
expect(isOffsetInCodeBlock(BLOCK + '\nafter', BLOCK.length + 5)).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles per fence across multiple blocks', () => {
|
||||
const two = BLOCK + '\ntext\n' + BLOCK;
|
||||
const secondBlock = two.lastIndexOf(BLOCK);
|
||||
expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false);
|
||||
expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true);
|
||||
expect(isOffsetInCodeBlock(two, two.length)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils';
|
||||
|
||||
// Layout of `hello [docs](file:///a/b) world foo`:
|
||||
// "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34
|
||||
const BADGE = '[docs](file:///a/b)';
|
||||
const SOURCE = `hello ${BADGE} world foo`;
|
||||
const BADGE_START = 6;
|
||||
const BADGE_END = 25;
|
||||
|
||||
describe('badgeAwareWordJump', () => {
|
||||
it('returns null when the buffer has no badge', () => {
|
||||
expect(badgeAwareWordJump('hello world', 0, 'forward')).toBeNull();
|
||||
expect(badgeAwareWordJump('hello world', 11, 'backward')).toBeNull();
|
||||
});
|
||||
|
||||
it('jumps forward onto a badge landing at its end, not the next word', () => {
|
||||
expect(badgeAwareWordJump(SOURCE, BADGE_START, 'forward')).toBe(BADGE_END);
|
||||
});
|
||||
|
||||
it('jumps forward from the space before a badge landing at its end', () => {
|
||||
expect(badgeAwareWordJump(SOURCE, BADGE_START - 1, 'forward')).toBe(BADGE_END);
|
||||
});
|
||||
|
||||
it('jumps backward over a badge landing at its start', () => {
|
||||
expect(badgeAwareWordJump(SOURCE, BADGE_END, 'backward')).toBe(BADGE_START);
|
||||
});
|
||||
|
||||
it('jumps backward from the next word onto the badge start', () => {
|
||||
// caret at the start of "world"
|
||||
expect(badgeAwareWordJump(SOURCE, BADGE_END + 1, 'backward')).toBe(BADGE_START);
|
||||
});
|
||||
|
||||
it('returns null for jumps that cross no badge', () => {
|
||||
// forward over "hello" only
|
||||
expect(badgeAwareWordJump(SOURCE, 0, 'forward')).toBeNull();
|
||||
// backward over "foo" only
|
||||
expect(badgeAwareWordJump(SOURCE, SOURCE.length, 'backward')).toBeNull();
|
||||
// backward away from the badge (over "hello")
|
||||
expect(badgeAwareWordJump(SOURCE, BADGE_START, 'backward')).toBeNull();
|
||||
});
|
||||
|
||||
it('treats a leading badge as one word in both directions', () => {
|
||||
const source = `${BADGE} rest`;
|
||||
expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length);
|
||||
expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0);
|
||||
});
|
||||
|
||||
it('treats adjacent badges as separate words', () => {
|
||||
// each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)"
|
||||
const source = '[a](file:///x)[b](file:///y)';
|
||||
expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14);
|
||||
expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28);
|
||||
expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14);
|
||||
expect(badgeAwareWordJump(source, 14, 'backward')).toBe(0);
|
||||
});
|
||||
|
||||
it('jumps over a badge following punctuation', () => {
|
||||
// "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27
|
||||
const source = `foo, ${BADGE} bar`;
|
||||
expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull();
|
||||
expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leadingBadgeEdgeOffset', () => {
|
||||
it('returns 0 when the caret sits exactly at a leading badge end', () => {
|
||||
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns null when the caret is anywhere else', () => {
|
||||
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, 0)).toBeNull();
|
||||
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length + 2)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the buffer does not start with a badge', () => {
|
||||
expect(leadingBadgeEdgeOffset(SOURCE, BADGE_END)).toBeNull();
|
||||
expect(leadingBadgeEdgeOffset('', 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/services/tools.service', () => ({
|
||||
ToolsService: { executeToolRaw: vi.fn() }
|
||||
}));
|
||||
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { GlobSearchType } from '$lib/enums';
|
||||
import { runGlobSearchWithChildren } from '$lib/utils';
|
||||
|
||||
const mockExecute = vi.mocked(ToolsService.executeToolRaw);
|
||||
|
||||
// Distinct roots per test so the module-level search cache never serves a
|
||||
// prior test's result under the same (type, path, glob, depth) key.
|
||||
beforeEach(() => {
|
||||
mockExecute.mockReset();
|
||||
});
|
||||
|
||||
describe('runGlobSearchWithChildren', () => {
|
||||
it('returns ranked outer entries as absolute paths without descending', async () => {
|
||||
mockExecute.mockResolvedValueOnce({
|
||||
base: '/Users/rootA',
|
||||
entries: [
|
||||
{ path: 'note.md', type: 'file' },
|
||||
{ path: 'src', type: 'dir' }
|
||||
]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'note',
|
||||
'/Users/rootA',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal
|
||||
);
|
||||
expect(res.error).toBeUndefined();
|
||||
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootA/note.md', '/Users/rootA/src']);
|
||||
expect(res.exactDir).toBeUndefined();
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('appends a matched directorys children when the query ends with a separator', async () => {
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ base: '/Users/rootB', entries: [{ path: 'src', type: 'dir' }] })
|
||||
.mockResolvedValueOnce({
|
||||
base: '/Users/rootB/src',
|
||||
entries: [
|
||||
{ path: 'a.txt', type: 'file' },
|
||||
{ path: 'sub', type: 'dir' }
|
||||
]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'/Users/rootB/src/',
|
||||
'/Users/rootB',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
|
||||
);
|
||||
expect(res.error).toBeUndefined();
|
||||
expect(res.exactDir).toBe('/Users/rootB/src');
|
||||
expect(res.entries.map((e) => e.path)).toEqual([
|
||||
'/Users/rootB/src',
|
||||
'/Users/rootB/src/a.txt',
|
||||
'/Users/rootB/src/sub'
|
||||
]);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not descend without a trailing separator in mention mode', async () => {
|
||||
mockExecute.mockResolvedValueOnce({
|
||||
base: '/Users/rootC',
|
||||
entries: [{ path: 'src', type: 'dir' }]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'/Users/rootC/src',
|
||||
'/Users/rootC',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
|
||||
);
|
||||
expect(res.exactDir).toBeUndefined();
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('descends on an exact directory match in WD mode', async () => {
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ base: '/Users/rootD', entries: [{ path: 'src', type: 'dir' }] })
|
||||
.mockResolvedValueOnce({
|
||||
base: '/Users/rootD/src',
|
||||
entries: [{ path: 'a.txt', type: 'file' }]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'/Users/rootD/src',
|
||||
'/Users/rootD',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ type: GlobSearchType.DIR }
|
||||
);
|
||||
expect(res.exactDir).toBe('/Users/rootD/src');
|
||||
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootD/src', '/Users/rootD/src/a.txt']);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('surfaces a server error without attempting a child walk', async () => {
|
||||
mockExecute.mockResolvedValueOnce({ error: 'boom' });
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'src',
|
||||
'/Users/rootE',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal
|
||||
);
|
||||
expect(res.error).toBe('boom');
|
||||
expect(res.entries).toEqual([]);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||
buildMentionInsertion,
|
||||
containsFileMentionLink,
|
||||
decodeFileLinkPath,
|
||||
encodeFileLinkPath,
|
||||
fileMentionLinkRe,
|
||||
getMentionBadgeIconPaths,
|
||||
getMentionBadgeLabel
|
||||
} from '$lib/utils';
|
||||
import { FileMentionEntryType } from '$lib/enums';
|
||||
|
||||
describe('encodeFileLinkPath', () => {
|
||||
it('leaves a clean path unchanged', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/bar.txt')).toBe('/Users/foo/bar.txt');
|
||||
});
|
||||
|
||||
it('encodes spaces per path segment', () => {
|
||||
expect(
|
||||
encodeFileLinkPath('/Users/allozaur/Desktop/Screenshot 2026-08-05 at 11.33.45.png')
|
||||
).toBe('/Users/allozaur/Desktop/Screenshot%202026-08-05%20at%2011.33.45.png');
|
||||
});
|
||||
|
||||
it('preserves the leading and trailing slash (directory marker)', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/bar/')).toBe('/Users/foo/bar/');
|
||||
});
|
||||
|
||||
it('encodes parentheses in macOS screenshot names', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/Pic (1).png')).toBe('/Users/foo/Pic%20(1).png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileMentionLinkRe', () => {
|
||||
it('matches a standard mention link', () => {
|
||||
expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true);
|
||||
expect(containsFileMentionLink('[docs](file:///a/b)')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match non-file links', () => {
|
||||
expect(fileMentionLinkRe().test('[foo](https://example.com)')).toBe(false);
|
||||
expect(fileMentionLinkRe().test('plain text')).toBe(false);
|
||||
});
|
||||
|
||||
it('admits a close paren in a macOS-style file name', () => {
|
||||
const match = fileMentionLinkRe().exec(
|
||||
'[Screenshot (1).png](file:///Users/foo/Screenshot (1).png)'
|
||||
);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.[1]).toBe('Screenshot (1).png');
|
||||
expect(match?.[2]).toBe('/Users/foo/Screenshot (1).png');
|
||||
});
|
||||
|
||||
it('admits a parenthesized folder segment', () => {
|
||||
expect(
|
||||
fileMentionLinkRe().exec('[main.rs](file:///Users/foo/Project (Stuff)/main.rs)')?.[2]
|
||||
).toBe('/Users/foo/Project (Stuff)/main.rs');
|
||||
});
|
||||
|
||||
it('stops at the closing paren of an adjacent badge', () => {
|
||||
expect(fileMentionLinkRe().exec('[a](file:///p)[b](file:///q)')?.[0]).toBe('[a](file:///p)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMentionBadgeIconPaths', () => {
|
||||
it('returns the folder glyphs for a trailing-separator path', () => {
|
||||
expect(getMentionBadgeIconPaths('/Users/foo/bar/')).toBe(MENTION_BADGE_FOLDER_ICON_PATHS);
|
||||
});
|
||||
|
||||
it('returns the file glyphs otherwise', () => {
|
||||
expect(getMentionBadgeIconPaths('/Users/foo/bar.txt')).toBe(MENTION_BADGE_FILE_ICON_PATHS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMentionBadgeLabel', () => {
|
||||
it('returns the name by default', () => {
|
||||
expect(getMentionBadgeLabel('bar', '/Users/foo/bar/', false)).toBe('bar');
|
||||
});
|
||||
|
||||
it('renders the decoded full path without the trailing separator', () => {
|
||||
expect(getMentionBadgeLabel('bar', '/Users/foo/bar/', true)).toBe('/Users/foo/bar');
|
||||
expect(getMentionBadgeLabel('shot', '/Users/foo/Screenshot%20(1).png', true)).toBe(
|
||||
'/Users/foo/Screenshot (1).png'
|
||||
);
|
||||
});
|
||||
|
||||
it('abbreviates a known home prefix to a tilde', () => {
|
||||
expect(getMentionBadgeLabel('main.rs', '/home/user/src/main.rs', true, '/home/user')).toBe(
|
||||
'~/src/main.rs'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the name when the decoded path is empty', () => {
|
||||
expect(getMentionBadgeLabel('root', '/', true)).toBe('root');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeFileLinkPath', () => {
|
||||
it('decodes encoded segments back to the original path', () => {
|
||||
expect(
|
||||
decodeFileLinkPath('/Users/allozaur/Desktop/Screenshot%202026-08-05%20at%2011.33.45.png')
|
||||
).toBe('/Users/allozaur/Desktop/Screenshot 2026-08-05 at 11.33.45.png');
|
||||
});
|
||||
|
||||
it('is the inverse of encodeFileLinkPath', () => {
|
||||
for (const path of [
|
||||
'/a/b.txt',
|
||||
'/Users/foo/Desktop/Screenshot 2026-08-05 at 11.33.45.png',
|
||||
'/Users/foo/bar (1)/dir/',
|
||||
'/sp ace/pa%th.txt'
|
||||
]) {
|
||||
expect(decodeFileLinkPath(encodeFileLinkPath(path))).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the input on malformed percent sequences', () => {
|
||||
expect(decodeFileLinkPath('/a/%zz.txt')).toBe('/a/%zz.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMentionInsertion', () => {
|
||||
const file = (path: string, name: string) => ({
|
||||
path,
|
||||
name,
|
||||
type: FileMentionEntryType.FILE
|
||||
});
|
||||
const dir = (path: string, name: string) => ({
|
||||
path,
|
||||
name,
|
||||
type: FileMentionEntryType.DIRECTORY
|
||||
});
|
||||
|
||||
it('splices a root-anchored file link in place of the token', () => {
|
||||
const value = 'hello @repo';
|
||||
const result = buildMentionInsertion(file('/Users/foo/myRepo', 'myRepo'), value, {
|
||||
start: 6,
|
||||
end: 11
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
const { newValue, caretOffset } = result!;
|
||||
expect(newValue).toBe('hello [myRepo](file:///Users/foo/myRepo) ');
|
||||
expect(caretOffset).toBe(6 + '[myRepo](file:///Users/foo/myRepo) '.length);
|
||||
});
|
||||
|
||||
it('keeps the trailing slash on the directory marker', () => {
|
||||
const value = 'see @src';
|
||||
const { newValue } = buildMentionInsertion(dir('/Users/foo/myRepo/src/', 'src'), value, {
|
||||
start: 4,
|
||||
end: 8
|
||||
})!;
|
||||
expect(newValue).toBe('see [src](file:///Users/foo/myRepo/src/) ');
|
||||
});
|
||||
|
||||
it('escapes spaces and parens in the target', () => {
|
||||
const value = '@pic';
|
||||
const { newValue } = buildMentionInsertion(
|
||||
file('/Users/foo/Desktop/Pic (1).png', 'Pic (1).png'),
|
||||
value,
|
||||
{ start: 0, end: 4 }
|
||||
)!;
|
||||
expect(newValue).toBe('[Pic (1).png](file:///Users/foo/Desktop/Pic%20(1).png) ');
|
||||
});
|
||||
|
||||
it('re-adds the directory marker when the cleaned path empties', () => {
|
||||
const { newValue } = buildMentionInsertion(dir('/', 'root'), '/', { start: 0, end: 1 })!;
|
||||
expect(newValue).toBe('[root](file:///) ');
|
||||
});
|
||||
|
||||
it('returns null for an out-of-range token', () => {
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 0, end: 5 })).toBeNull();
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findMentionToken, takeMentionDismissSnapshot } from '$lib/utils';
|
||||
|
||||
describe('findMentionToken', () => {
|
||||
it('returns null for an empty/bare cursor', () => {
|
||||
expect(findMentionToken('', 0)).toBeNull();
|
||||
expect(findMentionToken('text', 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('recognizes a mention at the start of the value', () => {
|
||||
expect(findMentionToken('@pr', 3)).toEqual({ start: 0, end: 3, query: 'pr' });
|
||||
});
|
||||
|
||||
it('recognizes a mention after a word boundary', () => {
|
||||
expect(findMentionToken('hello @pr', 9)).toEqual({ start: 6, end: 9, query: 'pr' });
|
||||
});
|
||||
|
||||
it('returns null when the @ is mid-identifier', () => {
|
||||
expect(findMentionToken('em@', 3)).toBeNull();
|
||||
expect(findMentionToken('text@pr', 7)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the cursor is past the whitespace break', () => {
|
||||
expect(findMentionToken('@pr hello', 9)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats boundary characters (parens, brackets, comma) as token starts', () => {
|
||||
expect(findMentionToken('(@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
|
||||
expect(findMentionToken('[@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
|
||||
expect(findMentionToken('a,@pr', 5)).toEqual({ start: 2, end: 5, query: 'pr' });
|
||||
});
|
||||
|
||||
it('does not treat an identifier character as a boundary', () => {
|
||||
expect(findMentionToken('user@abc', 8)).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts the whole token up to the trailing boundary as the query', () => {
|
||||
expect(findMentionToken('@', 1)).toEqual({ start: 0, end: 1, query: '' });
|
||||
expect(findMentionToken('@hello', 6)).toEqual({ start: 0, end: 6, query: 'hello' });
|
||||
});
|
||||
|
||||
it('keeps the whole token as the query when the caret is mid-token', () => {
|
||||
expect(findMentionToken('@hello', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
|
||||
expect(findMentionToken('@hello world', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
|
||||
});
|
||||
|
||||
it('ignores a boundary @ and keeps the most recent token', () => {
|
||||
expect(findMentionToken('a @foo @bar', 11)).toEqual({ start: 7, end: 11, query: 'bar' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('takeMentionDismissSnapshot', () => {
|
||||
it('returns null when there is no valid mention at the cursor', () => {
|
||||
expect(takeMentionDismissSnapshot('plain text', 5)).toBeNull();
|
||||
expect(takeMentionDismissSnapshot('user@abc', 8)).toBeNull();
|
||||
});
|
||||
|
||||
it('captures start and query of the current mention', () => {
|
||||
expect(takeMentionDismissSnapshot('hello @proj', 11)).toEqual({
|
||||
start: 6,
|
||||
query: 'proj'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SourceHistory } from '$lib/utils';
|
||||
|
||||
describe('SourceHistory', () => {
|
||||
it('coalesces pushes inside the group window into one undo step', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
h.push({ value: '', caret: 0 }, 1000);
|
||||
h.push({ value: 'a', caret: 1 }, 1200);
|
||||
h.push({ value: 'ab', caret: 2 }, 1500);
|
||||
|
||||
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
|
||||
expect(h.undo({ value: '', caret: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('starts a new group once the window has passed', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
h.push({ value: '', caret: 0 }, 1000);
|
||||
h.push({ value: 'abc', caret: 3 }, 2000);
|
||||
|
||||
expect(h.undo({ value: 'abcdef', caret: 6 })).toEqual({ value: 'abc', caret: 3 });
|
||||
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
|
||||
});
|
||||
|
||||
it('newGroup forces a separate entry even inside the window', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
h.push({ value: '', caret: 0 }, 1000);
|
||||
h.push({ value: 'abc', caret: 3 }, 1100, true);
|
||||
|
||||
expect(h.undo({ value: 'abc\n', caret: 4 })).toEqual({ value: 'abc', caret: 3 });
|
||||
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
|
||||
});
|
||||
|
||||
it('redo round-trips and a fresh push clears the redo stack', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
h.push({ value: '', caret: 0 }, 1000);
|
||||
|
||||
const undone = h.undo({ value: 'abc', caret: 3 });
|
||||
expect(undone).toEqual({ value: '', caret: 0 });
|
||||
expect(h.redo({ value: '', caret: 0 })).toEqual({ value: 'abc', caret: 3 });
|
||||
|
||||
h.undo({ value: 'abc', caret: 3 });
|
||||
h.push({ value: '', caret: 0 }, 5000);
|
||||
expect(h.redo({ value: 'x', caret: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
it('starts a new group on the first edit after an undo', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
h.push({ value: '', caret: 0 }, 1000);
|
||||
h.undo({ value: 'abc', caret: 3 });
|
||||
|
||||
h.push({ value: '', caret: 0 }, 1200);
|
||||
expect(h.undo({ value: 'x', caret: 1 })).toEqual({ value: '', caret: 0 });
|
||||
});
|
||||
|
||||
it('evicts the oldest entry past the limit', () => {
|
||||
const h = new SourceHistory(2, 800);
|
||||
h.push({ value: 'one', caret: 0 }, 1000);
|
||||
h.push({ value: 'two', caret: 0 }, 2000);
|
||||
h.push({ value: 'three', caret: 0 }, 3000);
|
||||
|
||||
expect(h.undo({ value: 'cur', caret: 0 })).toEqual({ value: 'three', caret: 0 });
|
||||
expect(h.undo({ value: 'three', caret: 0 })).toEqual({ value: 'two', caret: 0 });
|
||||
expect(h.undo({ value: 'two', caret: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
splitPathQuery,
|
||||
buildCaseInsensitiveGlob,
|
||||
buildGlobSearchArgs,
|
||||
rankEntries,
|
||||
joinPath,
|
||||
highlightMatch
|
||||
} from '$lib/utils';
|
||||
import { GLOB_WILDCARD, PATH_NAV_MAX_DEPTH } from '$lib/constants';
|
||||
|
||||
describe('splitPathQuery', () => {
|
||||
it('treats a plain query as a home-relative glob (not navigation)', () => {
|
||||
@@ -124,3 +126,41 @@ describe('highlightMatch', () => {
|
||||
expect(highlightMatch('abc', 'z')).toEqual([{ text: 'abc', match: false }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGlobSearchArgs', () => {
|
||||
const DEPTH = 6;
|
||||
|
||||
it('glob-matches home-relative within the scope path', () => {
|
||||
const args = buildGlobSearchArgs('docs', '/home', DEPTH);
|
||||
expect(args.path).toBe('/home');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('docs'));
|
||||
expect(args.maxDepth).toBe(DEPTH);
|
||||
expect(args.rankQuery).toBe('docs');
|
||||
expect(args.last).toBeUndefined();
|
||||
});
|
||||
|
||||
it('navigates home for a `~` path query', () => {
|
||||
const args = buildGlobSearchArgs('~/proj', '/home', DEPTH);
|
||||
expect(args.path).toBe('~');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('proj'));
|
||||
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
|
||||
expect(args.rankQuery).toBe('proj');
|
||||
expect(args.last).toBe('proj');
|
||||
});
|
||||
|
||||
it('lists the scope root when a path query has no last segment', () => {
|
||||
const args = buildGlobSearchArgs('~/', '/home', DEPTH);
|
||||
expect(args.path).toBe('~');
|
||||
expect(args.include).toBe(GLOB_WILDCARD);
|
||||
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
|
||||
});
|
||||
|
||||
it('navigates an absolute path under its root', () => {
|
||||
const args = buildGlobSearchArgs('/usr/local/bin', '/home', DEPTH);
|
||||
expect(args.path).toBe('/usr/local');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('bin'));
|
||||
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
|
||||
expect(args.rankQuery).toBe('bin');
|
||||
expect(args.last).toBe('bin');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user