diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..fd04874 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,17 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { use } from 'chai'; + +// Configure chai with sinon plugin +use(sinonChai); + +// Configure chai globally +declare global { + var expect: typeof import('chai').expect; +} + +(global as any).expect = expect; + +// Configure sinon +export { sinon }; \ No newline at end of file diff --git a/tests/typetests/index.test.ts b/tests/typetests/index.test.ts new file mode 100644 index 0000000..381f111 --- /dev/null +++ b/tests/typetests/index.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "tstyche"; +import type { Token, WorkerTasks, AsyncMessage } from "../../src/workers.js"; +import { CancellationSource } from "../../src/cancellation/CancellationSource.js"; +import { TaskCancelledError } from "../../src/cancellation/TaskCancelledError.js"; +import { Queue } from "../../src/misc/Queue.js"; +import { AsyncWorker, type Enqueue, type EnqueueFn } from "../../src/workers/AsyncWorker.js"; + +describe("Type Tests", () => { + test("Token should be Int32Array", () => { + expect().type.toBe(); + }); + + test("CancellationSource should extend Event", () => { + const cancellationSource = new CancellationSource(); + expect(cancellationSource.token).type.toBe(); + }); + + test("TaskCancelledError should extend Error", () => { + const error = new TaskCancelledError(); + expect(error).type.toBeAssignableTo(); + expect(error.message).type.toBe(); + }); + + test("Queue should be generic", () => { + const stringQueue = new Queue(); + const numberQueue = new Queue(); + + expect(stringQueue.enqueue("test")).type.toBe(); + expect(stringQueue.dequeue()).type.toBe(); + expect(stringQueue.peek()).type.toBe(); + expect(stringQueue.length).type.toBe(); + expect(stringQueue.isEmpty).type.toBe(); + + expect(numberQueue.enqueue(42)).type.toBe(); + expect(numberQueue.dequeue()).type.toBe(); + expect(numberQueue.peek()).type.toBe(); + }); + + test("WorkerTasks type helper should work correctly", () => { + type TestTasks = { + add: (args: { a: number; b: number }) => number; + greet: (args: { name: string }) => string; + noArgs: () => void; + }; + + type TaskTypes = WorkerTasks; + + expect().type.toBe<{ a: number; b: number }>(); + expect().type.toBe(); + expect().type.toBe<{ name: string }>(); + expect().type.toBe(); + expect().type.toBe(); + expect().type.toBe(); + }); + + test("AsyncMessage should have correct structure", () => { + type TestTasks = { + compute: (args: { value: number }) => string; + }; + + type Message = AsyncMessage; + + expect().type.toBe(); + expect().type.toBe(); + expect().type.toBe(); + expect().type.toBe<{ value: number } | undefined>(); + }); + + test("CancellationSource static methods should have correct signatures", () => { + const token: Token = new Int32Array(1); + + expect(CancellationSource.isSignaled).type.toBeCallableWith(token); + expect(CancellationSource.isSignaled(token)).type.toBe(); + + expect(CancellationSource.throwIfSignaled).type.toBeCallableWith(token); + expect(CancellationSource.throwIfSignaled).type.toBeCallableWith(undefined); + expect(CancellationSource.throwIfSignaled(token)).type.toBe(); + }); + + test("Queue should not accept wrong types", () => { + const stringQueue = new Queue(); + + expect(stringQueue.enqueue).type.not.toBeCallableWith(42); + expect(stringQueue.enqueue).type.not.toBeCallableWith(true); + expect(stringQueue.enqueue).type.not.toBeCallableWith({}); + }); +}); \ No newline at end of file diff --git a/tests/typetests/tsconfig.json b/tests/typetests/tsconfig.json new file mode 100644 index 0000000..e76e252 --- /dev/null +++ b/tests/typetests/tsconfig.json @@ -0,0 +1,108 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES2024", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ESNext", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./dist", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + "exactOptionalPropertyTypes": false, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/tests/ut/cancellation/CancellationSource.test.ts b/tests/ut/cancellation/CancellationSource.test.ts new file mode 100644 index 0000000..dab588a --- /dev/null +++ b/tests/ut/cancellation/CancellationSource.test.ts @@ -0,0 +1,57 @@ +import { describe, it, beforeEach } from 'mocha'; +import { expect } from 'chai'; +import { sinon } from '../../setup.js'; +import { CancellationSource } from '../../../src/cancellation/CancellationSource.js'; +import { TaskCancelledError } from '../../../src/cancellation/TaskCancelledError.js'; + +describe('CancellationSource', () => { + let cancellationSource: CancellationSource; + + beforeEach(() => { + // Restore all sinon stubs/spies before each test + sinon.restore(); + cancellationSource = new CancellationSource(); + }); + + describe('constructor', () => { + it('should create a new CancellationSource instance', () => { + expect(cancellationSource).to.be.instanceOf(CancellationSource); + }); + + it('should extend Event class', () => { + expect(cancellationSource.constructor.name).to.equal('CancellationSource'); + }); + }); + + describe('static isSignaled', () => { + it('should return false for a new cancellation token', () => { + const token = cancellationSource.token; + const result = CancellationSource.isSignaled(token); + expect(result).to.be.false; + }); + + it('should return true when cancellation source is signaled', () => { + const token = cancellationSource.token; + cancellationSource.signal(); + const result = CancellationSource.isSignaled(token); + expect(result).to.be.true; + }); + }); + + describe('static throwIfSignaled', () => { + it('should not throw when token is undefined', () => { + expect(() => CancellationSource.throwIfSignaled(undefined)).to.not.throw(); + }); + + it('should not throw when token is not signaled', () => { + const token = cancellationSource.token; + expect(() => CancellationSource.throwIfSignaled(token)).to.not.throw(); + }); + + it('should throw TaskCancelledError when token is signaled', () => { + const token = cancellationSource.token; + cancellationSource.signal(); + expect(() => CancellationSource.throwIfSignaled(token)).to.throw(TaskCancelledError); + }); + }); +}); \ No newline at end of file diff --git a/tests/ut/cancellation/TaskCancelledError.test.ts b/tests/ut/cancellation/TaskCancelledError.test.ts new file mode 100644 index 0000000..0386c4e --- /dev/null +++ b/tests/ut/cancellation/TaskCancelledError.test.ts @@ -0,0 +1,47 @@ +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { TaskCancelledError } from '../../../src/cancellation/TaskCancelledError.js'; + +describe('TaskCancelledError', () => { + describe('constructor', () => { + it('should create an error instance', () => { + const error = new TaskCancelledError(); + expect(error).to.be.instanceOf(Error); + expect(error).to.be.instanceOf(TaskCancelledError); + }); + + it('should accept a custom message', () => { + const message = 'Operation was cancelled'; + const error = new TaskCancelledError(message); + expect(error.message).to.equal(message); + }); + + it('should accept error options', () => { + const cause = new Error('Root cause'); + const error = new TaskCancelledError('Cancelled', { cause }); + expect(error.cause).to.equal(cause); + }); + + it('should have default empty message when none provided', () => { + const error = new TaskCancelledError(); + expect(error.message).to.equal(''); + }); + }); + + describe('inheritance', () => { + it('should be throwable', () => { + expect(() => { + throw new TaskCancelledError('Test error'); + }).to.throw(TaskCancelledError); + }); + + it('should be catchable as Error', () => { + try { + throw new TaskCancelledError('Test error'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect(error).to.be.instanceOf(TaskCancelledError); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/ut/helpers/helpers.ts b/tests/ut/helpers/helpers.ts new file mode 100644 index 0000000..5fcc021 --- /dev/null +++ b/tests/ut/helpers/helpers.ts @@ -0,0 +1,216 @@ +import { sinon } from "../../setup.js"; +import Worker from 'web-worker'; +import type { Token } from "../../../src/workers.js"; +import { SyncObject } from "../../../src/sync/SyncObject.js"; + +export type StubbedAtomics = { + [K in keyof Omit]: sinon.SinonStub; +} + +/** + * Creates a worker to test blocking Atomics.wait operations. + * This allows testing the actual ManualResetEvent.wait and AutoResetEvent.wait methods. + */ +export function createWorkerForWaitTesting( + sharedBuffer: Token, + waitFunction: 'ManualResetEvent.wait' | 'AutoResetEvent.wait', + timeout?: number +) { + const { promise, resolve, reject } = createPromise<{ wait: Promise<'ok' | 'timed-out' | 'not-equal'> }>(); + const { promise: waitPromise, resolve: waitResolve, reject: waitReject } = createPromise<'ok' | 'timed-out' | 'not-equal'>(); + const workerURL = new URL('./test-wait-worker.ts', import.meta.url); + const worker = new Worker(workerURL, { type: 'module' }); + + const cleanup = () => { + try { + worker.terminate(); + } catch (e) { + // Ignore cleanup errors + } + }; + + worker.onmessage = (event) => { + if (event.data === 'running') { + resolve({ wait: waitPromise }); + return; + } + cleanup(); + const { success, result, error } = event.data; + if (success) { + waitResolve(result); + } else { + waitReject(new Error(error)); + } + }; + + worker.onerror = (error) => { + cleanup(); + waitReject(error); + }; + + // Send the shared buffer and parameters to the worker + worker.postMessage({ sharedBuffer, waitFunction, timeout }); + return promise; +} + +/** + * Helper to test ManualResetEvent.wait in a worker thread + */ +export function testManualResetEventWaitInWorker( + token: Token, + timeout?: number +) { + return createWorkerForWaitTesting(token, 'ManualResetEvent.wait', timeout); +} + +/** + * Helper to test AutoResetEvent.wait in a worker thread + */ +export function testAutoResetEventWaitInWorker( + token: Token, + timeout?: number +) { + return createWorkerForWaitTesting(token, 'AutoResetEvent.wait', timeout); +} + +export function createWorkerForAcquireTesting( + source: 'Mutex' | 'Semaphore', + token: Token, + timeout?: number +) { + const { promise, resolve, reject } = createPromise<{ wait: Promise<{ success: boolean; }>}>(); + const { promise: waitPromise, resolve: waitResolve, reject: waitReject } = createPromise<{ success: boolean; }>(); + const workerURL = new URL('./test-acquire-worker.ts', import.meta.url); + const worker = new Worker(workerURL, { type: 'module' }); + + const cleanup = () => { + try { + worker.terminate(); + } catch (e) { + // Ignore cleanup errors + } + }; + + worker.onmessage = (event) => { + if (event.data === 'running') { + resolve({ wait: waitPromise }); + return; + } + cleanup(); + const { success, error } = event.data; + if (error === undefined) { + waitResolve({ success }); + } else { + waitReject(new Error(error)); + } + }; + + worker.onerror = (error) => { + cleanup(); + waitReject(error); + }; + + worker.postMessage({ source, token, timeout }); + return promise; +} + +export function testMutexAcquireInWorker( + token: Token, + timeout?: number +) { + return createWorkerForAcquireTesting('Mutex', token, timeout); +} + +export function testSemaphoreAcquireInWorker( + token: Token, + timeout?: number +) { + return createWorkerForAcquireTesting('Semaphore', token, timeout); +} + +export function createStubbedAtomics(): StubbedAtomics { + const mockAtomics = { + add: sinon.stub(Atomics, 'add'), + and: sinon.stub(Atomics, 'and'), + compareExchange: sinon.stub(Atomics, 'compareExchange'), + exchange: sinon.stub(Atomics, 'exchange'), + load: sinon.stub(Atomics, 'load'), + or: sinon.stub(Atomics, 'or'), + store: sinon.stub(Atomics, 'store'), + sub: sinon.stub(Atomics, 'sub'), + notify: sinon.stub(Atomics, 'notify'), + wait: sinon.stub(Atomics, 'wait'), + waitAsync: sinon.stub(Atomics, 'waitAsync'), + isLockFree: sinon.stub(Atomics, 'isLockFree'), + xor: sinon.stub(Atomics, 'xor'), + }; + return mockAtomics; +} + +export function createAtomicsSpy() { + return { + add: sinon.spy(Atomics, 'add'), + and: sinon.spy(Atomics, 'and'), + compareExchange: sinon.spy(Atomics, 'compareExchange'), + exchange: sinon.spy(Atomics, 'exchange'), + load: sinon.spy(Atomics, 'load'), + or: sinon.spy(Atomics, 'or'), + store: sinon.spy(Atomics, 'store'), + sub: sinon.spy(Atomics, 'sub'), + notify: sinon.spy(Atomics, 'notify'), + wait: sinon.spy(Atomics, 'wait'), + waitAsync: sinon.spy(Atomics, 'waitAsync'), + isLockFree: sinon.spy(Atomics, 'isLockFree'), + xor: sinon.spy(Atomics, 'xor'), + }; +} + +export function delay(time: number = 0) { + return new Promise(resolve => setTimeout(resolve, time)); +} + +export function createPromise() { + let resolve: (value: T) => void; + let reject: (reason?: any) => void; + const p = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise: p, resolve: resolve!, reject: reject! }; +} + +export function tokenTypeTest(foreignClass: new (...args: any[]) => SyncObject, fn: (token: Token) => any, expectedClass: new (...args: any[]) => SyncObject) { + it(`Should throw when the given token is not the token of the ${expectedClass.name} object.`, async () => { + const foreignEvent = new foreignClass(); + + // First try calling the function to see what it returns + let result: any; + let threwSynchronously = false; + + try { + result = fn(foreignEvent.token); + } catch (error) { + // Function threw synchronously + threwSynchronously = true; + expect(error).to.be.an('error'); + } + + if (!threwSynchronously) { + // Check if the function returns a Promise (async function) + if (result instanceof Promise) { + // For async functions, expect the Promise to be rejected + try { + await result; + // If we get here, the promise resolved instead of rejecting + expect.fail('Expected promise to be rejected, but it resolved.'); + } catch (error) { + // This is expected - the promise should reject + expect(error).to.be.an('error'); + } + } else { + // Function returned a value instead of throwing - this is unexpected + expect.fail('Expected function to throw or return a rejected promise.'); + } + } + }); +} \ No newline at end of file diff --git a/tests/ut/helpers/test-acquire-worker.ts b/tests/ut/helpers/test-acquire-worker.ts new file mode 100644 index 0000000..5997975 --- /dev/null +++ b/tests/ut/helpers/test-acquire-worker.ts @@ -0,0 +1,16 @@ +import { Semaphore } from '../../../dist/sync/Semaphore.js'; +import { Mutex } from '../../../dist/sync/Mutex.js'; + +self.onmessage = function (event) { + const { token, source, timeout } = event.data; + try { + self.postMessage('running'); + const releaser = timeout !== undefined + ? (source === "Mutex" ? Mutex.acquire(token, timeout) : Semaphore.acquire(token, timeout)) + : (source === "Mutex" ? Mutex.acquire(token) : Semaphore.acquire(token)); + self.postMessage({ success: typeof releaser === 'function' }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + self.postMessage({ success: false, error: errorMessage }); + } +}; diff --git a/tests/ut/helpers/test-wait-worker.ts b/tests/ut/helpers/test-wait-worker.ts new file mode 100644 index 0000000..b6031f8 --- /dev/null +++ b/tests/ut/helpers/test-wait-worker.ts @@ -0,0 +1,21 @@ +import { ManualResetEvent } from '../../../dist/sync/ManualResetEvent.js'; +import { AutoResetEvent } from '../../../dist/sync/AutoResetEvent.js'; + +self.onmessage = function(event) { + const { sharedBuffer, waitFunction, timeout } = event.data; + try { + let result; + self.postMessage('running'); + if (waitFunction === 'ManualResetEvent.wait') { + result = ManualResetEvent.wait(sharedBuffer, timeout); + } else if (waitFunction === 'AutoResetEvent.wait') { + result = AutoResetEvent.wait(sharedBuffer, timeout); + } else { + throw new Error('Unknown wait function: ' + waitFunction); + } + self.postMessage({ success: true, result }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + self.postMessage({ success: false, error: errorMessage }); + } +}; \ No newline at end of file diff --git a/tests/ut/misc/Queue.test.ts b/tests/ut/misc/Queue.test.ts new file mode 100644 index 0000000..b87ec52 --- /dev/null +++ b/tests/ut/misc/Queue.test.ts @@ -0,0 +1,131 @@ +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { Queue } from '../../../src/misc/Queue.js'; + +describe('Queue', () => { + let queue: Queue; + + beforeEach(() => { + queue = new Queue(); + }); + + describe('constructor', () => { + it('should create an empty queue', () => { + expect(queue.length).to.equal(0); + expect(queue.isEmpty).to.be.true; + }); + }); + + describe('enqueue', () => { + it('should add an item to the queue', () => { + const result = queue.enqueue('first'); + + expect(queue.length).to.equal(1); + expect(queue.isEmpty).to.be.false; + expect(result).to.equal(0); // Returns the index + }); + + it('should add multiple items in order', () => { + queue.enqueue('first'); + queue.enqueue('second'); + queue.enqueue('third'); + + expect(queue.length).to.equal(3); + }); + + it('should return consecutive indices', () => { + const firstIndex = queue.enqueue('first'); + const secondIndex = queue.enqueue('second'); + + expect(firstIndex).to.equal(0); + expect(secondIndex).to.equal(1); + }); + }); + + describe('dequeue', () => { + it('should throw error when dequeuing from empty queue', () => { + expect(() => queue.dequeue()).to.throw('Cannot dequeue from an empty queue.'); + }); + + it('should return the first item added', () => { + queue.enqueue('first'); + queue.enqueue('second'); + + const result = queue.dequeue(); + expect(result).to.equal('first'); + expect(queue.length).to.equal(1); + }); + + it('should maintain FIFO order', () => { + queue.enqueue('first'); + queue.enqueue('second'); + queue.enqueue('third'); + + expect(queue.dequeue()).to.equal('first'); + expect(queue.dequeue()).to.equal('second'); + expect(queue.dequeue()).to.equal('third'); + expect(queue.isEmpty).to.be.true; + }); + }); + + describe('peek', () => { + it('should throw error when peeking empty queue', () => { + expect(() => queue.peek()).to.throw('Cannot peek on an empty queue.'); + }); + + it('should return the first item without removing it', () => { + queue.enqueue('first'); + queue.enqueue('second'); + + const result = queue.peek(); + expect(result).to.equal('first'); + expect(queue.length).to.equal(2); // Should not change length + }); + + it('should always return the same item until dequeued', () => { + queue.enqueue('first'); + queue.enqueue('second'); + + expect(queue.peek()).to.equal('first'); + expect(queue.peek()).to.equal('first'); + + queue.dequeue(); + expect(queue.peek()).to.equal('second'); + }); + }); + + describe('length property', () => { + it('should update correctly with enqueue and dequeue operations', () => { + expect(queue.length).to.equal(0); + + queue.enqueue('item1'); + expect(queue.length).to.equal(1); + + queue.enqueue('item2'); + expect(queue.length).to.equal(2); + + queue.dequeue(); + expect(queue.length).to.equal(1); + + queue.dequeue(); + expect(queue.length).to.equal(0); + }); + }); + + describe('isEmpty property', () => { + it('should return true for empty queue', () => { + expect(queue.isEmpty).to.be.true; + }); + + it('should return false for non-empty queue', () => { + queue.enqueue('item'); + expect(queue.isEmpty).to.be.false; + }); + + it('should return true after emptying queue', () => { + queue.enqueue('item'); + queue.dequeue(); + expect(queue.isEmpty).to.be.true; + }); + }); +}); \ No newline at end of file diff --git a/tests/ut/sync/AutoResetEvent.test.ts b/tests/ut/sync/AutoResetEvent.test.ts new file mode 100644 index 0000000..f68b6c3 --- /dev/null +++ b/tests/ut/sync/AutoResetEvent.test.ts @@ -0,0 +1,141 @@ +import { describe, it, beforeEach, after } from 'mocha'; +import { expect } from 'chai'; +import { sinon } from '../../setup.js'; +import { AutoResetEvent } from '../../../src/sync/AutoResetEvent.js'; +import { autoResetEventIdentityData } from '../../../src/sync/identifiers.js'; +import { testAutoResetEventWaitInWorker } from '../helpers/helpers.js'; +import { ManualResetEvent } from '../../../src/sync/ManualResetEvent.js'; + +describe('AutoResetEvent', () => { + let eventObj: AutoResetEvent; + + beforeEach(() => { + sinon.reset(); + eventObj = new AutoResetEvent(); + }); + + after(() => { + sinon.restore(); + }); + + describe('constructor', () => { + it('Should create an AutoResetEvent instance.', () => { + expect(eventObj).to.be.instanceOf(AutoResetEvent); + }); + + it('Should have a token property.', () => { + expect(eventObj.token).to.be.instanceOf(Int32Array); + }); + + it('Should initialize token with correct identifier and state.', () => { + // Check that the type identifier is set correctly + expect(Atomics.load(eventObj.token, 1)).to.equal(autoResetEventIdentityData[0]); + // Check that the initial state is 0 (not signaled) + expect(Atomics.load(eventObj.token, 0)).to.equal(0); + }); + }); + + describe('signal', () => { + it('Should store 1 in token position 0 when signaled.', () => { + eventObj.signal(); + + expect(Atomics.load(eventObj.token, 0)).to.equal(1); + }); + }); + + describe('static isSignaled', () => { + [false, true].forEach((signal) => { + it(`Should return ${signal} when token is ${signal ? 'signaled' : 'not signaled'}.`, () => { + if (signal) { + eventObj.signal(); + } + const result = AutoResetEvent.isSignaled(eventObj.token); + + expect(result).to.equal(signal); + }); + }); + + it('Should throw when the given token is not the token of an AutoResetEvent object.', () => { + const foreignEvent = new ManualResetEvent(); + + expect(() => AutoResetEvent.isSignaled(foreignEvent.token)).to.throw(); + }); + }); + + describe('static wait', () => { + it('Should throw when the given token is not the token of an AutoResetEvent object.', () => { + const foreignEvent = new ManualResetEvent(); + + expect(() => AutoResetEvent.wait(foreignEvent.token)).to.throw(); + }); + + it('Should handle timeout.', () => { + const result = AutoResetEvent.wait(eventObj.token, 10); + + expect(result).to.equal('timed-out'); + }); + + it('Should handle immediate success when already signaled.', () => { + eventObj.signal(); + const result = AutoResetEvent.wait(eventObj.token); + + expect(result).to.equal('not-equal'); + // Verify the event auto-reset (signal was consumed) + expect(Atomics.load(eventObj.token, 0)).to.equal(0); + }); + + it('Should wait and succeed when signal becomes available.', async () => { + const waitComplete = (await testAutoResetEventWaitInWorker(eventObj.token, 1000)).wait; + + eventObj.signal(); + + const result = await waitComplete; + expect(result).to.equal('ok'); + expect(Atomics.load(eventObj.token, 0)).to.equal(0); + }); + + it('Should timeout when waiting and no signal occurs.', async () => { + const waitComplete = (await testAutoResetEventWaitInWorker(eventObj.token, 100)).wait; + + const result = await waitComplete; + + expect(result).to.equal('timed-out'); + }); + }); + + describe('static waitAsync', () => { + it('Should throw when the given token is not the token of an AutoResetEvent object.', () => { + const foreignEvent = new ManualResetEvent(); + + expect(() => AutoResetEvent.wait(foreignEvent.token)).to.throw(); + }); + it('Should handle async wait result.', async () => { + // Signal the event after a short delay to test async wait + setTimeout(() => { + eventObj.signal(); + }, 0); + + const result = await AutoResetEvent.waitAsync(eventObj.token, 1000); + + expect(result).to.equal('ok'); + expect(Atomics.load(eventObj.token, 0)).to.equal(0); + }); + + it('Should handle sync wait result.', async () => { + // Pre-signal the event so waitAsync returns immediately + eventObj.signal(); + + const result = await AutoResetEvent.waitAsync(eventObj.token); + + expect(result).to.equal('not-equal'); + // Verify the event auto-reset (signal was consumed) + expect(Atomics.load(eventObj.token, 0)).to.equal(0); + }); + + it('Should handle timeout in async wait.', async () => { + const result = await AutoResetEvent.waitAsync(eventObj.token, 10); + + expect(result).to.equal('timed-out'); + }); + }); +}); \ No newline at end of file diff --git a/tests/ut/sync/Event.test.ts b/tests/ut/sync/Event.test.ts new file mode 100644 index 0000000..eee5ccc --- /dev/null +++ b/tests/ut/sync/Event.test.ts @@ -0,0 +1,68 @@ +import { describe, it, beforeEach, before, after } from 'mocha'; +import { expect } from 'chai'; +import { sinon } from '../../setup.js'; +import { Event } from '../../../src/sync/Event.js'; +import { createAtomicsSpy } from '../helpers/helpers.js'; + +describe('Event', () => { + let eventObj: Event; + + before(() => { + createAtomicsSpy(); + }); + + after(() => { + sinon.restore(); + }); + + beforeEach(() => { + sinon.reset(); + eventObj = new Event(1, undefined); + }); + + describe('constructor', () => { + it('Should create an event with given identifier.', () => { + expect(eventObj).to.be.instanceOf(Event); + }); + + it('Should have a token of type Int32Array.', () => { + expect(eventObj.token).to.be.instanceOf(Int32Array); + }); + + it('Should have token with correct length.', () => { + // Based on SyncObject implementation, should have length >= 2 + expect(eventObj.token.length).to.be.at.least(2); + }); + + it("Should have identifier in token's position 1.", () => { + expect(Atomics.load(eventObj.token, 1)).to.equal(1); + }); + + it("Should have 0 in token's position 0 (not signaled).", () => { + expect(Atomics.load(eventObj.token, 0)).to.equal(0); + }); + }); + + describe('signal', () => { + it('Should store 1 in token position 0 when signaled.', () => { + sinon.reset(); + eventObj.signal(); + + expect(Atomics.load(eventObj.token, 0)).to.equal(1); + }); + + it('Should notify threads when signaled.', () => { + eventObj.signal(); + + expect(Atomics.notify).to.have.been.calledOnce; + expect(Atomics.notify).to.have.been.calledWith(eventObj.token, 0, undefined); + }); + + it('Should notify specific number of threads when configured.', () => { + const eventWithThreads = new Event(3, 5); + eventWithThreads.signal(); + + expect(Atomics.notify).to.have.been.calledWith(eventWithThreads.token, 0, 5); + }); + }); +}); diff --git a/tests/ut/sync/ManualResetEvent.test.ts b/tests/ut/sync/ManualResetEvent.test.ts new file mode 100644 index 0000000..e328e37 --- /dev/null +++ b/tests/ut/sync/ManualResetEvent.test.ts @@ -0,0 +1,126 @@ +import { describe, it, beforeEach } from 'mocha'; +import { expect } from 'chai'; +import { sinon } from '../../setup.js'; +import { ManualResetEvent } from '../../../src/sync/ManualResetEvent.js'; +import { testManualResetEventWaitInWorker, tokenTypeTest } from '../helpers/helpers.js'; +import { manualResetEventIdentityData } from '../../../src/sync/identifiers.js'; +import { AutoResetEvent } from '../../../src/sync/AutoResetEvent.js'; + +describe('ManualResetEvent', () => { + let eventObj: ManualResetEvent; + + beforeEach(() => { + sinon.reset(); + eventObj = new ManualResetEvent(); + }); + + after(() => { + sinon.restore(); + }); + + describe('constructor', () => { + it('Should create a ManualResetEvent instance.', () => { + expect(eventObj).to.be.instanceOf(ManualResetEvent); + }); + + it('Should have a token property.', () => { + expect(eventObj.token).to.be.instanceOf(Int32Array); + }); + it('Should provide a non-signaled token.', () => { + expect(Atomics.load(eventObj.token, 1)).to.equal(manualResetEventIdentityData[0]); + expect(Atomics.load(eventObj.token, 0)).to.equal(0); + }); + }); + + describe('signal', () => { + it('Should store 1 in token position 0 when signaled.', () => { + eventObj.signal(); + + expect(Atomics.load(eventObj.token, 0)).to.equal(1); + }); + }); + + describe('reset', () => { + it('Should store 0 in token position 0 when reset.', () => { + eventObj.reset(); + + expect(Atomics.load(eventObj.token, 0)).to.equal(0); + }); + }); + + describe('static isSignaled', () => { + [false, true].forEach((signal) => { + it(`Should return ${signal} when token is ${signal ? '' : 'not '}signaled.`, () => { + if (signal) { + eventObj.signal(); + } + const result = ManualResetEvent.isSignaled(eventObj.token); + + expect(result).to.equal(signal); + }); + }); + tokenTypeTest(AutoResetEvent, ManualResetEvent.isSignaled, ManualResetEvent); + }); + + describe('static wait', () => { + tokenTypeTest(AutoResetEvent, ManualResetEvent.wait, ManualResetEvent); + + it('Should handle timeout.', () => { + const result = ManualResetEvent.wait(eventObj.token, 10); + + expect(result).to.equal('timed-out'); + }); + + it('Should handle immediate success when already signaled.', () => { + eventObj.signal(); + const result = ManualResetEvent.wait(eventObj.token); + expect(result).to.equal('not-equal'); + }); + + it('Should wait and succeed when signal becomes available.', async () => { + const waitComplete = (await testManualResetEventWaitInWorker(eventObj.token, 1000)).wait; + + eventObj.signal(); + + const result = await waitComplete; + expect(result).to.equal('ok'); + }); + + it('Should timeout when waiting and no signal occurs.', async () => { + const waitComplete = (await testManualResetEventWaitInWorker(eventObj.token, 100)).wait; + + const result = await waitComplete; + + expect(result).to.equal('timed-out'); + }); + }); + + describe('static waitAsync', () => { + tokenTypeTest(AutoResetEvent, ManualResetEvent.waitAsync, ManualResetEvent); + it('Should handle async wait result.', async () => { + // Signal the event after a short delay to test async wait + setTimeout(() => { + eventObj.signal(); + }, 0); + + const result = await ManualResetEvent.waitAsync(eventObj.token, 10); + + expect(result).to.equal('ok'); + }); + + it('Should handle sync wait result.', async () => { + // Pre-signal the event so waitAsync returns immediately + eventObj.signal(); + + const result = await ManualResetEvent.waitAsync(eventObj.token); + + expect(result).to.equal('not-equal'); + }); + + it('Should handle timeout in async wait.', async () => { + const result = await ManualResetEvent.waitAsync(eventObj.token, 10); + + expect(result).to.equal('timed-out'); + }); + }); +}); \ No newline at end of file diff --git a/tests/ut/sync/Mutex.test.ts b/tests/ut/sync/Mutex.test.ts new file mode 100644 index 0000000..50896ba --- /dev/null +++ b/tests/ut/sync/Mutex.test.ts @@ -0,0 +1,152 @@ +import { describe, it, beforeEach } from 'mocha'; +import { expect } from 'chai'; +import { sinon } from '../../setup.js'; +import { Mutex } from '../../../src/sync/Mutex.js'; +import { testMutexAcquireInWorker } from '../helpers/helpers.js'; +import type { Releaser } from '../../../src/sync/Semaphore.js'; + +describe('Mutex', () => { + let mutex: Mutex; + + beforeEach(() => { + sinon.restore(); + }); + + describe('constructor', () => { + it('Should create a mutex with capacity 1.', () => { + mutex = new Mutex(); + expect(mutex).to.be.instanceOf(Mutex); + expect(mutex.token[0]).to.equal(1); + }); + + it('Should create disabled mutex when requested.', () => { + mutex = new Mutex(true); + expect(mutex.token[0]).to.equal(0); + }); + }); + + describe('enable', () => { + beforeEach(() => { + mutex = new Mutex(true); // Create disabled mutex + }); + + it('Should enable a disabled mutex.', () => { + const result = mutex.enable(); + + expect(result).to.be.true; + expect(mutex.token[0]).to.equal(1); + }); + + it('Should return false when enabling an already enabled mutex.', () => { + mutex.enable(); // Enable once + + const result = mutex.enable(); // Try to enable again + + expect(result).to.be.false; + expect(mutex.token[0]).to.equal(1); + }); + }); + + describe('static acquire', () => { + beforeEach(() => { + mutex = new Mutex(); + }); + + it('Should acquire immediately when mutex is available.', () => { + const releaser = Mutex.acquire(mutex.token); + + expect(typeof releaser).to.equal('function'); + }); + + it('Should wait when mutex is not available.', async () => { + const releaser = Mutex.acquire(mutex.token); + const waitAcquire = (await testMutexAcquireInWorker(mutex.token)).wait; + let mainThreadReleased = false; + setTimeout(() => { + mainThreadReleased = true; + releaser(); + }, 0); + const result = await waitAcquire; + + expect(mainThreadReleased).to.be.true; + expect(result.success).to.be.true; + }); + }); + + describe('static acquireAsync', () => { + beforeEach(() => { + mutex = new Mutex(); + }); + + it('Should acquire immediately when mutex is available.', async () => { + const releaser = await Mutex.acquireAsync(mutex.token); + + expect(typeof releaser).to.equal('function'); + }); + + it('Should wait asynchronously when mutex is not available.', async () => { + const releaser = Mutex.acquire(mutex.token); + let mainThreadReleased = false; + setTimeout(() => { + mainThreadReleased = true; + releaser(); + }, 0); + await Mutex.acquireAsync(mutex.token); + + expect(mainThreadReleased).to.be.true; + }); + }); + + describe('releaser function', () => { + beforeEach(() => { + mutex = new Mutex(); + }); + + it('Should release the mutex when called.', () => { + const releaser = Mutex.acquire(mutex.token); + + expect(typeof releaser).to.equal('function'); + + releaser(); + + expect(mutex.token[0]).to.equal(1); + }); + + it('Should throw error when called twice.', () => { + const releaser = Mutex.acquire(mutex.token); + + releaser(); // First release + + expect(() => releaser()).to.throw(); + }); + }); + + describe('mutual exclusion behavior', () => { + beforeEach(() => { + mutex = new Mutex(); + }); + + it('Should ensure the mutex cannot be acquired again asynchronously.', async () => { + const releaser1 = Mutex.acquire(mutex.token); + expect(typeof releaser1).to.equal('function'); + let releaser2 = await Mutex.acquireAsync(mutex.token, 0); + expect(releaser2).to.equal('timed-out'); + let mainThreadReleased = false; + setTimeout(() => { + mainThreadReleased = true; + releaser1(); + }, 0); + releaser2 = await Mutex.acquireAsync(mutex.token); + expect(mainThreadReleased).to.be.true; + expect(typeof releaser2).to.equal('function'); + }); + + it("Should ensure the mutex cannot be acquired from a different thread while it's held.", async () => { + const releaser1 = Mutex.acquire(mutex.token); + expect(typeof releaser1).to.equal('function'); + const waitAcquire = (await testMutexAcquireInWorker(mutex.token, 0)).wait; + const result = await waitAcquire; + expect(result.success).to.be.false; + }); + }); +}); \ No newline at end of file diff --git a/tests/ut/sync/Semaphore.test.ts b/tests/ut/sync/Semaphore.test.ts new file mode 100644 index 0000000..da9b8c9 --- /dev/null +++ b/tests/ut/sync/Semaphore.test.ts @@ -0,0 +1,137 @@ +import { describe, it, beforeEach } from 'mocha'; +import { expect } from 'chai'; +import { sinon } from '../../setup.js'; +import { Semaphore } from '../../../src/sync/Semaphore.js'; +import { testSemaphoreAcquireInWorker } from '../helpers/helpers.js'; + +describe('Semaphore', () => { + let semaphore: Semaphore; + + beforeEach(() => { + sinon.restore(); + }); + + describe('constructor', () => { + it('Should create a semaphore with given capacity.', () => { + semaphore = new Semaphore(3); + expect(semaphore).to.be.instanceOf(Semaphore); + }); + + [0, -1, 3.5].forEach(invalidCapacity => { + it(`Should throw error for invalid capacity ${invalidCapacity}.`, () => { + expect(() => new Semaphore(invalidCapacity)).to.throw(); + }); + }); + + it('Should create disabled semaphore when requested.', () => { + semaphore = new Semaphore(3, true); + expect(Semaphore.acquire(semaphore.token, 0)).to.equal('timed-out'); + }); + }); + + describe('enable', () => { + const initialCapacity = 3; + beforeEach(() => { + semaphore = new Semaphore(initialCapacity, true); // Create disabled semaphore + }); + + it('Should enable a disabled semaphore.', () => { + const result = semaphore.enable(); + + expect(result).to.be.true; + expect(semaphore.token[0]).to.equal(initialCapacity); + }); + + it('Should return false when enabling an already enabled semaphore.', () => { + semaphore.enable(); // Enable once + + const result = semaphore.enable(); // Try to enable again + + expect(result).to.be.false; + expect(semaphore.token[0]).to.equal(initialCapacity); + }); + }); + + describe('static acquire', () => { + const initialCapacity = 2; + beforeEach(() => { + semaphore = new Semaphore(initialCapacity); + }); + + it('Should acquire immediately when capacity is available.', () => { + const result = Semaphore.acquire(semaphore.token); + + expect(result).to.not.equal('timed-out'); + expect(typeof result).to.equal('function'); + }); + + it('Should return "timed-out" when timeout occurs.', () => { + for (let i = 0; i < initialCapacity; i++) { + Semaphore.acquire(semaphore.token); + } + const result = Semaphore.acquire(semaphore.token, 0); + + expect(result).to.equal('timed-out'); + }); + + it('Should wait and acquire when capacity becomes available.', async () => { + let releaser: Function; + for (let i = 0; i < initialCapacity; i++) { + releaser = Semaphore.acquire(semaphore.token); + } + const waitAcquire = (await testSemaphoreAcquireInWorker(semaphore.token)).wait; + releaser!(); + const result = await waitAcquire; + + expect(result.success).to.be.true; + }); + }); + + describe('static acquireAsync', () => { + const initialCapacity = 2; + beforeEach(() => { + semaphore = new Semaphore(initialCapacity); + }); + + it('Should acquire immediately when capacity is available.', async () => { + const result = await Semaphore.acquireAsync(semaphore.token); + + expect(typeof result).to.equal('function'); + }); + + it('Should return "timed-out" when timeout occurs.', async () => { + for (let i = 0; i < initialCapacity; i++) { + Semaphore.acquire(semaphore.token); + } + const result = await Semaphore.acquireAsync(semaphore.token, 0); + + expect(result).to.equal('timed-out'); + }); + }); + + describe('releaser function', () => { + beforeEach(() => { + semaphore = new Semaphore(1); + }); + + it('Should release the semaphore when called.', () => { + const releaser = Semaphore.acquire(semaphore.token); + + expect(typeof releaser).to.equal('function'); + if (typeof releaser === 'function') { + releaser(); + expect(Atomics.load(semaphore.token, 0)).to.equal(1); + } + }); + + it('Should throw error when called twice.', () => { + const releaser = Semaphore.acquire(semaphore.token); + + if (typeof releaser === 'function') { + releaser(); // First release + + expect(() => releaser()).to.throw(); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/ut/workers/WorkItem.test.ts b/tests/ut/workers/WorkItem.test.ts new file mode 100644 index 0000000..79f07a7 --- /dev/null +++ b/tests/ut/workers/WorkItem.test.ts @@ -0,0 +1,145 @@ +import { describe, it, beforeEach } from 'mocha'; +import { expect } from 'chai'; +import { sinon } from '../../setup.js'; +import { WorkItem } from '../../../src/workers/WorkItem.js'; +import { WorkItemInternal } from '../../../src/workers/WorkItemInternal.js'; +import { WorkItemStatus } from '../../../src/workers/AsyncWorker.js'; +import { CancelledMessage } from '../../../src/cancellation/CancelledMessage.js'; +import { CancellationSource } from '../../../src/cancellation/CancellationSource.js'; +import { WorkItemData, IWorker } from '../../../src/workers.js'; + +describe('WorkItem', () => { + let workItem: WorkItem; + let mockInternal: WorkItemInternal; + let mockPromise: Promise; + let mockResolve: (value: string) => void; + let mockReject: (reason: any) => void; + + beforeEach(() => { + sinon.restore(); + + // Create a real promise for testing + mockPromise = new Promise((resolve, reject) => { + mockResolve = resolve; + mockReject = reject; + }); + + // Create mock internal work item + const wiData: WorkItemData = { + id: 123, + promise: mockPromise, + resolve: mockResolve, + reject: mockReject, + task: 'testTask', + payload: 'test payload' + }; + const workerMock: IWorker = { + connect: sinon.stub().returns(() => { /* disconnect function */ }), + post: sinon.stub(), + terminate: sinon.stub().returns(true) + }; + mockInternal = new WorkItemInternal(workerMock, wiData); + workItem = new WorkItem(mockInternal); + }); + + describe('constructor', () => { + it('Should create a WorkItem instance.', () => { + expect(workItem).to.be.instanceOf(WorkItem); + }); + }); + + describe('promise property', () => { + it('Should return the internal promise.', () => { + expect(workItem.promise).to.equal(mockPromise); + }); + }); + + describe('id property', () => { + it('Should return the internal work item id.', () => { + expect(workItem.id).to.equal(123); + }); + }); + + describe('status property', () => { + it('Should return the current status.', () => { + expect(workItem.status).to.equal(WorkItemStatus.Enqueued); + }); + + it('Should reflect status changes.', () => { + mockInternal.status = WorkItemStatus.Started; + expect(workItem.status).to.equal(WorkItemStatus.Started); + }); + }); + + describe('cancel method', () => { + it('Should return false when no cancellation source and not enqueued.', () => { + mockInternal.status = WorkItemStatus.Started; + mockInternal.cancellationSource = undefined; + + const result = workItem.cancel(); + + expect(result).to.be.false; + }); + + it('Should signal cancellation source when available.', () => { + const cancellationSource = new CancellationSource(); + const signalSpy = sinon.spy(cancellationSource, 'signal'); + mockInternal.cancellationSource = cancellationSource; + + const result = workItem.cancel(); + + expect(signalSpy).to.have.been.calledOnce; + expect(result).to.be.true; + }); + + it('Should reject promise when work item is enqueued.', () => { + mockInternal.status = WorkItemStatus.Enqueued; + const rejectSpy = sinon.spy(mockInternal.data, 'reject'); + + workItem.cancel(); + + expect(rejectSpy).to.have.been.calledOnce; + expect(rejectSpy.firstCall.args[0]).to.be.instanceOf(CancelledMessage); + }); + + it('Should return true when cancellation source exists.', () => { + mockInternal.cancellationSource = new CancellationSource(); + + const result = workItem.cancel(); + + expect(result).to.be.true; + }); + + it('Should return true when work item status is Cancelled.', () => { + mockInternal.status = WorkItemStatus.Cancelled; + + const result = workItem.cancel(); + + expect(result).to.be.true; + }); + + it('Should mark internal as cancelled when conditions are met.', () => { + mockInternal.status = WorkItemStatus.Enqueued; + mockInternal.cancelled = false; + + workItem.cancel(); + + expect(mockInternal.cancelled).to.be.true; + }); + + it('Should handle both cancellation source and enqueued status.', () => { + const cancellationSource = new CancellationSource(); + const signalSpy = sinon.spy(cancellationSource, 'signal'); + const rejectSpy = sinon.spy(mockInternal.data, 'reject'); + + mockInternal.cancellationSource = cancellationSource; + mockInternal.status = WorkItemStatus.Enqueued; + + const result = workItem.cancel(); + + expect(signalSpy).to.have.been.calledOnce; + expect(rejectSpy).to.have.been.calledOnce; + expect(result).to.be.true; + }); + }); +}); \ No newline at end of file