diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..384e4eb
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,43 @@
+name: Main Workflow
+
+on:
+ push:
+ branches: [ "*" ]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test
+
+ package:
+ runs-on: ubuntu-latest
+ container:
+ image: ubuntu:24.04
+ options: --privileged
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install build tools
+ run: |
+ apt-get update
+ apt-get install -y meson ninja-build libglib2.0-dev libgtk-3-dev tar gzip
+
+ - name: Package extension
+ run: |
+ mkdir -p wiggle/extension
+ cp -r * wiggle/extension/
+ tar -czf wiggle.tar.gz -C wiggle extension
+ ls -la
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..0027989
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,24 @@
+name: Run Tests
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c20b848
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,28 @@
+# Node modules
+node_modules/
+
+# Test coverage
+coverage/
+
+# Logs and temporary files
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Environment files
+.env
+.env.local
+
+# OS generated files
+dcrcache/
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Firecrawl results
+.firecrawl/
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..d08d13a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,33 @@
+# AGENTS.md
+
+## Commands
+
+- `npm test` - Run unit tests with Mocha
+- `npx mocha --require ./tests/mocks/gjs.js tests/**/*.test.js` - Alternative test command
+
+## Architecture
+
+- This is a GNOME extension written in JavaScript using the GJS framework
+- The main entry point is `extension.js`
+- Effects are implemented as separate classes extending `EffectBase`
+- Two effect types exist: `MagnificationEffect` (cursor zoom) and `FindMouseEffect` (halo)
+- Settings are managed via GSettings schema in `schemas/org.gnome.shell.extensions.wiggle.gschema.xml`
+
+## Testing
+
+- Tests use Mocha + Chai + Sinon
+- Mock GJS modules in `tests/mocks/gjs.js` must be loaded before tests
+- Test files are in the `tests/` directory with `.test.js` extension
+- Node 24+ is used for testing (ESM modules)
+
+## Build & Install
+
+- Run `make install` to build and install the extension
+- Requires GNOME 46 or later
+- Extension UUID: `wiggle@mechtifs.github.com`
+
+## Important Notes
+
+- Do NOT commit `node_modules/` - it's in `.gitignore`
+- The `wiggle` subdirectory is a git submodule (the original repo)
+- Keep changes focused on the extension code, not test infrastructure
diff --git a/const.js b/const.js
index dcc0844..f7e8b49 100644
--- a/const.js
+++ b/const.js
@@ -13,4 +13,31 @@ export const Field = {
DIST: 'distance-threshold',
CHCK: 'check-interval',
DRAW: 'draw-interval',
+
+ // New Fields
+ MODE: 'effect-mode',
+ TRIGGER: 'trigger-type',
+ T_KEY: 'trigger-key',
+ HALO_COLOR: 'halo-color',
+ HALO_RADIUS: 'halo-radius',
+ HALO_OPACITY: 'halo-opacity',
+
+ // Spotlight Effect Settings
+ SPOTLIGHT_COLOR: 'spotlight-color',
+ SPOTLIGHT_SIZE: 'spotlight-size',
+ SPOTLIGHT_OPACITY: 'spotlight-opacity',
+
+ // Laser Pointer Settings
+ LASER_COLOR: 'laser-color',
+ LASER_THICKNESS: 'laser-thickness',
+ LASER_LENGTH: 'laser-length',
+
+ // Trail Effect Settings
+ TRAIL_COLOR: 'trail-color',
+ TRAIL_LENGTH: 'trail-length',
+ TRAIL_FADE: 'trail-fade-duration',
+
+ // Arrow Guide Settings
+ ARROW_COLOR: 'arrow-color',
+ ARROW_SIZE: 'arrow-size',
};
diff --git a/cursor.js b/cursor.js
index b0bb0a1..3b64cc2 100644
--- a/cursor.js
+++ b/cursor.js
@@ -1,10 +1,11 @@
'use strict';
import Clutter from 'gi://Clutter';
+import Meta from 'gi://Meta';
export default class Cursor {
constructor() {
- this._tracker = global.backend.get_cursor_tracker(global.display);
+ this._tracker = Meta.CursorTracker.get_for_display(global.display);
}
get hot() {
@@ -16,24 +17,13 @@ export default class Cursor {
}
show() {
- const seat = Clutter.get_default_backend().get_default_seat();
-
- if (seat.is_unfocus_inhibited()) {
- seat.uninhibit_unfocus();
- }
-
this._tracker.disconnectObject(this);
this._tracker.set_pointer_visible(true);
}
hide() {
- const seat = Clutter.get_default_backend().get_default_seat();
-
- if (!seat.is_unfocus_inhibited()) {
- seat.inhibit_unfocus();
- }
-
this._tracker.set_pointer_visible(false);
+
this._tracker.disconnectObject(this);
this._tracker.connectObject(
'visibility-changed', () => {
@@ -44,4 +34,11 @@ export default class Cursor {
this
);
}
+
+ destroy() {
+ if (this._tracker) {
+ this._tracker.disconnectObject(this);
+ this._tracker = null;
+ }
+ }
}
diff --git a/effect.js b/effect.js
index f35c356..a9c74da 100644
--- a/effect.js
+++ b/effect.js
@@ -10,20 +10,53 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import Cursor from './cursor.js';
-export default class Effect extends St.Icon {
+export class BaseEffect extends St.Widget {
static {
GObject.registerClass(this);
}
constructor() {
- super();
+ super({
+ width: 1,
+ height: 1,
+ reactive: false,
+ });
this.isHidden = true;
- this.magnifyDuration = 250;
- this.unmagnifyDuration = 150;
- this.unmagnifyDelay = 0;
this.isWiggling = false;
this.cursor = new Cursor();
[this._hotX, this._hotY] = this.cursor.hot;
+ }
+
+ move(x, y) {
+ // To be implemented by subclasses
+ throw new Error('move() must be implemented by subclass');
+ }
+
+ activate() {
+ // To be implemented by subclasses
+ throw new Error('activate() must be implemented by subclass');
+ }
+
+ deactivate() {
+ // To be implemented by subclasses
+ throw new Error('deactivate() must be implemented by subclass');
+ }
+
+ destroy() {
+ // Cleanup if necessary
+ }
+}
+
+export class MagnificationEffect extends BaseEffect {
+ static {
+ GObject.registerClass(this);
+ }
+
+ constructor() {
+ super();
+ this.magnifyDuration = 250;
+ this.unmagnifyDuration = 150;
+ this.unmagnifyDelay = 0;
this._spriteSize = this.cursor.sprite ? this.cursor.sprite.get_width() : 24;
this._pivot = new Graphene.Point({
@@ -45,7 +78,7 @@ export default class Effect extends St.Icon {
this.set_position(x - this._hotX * this._ratio, y - this._hotY * this._ratio);
}
- magnify() {
+ activate() {
this.isWiggling = true;
Main.uiGroup.add_child(this);
if (this.isHidden) {
@@ -61,7 +94,7 @@ export default class Effect extends St.Icon {
});
}
- unmagnify() {
+ deactivate() {
if (this._isInTransition) {
return;
}
@@ -92,5 +125,378 @@ export default class Effect extends St.Icon {
if (this._unmagnifyDelayId) {
GLib.Source.remove(this._unmagnifyDelayId);
}
+ if (this.cursor) {
+ this.cursor.destroy();
+ this.cursor = null;
+ }
+ }
+}
+
+export class FindMouseEffect extends BaseEffect {
+ static {
+ GObject.registerClass(this);
+ }
+
+ constructor() {
+ super();
+ this.haloColor = '#ffffff';
+ this.haloRadius = 50;
+ this.haloOpacity = 0.5;
+
+ // Create the halo using a circular shape
+ // The actor itself will be the halo
+ this._halo = new St.Widget({
+ width: this.haloRadius * 2,
+ height: this.haloRadius * 2,
+ style: `background-color: ${this.haloColor}; opacity: ${this.haloOpacity}; border-radius: 100%;`
+ });
+ this.add_child(this._halo);
+ }
+
+ move(x, y) {
+ // Center the halo on the cursor hot spot
+ this.set_position(x - this._hotX, y - this._hotY);
+ }
+
+ activate() {
+ this.isWiggling = true;
+ Main.uiGroup.add_child(this);
+ if (this.isHidden) {
+ this.cursor.hide();
+ }
+ }
+
+ deactivate() {
+ if (this.isHidden) {
+ this.cursor.show();
+ }
+ Main.uiGroup.remove_child(this);
+ this.isWiggling = false;
+ }
+}
+
+export class SpotlightEffect extends BaseEffect {
+ static {
+ GObject.registerClass(this);
+ }
+
+ constructor() {
+ super();
+ this.spotlightColor = '#ffff00';
+ this.spotlightSize = 150;
+ this.spotlightOpacity = 0.8;
+
+ // Create spotlight actor with gradient effect
+ this._spotlightActor = new St.Widget({
+ width: this.spotlightSize * 2,
+ height: this.spotlightSize * 2,
+ style_class: 'spotlight-effect',
+ style: `background: radial-gradient(circle, ${this.spotlightColor} ${this.spotlightOpacity}, rgba(0,0,0,0) 70%); border-radius: 50%;`
+ });
+ this.add_child(this._spotlightActor);
+ }
+
+ set spotlightColor(color) {
+ this._spotlightColor = color;
+ if (this._spotlightActor) {
+ this._spotlightActor.set_style(`background: radial-gradient(circle, ${color} ${this.spotlightOpacity}, rgba(0,0,0,0) 70%); border-radius: 50%;`);
+ }
+ }
+
+ set spotlightSize(size) {
+ this._spotlightSize = size;
+ if (this._spotlightActor) {
+ this._spotlightActor.set_size(size * 2, size * 2);
+ }
+ }
+
+ set spotlightOpacity(opacity) {
+ this._spotlightOpacity = opacity;
+ if (this._spotlightActor) {
+ this._spotlightActor.set_style(`background: radial-gradient(circle, ${this.spotlightColor} ${opacity}, rgba(0,0,0,0) 70%); border-radius: 50%;`);
+ }
+ }
+
+ get spotlightColor() {
+ return this._spotlightColor;
+ }
+
+ get spotlightSize() {
+ return this._spotlightSize;
+ }
+
+ get spotlightOpacity() {
+ return this._spotlightOpacity;
+ }
+
+ move(x, y) {
+ // Center the spotlight on the cursor hot spot
+ this.set_position(x - this._hotX, y - this._hotY);
+ }
+
+ activate() {
+ this.isWiggling = true;
+ Main.uiGroup.add_child(this);
+ if (this.isHidden) {
+ this.cursor.hide();
+ }
+ }
+
+ deactivate() {
+ if (this.isHidden) {
+ this.cursor.show();
+ }
+ Main.uiGroup.remove_child(this);
+ this.isWiggling = false;
+ }
+
+ destroy() {
+ // Cleanup resources
+ if (this._spotlightActor && this.get_parent()) {
+ this.remove_child(this._spotlightActor);
+ }
+ }
+}
+
+export class LaserPointerEffect extends BaseEffect {
+ static {
+ GObject.registerClass(this);
+ }
+
+ constructor() {
+ super();
+ this.laserColor = '#ff0000';
+ this.laserThickness = 4;
+ this.laserLength = 100;
+ this._prevX = null;
+ this._prevY = null;
+
+ // Create laser line actor
+ this._laserActor = new St.Widget({
+ width: this.laserLength,
+ height: this.laserThickness,
+ style_class: 'laser-pointer',
+ style: `background-color: ${this.laserColor}; border-radius: 2px;`
+ });
+ this.add_child(this._laserActor);
+ }
+
+ set laserColor(color) {
+ this._laserColor = color;
+ if (this._laserActor) {
+ this._laserActor.set_style(`background-color: ${color}; border-radius: 2px;`);
+ }
+ }
+
+ set laserThickness(thickness) {
+ this._laserThickness = thickness;
+ if (this._laserActor) {
+ this._laserActor.set_size(this.laserLength, thickness);
+ }
+ }
+
+ set laserLength(length) {
+ this._laserLength = length;
+ if (this._laserActor) {
+ this._laserActor.set_size(length, this.laserThickness);
+ }
+ }
+
+ get laserColor() {
+ return this._laserColor;
+ }
+
+ get laserThickness() {
+ return this._laserThickness;
+ }
+
+ get laserLength() {
+ return this._laserLength;
+ }
+
+ move(x, y) {
+ if (this._prevX !== null && this._prevY !== null) {
+ // Draw line from previous to current position
+ // For simplicity, we'll just update the position to show the laser
+ // In a real implementation, this would create a line between points
+ this.set_position(this._prevX - this._hotX, this._prevY - this._hotY);
+ }
+ this._prevX = x;
+ this._prevY = y;
+ }
+
+ activate() {
+ this.isWiggling = true;
+ Main.uiGroup.add_child(this);
+ if (this.isHidden) {
+ this.cursor.hide();
+ }
+ }
+
+ deactivate() {
+ if (this.isHidden) {
+ this.cursor.show();
+ }
+ Main.uiGroup.remove_child(this);
+ this.isWiggling = false;
+ // Clear previous position
+ this._prevX = null;
+ this._prevY = null;
+ }
+
+ destroy() {
+ // Cleanup resources
+ if (this._laserActor && this.get_parent()) {
+ this.remove_child(this._laserActor);
+ }
+ }
+}
+
+export class TrailEffect extends BaseEffect {
+ static {
+ GObject.registerClass(this);
+ }
+
+ constructor() {
+ super();
+ this.trailColor = '#00ff00';
+ this.trailLength = 15;
+ this.fadeDuration = 2000;
+ // Array to store trail points with timestamps
+ this._trailPoints = [];
+ }
+
+ set trailColor(color) {
+ this._trailColor = color;
+ }
+
+ set trailLength(length) {
+ this._trailLength = length;
+ // Remove excess points if we exceed the new limit
+ while (this._trailPoints.length > length) {
+ this._trailPoints.shift();
+ }
+ }
+
+ set fadeDuration(duration) {
+ this._fadeDuration = duration;
+ }
+
+ get trailColor() {
+ return this._trailColor;
+ }
+
+ get trailLength() {
+ return this._trailLength;
+ }
+
+ get fadeDuration() {
+ return this._fadeDuration;
+ }
+
+ move(x, y) {
+ const timestamp = GLib.get_monotonic_time();
+ this._trailPoints.push({x, y, timestamp});
+
+ // Remove old points if we exceed the trail length
+ while (this._trailPoints.length > this.trailLength) {
+ this._trailPoints.shift();
+ }
+ }
+
+ activate() {
+ this.isWiggling = true;
+ Main.uiGroup.add_child(this);
+ if (this.isHidden) {
+ this.cursor.hide();
+ }
+ }
+
+ deactivate() {
+ if (this.isHidden) {
+ this.cursor.show();
+ }
+ Main.uiGroup.remove_child(this);
+ this.isWiggling = false;
+ // Clear trail
+ this._trailPoints = [];
+ }
+
+ destroy() {
+ // Cleanup resources
+ this._trailPoints = null;
+ }
+}
+
+export class ArrowGuideEffect extends BaseEffect {
+ static {
+ GObject.registerClass(this);
+ }
+
+ constructor() {
+ super();
+ this.arrowColor = '#00ffff';
+ this.arrowSize = 30;
+
+ // Create arrow shape using a widget with CSS triangle
+ this._arrowActor = new St.Widget({
+ width: this.arrowSize * 2,
+ height: this.arrowSize * 1.5,
+ style_class: 'arrow-guide',
+ style: `background-color: ${this.arrowColor}; clip-path: polygon(0% 50%, 100% 0%, 100% 100%);`
+ });
+ this.add_child(this._arrowActor);
+ }
+
+ set arrowColor(color) {
+ this._arrowColor = color;
+ if (this._arrowActor) {
+ this._arrowActor.set_style(`background-color: ${color}; clip-path: polygon(0% 50%, 100% 0%, 100% 100%);`);
+ }
+ }
+
+ set arrowSize(size) {
+ this._arrowSize = size;
+ if (this._arrowActor) {
+ this._arrowActor.set_size(size * 2, size * 1.5);
+ }
+ }
+
+ get arrowColor() {
+ return this._arrowColor;
+ }
+
+ get arrowSize() {
+ return this._arrowSize;
+ }
+
+ move(x, y) {
+ // Position arrow near cursor with offset for better visibility
+ // Arrow points toward the cursor from a distance
+ this.set_position(x - this._hotX - this.arrowSize * 1.5,
+ y - this._hotY - this.arrowSize);
+ }
+
+ activate() {
+ this.isWiggling = true;
+ Main.uiGroup.add_child(this);
+ if (this.isHidden) {
+ this.cursor.hide();
+ }
+ }
+
+ deactivate() {
+ if (this.isHidden) {
+ this.cursor.show();
+ }
+ Main.uiGroup.remove_child(this);
+ this.isWiggling = false;
+ }
+
+ destroy() {
+ // Cleanup resources
+ if (this._arrowActor && this.get_parent()) {
+ this.remove_child(this._arrowActor);
+ }
}
}
diff --git a/extension.js b/extension.js
index 12e21c8..a58d35b 100644
--- a/extension.js
+++ b/extension.js
@@ -5,7 +5,7 @@ import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
import { getPointerWatcher } from 'resource:///org/gnome/shell/ui/pointerWatcher.js';
import { Field } from './const.js';
-import Effect from './effect.js';
+import { BaseEffect, MagnificationEffect, FindMouseEffect, SpotlightEffect, LaserPointerEffect, TrailEffect, ArrowGuideEffect } from './effect.js';
import History from './history.js';
const initSettings = (settings, entries) => {
@@ -30,18 +30,43 @@ export default class WiggleExtension extends Extension {
if (this._checkCursorHiddenByProgram()) {
return GLib.SOURCE_CONTINUE;
}
- if (this._history.check()) {
- if (!this._effect.isWiggling) {
- this._effect.move(this._history.lastCoords.x, this._history.lastCoords.y);
- this._effect.magnify();
+
+ const isMotionTrigger = this._settings.get_string(Field.TRIGGER) === 'motion';
+
+ if (isMotionTrigger) {
+ if (this._history.check()) {
+ if (!this._effect.isWiggling) {
+ this._effect.move(this._history.lastCoords.x, this._history.lastCoords.y);
+ this._effect.activate();
+ }
+ } else if (this._effect.isWiggling) {
+ this._effect.deactivate();
}
- } else if (this._effect.isWiggling) {
- this._effect.unmagnify();
}
+
return GLib.SOURCE_CONTINUE;
});
}
+ _onKeyPress(actor, event) {
+ const triggerKey = this._settings.get_string(Field.T_KEY);
+ // Note: In a real GNOME extension, we'd check the actual key symbol from the event.
+ // For this implementation, we assume the setting matches the logic needed.
+ // This is a simplified placeholder for the keypress detection logic.
+ if (this._settings.get_string(Field.TRIGGER) === 'keypress') {
+ // Simplified: trigger if any key is pressed that isn't handled elsewhere,
+ // or specifically check against triggerKey.
+ if (!this._effect.isWiggling) {
+ const x = global.display.get_pointer().get_position().x;
+ const y = global.display.get_pointer().get_position().y;
+ this._effect.move(x, y);
+ this._effect.activate();
+ } else {
+ this._effect.deactivate();
+ }
+ }
+ }
+
_checkCursorHiddenByProgram() {
// different program might take other methods to hide the cursor, so this check should contain more conditions
if (!this._effect.cursor.sprite) {
@@ -77,7 +102,7 @@ export default class WiggleExtension extends Extension {
}
} else {
if (this._effect.isWiggling) {
- this._effect.unmagnify();
+ this._effect.deactivate();
}
if (this._drawIntervalWatch) {
this._pointerWatcher._removeWatch(this._drawIntervalWatch);
@@ -87,24 +112,114 @@ export default class WiggleExtension extends Extension {
}
}
+ _reapplySettings() {
+ const settings = this._settings;
+ const effect = this._effect;
+
+ // Reapply all effect-related settings
+ if (effect instanceof MagnificationEffect) {
+ effect.cursorSize = settings.get_int(Field.SIZE);
+ effect.cursorPath = settings.get_string(Field.PATH);
+ effect.magnifyDuration = settings.get_int(Field.MAGN);
+ effect.unmagnifyDuration = settings.get_int(Field.UMGN);
+ effect.unmagnifyDelay = settings.get_int(Field.DLAY);
+ } else if (effect instanceof FindMouseEffect) {
+ effect.haloColor = settings.get_string(Field.HALO_COLOR);
+ effect.haloRadius = settings.get_int(Field.HALO_RADIUS);
+ effect.haloOpacity = settings.get_double(Field.HALO_OPACITY);
+ } else if (effect instanceof SpotlightEffect) {
+ effect.spotlightColor = settings.get_string(Field.SPOTLIGHT_COLOR);
+ effect.spotlightSize = settings.get_int(Field.SPOTLIGHT_SIZE);
+ effect.spotlightOpacity = settings.get_double(Field.SPOTLIGHT_OPACITY);
+ } else if (effect instanceof LaserPointerEffect) {
+ effect.laserColor = settings.get_string(Field.LASER_COLOR);
+ effect.laserThickness = settings.get_int(Field.LASER_THICKNESS);
+ effect.laserLength = settings.get_int(Field.LASER_LENGTH);
+ } else if (effect instanceof TrailEffect) {
+ effect.trailColor = settings.get_string(Field.TRAIL_COLOR);
+ effect.trailLength = settings.get_int(Field.TRAIL_LENGTH);
+ effect.fadeDuration = settings.get_int(Field.TRAIL_FADE);
+ } else if (effect instanceof ArrowGuideEffect) {
+ effect.arrowColor = settings.get_string(Field.ARROW_COLOR);
+ effect.arrowSize = settings.get_int(Field.ARROW_SIZE);
+ }
+ }
+
enable() {
this._pointerWatcher = getPointerWatcher();
this._history = new History();
- this._effect = new Effect();
+
this._settings = this.getSettings();
+ const mode = this._settings.get_string(Field.MODE);
+ this._effect =
+ mode === 'find-mouse' ? new FindMouseEffect() :
+ mode === 'spotlight' ? new SpotlightEffect() :
+ mode === 'laser-pointer' ? new LaserPointerEffect() :
+ mode === 'trail' ? new TrailEffect() :
+ mode === 'arrow-guide' ? new ArrowGuideEffect() :
+ new MagnificationEffect();
+
+ this._reapplySettings();
+
+ // Handle keypress trigger if configured
+ if (this._settings.get_string(Field.TRIGGER) === 'keypress') {
+ // In a real implementation, we would use global.display.get_default_seat().connect('key-pressed', ...)
+ // For now, we'll just prepare the structure.
+ }
+
initSettings(this._settings, [
[Field.HIDE, 'b', (r) => {this._effect.isHidden = r}],
- [Field.SIZE, 'i', (r) => {this._effect.cursorSize = r}],
- [Field.PATH, 's', (r) => {this._effect.cursorPath = r}],
- [Field.MAGN, 'i', (r) => {this._effect.magnifyDuration = r}],
- [Field.UMGN, 'i', (r) => {this._effect.unmagnifyDuration = r}],
- [Field.DLAY, 'i', (r) => {this._effect.unmagnifyDelay = r}],
+ [Field.SIZE, 'i', (r) => { if (this._effect instanceof MagnificationEffect) this._effect.cursorSize = r }],
+ [Field.PATH, 's', (r) => { if (this._effect instanceof MagnificationEffect) this._effect.cursorPath = r }],
+ [Field.MAGN, 'i', (r) => { if (this._effect instanceof MagnificationEffect) this._effect.magnifyDuration = r }],
+ [Field.UMGN, 'i', (r) => { if (this._effect instanceof MagnificationEffect) this._effect.unmagnifyDuration = r }],
+ [Field.DLAY, 'i', (r) => { if (this._effect instanceof MagnificationEffect) this._effect.unmagnifyDelay = r }],
[Field.SAMP, 'i', (r) => {this._history.sampleSize = r}],
[Field.RADI, 'i', (r) => {this._history.radiansThreshold = r}],
[Field.DIST, 'i', (r) => {this._history.distanceThreshold = r}],
[Field.CHCK, 'i', (r) => this._onCheckIntervalChange(r)],
[Field.DRAW, 'i', (r) => this._onDrawIntervalChange(r)],
+
+ // New settings
+ [Field.MODE, 's', (r) => {
+ this._effect.destroy();
+ this._effect =
+ r === 'find-mouse' ? new FindMouseEffect() :
+ r === 'spotlight' ? new SpotlightEffect() :
+ r === 'laser-pointer' ? new LaserPointerEffect() :
+ r === 'trail' ? new TrailEffect() :
+ r === 'arrow-guide' ? new ArrowGuideEffect() :
+ new MagnificationEffect();
+ // Reapply all current settings to the new effect
+ this._reapplySettings();
+ }],
+ [Field.TRIGGER, 's', (r) => {
+ // Logic to switch between motion and keypress monitoring
+ }],
+ [Field.T_KEY, 's', (r) => {}],
+ [Field.HALO_COLOR, 's', (r) => { if(this._effect instanceof FindMouseEffect) { this._effect.haloColor = r; if (this._effect._halo) { this._effect._halo.set_style(`background-color: ${r}; opacity: ${this._effect.haloOpacity}; border-radius: 100%;`); } } }],
+ [Field.HALO_RADIUS, 'i', (r) => { if(this._effect instanceof FindMouseEffect) { this._effect.haloRadius = r; if (this._effect._halo) { this._effect._halo.set_size(r*2, r*2); } } }],
+ [Field.HALO_OPACITY, 'd', (r) => { if(this._effect instanceof FindMouseEffect) { this._effect.haloOpacity = r; if (this._effect._halo) { this._effect._halo.set_style(`background-color: ${this._effect.haloColor}; opacity: ${r}; border-radius: 100%;`); } } }],
+
+ // Spotlight Effect Settings
+ [Field.SPOTLIGHT_COLOR, 's', (r) => { if(this._effect instanceof SpotlightEffect) this._effect.spotlightColor = r; }],
+ [Field.SPOTLIGHT_SIZE, 'i', (r) => { if(this._effect instanceof SpotlightEffect) this._effect.spotlightSize = r; }],
+ [Field.SPOTLIGHT_OPACITY, 'd', (r) => { if(this._effect instanceof SpotlightEffect) this._effect.spotlightOpacity = r; }],
+
+ // Laser Pointer Settings
+ [Field.LASER_COLOR, 's', (r) => { if(this._effect instanceof LaserPointerEffect) this._effect.laserColor = r; }],
+ [Field.LASER_THICKNESS, 'i', (r) => { if(this._effect instanceof LaserPointerEffect) this._effect.laserThickness = r; }],
+ [Field.LASER_LENGTH, 'i', (r) => { if(this._effect instanceof LaserPointerEffect) this._effect.laserLength = r; }],
+
+ // Trail Effect Settings
+ [Field.TRAIL_COLOR, 's', (r) => { if(this._effect instanceof TrailEffect) this._effect.trailColor = r; }],
+ [Field.TRAIL_LENGTH, 'i', (r) => { if(this._effect instanceof TrailEffect) this._effect.trailLength = r; }],
+ [Field.TRAIL_FADE, 'i', (r) => { if(this._effect instanceof TrailEffect) this._effect.fadeDuration = r; }],
+
+ // Arrow Guide Settings
+ [Field.ARROW_COLOR, 's', (r) => { if(this._effect instanceof ArrowGuideEffect) this._effect.arrowColor = r; }],
+ [Field.ARROW_SIZE, 'i', (r) => { if(this._effect instanceof ArrowGuideEffect) this._effect.arrowSize = r; }],
]);
}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..e3b4c93
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1145 @@
+{
+ "name": "wiggle-tests",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "wiggle-tests",
+ "version": "1.0.0",
+ "devDependencies": {
+ "chai": "^4.3.7",
+ "mocha": "^10.2.0",
+ "sinon": "^15.2.0"
+ }
+ },
+ "node_modules/@sinonjs/commons": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
+ "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "type-detect": "4.0.8"
+ }
+ },
+ "node_modules/@sinonjs/commons/node_modules/type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@sinonjs/fake-timers": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
+ "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@sinonjs/commons": "^3.0.0"
+ }
+ },
+ "node_modules/@sinonjs/samsam": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz",
+ "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@sinonjs/commons": "^3.0.1",
+ "type-detect": "^4.1.0"
+ }
+ },
+ "node_modules/@sinonjs/text-encoding": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz",
+ "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==",
+ "deprecated": "Deprecated: no longer maintained and no longer used by Sinon packages. See\n https://github.com/sinonjs/nise/issues/243 for replacement details.",
+ "dev": true,
+ "license": "(Unlicense OR Apache-2.0)"
+ },
+ "node_modules/ansi-colors": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+ "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browser-stdout": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
+ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/chai": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
+ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.3",
+ "deep-eql": "^4.1.3",
+ "get-func-name": "^2.0.2",
+ "loupe": "^2.3.6",
+ "pathval": "^1.1.1",
+ "type-detect": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+ "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.2"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+ "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^7.0.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz",
+ "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
+ "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-detect": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/diff": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
+ "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+ "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "bin": {
+ "flat": "cli.js"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-func-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/glob": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
+ "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^5.0.1",
+ "once": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-unicode-supported": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/just-extend": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz",
+ "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/log-symbols": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+ "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.1.0",
+ "is-unicode-supported": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+ "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.1"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/mocha": {
+ "version": "10.8.2",
+ "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz",
+ "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-colors": "^4.1.3",
+ "browser-stdout": "^1.3.1",
+ "chokidar": "^3.5.3",
+ "debug": "^4.3.5",
+ "diff": "^5.2.0",
+ "escape-string-regexp": "^4.0.0",
+ "find-up": "^5.0.0",
+ "glob": "^8.1.0",
+ "he": "^1.2.0",
+ "js-yaml": "^4.1.0",
+ "log-symbols": "^4.1.0",
+ "minimatch": "^5.1.6",
+ "ms": "^2.1.3",
+ "serialize-javascript": "^6.0.2",
+ "strip-json-comments": "^3.1.1",
+ "supports-color": "^8.1.1",
+ "workerpool": "^6.5.1",
+ "yargs": "^16.2.0",
+ "yargs-parser": "^20.2.9",
+ "yargs-unparser": "^2.0.0"
+ },
+ "bin": {
+ "_mocha": "bin/_mocha",
+ "mocha": "bin/mocha.js"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nise": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz",
+ "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@sinonjs/commons": "^3.0.0",
+ "@sinonjs/fake-timers": "^11.2.2",
+ "@sinonjs/text-encoding": "^0.7.2",
+ "just-extend": "^6.2.0",
+ "path-to-regexp": "^6.2.1"
+ }
+ },
+ "node_modules/nise/node_modules/@sinonjs/fake-timers": {
+ "version": "11.3.1",
+ "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz",
+ "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@sinonjs/commons": "^3.0.1"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/serialize-javascript": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
+ "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/sinon": {
+ "version": "15.2.0",
+ "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz",
+ "integrity": "sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==",
+ "deprecated": "16.1.1",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@sinonjs/commons": "^3.0.0",
+ "@sinonjs/fake-timers": "^10.3.0",
+ "@sinonjs/samsam": "^8.0.0",
+ "diff": "^5.1.0",
+ "nise": "^5.1.4",
+ "supports-color": "^7.2.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/sinon"
+ }
+ },
+ "node_modules/sinon/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/type-detect": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
+ "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/workerpool": {
+ "version": "6.5.1",
+ "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz",
+ "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "16.2.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+ "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^7.0.2",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^20.2.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "20.2.9",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
+ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs-unparser": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz",
+ "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "camelcase": "^6.0.0",
+ "decamelize": "^4.0.0",
+ "flat": "^5.0.2",
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5816827
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "wiggle-tests",
+ "version": "1.0.0",
+ "description": "Unit tests for Wiggle GNOME extension",
+ "main": "tests/index.js",
+ "type": "module",
+"scripts": {
+ "test": "node tests/runner.mjs",
+ "test:mocha": "mocha --require ./tests/mocks/gjs.js tests/**/*.test.js"
+}
+}
diff --git a/prefs.js b/prefs.js
index 987c0c6..f4afa52 100644
--- a/prefs.js
+++ b/prefs.js
@@ -26,29 +26,50 @@ const nSwitch = (title, subtitle) => new Adw.SwitchRow({
subtitle: _(subtitle),
});
+const nCombo = (title, subtitle, options) => {
+ const model = new Gtk.StringList();
+ options.forEach(opt => model.append(opt));
+ const row = new Adw.ComboRow({
+ title: _(title),
+ subtitle: _(subtitle),
+ model,
+ });
+ row._options = options;
+ return row;
+};
+
class PrefGroup extends Adw.PreferencesGroup {
static {
GObject.registerClass(this);
}
- constructor(title, description, rows) {
+ constructor(title, description, rows, visible) {
super({
title: _(title),
description: _(description),
+ visible: visible !== false,
});
this._rows = rows;
}
bind(settings) {
this._rows.forEach(([key, obj]) => {
- let prop = {
- Adw_ComboRow: 'selected',
- Adw_EntryRow: 'text',
- Adw_SpinRow: 'value',
- Adw_SwitchRow: 'active',
- }[obj.constructor.name];
this.add(obj);
- settings.bind(key, obj, prop, Gio.SettingsBindFlags.DEFAULT);
+ if (obj.constructor.name === 'Adw_ComboRow') {
+ const options = obj._options;
+ const current = settings.get_string(key);
+ obj.selected = options.indexOf(current);
+ obj.connect('notify::selected', () => {
+ settings.set_string(key, options[obj.selected]);
+ });
+ } else {
+ let prop = {
+ Adw_EntryRow: 'text',
+ Adw_SpinRow: 'value',
+ Adw_SwitchRow: 'active',
+ }[obj.constructor.name];
+ settings.bind(key, obj, prop, Gio.SettingsBindFlags.DEFAULT);
+ }
});
}
}
@@ -64,12 +85,40 @@ class PrefPage extends Adw.PreferencesPage {
icon_name,
});
this._groups = groups;
+ this._groups.forEach((group) => {
+ this.add(group);
+ });
}
bind(settings) {
this._groups.forEach((group) => {
group.bind(settings);
- this.add(group);
+ });
+ // Connect to mode changes to show/hide effect-specific settings
+ settings.connect('changed::effect-mode', () => {
+ const mode = settings.get_string('effect-mode');
+ this._updateVisibility(mode);
+ });
+ // Set initial visibility
+ this._updateVisibility(settings.get_string('effect-mode'));
+ }
+
+ _updateVisibility(mode) {
+ this._groups.forEach((group) => {
+ const title = group.title;
+ if (title === 'Magnification Settings') {
+ group.visible = (mode === 'magnification');
+ } else if (title === 'Halo Settings') {
+ group.visible = (mode === 'find-mouse');
+ } else if (title === 'Spotlight Settings') {
+ group.visible = (mode === 'spotlight');
+ } else if (title === 'Laser Pointer Settings') {
+ group.visible = (mode === 'laser-pointer');
+ } else if (title === 'Trail Settings') {
+ group.visible = (mode === 'trail');
+ } else if (title === 'Arrow Guide Settings') {
+ group.visible = (mode === 'arrow-guide');
+ }
});
}
}
@@ -82,13 +131,6 @@ export default class WigglePreferences extends ExtensionPreferences {
const _appearancePage = new PrefPage('Appearance', 'org.gnome.Settings-appearance', [
new PrefGroup('Cursor Icon', 'Configure the appearance of the cursor icon.', [
[Field.HIDE, nSwitch('Hide Original Cursor', 'Hide the original cursor when magnified.')],
- [Field.SIZE, nSpin('Cursor Size', 'Configure the size of the cursor.', 24, 256, 1)],
- [Field.PATH, nEntry('Cursor Icon Path')],
- ]),
- new PrefGroup('Cursor Effect', 'Configure the appearance of the cursor effect.', [
- [Field.MAGN, nSpin('Magnify Duration', 'Configure the duration (ms) of the magnify animation.', 0, 10000, 1)],
- [Field.UMGN, nSpin('Unmagnify Duration', 'Configure the duration (ms) of the unmagify animation.', 0, 10000, 1)],
- [Field.DLAY, nSpin('Unmagnify Delay', 'Configure the delay (ms) before the unmagnify animation is played.', 0, 10000, 1)],
]),
]);
@@ -104,10 +146,53 @@ export default class WigglePreferences extends ExtensionPreferences {
]),
]);
+ const _effectPage = new PrefPage('Effect Mode', 'org.gnome.Settings-power', [
+ new PrefGroup('Mode Settings', 'Choose between different cursor highlighting modes.', [
+ [Field.MODE, nCombo('Effect Mode', 'Select the type of effect to use when triggered.', ['magnification', 'find-mouse', 'spotlight', 'laser-pointer', 'trail', 'arrow-guide'])],
+ ]),
+ new PrefGroup('Trigger Settings', 'Configure how the effect is activated.', [
+ [Field.TRIGGER, nCombo('Trigger Type', 'Choose whether motion or keypress activates the effect.', ['motion', 'keypress'])],
+ [Field.T_KEY, nEntry('Trigger Key', 'Specify the key to press (e.g., "Control" for Ctrl).')],
+ ]),
+ new PrefGroup('Magnification Settings', 'Configure the magnification effect.', [
+ [Field.SIZE, nSpin('Cursor Size', 'Configure the size of the cursor.', 24, 256, 1)],
+ [Field.PATH, nEntry('Cursor Icon Path')],
+ [Field.MAGN, nSpin('Magnify Duration', 'Configure the duration (ms) of the magnify animation.', 0, 10000, 1)],
+ [Field.UMGN, nSpin('Unmagnify Duration', 'Configure the duration (ms) of the unmagify animation.', 0, 10000, 1)],
+ [Field.DLAY, nSpin('Unmagnify Delay', 'Configure the delay (ms) before the unmagnify animation is played.', 0, 10000, 1)],
+ ]),
+ new PrefGroup('Halo Settings', 'Configure the appearance of the Find My Mouse halo.', [
+ [Field.HALO_COLOR, nEntry('Halo Color', 'Color of the highlight (hex format, e.g., #ffffff).')],
+ [Field.HALO_RADIUS, nSpin('Halo Radius', 'Size of the highlight area in pixels.', 10, 256, 1)],
+ [Field.HALO_OPACITY, nSpin('Halo Opacity', 'Transparency level (0.0 to 1.0).', 0.0, 1.0, 0.05)],
+ ]),
+ new PrefGroup('Spotlight Settings', 'Configure the appearance of the spotlight effect.', [
+ [Field.SPOTLIGHT_COLOR, nEntry('Spotlight Color', 'Color of the spotlight (hex format, e.g., #ffff00).')],
+ [Field.SPOTLIGHT_SIZE, nSpin('Spotlight Size', 'Radius of the spotlight in pixels.', 50, 300, 5)],
+ [Field.SPOTLIGHT_OPACITY, nSpin('Spotlight Opacity', 'Transparency level (0.0 to 1.0).', 0.0, 1.0, 0.05)],
+ ]),
+ new PrefGroup('Laser Pointer Settings', 'Configure the appearance of the laser pointer.', [
+ [Field.LASER_COLOR, nEntry('Laser Color', 'Color of the laser (hex format, e.g., #ff0000).')],
+ [Field.LASER_THICKNESS, nSpin('Laser Thickness', 'Thickness of the laser line in pixels.', 1, 20, 1)],
+ [Field.LASER_LENGTH, nSpin('Laser Length', 'Length of the laser line in pixels.', 50, 500, 10)],
+ ]),
+ new PrefGroup('Trail Settings', 'Configure the cursor trail effect.', [
+ [Field.TRAIL_COLOR, nEntry('Trail Color', 'Color of the trail (hex format, e.g., #00ff00).')],
+ [Field.TRAIL_LENGTH, nSpin('Trail Length', 'Number of trail points to keep.', 5, 50, 1)],
+ [Field.TRAIL_FADE, nSpin('Fade Duration', 'Duration for trail to fade in milliseconds.', 500, 5000, 100)],
+ ]),
+ new PrefGroup('Arrow Guide Settings', 'Configure the arrow guide appearance.', [
+ [Field.ARROW_COLOR, nEntry('Arrow Color', 'Color of the arrow (hex format, e.g., #00ffff).')],
+ [Field.ARROW_SIZE, nSpin('Arrow Size', 'Size of the arrow in pixels.', 20, 100, 5)],
+ ]),
+ ]);
+
_appearancePage.bind(_settings);
_behaviorPage.bind(_settings);
+ _effectPage.bind(_settings);
window.add(_appearancePage);
window.add(_behaviorPage);
+ window.add(_effectPage);
}
}
diff --git a/schemas/gschemas.compiled b/schemas/gschemas.compiled
new file mode 100644
index 0000000..c503fd8
Binary files /dev/null and b/schemas/gschemas.compiled differ
diff --git a/schemas/org.gnome.shell.extensions.wiggle.gschema.xml b/schemas/org.gnome.shell.extensions.wiggle.gschema.xml
index e13fd6a..a0054b6 100644
--- a/schemas/org.gnome.shell.extensions.wiggle.gschema.xml
+++ b/schemas/org.gnome.shell.extensions.wiggle.gschema.xml
@@ -34,5 +34,56 @@
8
+
+ 'magnification'
+
+
+ 'motion'
+
+
+ 'Control'
+
+
+ '#ffffff'
+
+
+ 50
+
+
+ 0.5
+
+
+ '#ffff00'
+
+
+ 150
+
+
+ 0.8
+
+
+ '#ff0000'
+
+
+ 4
+
+
+ 100
+
+
+ '#00ff00'
+
+
+ 15
+
+
+ 2000
+
+
+ '#00ffff'
+
+
+ 30
+
diff --git a/tests/arrow-guide.test.js b/tests/arrow-guide.test.js
new file mode 100644
index 0000000..5c748ea
--- /dev/null
+++ b/tests/arrow-guide.test.js
@@ -0,0 +1,134 @@
+import { ArrowGuideEffect } from '../effect.js';
+
+// Mock GNOME libraries for testing
+global.St = class St {
+ static Widget(props) {
+ return {
+ set_size: () => {},
+ set_style: () => {}
+ };
+ }
+};
+
+global.Main = {
+ uiGroup: {
+ add_child: () => {},
+ remove_child: () => {}
+ }
+};
+
+describe('ArrowGuideEffect', () => {
+ it('should initialize with default arrow values', () => {
+ const effect = new ArrowGuideEffect();
+ expect(effect.arrowColor).to.equal('#00ffff');
+ expect(effect.arrowSize).to.equal(30);
+ });
+
+ it('should create arrow actor with correct dimensions', () => {
+ const effect = new ArrowGuideEffect();
+ // The actor should be created during construction
+ expect(effect._arrowActor).to.exist;
+ });
+
+ it('should update arrow color', () => {
+ const effect = new ArrowGuideEffect();
+ let styleCalled = false;
+ let lastStyle = '';
+
+ // Mock the set_style method
+ if (effect._arrowActor) {
+ effect._arrowActor.set_style = (style) => {
+ styleCalled = true;
+ lastStyle = style;
+ };
+ }
+
+ effect.arrowColor = '#ff00ff';
+ expect(effect.arrowColor).to.equal('#ff00ff');
+ expect(styleCalled).to.be.true;
+ expect(lastStyle).to.include('#ff00ff');
+ });
+
+ it('should update arrow size', () => {
+ const effect = new ArrowGuideEffect();
+ let sizeCalled = false;
+
+ // Mock the set_size method
+ if (effect._arrowActor) {
+ effect._arrowActor.set_size = (w, h) => {
+ sizeCalled = true;
+ expect(w).to.equal(40 * 2);
+ expect(h).to.equal(40 * 1.5);
+ };
+ }
+
+ effect.arrowSize = 40;
+ expect(effect.arrowSize).to.equal(40);
+ });
+
+ it('should position arrow with offset from cursor', () => {
+ const effect = new ArrowGuideEffect();
+ let lastX, lastY;
+ effect.set_position = (x, y) => {
+ lastX = x;
+ lastY = y;
+ };
+
+ effect.move(100, 200);
+ // Arrow should be positioned with offset from cursor
+ expect(lastX).to.be.closeTo(100 - 30 * 1.5, 1);
+ expect(lastY).to.be.closeTo(200 - 30, 1);
+ });
+
+ it('should activate and add to UI group', () => {
+ const effect = new ArrowGuideEffect();
+
+ let addedChild = null;
+ global.Main.uiGroup.add_child = (child) => {
+ addedChild = child;
+ };
+
+ effect.activate();
+ expect(addedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.true;
+ });
+
+ it('should deactivate and remove from UI group', () => {
+ const effect = new ArrowGuideEffect();
+
+ let removedChild = null;
+ global.Main.uiGroup.remove_child = (child) => {
+ removedChild = child;
+ };
+
+ effect.deactivate();
+ expect(removedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.false;
+ });
+
+ it('should handle cursor hiding on activate when hidden', () => {
+ const effect = new ArrowGuideEffect();
+ effect.isHidden = true;
+
+ let hideCalled = false;
+ effect.cursor.hide = () => {
+ hideCalled = true;
+ };
+
+ effect.activate();
+ expect(hideCalled).to.be.true;
+ });
+
+ it('should handle cursor showing on deactivate when hidden', () => {
+ const effect = new ArrowGuideEffect();
+ effect.isHidden = true;
+
+ let showCalled = false;
+ effect.cursor.show = () => {
+ showCalled = true;
+ };
+
+ effect.deactivate();
+ expect(showCalled).to.be.true;
+ });
+});
\ No newline at end of file
diff --git a/tests/effect-test.js b/tests/effect-test.js
new file mode 100644
index 0000000..d5dfa4f
--- /dev/null
+++ b/tests/effect-test.js
@@ -0,0 +1,145 @@
+// Test-specific version of effect.js without gi:// imports
+// This file is used only for running tests in Node.js environment
+
+export class BaseEffect {
+ constructor() {
+ this.isHidden = true;
+ this.isWiggling = false;
+ this.cursor = new global.Cursor();
+ [this._hotX, this._hotY] = this.cursor.hot;
+ }
+
+ move(x, y) {
+ // To be implemented by subclasses
+ throw new Error('move() must be implemented by subclass');
+ }
+
+ activate() {
+ // To be implemented by subclasses
+ throw new Error('activate() must be implemented by subclass');
+ }
+
+ deactivate() {
+ // To be implemented by subclasses
+ throw new Error('deactivate() must be implemented by subclass');
+ }
+
+ destroy() {
+ // Cleanup if necessary
+ }
+}
+
+export class MagnificationEffect extends BaseEffect {
+ constructor() {
+ super();
+ this.magnifyDuration = 250;
+ this.unmagnifyDuration = 150;
+ this.unmagnifyDelay = 0;
+ this._spriteSize = this.cursor.sprite ? this.cursor.sprite.get_width() : 24;
+ this._ratio = 1; // Default ratio
+
+ this._pivot = global.Graphene.Point({
+ x: this._hotX / this._spriteSize,
+ y: this._hotY / this._spriteSize,
+ });
+ }
+
+ set cursorSize(size) {
+ this.icon_size = size;
+ this._ratio = size / this._spriteSize;
+ }
+
+ set cursorPath(path) {
+ this.gicon = global.Gio.new_for_string(path || global.GLib.path_get_dirname(import.meta.url.slice(7)) + '/icons/cursor.svg');
+ }
+
+ move(x, y) {
+ this.set_position(x - this._hotX * this._ratio, y - this._hotY * this._ratio);
+ }
+
+ activate() {
+ this.isWiggling = true;
+ global.Main.uiGroup.add_child(this);
+ if (this.isHidden) {
+ this.cursor.hide();
+ }
+ this.remove_all_transitions();
+ this.ease({
+ duration: this.magnifyDuration,
+ transition: global.Clutter.AnimationMode.EASE_IN_QUAD,
+ scale_x: 1.0,
+ scale_y: 1.0,
+ pivot_point: this._pivot,
+ });
+ }
+
+ deactivate() {
+ if (this._isInTransition) {
+ return;
+ }
+ this._isInTransition = true;
+ this._unmagnifyDelayId = global.GLib.timeout_add(global.GLib.PRIORITY_DEFAULT, this.unmagnifyDelay, () => {
+ this.remove_all_transitions();
+ this.ease({
+ duration: this.unmagnifyDuration,
+ mode: global.Clutter.AnimationMode.EASE_OUT_QUAD,
+ scale_x: 1.0 / this._ratio,
+ scale_y: 1.0 / this._ratio,
+ pivot_point: this._pivot,
+ onComplete: () => {
+ global.Main.uiGroup.remove_child(this);
+ if (this.isHidden) {
+ this.cursor.show();
+ }
+ this.isWiggling = false;
+ this._isInTransition = false;
+ },
+ });
+ this._unmagnifyDelayId = null;
+ return global.GLib.SOURCE_REMOVE;
+ });
+ }
+
+ destroy() {
+ if (this._unmagnifyDelayId) {
+ global.GLib.Source.remove(this._unmagnifyDelayId);
+ }
+ }
+}
+
+export class FindMouseEffect extends BaseEffect {
+ constructor() {
+ super();
+ this.haloColor = '#ffffff';
+ this.haloRadius = 50;
+ this.haloOpacity = 0.5;
+
+ // Create the halo using a circular shape
+ this._halo = new global.St.Widget({
+ width: this.haloRadius * 2,
+ height: this.haloRadius * 2,
+ style: `background-color: ${this.haloColor}; opacity: ${this.haloOpacity}; border-radius: 100%;`
+ });
+ }
+
+ move(x, y) {
+ // Center the halo on the cursor hot spot
+ this.set_position(x - this._hotX, y - this._hotY);
+ }
+
+ activate() {
+ this.isWiggling = true;
+ global.Main.uiGroup.add_child(this);
+ if (this.isHidden) {
+ this.cursor.hide();
+ }
+ }
+
+ deactivate() {
+ if (this.isHidden) {
+ this.cursor.show();
+ }
+ global.Main.uiGroup.remove_child(this);
+ this.isWiggling = false;
+ }
+}
\ No newline at end of file
diff --git a/tests/effect.test.js b/tests/effect.test.js
new file mode 100644
index 0000000..150b8d6
--- /dev/null
+++ b/tests/effect.test.js
@@ -0,0 +1,78 @@
+import { BaseEffect, MagnificationEffect, FindMouseEffect } from '../effect.js';
+import { Field } from '../const.js';
+
+describe('BaseEffect', () => {
+ it('should be a base class that requires implementation', () => {
+ const effect = new BaseEffect();
+ expect(() => effect.move(0, 0)).to.throw('move() must be implemented by subclass');
+ expect(() => effect.activate()).to.throw('activate() must be implemented by subclass');
+ expect(() => effect.deactivate()).to.throw('deactivate() must be implemented by subclass');
+ });
+});
+
+describe('MagnificationEffect', () => {
+ it('should initialize with default values', () => {
+ const effect = new MagnificationEffect();
+ expect(effect.magnifyDuration).to.equal(250);
+ expect(effect.unmagnifyDuration).to.equal(150);
+ expect(effect.unmagnifyDelay).to.equal(0);
+ });
+
+ it('should set cursor size and path', () => {
+ const effect = new MagnificationEffect();
+ effect.cursorSize = 96;
+ expect(effect.icon_size).to.equal(96);
+
+ effect.cursorPath = '/path/to/cursor.svg';
+ expect(effect.gicon.path).to.equal('/path/to/cursor.svg');
+ });
+
+ it('should move to specified coordinates', () => {
+ const effect = new MagnificationEffect();
+ // Mock set_position
+ let lastX, lastY;
+ effect.set_position = (x, y) => { lastX = x; lastY = y; };
+
+ effect.move(100, 200);
+ expect(lastX).to.be.closeTo(100, 5);
+ expect(lastY).to.be.closeTo(200, 5);
+ });
+});
+
+describe('FindMouseEffect', () => {
+ it('should initialize with default halo values', () => {
+ const effect = new FindMouseEffect();
+ expect(effect.haloColor).to.equal('#ffffff');
+ expect(effect.haloRadius).to.equal(50);
+ expect(effect.haloOpacity).to.equal(0.5);
+ });
+
+ it('should move to specified coordinates', () => {
+ const effect = new FindMouseEffect();
+ // Mock set_position
+ let lastX, lastY;
+ effect.set_position = (x, y) => { lastX = x; lastY = y; };
+
+ effect.move(100, 200);
+ expect(lastX).to.equal(100);
+ expect(lastY).to.equal(200);
+ });
+
+ it('should activate and deactivate', () => {
+ const effect = new FindMouseEffect();
+
+ // Mock Main.uiGroup
+ let addedChild = null;
+ global.Main.uiGroup.add_child = (child) => { addedChild = child; };
+
+ effect.activate();
+ expect(addedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.true;
+
+ let removedChild = null;
+ global.Main.uiGroup.remove_child = (child) => { removedChild = child; };
+ effect.deactivate();
+ expect(removedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.false;
+ });
+});
diff --git a/tests/effects-test.js b/tests/effects-test.js
new file mode 100644
index 0000000..3b9206e
--- /dev/null
+++ b/tests/effects-test.js
@@ -0,0 +1,255 @@
+// Test-specific versions of the new effect classes
+// This file is used only for running tests in Node.js environment
+
+export class LaserPointerEffect {
+ constructor() {
+ this.isHidden = true;
+ this.isWiggling = false;
+ this.cursor = new global.Cursor();
+ [this._hotX, this._hotY] = this.cursor.hot;
+
+ this.laserColor = '#ff0000';
+ this.laserThickness = 4;
+ this.laserLength = 100;
+ this._prevX = null;
+ this._prevY = null;
+
+ // Create laser line actor
+ this._laserActor = new global.St.Widget({
+ width: this.laserLength,
+ height: this.laserThickness,
+ style_class: 'laser-pointer',
+ style: `background-color: ${this.laserColor}; border-radius: 2px;`
+ });
+ }
+
+ set laserColor(color) {
+ this._laserColor = color;
+ if (this._laserActor) {
+ this._laserActor.set_style(`background-color: ${color}; border-radius: 2px;`);
+ }
+ }
+
+ set laserThickness(thickness) {
+ this._laserThickness = thickness;
+ if (this._laserActor) {
+ this._laserActor.set_size(this.laserLength, thickness);
+ }
+ }
+
+ set laserLength(length) {
+ this._laserLength = length;
+ if (this._laserActor) {
+ this._laserActor.set_size(length, this.laserThickness);
+ }
+ }
+
+ get laserColor() {
+ return this._laserColor;
+ }
+
+ get laserThickness() {
+ return this._laserThickness;
+ }
+
+ get laserLength() {
+ return this._laserLength;
+ }
+
+ move(x, y) {
+ if (this._prevX !== null && this._prevY !== null) {
+ // Draw line from previous to current position
+ this.set_position(this._prevX - this._hotX, this._prevY - this._hotY);
+ }
+ this._prevX = x;
+ this._prevY = y;
+ }
+
+ activate() {
+ this.isWiggling = true;
+ global.Main.uiGroup.add_child(this);
+ if (this.isHidden) {
+ this.cursor.hide();
+ }
+ }
+
+ deactivate() {
+ if (this.isHidden) {
+ this.cursor.show();
+ }
+ global.Main.uiGroup.remove_child(this);
+ this.isWiggling = false;
+ // Clear previous position
+ this._prevX = null;
+ this._prevY = null;
+ }
+
+ destroy() {
+ // Cleanup resources
+ if (this._laserActor && this.get_parent()) {
+ this.remove_child(this._laserActor);
+ }
+ }
+
+ set_position(x, y) {}
+ get_content() { return this._laserActor; }
+ get_parent() { return null; }
+ remove_child(child) {}
+}
+
+export class TrailEffect {
+ constructor() {
+ this.isHidden = true;
+ this.isWiggling = false;
+ this.cursor = new global.Cursor();
+ [this._hotX, this._hotY] = this.cursor.hot;
+
+ this.trailColor = '#00ff00';
+ this.trailLength = 15;
+ this.fadeDuration = 2000;
+ // Array to store trail points with timestamps
+ this._trailPoints = [];
+ }
+
+ set trailColor(color) {
+ this._trailColor = color;
+ }
+
+ set trailLength(length) {
+ this._trailLength = length;
+ // Remove excess points if we exceed the new limit
+ while (this._trailPoints.length > length) {
+ this._trailPoints.shift();
+ }
+ }
+
+ set fadeDuration(duration) {
+ this._fadeDuration = duration;
+ }
+
+ get trailColor() {
+ return this._trailColor;
+ }
+
+ get trailLength() {
+ return this._trailLength;
+ }
+
+ get fadeDuration() {
+ return this._fadeDuration;
+ }
+
+ move(x, y) {
+ const timestamp = global.GLib.get_monotonic_time();
+ this._trailPoints.push({x, y, timestamp});
+
+ // Remove old points if we exceed the trail length
+ while (this._trailPoints.length > this.trailLength) {
+ this._trailPoints.shift();
+ }
+ }
+
+ activate() {
+ this.isWiggling = true;
+ global.Main.uiGroup.add_child(this);
+ if (this.isHidden) {
+ this.cursor.hide();
+ }
+ }
+
+ deactivate() {
+ if (this.isHidden) {
+ this.cursor.show();
+ }
+ global.Main.uiGroup.remove_child(this);
+ this.isWiggling = false;
+ // Clear trail
+ this._trailPoints = [];
+ }
+
+ destroy() {
+ // Cleanup resources
+ this._trailPoints = null;
+ }
+
+ set_position(x, y) {}
+ get_content() { return null; }
+ get_parent() { return null; }
+ remove_child(child) {}
+}
+
+export class ArrowGuideEffect {
+ constructor() {
+ this.isHidden = true;
+ this.isWiggling = false;
+ this.cursor = new global.Cursor();
+ [this._hotX, this._hotY] = this.cursor.hot;
+
+ this.arrowColor = '#00ffff';
+ this.arrowSize = 30;
+
+ // Create arrow shape using a widget with CSS triangle
+ this._arrowActor = new global.St.Widget({
+ width: this.arrowSize * 2,
+ height: this.arrowSize * 1.5,
+ style_class: 'arrow-guide',
+ style: `background-color: ${this.arrowColor}; clip-path: polygon(0% 50%, 100% 0%, 100% 100%);`
+ });
+ }
+
+ set arrowColor(color) {
+ this._arrowColor = color;
+ if (this._arrowActor) {
+ this._arrowActor.set_style(`background-color: ${color}; clip-path: polygon(0% 50%, 100% 0%, 100% 100%);`);
+ }
+ }
+
+ set arrowSize(size) {
+ this._arrowSize = size;
+ if (this._arrowActor) {
+ this._arrowActor.set_size(size * 2, size * 1.5);
+ }
+ }
+
+ get arrowColor() {
+ return this._arrowColor;
+ }
+
+ get arrowSize() {
+ return this._arrowSize;
+ }
+
+ move(x, y) {
+ // Position arrow near cursor with offset for better visibility
+ this.set_position(x - this._hotX - this.arrowSize * 1.5,
+ y - this._hotY - this.arrowSize);
+ }
+
+ activate() {
+ this.isWiggling = true;
+ global.Main.uiGroup.add_child(this);
+ if (this.isHidden) {
+ this.cursor.hide();
+ }
+ }
+
+ deactivate() {
+ if (this.isHidden) {
+ this.cursor.show();
+ }
+ global.Main.uiGroup.remove_child(this);
+ this.isWiggling = false;
+ }
+
+ destroy() {
+ // Cleanup resources
+ if (this._arrowActor && this.get_parent()) {
+ this.remove_child(this._arrowActor);
+ }
+ }
+
+ set_position(x, y) {}
+ get_content() { return this._arrowActor; }
+ get_parent() { return null; }
+ remove_child(child) {}
+}
\ No newline at end of file
diff --git a/tests/history-test.js b/tests/history-test.js
new file mode 100644
index 0000000..03b6cdd
--- /dev/null
+++ b/tests/history-test.js
@@ -0,0 +1,54 @@
+// Test-specific version of history.js without gi:// imports
+// This file is used only for running tests in Node.js environment
+
+export default class History {
+ constructor() {
+ this._samples = [];
+ this.sampleSize = 25;
+ this.radiansThreshold = 15;
+ this.distanceThreshold = 180;
+ }
+
+ get lastCoords() {
+ return this._samples[this._samples.length - 1];
+ }
+
+ clear() {
+ this._samples = [];
+ }
+
+ check() {
+ let now = global.GLib.get_monotonic_time();
+
+ for (let i = 0; i < this._samples.length; i++) {
+ if (now - this._samples[i].t > this.sampleSize * 1000) {
+ this._samples.splice(i, 1);
+ }
+ }
+
+ let radians = 0;
+ let distance = 0;
+ for (let i = 2; i < this._samples.length; i++) {
+ radians += calcGamma(this._samples[i - 2], this._samples[i - 1], this._samples[i]);
+ distance = Math.max(distance, calcDistance(this._samples[i - 1], this._samples[i]));
+ }
+ return radians > this.radiansThreshold && distance > this.distanceThreshold;
+ }
+
+ push(x, y) {
+ this._samples.push({ x: x, y: y, t: global.GLib.get_monotonic_time() });
+ }
+}
+
+const calcDistance = (p1, p2) => Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
+
+const calcGamma = (st, nd, rd) => {
+ var a = Math.sqrt(Math.pow(st.x - nd.x, 2) + Math.pow(st.y - nd.y, 2));
+ var b = Math.sqrt(Math.pow(nd.x - rd.x, 2) + Math.pow(nd.y - rd.y, 2));
+ var c = Math.sqrt(Math.pow(rd.x - st.x, 2) + Math.pow(rd.y - st.y, 2));
+
+ if (a * b === 0) {
+ return 0;
+ }
+ return Math.PI - Math.acos((Math.pow(a, 2) + Math.pow(b, 2) - Math.pow(c, 2)) / (2 * a * b));
+};
\ No newline at end of file
diff --git a/tests/history.test.js b/tests/history.test.js
new file mode 100644
index 0000000..b6c84ae
--- /dev/null
+++ b/tests/history.test.js
@@ -0,0 +1,57 @@
+import History from '../history.js';
+
+// Mock GLib for testing
+global.GLib = class GLib {
+ static get_monotonic_time() { return Date.now(); }
+};
+
+describe('History', () => {
+ it('should initialize with default values', () => {
+ const history = new History();
+ expect(history.sampleSize).to.equal(25);
+ expect(history.radiansThreshold).to.equal(15);
+ expect(history.distanceThreshold).to.equal(180);
+ });
+
+ it('should store cursor coordinates with timestamps', () => {
+ const history = new History();
+ history.push(100, 200);
+
+ expect(history._samples.length).to.equal(1);
+ expect(history._samples[0].x).to.equal(100);
+ expect(history._samples[0].y).to.equal(200);
+ });
+
+ it('should return the last coordinates', () => {
+ const history = new History();
+ history.push(50, 75);
+ history.push(100, 200);
+
+ expect(history.lastCoords.x).to.equal(100);
+ expect(history.lastCoords.y).to.equal(200);
+ });
+
+ it('should clear all samples', () => {
+ const history = new History();
+ history.push(50, 75);
+ history.push(100, 200);
+
+ expect(history._samples.length).to.equal(2);
+
+ history.clear();
+ expect(history._samples.length).to.equal(0);
+ });
+
+ it('should remove old samples based on sampleSize', () => {
+ const history = new History();
+ history.sampleSize = 1; // 1 second in ms
+
+ history.push(50, 75);
+
+ // Wait a bit to ensure timestamp difference
+ setTimeout(() => {
+ history.push(100, 200);
+ expect(history._samples.length).to.equal(1);
+ }, 10);
+ });
+});
diff --git a/tests/index.js b/tests/index.js
new file mode 100644
index 0000000..b0619b1
--- /dev/null
+++ b/tests/index.js
@@ -0,0 +1,12 @@
+// Test runner configuration for Mocha
+// This file should be run with a GJS-compatible test runner or mocked environment
+
+const tests = [
+ './tests/effect.test.js',
+ './tests/history.test.js'
+];
+
+console.log('Running unit tests...');
+tests.forEach(test => {
+ console.log(`\n=== ${test} ===`);
+});
diff --git a/tests/integration.test.js b/tests/integration.test.js
new file mode 100644
index 0000000..b7f519f
--- /dev/null
+++ b/tests/integration.test.js
@@ -0,0 +1,220 @@
+// Integration tests for extension with new effects
+import WiggleExtension from '../extension.js';
+import { MagnificationEffect, FindMouseEffect, SpotlightEffect, LaserPointerEffect, TrailEffect, ArrowGuideEffect } from '../effect.js';
+import { Field } from '../const.js';
+
+describe('Extension Integration Tests', () => {
+ let extension;
+ let mockSettings;
+
+ beforeEach(() => {
+ // Setup mock settings and extension
+ mockSettings = {
+ get_string: sinon.stub(),
+ get_boolean: sinon.stub(),
+ get_int: sinon.stub(),
+ get_double: sinon.stub(),
+ connect: sinon.stub()
+ };
+
+ extension = new WiggleExtension();
+ extension._settings = mockSettings;
+ });
+
+ describe('Effect Creation', () => {
+ it('should create MagnificationEffect by default', () => {
+ mockSettings.get_string.withArgs(Field.MODE).returns('magnification');
+ extension.enable();
+ expect(extension._effect).to.be.instanceOf(MagnificationEffect);
+ });
+
+ it('should create FindMouseEffect for find-mouse mode', () => {
+ mockSettings.get_string.withArgs(Field.MODE).returns('find-mouse');
+ extension.enable();
+ expect(extension._effect).to.be.instanceOf(FindMouseEffect);
+ });
+
+ it('should create SpotlightEffect for spotlight mode', () => {
+ mockSettings.get_string.withArgs(Field.MODE).returns('spotlight');
+ extension.enable();
+ expect(extension._effect).to.be.instanceOf(SpotlightEffect);
+ });
+
+ it('should create LaserPointerEffect for laser-pointer mode', () => {
+ mockSettings.get_string.withArgs(Field.MODE).returns('laser-pointer');
+ extension.enable();
+ expect(extension._effect).to.be.instanceOf(LaserPointerEffect);
+ });
+
+ it('should create TrailEffect for trail mode', () => {
+ mockSettings.get_string.withArgs(Field.MODE).returns('trail');
+ extension.enable();
+ expect(extension._effect).to.be.instanceOf(TrailEffect);
+ });
+
+ it('should create ArrowGuideEffect for arrow-guide mode', () => {
+ mockSettings.get_string.withArgs(Field.MODE).returns('arrow-guide');
+ extension.enable();
+ expect(extension._effect).to.be.instanceOf(ArrowGuideEffect);
+ });
+
+ it('should default to MagnificationEffect for unknown mode', () => {
+ mockSettings.get_string.withArgs(Field.MODE).returns('unknown-mode');
+ extension.enable();
+ expect(extension._effect).to.be.instanceOf(MagnificationEffect);
+ });
+ });
+
+ describe('Spotlight Effect Settings Integration', () => {
+ it('should bind spotlight color setting to SpotlightEffect', () => {
+ const effect = new SpotlightEffect();
+ extension._effect = effect;
+
+ mockSettings.get_string.withArgs(Field.SPOTLIGHT_COLOR).returns('#ff0000');
+
+ // Simulate the settings binding
+ effect.spotlightColor = '#ff0000';
+ expect(effect.spotlightColor).to.equal('#ff0000');
+ });
+
+ it('should bind spotlight size setting to SpotlightEffect', () => {
+ const effect = new SpotlightEffect();
+ extension._effect = effect;
+
+ mockSettings.get_int.withArgs(Field.SPOTLIGHT_SIZE).returns(200);
+
+ // Simulate the settings binding
+ effect.spotlightSize = 200;
+ expect(effect.spotlightSize).to.equal(200);
+ });
+
+ it('should bind spotlight opacity setting to SpotlightEffect', () => {
+ const effect = new SpotlightEffect();
+ extension._effect = effect;
+
+ mockSettings.get_double.withArgs(Field.SPOTLIGHT_OPACITY).returns(0.7);
+
+ // Simulate the settings binding
+ effect.spotlightOpacity = 0.7;
+ expect(effect.spotlightOpacity).to.equal(0.7);
+ });
+ });
+
+ describe('Laser Pointer Settings Integration', () => {
+ it('should bind laser color setting to LaserPointerEffect', () => {
+ const effect = new LaserPointerEffect();
+ extension._effect = effect;
+
+ mockSettings.get_string.withArgs(Field.LASER_COLOR).returns('#00ff00');
+
+ // Simulate the settings binding
+ effect.laserColor = '#00ff00';
+ expect(effect.laserColor).to.equal('#00ff00');
+ });
+
+ it('should bind laser thickness setting to LaserPointerEffect', () => {
+ const effect = new LaserPointerEffect();
+ extension._effect = effect;
+
+ mockSettings.get_int.withArgs(Field.LASER_THICKNESS).returns(6);
+
+ // Simulate the settings binding
+ effect.laserThickness = 6;
+ expect(effect.laserThickness).to.equal(6);
+ });
+
+ it('should bind laser length setting to LaserPointerEffect', () => {
+ const effect = new LaserPointerEffect();
+ extension._effect = effect;
+
+ mockSettings.get_int.withArgs(Field.LASER_LENGTH).returns(150);
+
+ // Simulate the settings binding
+ effect.laserLength = 150;
+ expect(effect.laserLength).to.equal(150);
+ });
+ });
+
+ describe('Trail Settings Integration', () => {
+ it('should bind trail color setting to TrailEffect', () => {
+ const effect = new TrailEffect();
+ extension._effect = effect;
+
+ mockSettings.get_string.withArgs(Field.TRAIL_COLOR).returns('#00ffff');
+
+ // Simulate the settings binding
+ effect.trailColor = '#00ffff';
+ expect(effect.trailColor).to.equal('#00ffff');
+ });
+
+ it('should bind trail length setting to TrailEffect', () => {
+ const effect = new TrailEffect();
+ extension._effect = effect;
+
+ mockSettings.get_int.withArgs(Field.TRAIL_LENGTH).returns(20);
+
+ // Simulate the settings binding
+ effect.trailLength = 20;
+ expect(effect.trailLength).to.equal(20);
+ });
+
+ it('should bind trail fade duration setting to TrailEffect', () => {
+ const effect = new TrailEffect();
+ extension._effect = effect;
+
+ mockSettings.get_int.withArgs(Field.TRAIL_FADE).returns(3000);
+
+ // Simulate the settings binding
+ effect.fadeDuration = 3000;
+ expect(effect.fadeDuration).to.equal(3000);
+ });
+ });
+
+ describe('Arrow Guide Settings Integration', () => {
+ it('should bind arrow color setting to ArrowGuideEffect', () => {
+ const effect = new ArrowGuideEffect();
+ extension._effect = effect;
+
+ mockSettings.get_string.withArgs(Field.ARROW_COLOR).returns('#ff00ff');
+
+ // Simulate the settings binding
+ effect.arrowColor = '#ff00ff';
+ expect(effect.arrowColor).to.equal('#ff00ff');
+ });
+
+ it('should bind arrow size setting to ArrowGuideEffect', () => {
+ const effect = new ArrowGuideEffect();
+ extension._effect = effect;
+
+ mockSettings.get_int.withArgs(Field.ARROW_SIZE).returns(40);
+
+ // Simulate the settings binding
+ effect.arrowSize = 40;
+ expect(effect.arrowSize).to.equal(40);
+ });
+ });
+
+ describe('Effect Switching', () => {
+ it('should properly destroy old effect when switching modes', () => {
+ const oldEffect = new SpotlightEffect();
+ extension._effect = oldEffect;
+
+ let destroyCalled = false;
+ oldEffect.destroy = () => { destroyCalled = true; };
+
+ // Simulate mode change
+ mockSettings.get_string.withArgs(Field.MODE).returns('laser-pointer');
+ const newEffect = new LaserPointerEffect();
+ extension._effect = newEffect;
+
+ expect(destroyCalled).to.be.true;
+ });
+
+ it('should maintain history across effect switches', () => {
+ // This would be tested in a more complex integration test
+ // For now, just verify the structure exists
+ extension.enable();
+ expect(extension._history).to.exist;
+ });
+ });
+});
\ No newline at end of file
diff --git a/tests/laser-pointer.test.js b/tests/laser-pointer.test.js
new file mode 100644
index 0000000..a110f8f
--- /dev/null
+++ b/tests/laser-pointer.test.js
@@ -0,0 +1,171 @@
+import { LaserPointerEffect } from '../effect.js';
+
+// Mock GNOME libraries for testing
+global.St = class St {
+ static Widget(props) {
+ return {
+ set_size: () => {},
+ set_style: () => {}
+ };
+ }
+};
+
+global.Main = {
+ uiGroup: {
+ add_child: () => {},
+ remove_child: () => {}
+ }
+};
+
+describe('LaserPointerEffect', () => {
+ it('should initialize with default laser values', () => {
+ const effect = new LaserPointerEffect();
+ expect(effect.laserColor).to.equal('#ff0000');
+ expect(effect.laserThickness).to.equal(4);
+ expect(effect.laserLength).to.equal(100);
+ });
+
+ it('should create laser actor with correct dimensions', () => {
+ const effect = new LaserPointerEffect();
+ // The actor should be created during construction
+ expect(effect._laserActor).to.exist;
+ });
+
+ it('should update laser color', () => {
+ const effect = new LaserPointerEffect();
+ let styleCalled = false;
+ let lastStyle = '';
+
+ // Mock the set_style method
+ if (effect._laserActor) {
+ effect._laserActor.set_style = (style) => {
+ styleCalled = true;
+ lastStyle = style;
+ };
+ }
+
+ effect.laserColor = '#00ff00';
+ expect(effect.laserColor).to.equal('#00ff00');
+ expect(styleCalled).to.be.true;
+ expect(lastStyle).to.include('#00ff00');
+ });
+
+ it('should update laser thickness', () => {
+ const effect = new LaserPointerEffect();
+ let sizeCalled = false;
+
+ // Mock the set_size method
+ if (effect._laserActor) {
+ effect._laserActor.set_size = (w, h) => {
+ sizeCalled = true;
+ expect(w).to.equal(100);
+ expect(h).to.equal(6);
+ };
+ }
+
+ effect.laserThickness = 6;
+ expect(effect.laserThickness).to.equal(6);
+ });
+
+ it('should update laser length', () => {
+ const effect = new LaserPointerEffect();
+ let sizeCalled = false;
+
+ // Mock the set_size method
+ if (effect._laserActor) {
+ effect._laserActor.set_size = (w, h) => {
+ sizeCalled = true;
+ expect(w).to.equal(150);
+ expect(h).to.equal(4);
+ };
+ }
+
+ effect.laserLength = 150;
+ expect(effect.laserLength).to.equal(150);
+ });
+
+ it('should track previous position for line drawing', () => {
+ const effect = new LaserPointerEffect();
+ let lastX, lastY;
+ effect.set_position = (x, y) => {
+ lastX = x;
+ lastY = y;
+ };
+
+ // First move should not draw line (no previous position)
+ effect.move(100, 200);
+ expect(lastX).to.be.undefined;
+ expect(effect._prevX).to.equal(100);
+ expect(effect._prevY).to.equal(200);
+
+ // Second move should draw from first to second position
+ effect.move(300, 400);
+ expect(lastX).to.equal(100);
+ expect(lastY).to.equal(200);
+ });
+
+ it('should activate and add to UI group', () => {
+ const effect = new LaserPointerEffect();
+
+ let addedChild = null;
+ global.Main.uiGroup.add_child = (child) => {
+ addedChild = child;
+ };
+
+ effect.activate();
+ expect(addedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.true;
+ });
+
+ it('should deactivate and remove from UI group', () => {
+ const effect = new LaserPointerEffect();
+
+ let removedChild = null;
+ global.Main.uiGroup.remove_child = (child) => {
+ removedChild = child;
+ };
+
+ effect.deactivate();
+ expect(removedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.false;
+ });
+
+ it('should clear previous position on deactivate', () => {
+ const effect = new LaserPointerEffect();
+ effect.move(100, 200);
+ effect.move(300, 400);
+
+ expect(effect._prevX).to.equal(300);
+ expect(effect._prevY).to.equal(400);
+
+ effect.deactivate();
+ expect(effect._prevX).to.be.null;
+ expect(effect._prevY).to.be.null;
+ });
+
+ it('should handle cursor hiding on activate when hidden', () => {
+ const effect = new LaserPointerEffect();
+ effect.isHidden = true;
+
+ let hideCalled = false;
+ effect.cursor.hide = () => {
+ hideCalled = true;
+ };
+
+ effect.activate();
+ expect(hideCalled).to.be.true;
+ });
+
+ it('should handle cursor showing on deactivate when hidden', () => {
+ const effect = new LaserPointerEffect();
+ effect.isHidden = true;
+
+ let showCalled = false;
+ effect.cursor.show = () => {
+ showCalled = true;
+ };
+
+ effect.deactivate();
+ expect(showCalled).to.be.true;
+ });
+});
\ No newline at end of file
diff --git a/tests/mocks/gjs.js b/tests/mocks/gjs.js
new file mode 100644
index 0000000..d0c8d42
--- /dev/null
+++ b/tests/mocks/gjs.js
@@ -0,0 +1,78 @@
+// Mock GJS modules for Mocha tests
+// This file should be required before any test imports
+
+global.Clutter = {
+ AnimationMode: {
+ EASE_IN_QUAD: 1,
+ EASE_OUT_QUAD: 2
+ }
+};
+
+global.Gio = class Gio {
+ static Icon() { return {}; }
+ static new_for_string(path) { return { path }; }
+};
+
+global.GLib = class GLib {
+ static timeout_add(priority, delay, callback) { return 1; }
+ static Source() {
+ return { remove: () => true };
+ }
+ static get_monotonic_time() { return Date.now(); }
+ static path_get_dirname(url) { return '/path/to/dir'; }
+};
+
+global.GObject = class GObject {
+ static registerClass(cls) { cls._registered = true; return cls; }
+};
+
+// Define ActorBase first
+const ActorBase = class {
+ constructor(props) {
+ Object.assign(this, props);
+ }
+ remove_all_transitions() {}
+ ease(props) {
+ if (props.onComplete) setTimeout(props.onComplete, props.duration || 0);
+ return this;
+ }
+};
+
+// Then define St class with reference to ActorBase and Widget
+class Widget {
+ constructor(props) {
+ Object.assign(this, props);
+ this.add_child = () => {};
+ }
+}
+
+global.St = class St {
+ static Actor() {
+ return class Actor extends ActorBase {};
+ }
+
+ static Widget = Widget;
+};
+
+global.Graphene = class Graphene {
+ static Point(coords) { return coords; }
+};
+
+global.Main = {
+ uiGroup: {
+ add_child: () => {},
+ remove_child: () => {}
+ }
+};
+
+// Mock Cursor
+class Cursor {
+ constructor() {
+ this.hot = [0, 0];
+ this.sprite = { get_width: () => 24 };
+ }
+ hide() {}
+ show() {}
+}
+
+global.Cursor = Cursor;
\ No newline at end of file
diff --git a/tests/runner.mjs b/tests/runner.mjs
new file mode 100644
index 0000000..5b1820f
--- /dev/null
+++ b/tests/runner.mjs
@@ -0,0 +1,356 @@
+import { pathToFileURL } from 'url';
+import { createRequire } from 'module';
+
+const require = createRequire(import.meta.url);
+
+// Mock all the GJS modules globally before any imports happen
+global.Clutter = {
+ AnimationMode: {
+ EASE_IN_QUAD: 1,
+ EASE_OUT_QUAD: 2
+ }
+};
+
+global.Gio = class Gio {
+ static Icon() { return {}; }
+ static new_for_string(path) { return { path }; }
+};
+
+global.GLib = class GLib {
+ static timeout_add(priority, delay, callback) { return 1; }
+ static Source() {
+ return { remove: () => true };
+ }
+ static get_monotonic_time() { return Date.now(); }
+ static path_get_dirname(url) { return '/path/to/dir'; }
+};
+
+global.GObject = class GObject {
+ static registerClass(cls) { cls._registered = true; return cls; }
+};
+
+// Define ActorBase first
+const ActorBase = class {
+ constructor(props) {
+ Object.assign(this, props);
+ }
+ remove_all_transitions() {}
+ ease(props) {
+ if (props.onComplete) setTimeout(props.onComplete, props.duration || 0);
+ return this;
+ }
+};
+
+// Then define St class with reference to ActorBase and Widget
+class Widget {
+ constructor(props) {
+ Object.assign(this, props);
+ this.add_child = () => {};
+ }
+}
+
+global.St = class St {
+ static Actor() {
+ return class Actor extends ActorBase {};
+ }
+
+ static Widget = Widget;
+};
+
+global.Graphene = class Graphene {
+ static Point(coords) { return coords; }
+};
+
+global.Main = {
+ uiGroup: {
+ add_child: () => {},
+ remove_child: () => {}
+ }
+};
+
+// Mock Cursor
+class Cursor {
+ constructor() {
+ this.hot = [0, 0];
+ this.sprite = { get_width: () => 24 };
+ }
+ hide() {}
+ show() {}
+}
+
+global.Cursor = Cursor;
+
+// Now load the actual test files manually
+const { default: chai } = await import('chai');
+const { expect } = chai;
+global.expect = expect;
+
+global.describe = global.describe || function(name, fn) {
+ console.log(`\n${name}`);
+ fn();
+};
+
+global.it = global.it || function(name, fn) {
+ try {
+ fn();
+ console.log(` ✓ ${name}`);
+ } catch (error) {
+ console.log(` ✖ ${name}`);
+ console.log(` ${error.message}`);
+ }
+};
+
+// Import and run the test files
+const effectModule = await import('./effect-test.js');
+const { BaseEffect, MagnificationEffect, FindMouseEffect } = effectModule;
+
+// Import new effects for testing
+const newEffectsModule = await import('./effects-test.js');
+const { SpotlightEffect, LaserPointerEffect, TrailEffect, ArrowGuideEffect } = newEffectsModule;
+
+// Import History for testing
+const historyModule = await import('./history-test.js');
+const History = historyModule.default;
+
+// Import new effects for testing
+const spotlightModule = await import('./spotlight.test.js');
+const laserPointerModule = await import('./laser-pointer.test.js');
+const trailModule = await import('./trail.test.js');
+const arrowGuideModule = await import('./arrow-guide.test.js');
+
+console.log('\nRunning tests...\n');
+
+// Run the tests manually
+describe('BaseEffect', () => {
+ it('should be a base class that requires implementation', () => {
+ const effect = new BaseEffect();
+ expect(() => effect.move(0, 0)).to.throw('move() must be implemented by subclass');
+ expect(() => effect.activate()).to.throw('activate() must be implemented by subclass');
+ expect(() => effect.deactivate()).to.throw('deactivate() must be implemented by subclass');
+ });
+});
+
+describe('MagnificationEffect', () => {
+ it('should initialize with default values', () => {
+ const effect = new MagnificationEffect();
+ expect(effect.magnifyDuration).to.equal(250);
+ expect(effect.unmagnifyDuration).to.equal(150);
+ expect(effect.unmagnifyDelay).to.equal(0);
+ });
+
+ it('should set cursor size and path', () => {
+ const effect = new MagnificationEffect();
+ effect.cursorSize = 96;
+ expect(effect.icon_size).to.equal(96);
+
+ effect.cursorPath = '/path/to/cursor.svg';
+ expect(effect.gicon.path).to.equal('/path/to/cursor.svg');
+ });
+
+ it('should move to specified coordinates', () => {
+ const effect = new MagnificationEffect();
+ // Mock set_position
+ let lastX, lastY;
+ effect.set_position = (x, y) => { lastX = x; lastY = y; };
+
+ effect.move(100, 200);
+ expect(lastX).to.be.closeTo(100, 5);
+ expect(lastY).to.be.closeTo(200, 5);
+ });
+});
+
+describe('FindMouseEffect', () => {
+ it('should initialize with default halo values', () => {
+ const effect = new FindMouseEffect();
+ expect(effect.haloColor).to.equal('#ffffff');
+ expect(effect.haloRadius).to.equal(50);
+ expect(effect.haloOpacity).to.equal(0.5);
+ });
+
+ it('should move to specified coordinates', () => {
+ const effect = new FindMouseEffect();
+ // Mock set_position
+ let lastX, lastY;
+ effect.set_position = (x, y) => { lastX = x; lastY = y; };
+
+ effect.move(100, 200);
+ expect(lastX).to.equal(100);
+ expect(lastY).to.equal(200);
+ });
+
+ it('should activate and deactivate', () => {
+ const effect = new FindMouseEffect();
+
+ // Mock Main.uiGroup
+ let addedChild = null;
+ global.Main.uiGroup.add_child = (child) => { addedChild = child; };
+
+ effect.activate();
+ expect(addedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.true;
+
+ let removedChild = null;
+ global.Main.uiGroup.remove_child = (child) => { removedChild = child; };
+ effect.deactivate();
+ expect(removedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.false;
+ });
+});
+
+// Run History tests
+describe('History', () => {
+ it('should initialize with default values', () => {
+ const history = new History();
+ expect(history.sampleSize).to.equal(25);
+ expect(history.radiansThreshold).to.equal(15);
+ expect(history.distanceThreshold).to.equal(180);
+ });
+
+ it('should store cursor coordinates with timestamps', () => {
+ const history = new History();
+ history.push(100, 200);
+
+ expect(history._samples.length).to.equal(1);
+ expect(history._samples[0].x).to.equal(100);
+ expect(history._samples[0].y).to.equal(200);
+ });
+
+ it('should return the last coordinates', () => {
+ const history = new History();
+ history.push(50, 75);
+ history.push(100, 200);
+
+ expect(history.lastCoords.x).to.equal(100);
+ expect(history.lastCoords.y).to.equal(200);
+ });
+
+ it('should clear all samples', () => {
+ const history = new History();
+ history.push(50, 75);
+ history.push(100, 200);
+
+ expect(history._samples.length).to.equal(2);
+
+ history.clear();
+ expect(history._samples.length).to.equal(0);
+ });
+});
+
+// Run new effect tests
+describe('SpotlightEffect', () => {
+ it('should initialize with default spotlight values', () => {
+ const effect = new SpotlightEffect();
+ expect(effect.spotlightColor).to.equal('#ffff00');
+ expect(effect.spotlightSize).to.equal(150);
+ expect(effect.spotlightOpacity).to.equal(0.8);
+ });
+
+ it('should update spotlight color', () => {
+ const effect = new SpotlightEffect();
+ effect.spotlightColor = '#ff0000';
+ expect(effect.spotlightColor).to.equal('#ff0000');
+ });
+
+ it('should update spotlight size', () => {
+ const effect = new SpotlightEffect();
+ effect.spotlightSize = 200;
+ expect(effect.spotlightSize).to.equal(200);
+ });
+
+ it('should update spotlight opacity', () => {
+ const effect = new SpotlightEffect();
+ effect.spotlightOpacity = 0.5;
+ expect(effect.spotlightOpacity).to.equal(0.5);
+ });
+
+ it('should move to specified coordinates', () => {
+ const effect = new SpotlightEffect();
+ let lastX, lastY;
+ effect.set_position = (x, y) => { lastX = x; lastY = y; };
+
+ effect.move(100, 200);
+ expect(lastX).to.be.closeTo(100, 5);
+ expect(lastY).to.be.closeTo(200, 5);
+ });
+});
+
+describe('LaserPointerEffect', () => {
+ it('should initialize with default laser values', () => {
+ const effect = new LaserPointerEffect();
+ expect(effect.laserColor).to.equal('#ff0000');
+ expect(effect.laserThickness).to.equal(4);
+ expect(effect.laserLength).to.equal(100);
+ });
+
+ it('should update laser color', () => {
+ const effect = new LaserPointerEffect();
+ effect.laserColor = '#00ff00';
+ expect(effect.laserColor).to.equal('#00ff00');
+ });
+
+ it('should update laser thickness', () => {
+ const effect = new LaserPointerEffect();
+ effect.laserThickness = 6;
+ expect(effect.laserThickness).to.equal(6);
+ });
+
+ it('should update laser length', () => {
+ const effect = new LaserPointerEffect();
+ effect.laserLength = 150;
+ expect(effect.laserLength).to.equal(150);
+ });
+});
+
+describe('TrailEffect', () => {
+ it('should initialize with default trail values', () => {
+ const effect = new TrailEffect();
+ expect(effect.trailColor).to.equal('#00ff00');
+ expect(effect.trailLength).to.equal(15);
+ expect(effect.fadeDuration).to.equal(2000);
+ });
+
+ it('should update trail color', () => {
+ const effect = new TrailEffect();
+ effect.trailColor = '#ff00ff';
+ expect(effect.trailColor).to.equal('#ff00ff');
+ });
+
+ it('should update trail length and trim excess points', () => {
+ const effect = new TrailEffect();
+
+ // Add some points
+ effect._trailPoints = [{x: 1, y: 2}, {x: 3, y: 4}, {x: 5, y: 6}];
+ expect(effect._trailPoints.length).to.equal(3);
+
+ // Set a shorter length
+ effect.trailLength = 1;
+ expect(effect.trailLength).to.equal(1);
+ expect(effect._trailPoints.length).to.equal(1);
+ });
+
+ it('should update fade duration', () => {
+ const effect = new TrailEffect();
+ effect.fadeDuration = 3000;
+ expect(effect.fadeDuration).to.equal(3000);
+ });
+});
+
+describe('ArrowGuideEffect', () => {
+ it('should initialize with default arrow values', () => {
+ const effect = new ArrowGuideEffect();
+ expect(effect.arrowColor).to.equal('#00ffff');
+ expect(effect.arrowSize).to.equal(30);
+ });
+
+ it('should update arrow color', () => {
+ const effect = new ArrowGuideEffect();
+ effect.arrowColor = '#ff00ff';
+ expect(effect.arrowColor).to.equal('#ff00ff');
+ });
+
+ it('should update arrow size', () => {
+ const effect = new ArrowGuideEffect();
+ effect.arrowSize = 40;
+ expect(effect.arrowSize).to.equal(40);
+ });
+});
\ No newline at end of file
diff --git a/tests/simple-runner.mjs b/tests/simple-runner.mjs
new file mode 100644
index 0000000..bc0db30
--- /dev/null
+++ b/tests/simple-runner.mjs
@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+
+// Simple test runner for the new effect classes
+import { SpotlightEffect, LaserPointerEffect, TrailEffect, ArrowGuideEffect } from '../effect.js';
+
+console.log('Testing new effect classes...\n');
+
+let passed = 0;
+let failed = 0;
+
+function test(name, fn) {
+ try {
+ fn();
+ console.log(`✓ ${name}`);
+ passed++;
+ } catch (error) {
+ console.log(`✖ ${name}`);
+ console.log(` ${error.message}`);
+ failed++;
+ }
+}
+
+// Test SpotlightEffect
+console.log('SpotlightEffect tests:');
+test('should initialize with default values', () => {
+ const effect = new SpotlightEffect();
+ if (effect.spotlightColor !== '#ffff00') throw new Error(`Expected #ffff00, got ${effect.spotlightColor}`);
+ if (effect.spotlightSize !== 150) throw new Error(`Expected 150, got ${effect.spotlightSize}`);
+ if (effect.spotlightOpacity !== 0.8) throw new Error(`Expected 0.8, got ${effect.spotlightOpacity}`);
+});
+
+test('should update spotlight color', () => {
+ const effect = new SpotlightEffect();
+ effect.spotlightColor = '#ff0000';
+ if (effect.spotlightColor !== '#ff0000') throw new Error(`Expected #ff0000, got ${effect.spotlightColor}`);
+});
+
+test('should update spotlight size', () => {
+ const effect = new SpotlightEffect();
+ effect.spotlightSize = 200;
+ if (effect.spotlightSize !== 200) throw new Error(`Expected 200, got ${effect.spotlightSize}`);
+});
+
+// Test LaserPointerEffect
+console.log('\nLaserPointerEffect tests:');
+test('should initialize with default values', () => {
+ const effect = new LaserPointerEffect();
+ if (effect.laserColor !== '#ff0000') throw new Error(`Expected #ff0000, got ${effect.laserColor}`);
+ if (effect.laserThickness !== 4) throw new Error(`Expected 4, got ${effect.laserThickness}`);
+ if (effect.laserLength !== 100) throw new Error(`Expected 100, got ${effect.laserLength}`);
+});
+
+test('should update laser color', () => {
+ const effect = new LaserPointerEffect();
+ effect.laserColor = '#00ff00';
+ if (effect.laserColor !== '#00ff00') throw new Error(`Expected #00ff00, got ${effect.laserColor}`);
+});
+
+// Test TrailEffect
+console.log('\nTrailEffect tests:');
+test('should initialize with default values', () => {
+ const effect = new TrailEffect();
+ if (effect.trailColor !== '#00ff00') throw new Error(`Expected #00ff00, got ${effect.trailColor}`);
+ if (effect.trailLength !== 15) throw new Error(`Expected 15, got ${effect.trailLength}`);
+ if (effect.fadeDuration !== 2000) throw new Error(`Expected 2000, got ${effect.fadeDuration}`);
+});
+
+// Test ArrowGuideEffect
+console.log('\nArrowGuideEffect tests:');
+test('should initialize with default values', () => {
+ const effect = new ArrowGuideEffect();
+ if (effect.arrowColor !== '#00ffff') throw new Error(`Expected #00ffff, got ${effect.arrowColor}`);
+ if (effect.arrowSize !== 30) throw new Error(`Expected 30, got ${effect.arrowSize}`);
+});
+
+console.log('\n' + '='.repeat(50));
+console.log(`Tests passed: ${passed}`);
+console.log(`Tests failed: ${failed}`);
+console.log('='.repeat(50));
\ No newline at end of file
diff --git a/tests/spotlight.test.js b/tests/spotlight.test.js
new file mode 100644
index 0000000..58538a4
--- /dev/null
+++ b/tests/spotlight.test.js
@@ -0,0 +1,154 @@
+import { SpotlightEffect } from '../effect.js';
+
+// Mock GNOME libraries for testing
+global.St = class St {
+ static Widget(props) {
+ return {
+ set_size: () => {},
+ set_style: () => {},
+ remove_child: () => {}
+ };
+ }
+};
+
+global.Main = {
+ uiGroup: {
+ add_child: () => {},
+ remove_child: () => {}
+ }
+};
+
+describe('SpotlightEffect', () => {
+ it('should initialize with default spotlight values', () => {
+ const effect = new SpotlightEffect();
+ expect(effect.spotlightColor).to.equal('#ffff00');
+ expect(effect.spotlightSize).to.equal(150);
+ expect(effect.spotlightOpacity).to.equal(0.8);
+ });
+
+ it('should create spotlight actor with correct dimensions', () => {
+ const effect = new SpotlightEffect();
+ // The actor should be created during construction
+ expect(effect._spotlightActor).to.exist;
+ });
+
+ it('should update spotlight color', () => {
+ const effect = new SpotlightEffect();
+ let styleCalled = false;
+ let lastStyle = '';
+
+ // Mock the set_style method
+ if (effect._spotlightActor) {
+ effect._spotlightActor.set_style = (style) => {
+ styleCalled = true;
+ lastStyle = style;
+ };
+ }
+
+ effect.spotlightColor = '#ff0000';
+ expect(effect.spotlightColor).to.equal('#ff0000');
+ expect(styleCalled).to.be.true;
+ expect(lastStyle).to.include('#ff0000');
+ });
+
+ it('should update spotlight size', () => {
+ const effect = new SpotlightEffect();
+ let sizeCalled = false;
+
+ // Mock the set_size method
+ if (effect._spotlightActor) {
+ effect._spotlightActor.set_size = (w, h) => {
+ sizeCalled = true;
+ expect(w).to.equal(200 * 2);
+ expect(h).to.equal(200 * 2);
+ };
+ }
+
+ effect.spotlightSize = 200;
+ expect(effect.spotlightSize).to.equal(200);
+ });
+
+ it('should update spotlight opacity', () => {
+ const effect = new SpotlightEffect();
+ let styleCalled = false;
+ let lastStyle = '';
+
+ // Mock the set_style method
+ if (effect._spotlightActor) {
+ effect._spotlightActor.set_style = (style) => {
+ styleCalled = true;
+ lastStyle = style;
+ };
+ }
+
+ effect.spotlightOpacity = 0.6;
+ expect(effect.spotlightOpacity).to.equal(0.6);
+ expect(styleCalled).to.be.true;
+ expect(lastStyle).to.include('0.6');
+ });
+
+ it('should move to specified coordinates', () => {
+ const effect = new SpotlightEffect();
+ let lastX, lastY;
+ effect.set_position = (x, y) => {
+ lastX = x;
+ lastY = y;
+ };
+
+ effect.move(100, 200);
+ expect(lastX).to.equal(100);
+ expect(lastY).to.equal(200);
+ });
+
+ it('should activate and add to UI group', () => {
+ const effect = new SpotlightEffect();
+
+ let addedChild = null;
+ global.Main.uiGroup.add_child = (child) => {
+ addedChild = child;
+ };
+
+ effect.activate();
+ expect(addedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.true;
+ });
+
+ it('should deactivate and remove from UI group', () => {
+ const effect = new SpotlightEffect();
+
+ let removedChild = null;
+ global.Main.uiGroup.remove_child = (child) => {
+ removedChild = child;
+ };
+
+ effect.deactivate();
+ expect(removedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.false;
+ });
+
+ it('should handle cursor hiding on activate when hidden', () => {
+ const effect = new SpotlightEffect();
+ effect.isHidden = true;
+
+ let hideCalled = false;
+ effect.cursor.hide = () => {
+ hideCalled = true;
+ };
+
+ effect.activate();
+ expect(hideCalled).to.be.true;
+ });
+
+ it('should handle cursor showing on deactivate when hidden', () => {
+ const effect = new SpotlightEffect();
+ effect.isHidden = true;
+
+ let showCalled = false;
+ effect.cursor.show = () => {
+ showCalled = true;
+ };
+
+ effect.deactivate();
+ expect(showCalled).to.be.true;
+ });
+});
\ No newline at end of file
diff --git a/tests/trail.test.js b/tests/trail.test.js
new file mode 100644
index 0000000..c025fab
--- /dev/null
+++ b/tests/trail.test.js
@@ -0,0 +1,157 @@
+import { TrailEffect } from '../effect.js';
+
+// Mock GNOME libraries for testing
+global.GLib = class GLib {
+ static get_monotonic_time() {
+ return Date.now();
+ }
+};
+
+global.Main = {
+ uiGroup: {
+ add_child: () => {},
+ remove_child: () => {}
+ }
+};
+
+describe('TrailEffect', () => {
+ it('should initialize with default trail values', () => {
+ const effect = new TrailEffect();
+ expect(effect.trailColor).to.equal('#00ff00');
+ expect(effect.trailLength).to.equal(15);
+ expect(effect.fadeDuration).to.equal(2000);
+ });
+
+ it('should initialize with empty trail points array', () => {
+ const effect = new TrailEffect();
+ expect(effect._trailPoints).to.exist;
+ expect(effect._trailPoints.length).to.equal(0);
+ });
+
+ it('should update trail color', () => {
+ const effect = new TrailEffect();
+ effect.trailColor = '#ff00ff';
+ expect(effect.trailColor).to.equal('#ff00ff');
+ });
+
+ it('should update trail length and trim excess points', () => {
+ const effect = new TrailEffect();
+
+ // Add some points
+ effect._trailPoints = [{x: 1, y: 2}, {x: 3, y: 4}, {x: 5, y: 6}];
+ expect(effect._trailPoints.length).to.equal(3);
+
+ // Set a shorter length
+ effect.trailLength = 1;
+ expect(effect.trailLength).to.equal(1);
+ expect(effect._trailPoints.length).to.equal(1);
+ });
+
+ it('should update fade duration', () => {
+ const effect = new TrailEffect();
+ effect.fadeDuration = 3000;
+ expect(effect.fadeDuration).to.equal(3000);
+ });
+
+ it('should store trail points with timestamps', () => {
+ const effect = new TrailEffect();
+
+ // Mock the timestamp to be predictable for testing
+ let callCount = 1000;
+ global.GLib.get_monotonic_time = () => ++callCount;
+
+ effect.move(100, 200);
+ expect(effect._trailPoints.length).to.equal(1);
+ expect(effect._trailPoints[0].x).to.equal(100);
+ expect(effect._trailPoints[0].y).to.equal(200);
+ expect(effect._trailPoints[0].timestamp).to.equal(1001);
+
+ effect.move(300, 400);
+ expect(effect._trailPoints.length).to.equal(2);
+ expect(effect._trailPoints[1].x).to.equal(300);
+ expect(effect._trailPoints[1].y).to.equal(400);
+ });
+
+ it('should limit trail to specified length', () => {
+ const effect = new TrailEffect();
+ effect.trailLength = 2;
+
+ // Mock the timestamp
+ let callCount = 1000;
+ global.GLib.get_monotonic_time = () => ++callCount;
+
+ effect.move(100, 200);
+ effect.move(300, 400);
+ effect.move(500, 600);
+
+ expect(effect._trailPoints.length).to.equal(2);
+ });
+
+ it('should activate and add to UI group', () => {
+ const effect = new TrailEffect();
+
+ let addedChild = null;
+ global.Main.uiGroup.add_child = (child) => {
+ addedChild = child;
+ };
+
+ effect.activate();
+ expect(addedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.true;
+ });
+
+ it('should deactivate and remove from UI group', () => {
+ const effect = new TrailEffect();
+
+ let removedChild = null;
+ global.Main.uiGroup.remove_child = (child) => {
+ removedChild = child;
+ };
+
+ effect.deactivate();
+ expect(removedChild).to.equal(effect);
+ expect(effect.isWiggling).to.be.false;
+ });
+
+ it('should clear trail on deactivate', () => {
+ const effect = new TrailEffect();
+
+ // Mock the timestamp
+ let callCount = 1000;
+ global.GLib.get_monotonic_time = () => ++callCount;
+
+ effect.move(100, 200);
+ effect.move(300, 400);
+
+ expect(effect._trailPoints.length).to.equal(2);
+
+ effect.deactivate();
+ expect(effect._trailPoints.length).to.equal(0);
+ });
+
+ it('should handle cursor hiding on activate when hidden', () => {
+ const effect = new TrailEffect();
+ effect.isHidden = true;
+
+ let hideCalled = false;
+ effect.cursor.hide = () => {
+ hideCalled = true;
+ };
+
+ effect.activate();
+ expect(hideCalled).to.be.true;
+ });
+
+ it('should handle cursor showing on deactivate when hidden', () => {
+ const effect = new TrailEffect();
+ effect.isHidden = true;
+
+ let showCalled = false;
+ effect.cursor.show = () => {
+ showCalled = true;
+ };
+
+ effect.deactivate();
+ expect(showCalled).to.be.true;
+ });
+});
\ No newline at end of file