diff --git a/packages/viewer/.vscode/keybindings.json b/packages/viewer/.vscode/keybindings.json new file mode 100644 index 00000000..c7882058 --- /dev/null +++ b/packages/viewer/.vscode/keybindings.json @@ -0,0 +1,27 @@ +[ + { + "key": "cmd+shift+t", + "command": "workbench.action.tasks.runTask", + "args": "Run Tests" + }, + { + "key": "cmd+shift+w", + "command": "workbench.action.tasks.runTask", + "args": "Watch Tests" + }, + { + "key": "cmd+shift+c", + "command": "workbench.action.tasks.runTask", + "args": "Test with Coverage" + }, + { + "key": "cmd+shift+v", + "command": "workbench.action.tasks.runTask", + "args": "View Coverage Report" + }, + { + "key": "cmd+shift+y", + "command": "workbench.action.tasks.runTask", + "args": "Open Cypress" + } +] diff --git a/packages/viewer/.vscode/launch.json b/packages/viewer/.vscode/launch.json new file mode 100644 index 00000000..83d3d8f2 --- /dev/null +++ b/packages/viewer/.vscode/launch.json @@ -0,0 +1,44 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Current Test File", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", + "args": ["run", "${relativeFile}"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "cwd": "${workspaceFolder}", + "env": { + "NODE_ENV": "test" + } + }, + { + "name": "Debug All Tests", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", + "args": ["run"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "cwd": "${workspaceFolder}", + "env": { + "NODE_ENV": "test" + } + }, + { + "name": "Debug Tests in Watch Mode", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", + "args": ["--watch"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "cwd": "${workspaceFolder}", + "env": { + "NODE_ENV": "test" + } + } + ] +} diff --git a/packages/viewer/.vscode/settings.json b/packages/viewer/.vscode/settings.json new file mode 100644 index 00000000..f96332aa --- /dev/null +++ b/packages/viewer/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "vitest.enable": true, + "vitest.workspaceConfig": "./vite.config.js", + "vitest.commandLine": "npm run test:watch", + "testing.automaticallyOpenPeekView": "failureInVisibleDocument", + "testing.defaultGutterClickAction": "run", + "testing.followRunningTest": "true", + "testing.openTesting": "neverOpen", + "files.watcherExclude": { + "**/coverage/**": true + }, + "testing.automaticallyOpenTestResults": "neverOpen" +} diff --git a/packages/viewer/.vscode/tasks.json b/packages/viewer/.vscode/tasks.json new file mode 100644 index 00000000..79d13207 --- /dev/null +++ b/packages/viewer/.vscode/tasks.json @@ -0,0 +1,113 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Tests", + "type": "shell", + "command": "npm run test", + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": [] + }, + { + "label": "Watch Tests", + "type": "shell", + "command": "npm run test:watch", + "group": "test", + "isBackground": true, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": [] + }, + { + "label": "Test with Coverage", + "type": "shell", + "command": "npm run test:cov", + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": [] + }, + { + "label": "View Coverage Report", + "type": "shell", + "command": "open", + "args": ["coverage/index.html"], + "group": "test", + "dependsOn": "Test with Coverage", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": false + }, + "problemMatcher": [] + }, + { + "label": "Open Cypress", + "type": "shell", + "command": "npm run cy:open", + "group": "test", + "isBackground": true, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": [] + }, + { + "label": "Run Single Test File", + "type": "shell", + "command": "npx vitest run ${relativeFile}", + "group": "test", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": [] + }, + { + "label": "Clean Coverage", + "type": "shell", + "command": "rm -rf coverage", + "group": "build", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": false + } + } + ] +} diff --git a/packages/viewer/src/analytics.ts b/packages/viewer/src/analytics.ts new file mode 100644 index 00000000..4d5dea7a --- /dev/null +++ b/packages/viewer/src/analytics.ts @@ -0,0 +1,141 @@ +/** + * Cloudflare Workers Analytics Engine integration utility + */ + +export interface AnalyticsEngineDataset { + writeDataPoint(data: AnalyticsDataPoint): void +} + +export interface AnalyticsDataPoint { + blobs?: (string | null)[] + doubles?: number[] + indexes?: (string | null)[] +} + +export interface WorkerMetrics { + duration: number + endpoint: string + method: string + status: number + userAgent?: string + country?: string + timestamp: number +} + +/** + * Helper class for sending metrics to Cloudflare Analytics Engine + */ +export class AnalyticsReporter { + private analyticsEngine: AnalyticsEngineDataset | null = null + + constructor(analyticsEngine?: AnalyticsEngineDataset) { + this.analyticsEngine = analyticsEngine || null + } + + /** + * Report worker execution metrics to Analytics Engine + */ + reportWorkerMetrics(metrics: WorkerMetrics): void { + if (!this.analyticsEngine) { + // In development or when Analytics Engine is not available + console.log('Worker Metrics:', metrics) + return + } + + try { + this.analyticsEngine.writeDataPoint({ + // Indexed fields (can be queried and filtered) + indexes: [ + metrics.endpoint, // index[1]: endpoint path + metrics.method, // index[2]: HTTP method + metrics.status.toString(), // index[3]: status code + metrics.country || null, // index[4]: user country + ], + // Blob fields (string data) + blobs: [ + metrics.userAgent || null, // blob[1]: user agent + ], + // Double fields (numeric data for aggregation) + doubles: [ + metrics.duration, // double[1]: duration in milliseconds + metrics.timestamp, // double[2]: timestamp + ], + }) + } catch (error) { + console.error('Failed to report analytics:', error) + } + } + + /** + * Report custom metrics to Analytics Engine + */ + reportCustomMetric( + name: string, + value: number, + tags: Record = {}, + ): void { + if (!this.analyticsEngine) { + console.log('Custom Metric:', { name, value, tags }) + return + } + + try { + const indexes = [name, null, null, null] // Start with metric name + const tagKeys = Object.keys(tags).slice(0, 3) // Limit to 3 additional tags + + tagKeys.forEach((key, index) => { + indexes[index + 1] = tags[key] + }) + + this.analyticsEngine.writeDataPoint({ + indexes: indexes as (string | null)[], + doubles: [value, Date.now()], + blobs: [null], + }) + } catch (error) { + console.error('Failed to report custom metric:', error) + } + } +} + +/** + * Extract country from Cloudflare request headers + */ +export function getCountryFromRequest(request: Request): string | undefined { + return request.headers.get('CF-IPCountry') || undefined +} + +/** + * Create a performance timing wrapper for functions + */ +export function withTiming( + fn: (...args: T) => R | Promise, + onComplete: (duration: number) => void, +): (...args: T) => R | Promise { + return (...args: T) => { + const startTime = performance.now() + + const handleComplete = (result: R) => { + const duration = performance.now() - startTime + onComplete(duration) + return result + } + + try { + const result = fn(...args) + if (result instanceof Promise) { + return result.then(handleComplete).catch(error => { + const duration = performance.now() - startTime + onComplete(duration) + throw error + }) + } else { + return handleComplete(result) + } + } catch (error) { + const duration = performance.now() - startTime + onComplete(duration) + throw error + } + } +} diff --git a/packages/viewer/src/app.d.ts b/packages/viewer/src/app.d.ts new file mode 100644 index 00000000..c70ce999 --- /dev/null +++ b/packages/viewer/src/app.d.ts @@ -0,0 +1,23 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + interface Platform { + env: { + ANALYTICS_ENGINE?: import('./src/analytics.js').AnalyticsEngineDataset + // Add other Cloudflare environment variables as needed + [key: string]: unknown + } + context: { + waitUntil(promise: Promise): void + } + caches: CacheStorage & { default: Cache } + } + } +} + +export {} diff --git a/packages/viewer/src/context.test.ts b/packages/viewer/src/context.test.ts new file mode 100644 index 00000000..6b2fd21c --- /dev/null +++ b/packages/viewer/src/context.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi, type MockedFunction } from 'vitest' +import { initialize } from './context' +import { writable, type Writable } from 'svelte/store' +import * as Errors from './errors' +import type * as Gateway from './gateway' +import type { Local } from './repositories' +import { renderer } from './parser' +import { type Prestore } from './stores' +import { property, space, theorem } from './__test__/factories' +import { get } from 'svelte/store' +import type { Result } from './gateway' + +type InitializeParams = Parameters[0] + +type SetupParams = { + local: Partial + remote: Result +} & Pick + +describe(initialize, () => { + let db: Local & { + subscribe: MockedFunction['subscribe']> + } + let errorHandler: MockedFunction + let gateway: MockedFunction + let mockRenderer: typeof renderer + + function setup(args: Partial = {}) { + vi.clearAllMocks() + + errorHandler = vi.fn() + mockRenderer = vi.fn() + + const prestore: Prestore = { + spaces: [], + properties: [], + theorems: [], + traits: [], + deduction: { checked: new Set(), all: new Set() }, + source: { host: 'example.com', branch: 'main' }, + sync: undefined, + ...args.local, + } + + db = { + load: () => prestore, + subscribe: vi.fn(), + } + + gateway = vi.fn(async (_host: string, _branch: string) => args.remote) + + return initialize({ + db, + errorHandler, + gateway, + typesetter: mockRenderer, + showDev: args.showDev || false, + source: args.source || {}, + }) + } + + it('should create a context with default dependencies', () => { + const { showDev, errorHandler } = setup() + + expect(showDev).toBe(false) + expect(errorHandler).toEqual(errorHandler) + }) + + it('use the provided source', () => { + const { source } = setup({ + source: { host: 'default.com', branch: 'master' }, + }) + + expect(get(source)).toEqual({ host: 'default.com', branch: 'master' }) + }) + + it('should trigger sync when local sync state is undefined', () => { + setup({ + local: { + source: { host: 'example.com', branch: 'main' }, + sync: undefined, + }, + }) + + expect(gateway).toHaveBeenCalledWith('example.com', 'main', undefined) + }) + + it('persists remote sync state to the local db', async () => { + const context = setup({ + local: { + source: { host: 'example.com', branch: 'main' }, + sync: undefined, + }, + remote: { + spaces: [space({ id: 123 })], + properties: [property({ id: 456 })], + theorems: [], + traits: [], + etag: 'etag', + sha: 'sha', + }, + }) + + await context.loaded() + + const { spaces, properties, sync } = db.subscribe.mock.calls[0][0] + + expect(get(spaces).map(s => s.id)).toEqual([123]) + expect(get(properties).map(p => p.id)).toEqual([456]) + expect(get(sync)).toEqual( + expect.objectContaining({ + kind: 'fetched', + value: { etag: 'etag', sha: 'sha' }, + }), + ) + }) + + it('runs deductions', async () => { + const context = setup({ + local: { + spaces: [space({ id: 1 })], + properties: [property({ id: 1 }), property({ id: 2 })], + traits: [ + { + asserted: true, + space: 1, + property: 1, + value: true, + description: '', + refs: [], + }, + ], + theorems: [ + theorem({ + id: 1, + when: { kind: 'atom', property: 1, value: true }, + then: { kind: 'atom', property: 2, value: true }, + }), + ], + }, + }) + + await context.checked('S1') + + const { trait, proof } = get(context.traits).lookup({ + spaceId: 'S1', + propertyId: 'P2', + theorems: get(context.theorems), + })! + + expect(trait?.value).toEqual(true) + expect(proof?.theorems.map(t => t.id)).toEqual([1]) + }) + + it('can wait for a space to be checked', async () => { + const { checked } = setup({ + local: { + spaces: [space({ id: 123 })], + }, + }) + + expect(checked('S123')).resolves.toBeUndefined() + }) + + describe('load', () => { + let store: Writable + + it('resolves when lookup finds value', async () => { + store = writable(0) + const context = setup() + + const loadPromise = context.load(store, n => (n > 10 ? n : false)) + + store.set(5) + store.set(13) + + await expect(loadPromise).resolves.toBe(13) + }) + + it('rejects when until promise resolves first', async () => { + store = writable(0) + const context = setup() + + const loadPromise = context.load(store, n => n > 0, Promise.resolve()) + + await expect(loadPromise).rejects.toBeUndefined() + }) + }) +}) diff --git a/packages/viewer/src/context.ts b/packages/viewer/src/context.ts index 8f7778a7..5ad3a726 100644 --- a/packages/viewer/src/context.ts +++ b/packages/viewer/src/context.ts @@ -2,7 +2,7 @@ export type { Context } from './context/types' import { formula as F } from '@pi-base/core' import { getContext, setContext } from 'svelte' -import { derived, get, type Readable } from 'svelte/store' +import { derived, type Readable } from 'svelte/store' import type { Context } from '@/context/types' import { trace } from '@/debug' diff --git a/packages/viewer/src/hooks.server.ts b/packages/viewer/src/hooks.server.ts index cb9ec669..bc6eaf56 100644 --- a/packages/viewer/src/hooks.server.ts +++ b/packages/viewer/src/hooks.server.ts @@ -1,12 +1,102 @@ import type { Handle } from '@sveltejs/kit' +import { + AnalyticsReporter, + getCountryFromRequest, + withTiming, + type AnalyticsEngineDataset, +} from './analytics.js' // See https://kit.svelte.dev/docs/hooks#server-hooks export const handle: Handle = async ({ event, resolve }) => { - return resolve(event, { - // We use the `etag` header from S3 to determine whether the bundle has - // changed (and deduction needs to be re-run), so we need to preserve it. - filterSerializedResponseHeaders(name, _value) { - return name === 'etag' - }, - }) + const startTime = performance.now() + + // Initialize Analytics Engine if available (in Cloudflare Workers environment) + console.log({ platform: event.platform }) + const analyticsEngine = event.platform?.env?.ANALYTICS_ENGINE as + | AnalyticsEngineDataset + | undefined + const analytics = new AnalyticsReporter(analyticsEngine) + + // Extract request information for metrics + const method = event.request.method + const url = new URL(event.request.url) + const endpoint = url.pathname + const userAgent = event.request.headers.get('User-Agent') || undefined + const country = getCountryFromRequest(event.request) + + try { + // Wrap the resolve function with timing + const timedResolve = withTiming( + (evt: typeof event, options: Parameters[1]) => + resolve(evt, options), + duration => { + // This will be called when resolve completes + console.log( + `Request ${method} ${endpoint} took ${duration.toFixed(2)}ms`, + ) + }, + ) + + const response = await timedResolve(event, { + // We use the `etag` header from S3 to determine whether the bundle has + // changed (and deduction needs to be re-run), so we need to preserve it. + filterSerializedResponseHeaders(name, _value) { + return name === 'etag' + }, + }) + + // Calculate total duration and report metrics + const totalDuration = performance.now() - startTime + + analytics.reportWorkerMetrics({ + duration: totalDuration, + endpoint, + method, + status: response.status, + userAgent, + country, + timestamp: Date.now(), + }) + + // Report additional custom metrics based on response characteristics + if (response.status >= 400) { + analytics.reportCustomMetric('error_count', 1, { + status: response.status.toString(), + endpoint, + method, + }) + } + + if (totalDuration > 1000) { + // Slow requests > 1 second + analytics.reportCustomMetric('slow_request_count', 1, { + endpoint, + method, + duration_bucket: totalDuration > 5000 ? 'very_slow' : 'slow', + }) + } + + return response + } catch (error) { + const totalDuration = performance.now() - startTime + + // Report error metrics + analytics.reportWorkerMetrics({ + duration: totalDuration, + endpoint, + method, + status: 500, + userAgent, + country, + timestamp: Date.now(), + }) + + analytics.reportCustomMetric('exception_count', 1, { + endpoint, + method, + error_type: error instanceof Error ? error.name : 'unknown', + }) + + throw error + } } diff --git a/packages/viewer/src/models/Traits.ts b/packages/viewer/src/models/Traits.ts index 9c143e48..92f51c9f 100644 --- a/packages/viewer/src/models/Traits.ts +++ b/packages/viewer/src/models/Traits.ts @@ -74,14 +74,14 @@ export default class Traits { lookup({ spaceId, propertyId, - spaces, - properties, + spaces = this.spaces, + properties = this.properties, theorems, }: { spaceId: string propertyId: string - spaces: Collection - properties: Collection + spaces?: Collection + properties?: Collection theorems: Theorems }) { const space = spaces.find(spaceId) diff --git a/packages/viewer/src/stores/deduction.test.ts b/packages/viewer/src/stores/deduction.test.ts new file mode 100644 index 00000000..79c7c336 --- /dev/null +++ b/packages/viewer/src/stores/deduction.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from 'vitest' +import { writable, get } from 'svelte/store' +import { + Collection, + Theorem, + Theorems, + Traits, + type Property, + type Space, + type Trait, +} from '@/models' +import * as Deduction from './deduction' +import { + and, + atom, + property, + space, + theorem, + trait, +} from '@/__test__/factories' +import type { Formula, SerializedTheorem } from '@pi-base/core' + +type SetupParams = { + initial?: Deduction.State + spaces?: Space[] + properties?: Property[] + theorems?: SerializedTheorem[] + traits?: Trait[] +} + +describe(Deduction.create, () => { + let addedTraits: Trait[] = [] + + function addTraits(traits: Trait[]) { + addedTraits.push(...traits) + } + + function setup(args: Partial = {}) { + addedTraits = [] + + const spaces = writable(Collection.byId(args.spaces || [])) + const properties = Collection.byId(args.properties || []) + const theorems = writable(Theorems.build(args.theorems || [], properties)) + const traits = writable( + Traits.build(args.traits || [], get(spaces), properties), + ) + + return Deduction.create(args.initial, spaces, traits, theorems, addTraits) + } + + it('should initialize with spaces if no initial state provided', () => { + const store = setup({ + spaces: [space({ id: 1 }), space({ id: 2 })], + }) + const state = get(store) + + expect(state.all).toEqual(new Set([1, 2])) + expect(state.checked).toEqual(new Set()) + }) + + it('should run deductions and add new traits', async () => { + const store = setup({ + spaces: [space({ id: 1 }), space({ id: 2 })], + properties: [ + property({ id: 1 }), + property({ id: 2 }), + property({ id: 3 }), + ], + traits: [ + trait({ + space: 1, + property: 1, + value: true, + }), + trait({ + space: 2, + property: 3, + value: false, + }), + ], + theorems: [ + theorem({ + id: 1, + when: { kind: 'atom', property: 1, value: true }, + then: { kind: 'atom', property: 2, value: true }, + }), + theorem({ + id: 2, + when: { kind: 'atom', property: 2, value: true }, + then: { kind: 'atom', property: 3, value: true }, + }), + ], + }) + + const stores: Deduction.State[] = [] + store.subscribe($store => stores.push($store)) + + await store.run() + + // FIXME: this documents the current behavior, but it's surprising that we're + // getting duplicated entries. + expect(stores.map(({ checking, checked }) => [checking, checked])).toEqual([ + [undefined, new Set()], + [undefined, new Set()], + ['Space 1', new Set()], + ['Space 1', new Set([1])], + ['Space 1', new Set([1])], + ['Space 2', new Set([1])], + ['Space 2', new Set([1, 2])], + ['Space 2', new Set([1, 2])], + ['Space 2', new Set([1, 2])], + ]) + + expect( + addedTraits.map(({ space, property, value }) => [space, property, value]), + ).toEqual([ + [1, 2, true], + [1, 3, true], + [2, 2, false], + [2, 1, false], + [2, 2, false], + [2, 1, false], + ]) + }) + + it('should mark spaces as checked after deduction', async () => { + const store = setup({ + spaces: [space({ id: 1 })], + properties: [property({ id: 1 })], + traits: [ + trait({ + space: 1, + property: 1, + value: true, + }), + ], + }) + + await store.checked(1) + + const state = get(store) + expect(state.checked).toContain(1) + }) + + it('should handle contradictions', async () => { + // Create a setup that will lead to contradiction + const store = setup({ + spaces: [space({ id: 1 })], + properties: [property({ id: 1 }), property({ id: 2 })], + traits: [ + trait({ + space: 1, + property: 1, + value: true, + }), + trait({ + space: 1, + property: 2, + value: false, + }), + ], + theorems: [ + theorem({ + id: 1, + when: { kind: 'atom', property: 1, value: true }, + then: { kind: 'atom', property: 2, value: true }, + }), + ], + }) + + await store.checked(1) + + const state = get(store) + expect(state.contradiction?.theorems).toContain(1) + }) + + it('should reset state when run with reset=true', async () => { + const store = setup({ + spaces: [space({ id: 1 }), space({ id: 2 })], + initial: { + checked: new Set([3]), + all: new Set([1, 2, 3]), + }, + }) + + await store.run(true) + + const state = get(store) + expect(state.checked).toEqual(new Set([1, 2])) + expect(state.all).toEqual(new Set([1, 2])) + }) + + it('should prove theorems correctly', () => { + const store = setup({ + properties: [ + property({ id: 1 }), + property({ id: 2 }), + property({ id: 3 }), + ], + theorems: [ + theorem({ + id: 1, + when: { kind: 'atom', property: 1, value: true }, + then: { kind: 'atom', property: 2, value: true }, + }), + theorem({ + id: 2, + when: { kind: 'atom', property: 2, value: true }, + then: { kind: 'atom', property: 3, value: false }, + }), + ], + }) + + const proof = store.prove({ + when: { + kind: 'atom', + property: property({ id: 1 }), + value: true, + }, + then: { + kind: 'atom', + property: property({ id: 3 }), + value: false, + }, + } as any) + + expect((proof as Theorem[]).map(t => t.id)).toEqual([1, 2]) + }) + + it('should handle multiple spaces in deduction', async () => { + const store = setup({ + spaces: [space({ id: 1 }), space({ id: 2 })], + properties: [property({ id: 1 })], + traits: [ + trait({ + space: 1, + property: 1, + value: true, + }), + trait({ + space: 2, + property: 1, + value: false, + }), + ], + }) + + await store.run() + + expect(get(store).checked).toEqual(new Set([1, 2])) + }) + + it('should update all spaces set when new spaces are added', () => { + const spacesStore = writable(Collection.byId([space({ id: 1 })])) + + const store = Deduction.create( + undefined, + spacesStore, + writable(Traits.empty()), + writable(new Theorems()), + addTraits, + ) + + // Add a new space + spacesStore.set(Collection.byId([space({ id: 1 }), space({ id: 2 })])) + store.run() + + const state = get(store) + expect(state.all).toContain(1) + expect(state.all).toContain(2) + }) + + describe('disprove function', () => { + it('should disprove a formula using theorems', () => { + const t1 = theorem({ + id: 1, + when: { kind: 'atom', property: 1, value: true }, + then: { kind: 'atom', property: 2, value: true }, + }) + + const properties = Collection.byId([ + property({ id: 1 }), + property({ id: 2 }), + ]) + + const theoremsStore = writable(Theorems.build([t1], properties)) + + const formula: Formula = and( + atom(property({ id: 1 }), true), + atom(property({ id: 2 }), false), + ) + + expect( + (Deduction.disprove(theoremsStore, formula) as Theorem[]).map( + t => t.id, + ), + ).toEqual([1]) + }) + }) +}) diff --git a/packages/viewer/src/stores/deduction.ts b/packages/viewer/src/stores/deduction.ts index 17c5bb30..0bc1cdcb 100644 --- a/packages/viewer/src/stores/deduction.ts +++ b/packages/viewer/src/stores/deduction.ts @@ -26,11 +26,12 @@ export type State = { properties: number[] theorems: number[] } + checking?: string } export type Store = Readable & { checked(spaceId: number): Promise - run(reset?: boolean): void + run(reset?: boolean): Promise prove(theorem: Theorem): Theorem[] | 'tautology' | null } @@ -87,7 +88,7 @@ export function create( setTimeout(run, 0) }) - function run(reset = false) { + function run(reset = false): Promise { if (reset) { store.set(initialize(read(spaces))) } @@ -108,7 +109,7 @@ export function create( } }) - eachTick(unchecked, (s: Space, halt: () => void) => { + return eachTick(unchecked, (s: Space, halt: () => void) => { store.update(state => ({ ...state, checking: s.name })) const map = new Map( diff --git a/packages/viewer/vite.config.js b/packages/viewer/vite.config.js index ef2a9283..4d85b55f 100644 --- a/packages/viewer/vite.config.js +++ b/packages/viewer/vite.config.js @@ -17,6 +17,9 @@ export default defineConfig({ test: { include: ['src/**/*.{test,spec}.{js,ts}'], coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov', 'clover'], + reportsDirectory: './coverage', lines: 82.99, branches: 85.71, statements: 82.99, diff --git a/packages/viewer/wrangler.toml b/packages/viewer/wrangler.toml new file mode 100644 index 00000000..c09bf047 --- /dev/null +++ b/packages/viewer/wrangler.toml @@ -0,0 +1,7 @@ +name = "topology" +pages_build_output_dir = "./packages/viewer/.svelte-kit/cloudflare" +compatibility_date = "2024-01-01" + +[[analytics_engine_datasets]] +binding = "ANALYTICS_ENGINE" +dataset = "pi_base_worker_analytics" diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 00000000..3b436146 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,5 @@ +compatibility_date = "2024-01-01" + +[[analytics_engine_datasets]] +binding = "ANALYTICS_ENGINE" +dataset = "pi_base_worker_analytics"