Merge branch 'main' into experiment/editContext

This commit is contained in:
Chris Kruining 2025-02-20 16:37:33 +11:00
commit 213a1f7ae7
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
26 changed files with 696 additions and 231 deletions

View file

@ -0,0 +1,16 @@
.error {
display: grid;
place-content: center;
background: repeating-linear-gradient(-45deg,
color(from var(--fail) xyz x y z / .05),
color(from var(--fail) xyz x y z / .05) 10px,
color(from var(--fail) xyz x y z / .25) 10px,
color(from var(--fail) xyz x y z / .25) 12px,
color(from var(--fail) xyz x y z / .05) 12px);
color: var(--text-2);
border: 1px solid var(--fail);
border-radius: var(--radii-m);
margin: var(--padding-l);
}

View file

@ -0,0 +1,16 @@
import { Component, Show } from "solid-js";
import css from './error.module.css';
export const ErrorComp: Component<{ error: Error }> = (props) => {
return <div class={css.error}>
<b>{props.error.message}</b>
<Show when={props.error.cause}>{
cause => <>{cause().description}</>
}</Show>
{props.error.stack}
<a href="/">Return to start</a>
</div>;
};

View file

@ -0,0 +1 @@
export { ErrorComp } from './error';

View file

@ -1,15 +1,44 @@
import { describe, it, expect } from 'bun:test';
import { describe, expect } from 'vitest';
import { render } from "@solidjs/testing-library"
import { Table } from './table';
import { createDataSet } from './dataset';
import { createDataSet } from '~/features/dataset';
import { createSignal } from 'solid-js';
import { it } from '~/test-helpers';
type TableItem = {};
describe('`<Table />`', () => {
it('should render', async () => {
const result = render(() => {
const [data] = createSignal([]);
const dataset = createDataSet(data);
// describe('<Table />', () => {
// it('should render', async () => {
// const dataset = createDataSet<TableItem>([]);
// const result = render(() => <Table rows={dataset} columns={[]} />);
return <Table rows={dataset} columns={[]} />;
});
// expect(true).toBe(true);
// });
// });
expect(true).toBe(true);
});
it('should render with groups', async () => {
const result = render(() => {
const [data] = createSignal([
{ id: '1', name: 'a first name', amount: 30, group: 'a' },
{ id: '2', name: 'a second name', amount: 20, group: 'a' },
{ id: '3', name: 'a third name', amount: 10, group: 'a' },
{ id: '4', name: 'a first name', amount: 30, group: 'b' },
{ id: '5', name: 'a second name', amount: 20, group: 'b' },
{ id: '6', name: 'a third name', amount: 10, group: 'b' },
]);
const dataset = createDataSet(data, {
group: { by: 'group' }
});
return <Table rows={dataset} columns={[
{ id: 'id', label: 'id' },
{ id: 'name', label: 'name' },
{ id: 'amount', label: 'amount' },
{ id: 'group', label: 'group' },
]} />;
});
expect(true).toBe(true);
});
});

View file

@ -10,15 +10,25 @@
unicode-bidi: plaintext;
cursor: text;
& [data-marker="spelling"] {
& ::highlight(search-results) {
background-color: var(--secondary-900);
}
& ::highlight(spelling-error) {
text-decoration-line: spelling-error;
}
& [data-marker="grammar"] {
& ::highlight(grammar-error) {
text-decoration-line: grammar-error;
}
}
.search {
position: absolute;
inset-inline-end: 0;
inset-block-start: 0;
}
.suggestions {
position-anchor: --suggestions;

View file

@ -1,6 +1,6 @@
import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, onMount, untrack, useContext } from 'solid-js';
import { Component, createEffect, createMemo, createSignal, For, onMount, untrack } from 'solid-js';
import { debounce } from '@solid-primitives/scheduled';
import { createSelection } from '@solid-primitives/selection';
import { createSelection, getTextNodes } from '@solid-primitives/selection';
import { createSource } from '~/features/source';
import { isServer } from 'solid-js/web';
import css from './textarea.module.css';
@ -30,7 +30,7 @@ export function Textarea(props: TextareaProps) {
});
const mutate = debounce(() => {
const [el, start, end] = selection();
const [, start, end] = selection();
const ref = editorRef();
if (ref) {
@ -74,8 +74,21 @@ export function Textarea(props: TextareaProps) {
// });
});
createEffect(() => {
createHighlights(editorRef()!, 'spelling-error', source.spellingErrors);
});
createEffect(() => {
createHighlights(editorRef()!, 'grammar-error', source.grammarErrors);
});
createEffect(() => {
createHighlights(editorRef()!, 'search-results', source.queryResults);
});
return <>
<Suggestions />
<input class={css.search} type="search" oninput={e => source.query = e.target.value} />
<div
ref={setEditorRef}
class={`${css.textarea} ${props.class}`}
@ -161,4 +174,52 @@ const findMarkerNode = (node: Node | null) => {
}
return node;
};
};
const spellChecker = checker(/\w+/gi);
const grammarChecker = checker(/\w+\s+\w+/gi);
function checker(regex: RegExp) {
return (subject: string, lang: string): [number, number][] => {
// return [];
const threshold = .75//.99;
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).map(({ 0: match, index }) => {
return [index, index + match.length] as const;
});
}
}
const createHighlights = (node: Node, type: string, ranges: [number, number][]) => {
queueMicrotask(() => {
const nodes = getTextNodes(node);
CSS.highlights.set(type, new Highlight(...ranges.map(([start, end]) => indicesToRange(start, end, nodes))));
});
};
const indicesToRange = (start: number, end: number, textNodes: Node[]) => {
const [startNode, startPos] = getRangeArgs(start, textNodes);
const [endNode, endPos] = start === end ? [startNode, startPos] : getRangeArgs(end, textNodes);
const range = new Range();
if (startNode && endNode && startPos !== -1 && endPos !== -1) {
range.setStart(startNode, startPos);
range.setEnd(endNode, endPos);
}
return range;
}
const getRangeArgs = (offset: number, texts: Node[]): [node: Node | null, offset: number] =>
texts.reduce(
([node, pos], text) =>
node
? [node, pos]
: pos <= (text as Text).data.length
? [text, pos]
: [null, pos - (text as Text).data.length],
[null, offset] as [node: Node | null, pos: number],
);

View file

@ -36,8 +36,8 @@
background-color: color(from var(--info) xyz x y z / .5);
}
& b {
color: var(--text-1);
& ::highlight(command-pelette-query) {
background-color: var(--secondary-900);
}
}
}

View file

@ -4,6 +4,7 @@ import { CommandType } from "./command";
import { useCommands } from "./context";
import css from "./palette.module.css";
import { split_by_filter } from "~/utilities";
import { getTextNodes } from "@solid-primitives/selection";
export interface CommandPaletteApi {
readonly open: Accessor<boolean>;
@ -56,14 +57,9 @@ export const CommandPalette: Component<{ api?: (api: CommandPaletteApi) => any,
};
return <dialog ref={setRoot} class={css.commandPalette} onClose={() => setOpen(false)}>
<SearchableList title="command palette" items={context.commands()} keySelector={item => t(item.label) as string} context={setSearch} onSubmit={onSubmit}>{
(item, ctx) => {
const label = t(item.label) as string;
const filter = ctx.filter().toLowerCase();
return <For each={split_by_filter(label, filter)}>{
([is_hit, part]) => <Show when={is_hit} fallback={part}><b>{part}</b></Show>
}</For>;
<SearchableList title="command palette" items={context.commands()} keySelector={item => (t(item.label) ?? item.label) as string} context={setSearch} onSubmit={onSubmit}>{
(item) => {
return <>{t(item.label) ?? item.label}</>;
}
}</SearchableList>
</dialog>;
@ -86,7 +82,7 @@ interface SearchableListProps<T> {
title?: string;
keySelector(item: T): string;
filter?: (item: T, search: string) => boolean;
children(item: T, context: SearchContext<T>): JSX.Element;
children(item: T): JSX.Element;
context?: (context: SearchContext<T>) => any,
onSubmit?: SubmitHandler<T>;
}
@ -94,6 +90,7 @@ interface SearchableListProps<T> {
function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
const [term, setTerm] = createSignal<string>('');
const [selected, setSelected] = createSignal<number>(0);
const [outputRef, setOutputRef] = createSignal<HTMLElement>();
const id = createUniqueId();
const results = createMemo(() => {
@ -131,6 +128,25 @@ function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
setSelected(current => Math.min(current, length));
});
createEffect(() => {
const filter = term();
const regexp = new RegExp(filter, 'gi');
const ref = outputRef()!;
const ranges = getTextNodes(ref).flatMap(node => {
return node.textContent!.matchAll(regexp).map(({ index }) => {
const range = new Range();
range.setStart(node, index);
range.setEnd(node, index + filter.length);
return range;
}).toArray();
});
CSS.highlights.set('command-pelette-query', new Highlight(...ranges));
});
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowUp') {
setSelected(current => Math.max(0, current - 1));
@ -162,9 +178,9 @@ function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
<form method="dialog" class={css.search} onkeydown={onKeyDown} onsubmit={onSubmit}>
<input id={`search-${id}`} value={term()} onInput={(e) => setTerm(e.target.value)} placeholder="start typing for command" autofocus autocomplete="off" enterkeyhint="go" />
<output for={`search-${id}`}>
<output ref={setOutputRef} for={`search-${id}`}>
<For each={results()}>{
(result, index) => <div class={`${index() === selected() ? css.selected : ''}`}>{props.children(result, ctx)}</div>
(result, index) => <div class={`${index() === selected() ? css.selected : ''}`}>{props.children(result)}</div>
}</For>
</output>
</form>
@ -172,5 +188,4 @@ function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
};
let keyCounter = 0;
const createUniqueId = () => `key-${keyCounter++}`;
const createUniqueId = () => `key-${keyCounter++}`;

View file

@ -1,6 +1,10 @@
import { describe, expect, it } from "bun:test";
// import { describe, expect, it } from "bun:test";
import "@testing-library/jest-dom/vitest";
import { createDataSet } from "./index";
import { createSignal } from "solid-js";
import { createEffect, createSignal } from "solid-js";
import { testEffect, } from "@solidjs/testing-library";
import { describe, expect } from "vitest";
import { it } from '~/test-helpers';
interface DataEntry {
id: string;
@ -49,6 +53,38 @@ describe('dataset', () => {
expect(actual).toEqual(expect.objectContaining({ value: defaultData() }))
});
it('update if the source value changes', () => {
// Arrange
const [data, setData] = createSignal([
{ id: '1', name: 'a first name', amount: 30 },
{ id: '2', name: 'a second name', amount: 20 },
{ id: '3', name: 'a third name', amount: 10 },
]);
const dataset = createDataSet(data);
dataset.mutateEach(item => ({ ...item, amount: item.amount * 2 }));
// Act
setData([
{ id: '4', name: 'a first name', amount: 30 },
{ id: '5', name: 'a second name', amount: 20 },
{ id: '6', name: 'a third name', amount: 10 },
]);
// Assert
return testEffect(done =>
createEffect(() => {
expect(dataset.value).toEqual([
{ id: '4', name: 'a first name', amount: 60 },
{ id: '5', name: 'a second name', amount: 40 },
{ id: '6', name: 'a third name', amount: 20 },
])
done()
})
);
});
describe('mutate', () => {
it('mutates the value', async () => {
// Arrange

View file

@ -1,5 +1,5 @@
import { Accessor, createEffect, createMemo, untrack } from "solid-js";
import { createStore, produce } from "solid-js/store";
import { createStore } from "solid-js/store";
import { CustomPartial } from "solid-js/store/types/store.js";
import { deepCopy, deepDiff, MutarionKind, Mutation } from "~/utilities";

View file

@ -1,4 +1,4 @@
import { Accessor, createEffect, createResource, createSignal, InitializedResource, onCleanup, Resource } from "solid-js";
import { Accessor, createEffect, from, createSignal } from "solid-js";
import { json } from "./parser";
import { filter } from "~/utilities";
@ -88,17 +88,11 @@ function createPolled<S, T>(source: Accessor<S>, callback: (source: S, prev: T)
};
function createTicker(interval: number): Accessor<boolean> {
const [tick, update] = createSignal(true);
return from(set => {
const ref = setInterval(() => set((v = true) => !v), interval);
const intervalId = setInterval(() => {
update(v => !v);
}, interval);
onCleanup(() => {
clearInterval(intervalId);
});
return tick;
return () => clearInterval(ref);
}) as Accessor<boolean>;
}
async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): AsyncGenerator<{ id: string, handle: FileSystemFileHandle, path: string[], file: File }, void, never> {

View file

@ -0,0 +1,49 @@
import { describe, expect } from "vitest";
import { createSource } from "./source";
import { it } from "~/test-helpers";
import { testEffect } from "@solidjs/testing-library";
import { createEffect, createSignal } from "solid-js";
describe('Source', () => {
describe('Source', () => {
it('should return a `Source`', () => {
// Arrange
// Act
const actual = createSource('');
// Assert
expect(actual.out).toBe('');
});
it('should transform the input format to output format', () => {
// Arrange
const given = '**text**\n';
const expected = '<p><strong>text</strong></p>';
// Act
const actual = createSource(given);
// Assert
expect(actual.out).toBe(expected);
});
it('should contain query results', () => {
// Arrange
const expected: [number, number][] = [[8, 9], [12, 13], [15, 16]];
const source = createSource('this is a seachable string');
// Act
source.query = 'a';
// Assert
return testEffect(done => {
createEffect(() => {
expect(source.queryResults).toEqual(expected);
done()
});
});
});
});
});

View file

@ -1,29 +1,59 @@
import { onMount } from "solid-js";
import { createEffect, onMount } from "solid-js";
import { createStore } from "solid-js/store";
import { unified, Transformer } from 'unified'
import { Node, Text, Element } from 'hast'
import { unified } from 'unified'
import { Text, Root } from 'hast'
import { visit } from "unist-util-visit";
import { decode } from "~/utilities";
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import remarkStringify from 'remark-stringify'
import rehypeParse from 'rehype-dom-parse'
import rehypeParse from 'rehype-parse'
import rehypeRemark from 'rehype-remark'
import rehypeStringify from 'rehype-dom-stringify'
import rehypeStringify from 'rehype-stringify'
interface SourceStore {
in: string;
out: string;
plain: string;
query: string;
metadata: {
spellingErrors: [number, number][];
grammarErrors: [number, number][];
queryResults: [number, number][];
};
}
export interface Source {
in: string;
out: string;
query: string;
readonly spellingErrors: [number, number][];
readonly grammarErrors: [number, number][];
readonly queryResults: [number, number][];
}
// TODO :: make this configurable, right now we can only do markdown <--> html.
const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(addErrors).use(rehypeStringify);
const outToInProcessor = unified().use(rehypeParse).use(clearErrors).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(rehypeStringify);
const outToInProcessor = unified().use(rehypeParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
export function createSource(initalValue: string): Source {
const [store, setStore] = createStore({ in: initalValue, out: '' });
const ast = inToOutProcessor.runSync(inToOutProcessor.parse(initalValue));
const out = String(inToOutProcessor.stringify(ast));
const plain = String(unified().use(plainTextStringify).stringify(ast));
onMount(() => {
setStore('out', String(inToOutProcessor.processSync(initalValue)));
const [store, setStore] = createStore<SourceStore>({ in: initalValue, out, plain, query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
createEffect(() => {
const value = store.plain;
setStore('metadata', {
spellingErrors: spellChecker(value, ''),
grammarErrors: grammarChecker(value, ''),
});
});
createEffect(() => {
setStore('metadata', 'queryResults', findMatches(store.plain, store.query).toArray());
});
return {
@ -31,9 +61,12 @@ export function createSource(initalValue: string): Source {
return store.in;
},
set in(next) {
const ast = inToOutProcessor.runSync(inToOutProcessor.parse(next));
setStore({
in: next,
out: String(inToOutProcessor.processSync(next)),
out: String(inToOutProcessor.stringify(ast)),
plain: String(unified().use(plainTextStringify).stringify(ast)),
});
},
@ -41,84 +74,67 @@ export function createSource(initalValue: string): Source {
return store.out;
},
set out(next) {
const ast = outToInProcessor.parse(next);
setStore({
in: String(outToInProcessor.processSync(next)).trim(),
in: String(outToInProcessor.stringify(outToInProcessor.runSync(ast))).trim(),
out: next,
plain: String(unified().use(plainTextStringify).stringify(ast)),
});
},
get query() {
return store.query;
},
set query(next) {
setStore('query', next)
},
get spellingErrors() {
return store.metadata.spellingErrors;
},
get grammarErrors() {
return store.metadata.grammarErrors;
},
get queryResults() {
return store.metadata.queryResults;
},
};
}
const isMarker = (node: Node) => node.type === 'element' && Object.hasOwn((node as Element).properties, 'dataMarker')
function plainTextStringify() {
this.compiler = function (tree: Root) {
const nodes: string[] = [];
function addErrors(): Transformer {
const wrapInMarker = (text: Text, type: string): Element => ({
type: 'element',
tagName: 'span',
properties: {
dataMarker: type,
},
children: [
text
]
});
return function (tree) {
visit(tree, n => n.type === 'text', (n, i, p: Element) => {
if (typeof i !== 'number' || p === undefined) {
return;
}
if (isMarker(p)) {
return;
}
const errors = grammarChecker(n.value, 'en-GB');
if (errors.length === 0) {
return;
}
p.children.splice(i, 1, ...errors.map(([isHit, value]) => {
const textNode: Text = { type: 'text', value };
return isHit ? wrapInMarker(textNode, 'grammar') : textNode;
}))
visit(tree, n => n.type === 'text', (n) => {
nodes.push((n as Text).value);
});
visit(tree, n => n.type === 'text', (n, i, p: Element) => {
if (typeof i !== 'number' || p === undefined) {
return;
}
if (isMarker(p)) {
return;
}
const errors = spellChecker(n.value, 'en-GB');
if (errors.length === 0) {
return;
}
p.children.splice(i, 1, ...errors.map(([isHit, value]) => {
const textNode: Text = { type: 'text', value };
return isHit ? wrapInMarker(textNode, 'spelling') : textNode;
}))
});
}
return decode(nodes.join(''));
};
}
function clearErrors(): Transformer {
return function (tree) {
visit(tree, isMarker, (n, i, p: Element) => {
if (typeof i !== 'number' || p === undefined) {
return;
}
function* findMatches(text: string, query: string): Generator<[number, number], void, unknown> {
if (query.length < 1) {
return;
}
p.children.splice(i, 1, ...(n as Element).children);
})
let startIndex = 0;
while (startIndex < text.length) {
const index = text.indexOf(query, startIndex);
if (index === -1) {
break;
}
const end = index + query.length;
yield [index, end];
startIndex = end;
}
}
@ -126,22 +142,13 @@ const spellChecker = checker(/\w+/gi);
const grammarChecker = checker(/\w+\s+\w+/gi);
function checker(regex: RegExp) {
return (subject: string, lang: string): (readonly [boolean, string])[] => {
return (subject: string, lang: string): [number, number][] => {
return [];
let lastIndex = 0;
const threshold = .75//.99;
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).flatMap<readonly [boolean, string]>(({ 0: match, index }) => {
const end = index + match.length;
const result = [
[false, subject.slice(lastIndex, index)],
[true, subject.slice(index, end)],
] as const;
lastIndex = end;
return result;
}).concat([[false, subject.slice(lastIndex, subject.length)]]);
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).map(({ 0: match, index }) => {
return [index, index + match.length] as const;
});
}
}

View file

@ -9,7 +9,7 @@
"title": "Hi, welcome!",
"subtitle": "Lets get started",
"edit": "Start editing",
"instructions": "Read the instructions",
"instructions": "Read the **instructions**",
"about": "Abut this app"
},
"edit": {
@ -51,4 +51,4 @@
}
}
}
}
}

View file

@ -9,8 +9,9 @@ import { HttpHeader } from "@solidjs/start";
import { FaSolidPalette } from "solid-icons/fa";
import { LocalePicker } from "~/features/i18n";
import { ColorScheme, ColorSchemePicker, getState, useTheme } from "~/features/theme";
import css from "./editor.module.css";
import { Dropdown } from "~/components/dropdown";
import { ErrorComp } from "~/components/error";
import css from "./editor.module.css";
const event = getRequestEvent();
@ -99,19 +100,5 @@ export default function Editor(props: ParentProps) {
</MenuProvider>
}
const ErrorComp: Component<{ error: Error }> = (props) => {
return <div class={css.error}>
<b>{props.error.message}</b>
<Show when={props.error.cause}>{
cause => <>{cause().description}</>
}</Show>
{props.error.stack}
<a href="/">Return to start</a>
</div>;
};
let keyCounter = 0;
const createUniqueId = () => `key-${keyCounter++}`;

View file

@ -1,8 +1,9 @@
import { ParentProps } from "solid-js";
import { ErrorBoundary, ParentProps } from "solid-js";
import { Menu } from "~/features/menu";
import { createCommand } from "~/features/command";
import { useNavigate } from "@solidjs/router";
import { ErrorComp } from "~/components/error";
export default function Experimental(props: ParentProps) {
const navigate = useNavigate();
@ -19,6 +20,8 @@ export default function Experimental(props: ParentProps) {
<Menu.Item command={goTo.withLabel('formatter').with('formatter')} />
</Menu.Root>
{props.children}
<ErrorBoundary fallback={e => <ErrorComp error={e} />}>
{props.children}
</ErrorBoundary>
</>;
}

View file

@ -1,4 +1,5 @@
.root {
position: relative;
margin: 1em;
padding: .5em;
gap: 1em;

View file

@ -108,23 +108,6 @@
}
}
.error {
display: grid;
place-content: center;
background: repeating-linear-gradient(-45deg,
color(from var(--fail) xyz x y z / .05),
color(from var(--fail) xyz x y z / .05) 10px,
color(from var(--fail) xyz x y z / .25) 10px,
color(from var(--fail) xyz x y z / .25) 12px,
color(from var(--fail) xyz x y z / .05) 12px);
color: var(--text-2);
border: 1px solid var(--fail);
border-radius: var(--radii-m);
margin: var(--padding-l);
}
@keyframes slide-left {
from {
translate: 0% 0;

14
src/test-helpers.ts Normal file
View file

@ -0,0 +1,14 @@
import { createRoot } from 'solid-js';
import { it as vit } from 'vitest';
export const it = (name: string, fn: () => any) => {
return vit(name, () => {
return createRoot(async (cleanup) => {
const res = await fn();
cleanup();
return res;
});
})
}

View file

@ -1,20 +1,9 @@
import { describe, beforeEach, it, expect, afterAll, spyOn } from 'bun:test';
import { describe, expect, vi } from 'vitest';
import { decode, deepCopy, deepDiff, filter, gen__split_by_filter, map, MutarionKind, split_by_filter, splitAt } from './utilities';
import { install } from '@sinonjs/fake-timers';
import { it } from '~/test-helpers';
type MilliSeconds = number;
const useFakeTimers = () => {
const clock = install();
const { spyOn } = vi;
beforeEach(() => clock.reset());
afterAll(() => clock.uninstall());
return {
tick(timeToAdvance: MilliSeconds) {
clock.tick(timeToAdvance);
},
};
};
const first = <T>(iterable: Iterable<T>): T | undefined => {
for (const value of iterable) {
return value;
@ -123,6 +112,18 @@ describe('utilities', () => {
expect(actual).toBe(expected);
});
it('should decode \\b characters', async () => {
// Arrange
const given = 'this is\\ba string';
const expected = 'this is\ba string';
// Act
const actual = decode(given);
// Assert
expect(actual).toBe(expected);
});
it('should decode \\n characters', async () => {
// Arrange
const given = 'this is\\na string';
@ -135,6 +136,54 @@ describe('utilities', () => {
expect(actual).toBe(expected);
});
it('should decode \\r characters', async () => {
// Arrange
const given = 'this is\\ra string';
const expected = 'this is\ra string';
// Act
const actual = decode(given);
// Assert
expect(actual).toBe(expected);
});
it('should decode \\f characters', async () => {
// Arrange
const given = 'this is\\fa string';
const expected = 'this is\fa string';
// Act
const actual = decode(given);
// Assert
expect(actual).toBe(expected);
});
it('should decode \' characters', async () => {
// Arrange
const given = 'this is\\\'a string';
const expected = 'this is\'a string';
// Act
const actual = decode(given);
// Assert
expect(actual).toBe(expected);
});
it('should decode \" characters', async () => {
// Arrange
const given = 'this is\"a string';
const expected = 'this is"a string';
// Act
const actual = decode(given);
// Assert
expect(actual).toBe(expected);
});
it('should decode \\uHHHH characters', async () => {
// Arrange
const given = 'this is \\u1234 a string';
@ -298,6 +347,18 @@ describe('utilities', () => {
expect(actual).toEqual({ kind: MutarionKind.Create, key: 'key', value: 'value' });
});
it('should yield a mutation of type create when `b` contains a value that `a` does not', async () => {
// arrange
const a = { key: null };
const b = { key: 'value' };
// Act
const actual = first(deepDiff(a, b));
// Arrange
expect(actual).toEqual({ kind: MutarionKind.Create, key: 'key', value: 'value' });
});
it('should yield a mutation of type delete when `a` contains a key that `b` does not', async () => {
// arrange
const a = { key: 'value' };
@ -310,6 +371,18 @@ describe('utilities', () => {
expect(actual).toEqual({ kind: MutarionKind.Delete, key: 'key', original: 'value' });
});
it('should yield a mutation of type delete when `a` contains a key that `b` does not', async () => {
// arrange
const a = { key: 'value' };
const b = { key: undefined };
// Act
const actual = first(deepDiff(a, b));
// Arrange
expect(actual).toEqual({ kind: MutarionKind.Delete, key: 'key', original: 'value' });
});
it('should yield a mutation of type update when the value of a key in `a` is not equal to the value of the same key in `b`', async () => {
// arrange
const a = { key: 'old' };

View file

@ -27,8 +27,11 @@ export function split_by_filter(subject: string, filter: string): (readonly [boo
return Array.from<readonly [boolean, string]>(gen__split_by_filter(subject, filter));
}
type Hex = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 'a' | 'b' | 'c' | 'd' | 'e' | 'f';
type EncodedChar = 't' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | `u${Hex}${Hex | ''}${Hex | ''}${Hex | ''}`
const decodeRegex = /(?<!\\)\\(t|b|n|r|f|'|"|u[0-9a-f]{1,4})/gi;
const decodeReplacer = (_: any, char: string) => ({
const decodeReplacer = (_: any, char: EncodedChar) => ({
t: '\t',
b: '\b',
n: '\n',
@ -37,7 +40,7 @@ const decodeReplacer = (_: any, char: string) => ({
"'": '\'',
'"': '\"',
u: String.fromCharCode(Number.parseInt(`0x${char.slice(1)}`)),
}[char.charAt(0)] ?? '');
}[char.charAt(0) as ('t' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | 'u')]);
export const decode = (subject: string): string => subject.replace(decodeRegex, decodeReplacer);
export const deepCopy = <T>(original: T): T => {
@ -110,8 +113,13 @@ export function* deepDiff<T1 extends object, T2 extends object>(a: T1, b: T2, pa
const key = path.concat(keyA!.toString()).join('.');
yield ((): Mutation => {
if (valueA === null || valueA === undefined) return { key, kind: MutarionKind.Create, value: valueB };
if (valueB === null || valueB === undefined) return { key, kind: MutarionKind.Delete, original: valueA };
if (valueA === null || valueA === undefined) {
return { key, kind: MutarionKind.Create, value: valueB };
}
if (valueB === null || valueB === undefined) {
return { key, kind: MutarionKind.Delete, original: valueA };
}
return { key, kind: MutarionKind.Update, value: valueB, original: valueA };
})();
@ -200,7 +208,7 @@ const bufferredIterator = <T extends readonly [string | number, any]>(subject: I
const next = () => {
const res = iterator.next();
done = res.done ?? false;
done = res.done!;
if (!done) {
buffer.push(res.value);