diff --git a/docs/app/content/articles/core-build.md b/docs/app/content/articles/core-build.md index de0257f..7af1977 100644 --- a/docs/app/content/articles/core-build.md +++ b/docs/app/content/articles/core-build.md @@ -1,11 +1,11 @@ --- title: Core build -description: Use the core engine (~10 kB) and compose only the actions you need +description: Use the core engine kB) and compose only the actions you need category: advanced position: 3 --- -The full Attractive.js build includes all built-in actions at ~20 kB (6.6 kB gzipped). The core build strips out all actions, only the engine at ~10 kB (~3.5 kB gzipped). +The full Attractive.js build includes all built-in actions, you can also just use the core and compose actions as you need. ```js import Attractive from "attractivejs/core"; ``` diff --git a/docs/app/content/articles/element.md b/docs/app/content/articles/element.md new file mode 100644 index 0000000..1316c62 --- /dev/null +++ b/docs/app/content/articles/element.md @@ -0,0 +1,127 @@ +--- +title: Attractive Element +description: A base class for custom elements that scopes Attractive to the component. Actions, targets and lifecycle without the boilerplate. +category: extensions +position: 5 +--- + +`AttractiveElement` is a base class for custom elements. It scopes Attractive to the component, so `@click`, `@target`, gates and triggers work inside the component without a `connectedCallback`, `querySelector` or manual teardown. + +```js +import { AttractiveElement } from "attractivejs/element"; +``` + + +## A simple example: tabs + +Actions in HTML call component methods directly. The component manages its own state and resolves targets within its own subtree. + +```html + + + +
+ +
+``` + +```js +class Tabs extends AttractiveElement { + connect() { + this.#show("details"); + } + + select(element, { dataset }) { + this.#show(dataset.panel); + } + + // private + + #show(name) { + this.targets(".panel").forEach((panel) => (panel.hidden = true)); + + this.target(name).hidden = false; + } +} + +customElements.define("ui-tabs", Tabs); +``` + +The action name in the attribute resolves to a method on the class, called with the element and its context (so `select` receives the clicked button's `dataset`). Private methods stay out of HTML. + +What the class handles for you: + +| Boilerplate | Replaced by | +| ----------------------------- | ------------------------ | +| `connectedCallback` | `connect()` | +| `disconnectedCallback` | `disconnect()` | +| `new Attractive()` + `activate({ on: this })` | automatic | +| `deactivate()` | automatic | +| `this.querySelector("#menu")` | `this.target("menu")` | +| `document.querySelector(…)` | `this.element(…)` | + + +## Lifecycle + +`connect()` runs once the component's scope is active; `disconnect()` runs before it is torn down. + +```js +class Counter extends AttractiveElement { + connect() { + this.count = 0; + } + + increment() { + this.target("count").textContent = ++this.count; + } +} +``` + + +## Scoped targets + +`this.target(id)` and `this.targets(selector)` query within the component. `target()` takes a bare id, the same convention as `@target`. + +```html + + + + 0 + +``` + +Each component resolves its own `#count`, even when the same id appears on the page twice. + + +## Any element on the page + +`this.element(selector)` and `this.elements(selector)` reach the rest of the document. This is useful for calling methods on other components. + +```js +class Form extends AttractiveElement { + submit() { + this.element("ui-button").activate(); + } +} +``` + + +## Reactive targets + +A method named `{id}TargetConnected()` runs when an element with that id enters the component, and `{id}TargetDisconnected()` when it leaves. This catches targets added after the component connects. + +```js +class Panel extends AttractiveElement { + detailsTargetConnected(element) { + element.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 }); + } +} +``` + + +## Alongside a document-wide activation + +Components work when a global `Attractive.activate()` is also running. The component manages its own subtree and the page-wide activation leaves it alone, so actions never fire twice. diff --git a/package.json b/package.json index a095ad0..0182644 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "./core": { "import": "./dist/attractive.core.js" }, + "./element": { + "import": "./dist/element.js" + }, "./actions": { "import": "./dist/actions/index.js" }, diff --git a/playground/index.html b/playground/index.html index 95bf6b4..ad0fc60 100644 --- a/playground/index.html +++ b/playground/index.html @@ -8,6 +8,36 @@ /> @@ -21,6 +51,34 @@

Copy

+

Tabs

+

+ A <ui-tabs> component: HTML actions call the class + methods, target()/targets() stay scoped. +

+ + + + +
Details content
+ +
+ +

Counter

+

+ A <ui-counter> component: state in + connect(), HTML actions call methods, and + target("count") stays scoped. +

+ + + + 0 + + diff --git a/rolldown.config.js b/rolldown.config.js index eafc331..8cad022 100644 --- a/rolldown.config.js +++ b/rolldown.config.js @@ -172,5 +172,22 @@ export default [ } }), + defineConfig({ + input: "src/element.js", + output: { + file: "dist/element.js", + format: "es" + } + }), + + defineConfig({ + input: "src/element.js", + output: { + file: "dist/element.min.js", + format: "es", + minify: true + } + }), + ...actionConfigs ]; diff --git a/src/actions/base.js b/src/actions/base.js index 7906b9a..750ca83 100644 --- a/src/actions/base.js +++ b/src/actions/base.js @@ -1,4 +1,5 @@ import Debug from "./../debug"; +import { scopeOf } from "../core/scopes"; export default class ActionBase { static actionFor(method) { @@ -19,12 +20,14 @@ export default class ActionBase { } get targets() { + const scope = scopeOf(this.element); + if (this.targetsSelector) { - return Array.from(document.querySelectorAll(this.targetsSelector)); + return Array.from(scope.querySelectorAll(this.targetsSelector)); } if (this.target) { - const target = document.getElementById(this.target); + const target = scope.querySelector(`[id="${this.target}"]`); if (!target) { Debug.warn(`Target "#${this.target}" not found`); diff --git a/src/core.js b/src/core.js index 22acbc5..40b7c66 100644 --- a/src/core.js +++ b/src/core.js @@ -203,6 +203,19 @@ class Attractive { return this; } + onTargetConnected(id, callback) { + this.#elementLifecycle.onTargetAdded(id, callback); + this.#activation.notifyExistingTargets(id); + + return this; + } + + onTargetDisconnected(id, callback) { + this.#elementLifecycle.onTargetRemoved(id, callback); + + return this; + } + addEventListener(type, callback) { this.#subscriptions.add(type, callback); diff --git a/src/core/actions.js b/src/core/actions.js index c0d48ff..83d5a4a 100644 --- a/src/core/actions.js +++ b/src/core/actions.js @@ -1,5 +1,6 @@ import debounce from "./helpers/debounce"; import { actionAttributes, getActionAttributes } from "./attributes"; +import { insideNestedScope } from "./scopes"; const debounceTimers = new WeakMap(); @@ -53,6 +54,8 @@ class Actions { if (!this.#scope.contains(element)) return; + if (insideNestedScope(element, this.#scope)) return; + this.#debounced( () => this.#execute({ event, with: context, on: element }), @@ -79,13 +82,15 @@ class Actions { } if (Actions.#nonBubblingEvents.has(eventType)) { - element.addEventListener(eventType, (event) => + element.addEventListener(eventType, (event) => { + if (insideNestedScope(element, this.#scope)) return; + this.#events.process(event, { on: element, using: this.#defaultEventType({ for: element }), with: value - }) - ); + }); + }); } } } @@ -181,6 +186,8 @@ class Actions { #execute({ event, with: context, on: element }) { if (!this.#scope.contains(element)) return; + if (insideNestedScope(element, this.#scope)) return; + const defaultEventType = context ? context.eventType : this.#defaultEventType({ for: element }); diff --git a/src/core/activation.js b/src/core/activation.js index d9ba856..f458eab 100644 --- a/src/core/activation.js +++ b/src/core/activation.js @@ -2,6 +2,7 @@ import Actions from "./actions"; import EventListeners from "./event_listeners"; import Observer from "./observer"; import Debug from "./../debug"; +import { registerScope, unregisterScope, insideNestedScope } from "./scopes"; class Activation { #registry; @@ -67,6 +68,7 @@ class Activation { this.#registry.addTrigger(name, action); this.#scope = on; + registerScope(on); this.#subscriptions.setScope(on); this.#actions = new Actions( @@ -82,26 +84,28 @@ class Activation { (element) => { this.#actions.prepare(element); - this.#elementLifecycle.runAdded(element); + this.#elementLifecycle.notifyAdded(element); + this.#elementLifecycle.notifyTargetAdded(element); }, (element) => { this.#listeners.cleanup(element); - this.#elementLifecycle.runRemoved(element); + this.#elementLifecycle.notifyRemoved(element); + this.#elementLifecycle.notifyTargetRemoved(element); }, on, (element) => { - this.#elementLifecycle.runBeforeRemove(element); + this.#elementLifecycle.notifyBeforeRemove(element); } ); - const elements = on.querySelectorAll("*"); + const elements = [on, ...on.querySelectorAll("*")]; const actionElements = []; for (const element of elements) { - if (this.#attributePrefixes.matches(element)) { + if (this.#shouldPrepare(element)) { actionElements.push(element); } } @@ -114,10 +118,11 @@ class Activation { this.#actions.prepare(element); }); - this.#observe.start((element) => this.#attributePrefixes.matches(element)); + this.#observe.start((element) => this.#shouldPrepare(element)); actionElements.forEach((element) => { - this.#elementLifecycle.runAdded(element); + this.#elementLifecycle.notifyAdded(element); + this.#elementLifecycle.notifyTargetAdded(element); }); this.#initialized = true; @@ -134,6 +139,7 @@ class Activation { deactivate() { if (!this.#initialized) return this; + unregisterScope(this.#scope); this.#listeners.removeAll(); if (this.#observe) { @@ -150,6 +156,24 @@ class Activation { return this; } + + notifyExistingTargets(id) { + if (!this.#scope) return this; + + this.#scope.querySelectorAll(`[id="${id}"]`).forEach((element) => { + this.#elementLifecycle.notifyTargetAdded(element); + }); + + return this; + } + + #shouldPrepare(element) { + return ( + (this.#attributePrefixes.matches(element) || + this.#elementLifecycle.hasTarget(element.id)) && + !insideNestedScope(element, this.#scope) + ); + } } export default Activation; diff --git a/src/core/element_lifecycle_hooks.js b/src/core/element_lifecycle_hooks.js index 2db2f53..43b82fc 100644 --- a/src/core/element_lifecycle_hooks.js +++ b/src/core/element_lifecycle_hooks.js @@ -2,6 +2,8 @@ class ElementLifecycleHooks { #added = new Set(); #removed = new Set(); #beforeRemove = new Set(); + #targetAdded = new Map(); + #targetRemoved = new Map(); onAdded(callback) { this.#added.add(callback); @@ -21,22 +23,52 @@ class ElementLifecycleHooks { return this; } - runAdded(element) { + onTargetAdded(id, callback) { + if (!this.#targetAdded.has(id)) this.#targetAdded.set(id, new Set()); + + this.#targetAdded.get(id).add(callback); + + return this; + } + + onTargetRemoved(id, callback) { + if (!this.#targetRemoved.has(id)) this.#targetRemoved.set(id, new Set()); + + this.#targetRemoved.get(id).add(callback); + + return this; + } + + hasTarget(id) { + return this.#targetAdded.has(id) || this.#targetRemoved.has(id); + } + + notifyAdded(element) { this.#added.forEach((fn) => fn(element)); } - runRemoved(element) { + notifyRemoved(element) { this.#removed.forEach((fn) => fn(element)); } - runBeforeRemove(element) { + notifyBeforeRemove(element) { this.#beforeRemove.forEach((fn) => fn(element)); } + notifyTargetAdded(element) { + this.#targetAdded.get(element.id)?.forEach((fn) => fn(element)); + } + + notifyTargetRemoved(element) { + this.#targetRemoved.get(element.id)?.forEach((fn) => fn(element)); + } + clear() { this.#added.clear(); this.#removed.clear(); this.#beforeRemove.clear(); + this.#targetAdded.clear(); + this.#targetRemoved.clear(); } } diff --git a/src/core/scopes.js b/src/core/scopes.js new file mode 100644 index 0000000..50b2503 --- /dev/null +++ b/src/core/scopes.js @@ -0,0 +1,40 @@ +const SCOPE_PROPERTY = "__attractiveScope"; + +export function registerScope(root) { + if (root && root.nodeType === 1 && !root[SCOPE_PROPERTY]) { + Object.defineProperty(root, SCOPE_PROPERTY, { + value: true, + configurable: true + }); + } +} + +export function unregisterScope(root) { + if (root && root.nodeType === 1 && root[SCOPE_PROPERTY]) { + delete root[SCOPE_PROPERTY]; + } +} + +export function insideNestedScope(element, scope) { + let node = element; + + while (node && node.nodeType === 1 && node !== scope) { + if (node[SCOPE_PROPERTY]) return true; + + node = node.parentElement; + } + + return false; +} + +export function scopeOf(element) { + let node = element; + + while (node && node.nodeType === 1) { + if (node[SCOPE_PROPERTY]) return node; + + node = node.parentElement; + } + + return document; +} diff --git a/src/element.js b/src/element.js new file mode 100644 index 0000000..b02acc4 --- /dev/null +++ b/src/element.js @@ -0,0 +1,135 @@ +import Attractive from "./core"; +import builtinActions from "./actions"; +import { builtinGates, builtinTriggers } from "./core/builtin_directives"; +import Debug from "./debug"; + +const RESERVED = new Set([ + "connect", + "disconnect", + "target", + "targets", + "element", + "elements" +]); + +class AttractiveElement extends HTMLElement { + #attractive; + + connectedCallback() { + this.#attractive = new Attractive(); + this.#attractive.activate({ + on: this, + addActions: { ...builtinActions, ...this.#actions() }, + addGates: builtinGates, + addTriggers: builtinTriggers + }); + + this.connect?.(); + + this.#registerTargetLifecycle(); + + const tag = this.tagName.toLowerCase(); + const id = this.id ? `#${this.id}` : ""; + + Debug.log("element connected →", `${tag}${id}`); + } + + disconnectedCallback() { + this.disconnect?.(); + this.#attractive?.deactivate(); + + const tag = this.tagName.toLowerCase(); + const id = this.id ? `#${this.id}` : ""; + + Debug.log("element disconnected →", `${tag}${id}`); + } + + target(id) { + return this.querySelector(`[id="${id}"]`); + } + + targets(selector) { + return Array.from(this.querySelectorAll(selector)); + } + + element(selector) { + return document.querySelector(selector); + } + + elements(selector) { + return Array.from(document.querySelectorAll(selector)); + } + + // private + + #actions() { + const actions = {}; + + for (const [name, method] of this.#publicMethods()) { + if (RESERVED.has(name)) continue; + + actions[name] = (element, context) => method.call(this, element, context); + } + + return actions; + } + + #registerTargetLifecycle() { + for (const [name, method] of this.#publicMethods()) { + const connected = name.match(/^(.+)TargetConnected$/); + if (connected) { + const targetName = connected[1]; + this.#attractive.onTargetConnected(targetName, (element) => { + const tag = this.tagName.toLowerCase(); + const id = this.id ? `#${this.id}` : ""; + + Debug.log( + "target connected →", + `#${targetName}`, + "in", + `${tag}${id}` + ); + method.call(this, element); + }); + } + + const disconnected = name.match(/^(.+)TargetDisconnected$/); + if (disconnected) { + const targetName = disconnected[1]; + this.#attractive.onTargetDisconnected(targetName, (element) => { + const tag = this.tagName.toLowerCase(); + const id = this.id ? `#${this.id}` : ""; + + Debug.log( + "target disconnected →", + `#${targetName}`, + "in", + `${tag}${id}` + ); + method.call(this, element); + }); + } + } + } + + #publicMethods() { + const methods = new Map(); + let prototype = this.constructor.prototype; + + while (prototype && prototype !== HTMLElement.prototype) { + for (const name of Object.getOwnPropertyNames(prototype)) { + if (name === "constructor" || methods.has(name)) continue; + if (typeof prototype[name] === "function") { + methods.set(name, prototype[name]); + } + } + + prototype = Object.getPrototypeOf(prototype); + } + + return methods; + } +} + +export default AttractiveElement; +export { AttractiveElement }; diff --git a/tests/addons/element.test.js b/tests/addons/element.test.js new file mode 100644 index 0000000..175695f --- /dev/null +++ b/tests/addons/element.test.js @@ -0,0 +1,152 @@ +import { test, expect, beforeEach, vi } from "vitest"; +import AttractiveElement from "../../src/element.js"; + +let connectCount = 0; + +class Counter extends AttractiveElement { + connect() { + connectCount += 1; + this.count = 0; + } + + increment() { + this.count += 1; + } +} + +customElements.define("ui-counter", Counter); + +class TargetTester extends AttractiveElement { + reveal() { + this.target("panel").hidden = false; + } +} + +customElements.define("ui-target-tester", TargetTester); + +class StatusPanel extends AttractiveElement { + connect() { + this.connections = 0; + } + + statusTargetConnected(element) { + element.textContent = "Connected"; + this.connections += 1; + } +} + +customElements.define("ui-status-panel", StatusPanel); + +class ToggleTester extends AttractiveElement {} + +customElements.define("ui-toggle-tester", ToggleTester); + +beforeEach(() => { + document.body.innerHTML = ""; + connectCount = 0; +}); + +test("works standalone: HTML actions call component methods", () => { + document.body.innerHTML = ` + + + + `; + + document.querySelector("button").click(); + expect(document.querySelector("ui-counter").count).toBe(1); +}); + +test("connect and disconnect run on lifecycle", () => { + const el = document.createElement("ui-counter"); + document.body.appendChild(el); + expect(connectCount).toBe(1); + + el.remove(); + expect(connectCount).toBe(1); + + document.body.appendChild(el); + expect(connectCount).toBe(2); +}); + +test("target() resolves within the component when ids repeat", () => { + document.body.innerHTML = ` + + + + + + + + + `; + + const components = document.querySelectorAll("ui-target-tester"); + const buttons = document.querySelectorAll("button"); + + buttons[0].click(); + expect(components[0].querySelector('[id="panel"]').hidden).toBe(false); + expect(components[1].querySelector('[id="panel"]').hidden).toBe(true); + + buttons[1].click(); + expect(components[1].querySelector('[id="panel"]').hidden).toBe(false); +}); + +test("@target resolves within the component, not the document", () => { + document.body.innerHTML = ` + + +
+
+ + +
+
+ `; + + document.querySelectorAll("button")[0].click(); + + const panels = document.querySelectorAll("ui-toggle-tester #panel"); + expect(panels[0].classList.contains("active")).toBe(true); + expect(panels[1].classList.contains("active")).toBe(false); +}); + +test("*TargetConnected fires for a target present at connect", () => { + document.body.innerHTML = ` + +
+
+ `; + + const el = document.querySelector("ui-status-panel"); + expect(el.connections).toBe(1); + expect(el.querySelector("#status").textContent).toBe("Connected"); +}); + +test("*TargetConnected fires when the target is added later", async () => { + document.body.innerHTML = ``; + + const el = document.querySelector("ui-status-panel"); + expect(el.connections).toBe(0); + + el.insertAdjacentHTML("beforeend", `
`); + + await vi.waitFor(() => expect(el.connections).toBe(1)); + expect(el.querySelector("#status").textContent).toBe("Connected"); +}); + +test("targets() and elements() return arrays", () => { + document.body.innerHTML = ` + +
+ `; + + const el = document.querySelector("ui-counter"); + expect(el.element("#form")).toBe(document.getElementById("form")); + expect(el.targets("[data-tag]")).toEqual([ + ...el.querySelectorAll("[data-tag]") + ]); + expect(el.elements("[data-tag]")).toEqual([ + ...document.querySelectorAll("[data-tag]") + ]); +}); diff --git a/tests/core/scopes.test.js b/tests/core/scopes.test.js new file mode 100644 index 0000000..d772702 --- /dev/null +++ b/tests/core/scopes.test.js @@ -0,0 +1,148 @@ +import { test, expect, beforeEach, afterEach } from "vitest"; +import Attractive from "../../src/index.js"; +import AttractiveElement from "../../src/element.js"; +import builtinActions from "../../src/actions/index.js"; +import { + builtinGates, + builtinTriggers +} from "../../src/core/builtin_directives.js"; + +let attractive; +let count; +let created; + +const baseOptions = { + addActions: { ...builtinActions, count: () => (count += 1) }, + addGates: builtinGates, + addTriggers: builtinTriggers +}; + +class CounterElement extends AttractiveElement { + count() { + count += 1; + } +} + +customElements.define("ui-scope-counter", CounterElement); + +beforeEach(() => { + document.body.innerHTML = ""; + count = 0; + + attractive = new Attractive(); + created = [attractive]; +}); + +afterEach(() => { + created.forEach((instance) => instance.deactivate()); +}); + +function activateOn(id) { + const instance = new Attractive(); + created.push(instance); + instance.activate({ on: document.getElementById(id), ...baseOptions }); + + return instance; +} + +test("action inside a nested scoped activation fires once", () => { + document.body.innerHTML = `
`; + + attractive.activate(baseOptions); + const nested = activateOn("outer"); + + document.querySelector("button").click(); + expect(count).toBe(1); + + nested.deactivate(); + + document.querySelector("button").click(); + expect(count).toBe(2); +}); + +test("scoped activation registered after the outer scan still prevents double-fire", () => { + document.body.innerHTML = `
`; + + attractive.activate(baseOptions); + activateOn("outer"); + + document.querySelector("button").click(); + expect(count).toBe(1); + + created[1].deactivate(); + + document.querySelector("button").click(); + expect(count).toBe(2); +}); + +test("nested scoped activation does not double-process, even nested in another", () => { + document.body.innerHTML = ` +
+ `; + + attractive.activate(baseOptions); + activateOn("outer"); + activateOn("inner"); + + document.querySelector("button").click(); + expect(count).toBe(1); +}); + +test("a scoped activation ignores elements outside its scope", () => { + document.body.innerHTML = ` +
+ + `; + + activateOn("scope"); + + document.getElementById("outside").click(); + expect(count).toBe(0); +}); + +test("window-targeted actions inside a nested scope fire once", () => { + document.body.innerHTML = `
`; + + attractive.activate(baseOptions); + activateOn("outer"); + + window.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(count).toBe(1); +}); + +test("a scoped activation resolves @target within its own scope", () => { + document.body.innerHTML = ` +
+ +
+
+
+ +
+
+ `; + + activateOn("first"); + activateOn("second"); + + document.querySelector("#first button").click(); + + const firstPanel = document.querySelector("#first [id='panel']"); + const secondPanel = document.querySelector("#second [id='panel']"); + + expect(firstPanel.classList.contains("active")).toBe(true); + expect(secondPanel.classList.contains("active")).toBe(false); +}); + +test("document-wide activation does not double-process a component", () => { + document.body.innerHTML = ` + + + + `; + + attractive.activate(baseOptions); + + document.querySelector("button").click(); + expect(count).toBe(1); +}); diff --git a/tests/core/target-lifecycle.test.js b/tests/core/target-lifecycle.test.js new file mode 100644 index 0000000..a3d76b3 --- /dev/null +++ b/tests/core/target-lifecycle.test.js @@ -0,0 +1,64 @@ +import { test, expect, beforeEach, vi } from "vitest"; +import Attractive from "../../src/index.js"; + +let attractive; + +beforeEach(() => { + if (attractive) attractive.deactivate(); + + document.body.innerHTML = ""; + + attractive = new Attractive(); + attractive.activate(); +}); + +test("fires for an element already in the scope at registration", () => { + document.body.innerHTML = `
`; + + const spy = vi.fn(); + attractive.onTargetConnected("status", spy); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(document.getElementById("status")); +}); + +test("fires when a matching element is added later", async () => { + const spy = vi.fn(); + attractive.onTargetConnected("status", spy); + + document.body.innerHTML = `
`; + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); +}); + +test("fires when the matching element is removed", async () => { + document.body.innerHTML = `
`; + + const spy = vi.fn(); + attractive.onTargetDisconnected("status", spy); + + document.body.innerHTML = ""; + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); +}); + +test("does not fire for other ids", async () => { + const spy = vi.fn(); + attractive.onTargetConnected("status", spy); + + document.body.innerHTML = `
`; + await Promise.resolve(); + + expect(spy).not.toHaveBeenCalled(); +}); + +test("fires during the initial scan when registered before activation", () => { + document.body.innerHTML = `
`; + + const spy = vi.fn(); + const fresh = new Attractive(); + fresh.onTargetConnected("status", spy); + fresh.activate(); + + expect(spy).toHaveBeenCalledTimes(1); +});