diff --git a/.gitignore b/.gitignore index d16c893..cd61559 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ dist +.coverage .solid .output .vercel diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5fc0918 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "bun", + "request": "launch", + "name": "Debug Bun", + // The path to a JavaScript or TypeScript file to run. + "program": "${file}", + // The arguments to pass to the program, if any. + "args": [], + // The working directory of the program. + "cwd": "${workspaceFolder}", + // The environment variables to pass to the program. + "env": {}, + // If the environment variables should not be inherited from the parent process. + "strictEnv": false, + // If the program should be run in watch mode. + // This is equivalent to passing `--watch` to the `bun` executable. + // You can also set this to "hot" to enable hot reloading using `--hot`. + "watchMode": false, + // If the debugger should stop on the first line of the program. + "stopOnEntry": false, + // If the debugger should be disabled. (for example, breakpoints will not be hit) + "noDebug": false, + // The path to the `bun` executable, defaults to your `PATH` environment variable. + "runtime": "bun", + // The arguments to pass to the `bun` executable, if any. + // Unlike `args`, these are passed to the executable itself, not the program. + "runtimeArgs": [], + }, + { + "type": "bun", + "request": "attach", + "name": "Attach to Bun", + // The URL of the WebSocket inspector to attach to. + // This value can be retreived by using `bun --inspect`. + "url": "ws://localhost:6499/", + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e57826f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + // The path to the `bun` executable. + "bun.runtime": "/path/to/bun", + "bun.debugTerminal": { + // If support for Bun should be added to the default "JavaScript Debug Terminal". + "enabled": true, + // If the debugger should stop on the first line of the program. + "stopOnEntry": false, + } +} \ No newline at end of file diff --git a/app.config.ts b/app.config.ts index d2f21f0..9c8e86f 100644 --- a/app.config.ts +++ b/app.config.ts @@ -7,69 +7,64 @@ export default defineConfig({ cspNonce: 'KAAS_IS_AWESOME', }, plugins: [ - VitePWA({ - strategies: 'injectManifest', - mode: 'development', + // VitePWA({ + // strategies: 'injectManifest', + // registerType: 'autoUpdate', + // injectRegister: false, - registerType: 'autoUpdate', - injectRegister: false, + // workbox: { + // globPatterns: ['**/*.{js,css,html,svg,png,svg,ico}'], + // cleanupOutdatedCaches: true, + // clientsClaim: true, + // }, + // injectManifest: { + // globPatterns: ['**/*.{js,css,html,svg,png,svg,ico}'], + // }, - // pwaAssets: { disabled: false, config: true, htmlPreset: '2023', overrideManifestIcons: true }, - workbox: { - globPatterns: ['**/*.{js,css,html,svg,png,svg,ico}'], - cleanupOutdatedCaches: true, - clientsClaim: true, - }, - injectManifest: { - globPatterns: ['**/*.{js,css,html,svg,png,svg,ico}'], - }, + // manifest: { + // name: 'Calque - manage your i18n files', + // short_name: 'KAAS', + // description: 'Simple tool for maitaining i18n files', + // icons: [ + // { + // src: '/images/favicon.dark.svg', + // type: 'image/svg+xml', + // sizes: 'any' + // } + // ], + // display_override: ['window-controls-overlay'], + // screenshots: [ + // { + // src: '/images/screenshots/narrow.png', + // type: 'image/png', + // sizes: '538x1133', + // form_factor: 'narrow' + // }, + // { + // src: '/images/screenshots/wide.png', + // type: 'image/png', + // sizes: '2092x1295', + // form_factor: 'wide' + // } + // ], + // file_handlers: [ + // { + // action: '/edit', + // accept: { + // 'text/*': [ + // '.json' + // ] + // } + // } + // ] + // }, - base: '/', - manifest: { - name: 'Calque - manage your i18n files', - short_name: 'KAAS', - description: 'Simple tool for maitaining i18n files', - icons: [ - { - src: '/images/favicon.dark.svg', - type: 'image/svg+xml', - sizes: 'any' - } - ], - display_override: ['window-controls-overlay'], - screenshots: [ - { - src: '/images/screenshots/narrow.png', - type: 'image/png', - sizes: '538x1133', - form_factor: 'narrow' - }, - { - src: '/images/screenshots/wide.png', - type: 'image/png', - sizes: '2092x1295', - form_factor: 'wide' - } - ], - file_handlers: [ - { - action: '/edit', - accept: { - 'text/*': [ - '.json' - ] - } - } - ] - }, - - devOptions: { - enabled: true, - type: 'module', - navigateFallback: 'index.html', - resolveTempFolder: () => './.output/public', - }, - }), + // devOptions: { + // enabled: true, + // type: 'module', + // navigateFallback: 'index.html', + // }, + // }), ], }, solid: { @@ -81,8 +76,5 @@ export default defineConfig({ prerender: { crawlLinks: true, }, - routeRules: { - '/manifest.json': { static: true } - }, }, }); diff --git a/bun.lockb b/bun.lockb index 624c3fe..ff7924a 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..67811e5 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +[test] +coverage = true +coverageReporter = ['text', 'lcov'] +coverageDir = './.coverage' +preload = "./test.config.ts" diff --git a/package.json b/package.json index a11bdce..d3ce423 100644 --- a/package.json +++ b/package.json @@ -18,18 +18,20 @@ "dev": "vinxi dev", "build": "vinxi build", "start": "vinxi start", - "version": "vinxi version", - "test": "vitest" + "version": "vinxi version" }, "type": "module", "devDependencies": { + "@happy-dom/global-registrator": "^15.11.0", + "@sinonjs/fake-timers": "^13.0.5", "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.5.2", + "@types/sinonjs__fake-timers": "^8.1.5", "@types/wicg-file-system-access": "^2023.10.5", + "bun-types": "^1.1.34", "jsdom": "^25.0.1", "vite-plugin-pwa": "^0.20.5", - "vitest": "^2.1.4", "workbox-window": "^7.3.0" } } \ No newline at end of file diff --git a/src/components/colorschemepicker.spec.tsx b/src/components/colorschemepicker.spec.tsx new file mode 100644 index 0000000..037adc9 --- /dev/null +++ b/src/components/colorschemepicker.spec.tsx @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'bun:test'; +import { render } from "@solidjs/testing-library" +import { ColorSchemePicker } from "./colorschemepicker"; + +// describe('', () => { +// it('should render', async () => { +// render(() => ); + +// expect(true).toBe(true); +// }); +// }); \ No newline at end of file diff --git a/src/utilities.spec.ts b/src/utilities.spec.ts index 46e096c..6b87caf 100644 --- a/src/utilities.spec.ts +++ b/src/utilities.spec.ts @@ -1,22 +1,430 @@ -import { expect, describe, it, beforeEach, vi } from "vitest" -import { debounce } from "./utilities" +import { describe, beforeEach, it, expect, mock, afterAll, spyOn } from 'bun:test'; +import { debounce, deepCopy, deepDiff, filter, map, MutarionKind, splitAt } from './utilities'; +import { install } from '@sinonjs/fake-timers'; + +type MilliSeconds = number; +const useFakeTimers = () => { + const clock = install(); + + beforeEach(() => clock.reset()); + afterAll(() => clock.uninstall()); + + return { + tick(timeToAdvance: MilliSeconds) { + clock.tick(timeToAdvance); + }, + }; +}; +const first = (iterable: Iterable): T | undefined => { + for (const value of iterable) { + return value; + } +} + +describe('splitAt', () => { + it('should split the given string at the given index', async () => { + // Arrange + const given = 'this.is.some.concatenated.string'; + const expected = [ + 'this.is.some.concatenated', + 'string', + ] as const; + + // Act + const [a, b] = splitAt(given, given.lastIndexOf('.')); + + // Assert + expect(a).toBe(expected[0]); + expect(b).toBe(expected[1]); + }); + + it('should return an empty second result when the index is negative', async () => { + // Arrange + const given = 'this.is.some.concatenated.string'; + const expected = [ + 'this.is.some.concatenated.string', + '', + ] as const; + + // Act + const [a, b] = splitAt(given, -1); + + // Assert + expect(a).toBe(expected[0]); + expect(b).toBe(expected[1]); + }); + + it('should return an empty second result when the index is larger then subject length', async () => { + // Arrange + const given = 'this.is.some.concatenated.string'; + const expected = [ + 'this.is.some.concatenated.string', + '', + ] as const; + + // Act + const [a, b] = splitAt(given, given.length * 2); + + // Assert + expect(a).toBe(expected[0]); + expect(b).toBe(expected[1]); + }); +}); describe('debounce', () => { - beforeEach(() => { - vi.useFakeTimers(); - }) + const { tick } = useFakeTimers(); it('should run the given callback after the provided time', async () => { // Arrange - const callback = vi.fn(() => { }); + const callback = mock(() => { }); const delay = 1000; const debounced = debounce(callback, delay); // Act debounced(); - vi.runAllTimers(); + tick(delay); // Assert - expect(callback).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledTimes(1); }); -}); \ No newline at end of file + + it('should reset if another call is made', async () => { + // Arrange + const callback = mock(() => { }); + const delay = 1000; + const debounced = debounce(callback, delay); + + // Act + debounced(); + tick(delay / 2); + debounced(); + tick(delay); + + // Assert + expect(callback).toHaveBeenCalledTimes(1); + }); +}); + +describe('deepCopy', () => { + it('can skip values passed by reference (non-objects, null, and undefined)', async () => { + // arrange + const given = 'some string'; + + // Act + const actual = deepCopy(given); + + // Arrange + expect(actual).toBe(given); + }); + + it('should return a value that does not point to same memory', async () => { + // Arrange + const given = {}; + + // Act + const actual = deepCopy(given); + + // Assert + expect(actual).not.toBe(given); + }); + + it('should handle Date types', async () => { + // Arrange + const given = new Date(); + + // Act + const actual = deepCopy(given); + + // Assert + expect(actual).not.toBe(given); + }); + + it('should handle Arrays', async () => { + // Arrange + const given: any[] = []; + + // Act + const actual = deepCopy(given); + + // Assert + expect(actual).not.toBe(given); + }); + + it('should handle Sets', async () => { + // Arrange + const given = new Set(); + + // Act + const actual = deepCopy(given); + + // Assert + expect(actual).not.toBe(given); + }); + + it('should handle Maps', async () => { + // Arrange + const given = new Map(); + + // Act + const actual = deepCopy(given); + + // Assert + expect(actual).not.toBe(given); + }); + + it('should return a value that does not point to same memory for nested properties', async () => { + // Arrange + const given = { + some: { + deep: { + value: {} + } + } + }; + + // Act + const actual = deepCopy(given); + + // Assert + expect(actual.some.deep.value).not.toBe(given.some.deep.value); + }); +}); + +describe('deepDiff', () => { + it('should immedietly return when either `a` is not iterable', async () => { + // arrange + const a: any = 0; + const b = {}; + const spy = spyOn(console, 'error').mockReturnValue(undefined); + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([]); + expect(spy).toHaveBeenCalled(); + }); + + it('should immedietly return when either `b` is not iterable', async () => { + // arrange + const a = {}; + const b: any = 0; + const spy = spyOn(console, 'error').mockReturnValue(undefined); + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([]); + expect(spy).toHaveBeenCalled(); + }); + + it('should yield no results when both a and b are empty', async () => { + // arrange + const a = {}; + const b = {}; + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([]); + }); + + it('should yield no results when both a and b are equal', async () => { + // arrange + const a = { key: 'value' }; + const b = { key: 'value' }; + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([]); + }); + + it('should yield a mutation of type create when `b` contains a key that `a` does not', async () => { + // arrange + const a = {}; + 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' }; + const b = {}; + + // Act + const actual = first(deepDiff(a, b)); + + // Arrange + expect(actual).toEqual({ kind: MutarionKind.Delete, key: 'key' }); + }); + + 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' }; + const b = { key: 'new' }; + + // Act + const actual = first(deepDiff(a, b)); + + // Arrange + expect(actual).toEqual({ kind: MutarionKind.Update, key: 'key', original: 'old', value: 'new' }); + }); + + it('should iterate over nested values', async () => { + // arrange + const a = { some: { nested: { key: 'old' } } }; + const b = { some: { nested: { key: 'new' } } }; + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([{ kind: MutarionKind.Update, key: 'some.nested.key', original: 'old', value: 'new' }]); + }); + + it('should handle deleted keys', async () => { + // arrange + const a = { key1: 'value1', key2: 'value2', key3: 'value3', key4: 'value4', key5: 'value5' }; + const b = { key1: 'value1', key4: 'value4', key5: 'value5' }; + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([ + { kind: MutarionKind.Delete, key: 'key2' }, + { kind: MutarionKind.Delete, key: 'key3' }, + ]); + }); + + it('should handle created keys', async () => { + // arrange + const a = { key1: 'value1', key4: 'value4', key5: 'value5' }; + const b = { key1: 'value1', key2: 'value2', key3: 'value3', key4: 'value4', key5: 'value5' }; + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([ + { kind: MutarionKind.Create, key: 'key2', value: 'value2' }, + { kind: MutarionKind.Create, key: 'key3', value: 'value3' }, + ]); + }); + + it('should handle renamed keys', async () => { + // arrange + const a = { key1: 'value1', key2_old: 'value2', key3: 'value3' }; + const b = { key1: 'value1', key2_new: 'value2', key3: 'value3', }; + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([ + { kind: MutarionKind.Delete, key: 'key2_old' }, + { kind: MutarionKind.Create, key: 'key2_new', value: 'value2' }, + ]); + }); + + it('should handle `Array` values', async () => { + // arrange + const a = { key: [1] }; + const b = { key: [2] }; + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([ + { kind: MutarionKind.Update, key: 'key.0', original: 1, value: 2 }, + ]); + }); + + it('should handle `Set` values', async () => { + // arrange + const a = { key: new Set([1, 2, 3]) }; + const b = { key: new Set([1, 5, 3]) }; + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([ + { kind: MutarionKind.Delete, key: 'key.2' }, + { kind: MutarionKind.Create, key: 'key.5', value: 5 }, + ]); + }); + + it('should handle `Map` values', async () => { + // arrange + const a = { key: new Map([['key', 'old']]) }; + const b = { key: new Map([['key', 'new']]) }; + + // Act + const actual = deepDiff(a, b).toArray(); + + // Arrange + expect(actual).toEqual([ + { kind: MutarionKind.Update, key: 'key.key', original: 'old', value: 'new' }, + ]); + }); +}); + +describe('filter', () => { + it('should yield a value when the predicate returns true', async () => { + // arrange + const generator = async function* () { + for (const i of new Array(10).fill('').map((_, i) => i)) { + yield i; + } + }; + const predicate = (i: number) => i % 2 === 0; + + // Act + const actual = await Array.fromAsync(filter(generator(), predicate as any)); + + // Arrange + expect(actual).toEqual([0, 2, 4, 6, 8]); + }); +}); + +describe('map', () => { + const generator = async function* () { + for (const i of new Array(10).fill('').map((_, i) => i)) { + yield i; + } + }; + + it('should yield a value when the predicate returns true', async () => { + // arrange + const mapFn = (i: number) => `nr ${i}`; + + // Act + const actual = await Array.fromAsync(map(generator(), mapFn)); + + // Arrange + expect(actual).toEqual([ + 'nr 0', + 'nr 1', + 'nr 2', + 'nr 3', + 'nr 4', + 'nr 5', + 'nr 6', + 'nr 7', + 'nr 8', + 'nr 9', + ]); + }); +}); + diff --git a/src/utilities.ts b/src/utilities.ts index b5e000b..8408a4e 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -1,5 +1,13 @@ export const splitAt = (subject: string, index: number): readonly [string, string] => { - return [subject.slice(0, index), subject.slice(index + 1)] as const; + if (index < 0) { + return [subject, '']; + } + + if (index > subject.length) { + return [subject, '']; + } + + return [subject.slice(0, index), subject.slice(index + 1)]; }; export const debounce = void>(callback: T, delay: number): ((...args: Parameters) => void) => { @@ -53,23 +61,19 @@ export type Mutation = { key: string } & (Created | Updated | Deleted); export function* deepDiff(a: T1, b: T2, path: string[] = []): Generator { if (!isIterable(a) || !isIterable(b)) { - console.log('Edge cases', a, b); + console.error('Edge cases', a, b); return; } for (const [[keyA, valueA], [keyB, valueB]] of zip(entriesOf(a), entriesOf(b))) { - if (!keyA && !keyB) { - throw new Error('this code should not be reachable, there is a bug with an unhandled/unknown edge case'); - } - - if (!keyA && keyB) { + if (keyA === undefined && keyB) { yield { key: path.concat(keyB.toString()).join('.'), kind: MutarionKind.Create, value: valueB }; continue; } - if (keyA && !keyB) { + if (keyA && keyB === undefined) { yield { key: path.concat(keyA.toString()).join('.'), kind: MutarionKind.Delete }; continue; @@ -112,40 +116,42 @@ const entriesOf = (subject: object): Iterable = return Object.entries(subject); }; -const zip = function* (a: Iterable, b: Iterable): Generator { + +type ZippedPair = + | readonly [readonly [string | number, any], readonly [string | number, any]] + | readonly [readonly [undefined, undefined], readonly [string | number, any]] + | readonly [readonly [string | number, any], readonly [undefined, undefined]] + ; +const zip = function* (a: Iterable, b: Iterable): Generator { const iterA = bufferredIterator(a); const iterB = bufferredIterator(b); - const EMPTY = [undefined, undefined] as [string | number | undefined, any]; + const EMPTY = [undefined, undefined] as const; while (!iterA.done || !iterB.done) { // if we have a match on the keys of a and b we can simply consume and yield if (iterA.current.key === iterB.current.key) { + // When we match keys it could have happened that + // there are as many keys added as there are deleted, + // therefor we can now flush both a and b because we are aligned + yield* iterA.flush().map(entry => [entry, EMPTY] as const); + yield* iterB.flush().map(entry => [EMPTY, entry] as const); + yield [iterA.consume(), iterB.consume()]; } // key of a aligns with last key in buffer b // conclusion: a has key(s) that b does not else if (iterA.current.key === iterB.top.key) { - const a = iterA.pop()!; - - for (const [key, value] of iterA.flush()) { - yield [[key, value], EMPTY]; - } - - yield [a, iterB.consume()]; + yield* iterA.flush().map(entry => [entry, EMPTY] as const); + yield [iterA.consume(), iterB.consume()]; } // the reverse case, key of b is aligns with the last key in buffer a // conclusion: a is missing key(s) the b does have else if (iterB.current.key === iterA.top.key) { - const b = iterB.pop()!; - - for (const [key, value] of iterB.flush()) { - yield [EMPTY, [key, value]]; - } - - yield [iterA.consume(), b]; + yield* iterB.flush().map(entry => [EMPTY, entry] as const); + yield [iterA.consume(), iterB.consume()]; } else if (iterA.done && !iterB.done) { @@ -168,7 +174,6 @@ const zip = function* (a: Iterable, b: Iterable const bufferredIterator = (subject: Iterable) => { const iterator = Iterator.from(subject); const buffer: T[] = []; - let cursor: number = 0; let done = false; const next = () => { @@ -176,7 +181,7 @@ const bufferredIterator = (subject: I done = res.done ?? false; if (!done) { - cursor = buffer.push(res.value) - 1; + buffer.push(res.value) } }; @@ -184,16 +189,10 @@ const bufferredIterator = (subject: I return { advance() { - if (buffer.length > 0 && cursor < (buffer.length - 1)) { - cursor++; - } - else { - next(); - } + next(); }, consume() { - cursor = 0; const value = buffer.shift()!; this.advance(); @@ -202,15 +201,9 @@ const bufferredIterator = (subject: I }, flush(): T[] { - cursor = 0; + const entries = buffer.splice(0, buffer.length - 1); - return buffer.splice(0, buffer.length); - }, - - pop() { - cursor--; - - return buffer.pop(); + return entries; }, get done() { @@ -224,14 +217,10 @@ const bufferredIterator = (subject: I }, get current() { - const [key = undefined, value = undefined] = buffer.at(cursor) ?? []; + const [key = undefined, value = undefined] = buffer.at(-1) ?? []; return { key, value }; }, - - get entry() { - return [this.current.key, this.current.value] as const; - } }; }; diff --git a/test.config.ts b/test.config.ts new file mode 100644 index 0000000..9cae201 --- /dev/null +++ b/test.config.ts @@ -0,0 +1,3 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +GlobalRegistrator.register(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 65df43c..2464cc2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "@testing-library/jest-dom", "@types/wicg-file-system-access", "vinxi/types/client", - "vite-plugin-pwa/solid" + "vite-plugin-pwa/solid", + "bun-types" ], "isolatedModules": true, "paths": { diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index a13a54d..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import solid from "vite-plugin-solid" -import { defineConfig } from "vitest/config" - -export default defineConfig({ - plugins: [solid()], - root: './src', - resolve: { - conditions: ["development", "browser"], - }, -}) \ No newline at end of file