diff --git a/packages/binding.core/spec/submitBehaviors.ts b/packages/binding.core/spec/submitBehaviors.ts index e96837e37..1caa4fe09 100644 --- a/packages/binding.core/spec/submitBehaviors.ts +++ b/packages/binding.core/spec/submitBehaviors.ts @@ -39,4 +39,39 @@ describe('Binding: Submit', function () { expect(model.wasCalled).to.equal(true) expect(firstParamStored).to.equal(formNode) }) + + it('Should throw when the bound value is not a function', function () { + testNode.innerHTML = '
' + expect(() => applyBindings({}, testNode)).to.throw(/value for a submit binding must be a function/) + }) + + it('Should not prevent the default form submission when the handler returns true', function () { + testNode.innerHTML = "" + const handler = function () { + return true + } + applyBindings({ doCall: handler }, testNode) + const formNode = testNode.children[0] as HTMLFormElement + let defaultPrevented: boolean | undefined + formNode.addEventListener('submit', function (event) { + defaultPrevented = event.defaultPrevented + // stop the actual form navigation in case preventDefault was skipped + event.preventDefault() + }) + triggerEvent(formNode, 'submit') + expect(defaultPrevented).to.equal(false) + }) + + it('Should prevent the default form submission when the handler returns a non-true value', function () { + testNode.innerHTML = "" + applyBindings({ doCall: function () {} }, testNode) + const formNode = testNode.children[0] as HTMLFormElement + let defaultPrevented: boolean | undefined + formNode.addEventListener('submit', function (event) { + defaultPrevented = event.defaultPrevented + event.preventDefault() + }) + triggerEvent(formNode, 'submit') + expect(defaultPrevented).to.equal(true) + }) }) diff --git a/packages/observable/spec/subscribableBehaviors.ts b/packages/observable/spec/subscribableBehaviors.ts index 1f99ca1b0..6edb4fbc0 100644 --- a/packages/observable/spec/subscribableBehaviors.ts +++ b/packages/observable/spec/subscribableBehaviors.ts @@ -50,6 +50,27 @@ describe('Subscribable', function () { expect(notifiedValue).to.equal(undefined) }) + it('Should expose a TC39 Observable-style `unsubscribe()` alias for dispose', function () { + const instance = new subscribable() + let notified = 0 + const subscription = instance.subscribe(function () { + notified++ + }) + expect(subscription.closed).to.equal(false) + subscription.unsubscribe() + expect(subscription.closed).to.equal(true) + instance.notifySubscribers('value') + expect(notified).to.equal(0) + }) + + it('Should report `closed` as true once disposed', function () { + const instance = new subscribable() + const subscription = instance.subscribe(function () {}) + expect(subscription.closed).to.equal(false) + subscription.dispose() + expect(subscription.closed).to.equal(true) + }) + it("Should be able to specify a 'this' pointer for the callback", function () { const model = { someProperty: 123, diff --git a/packages/provider/spec/BindingHandlerObjectBehaviors.ts b/packages/provider/spec/BindingHandlerObjectBehaviors.ts new file mode 100644 index 000000000..67b0550a9 --- /dev/null +++ b/packages/provider/spec/BindingHandlerObjectBehaviors.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai' +import sinon from 'sinon' + +import { options } from '@tko/utils' + +import { BindingHandlerObject } from '../src' + +describe('BindingHandlerObject', function () { + let originalOnError: typeof options.onError + + beforeEach(function () { + originalOnError = options.onError + }) + + afterEach(function () { + options.onError = originalOnError + }) + + it('registers a single handler when set() is called with (name, value)', function () { + const handlers = new BindingHandlerObject() + const handler = { init: () => {} } + handlers.set('myHandler', handler) + expect((handlers as any).myHandler).to.equal(handler) + expect(handlers.get('myHandler')).to.equal(handler) + }) + + it('registers multiple handlers when set() is called with an object', function () { + const handlers = new BindingHandlerObject() + const a = { init: () => {} } + const b = { update: () => {} } + handlers.set({ a, b }) + expect(handlers.get('a')).to.equal(a) + expect(handlers.get('b')).to.equal(b) + }) + + it('reports onError when set() is called with both an object and a value', function () { + const handlers = new BindingHandlerObject() + const onError = sinon.stub() + options.onError = onError + handlers.set({ a: () => {} }, 'extraneous') + expect(onError.calledOnce).to.equal(true) + expect(onError.firstCall.args[0]).to.be.instanceOf(Error) + expect(onError.firstCall.args[0].message).to.match(/extraneous `value` parameter/) + }) + + it('reports onError when set() is called with a non-string, non-object key', function () { + const handlers = new BindingHandlerObject() + const onError = sinon.stub() + options.onError = onError + handlers.set(undefined as unknown as string) + expect(onError.calledOnce).to.equal(true) + expect(onError.firstCall.args[0].message).to.match(/bad binding handler type/) + + onError.resetHistory() + handlers.set(42 as unknown as string) + expect(onError.calledOnce).to.equal(true) + expect(onError.firstCall.args[0].message).to.match(/bad binding handler type/) + }) + + it('get() resolves dotted handler names to the root segment', function () { + const handlers = new BindingHandlerObject() + const attr = { init: () => {} } + handlers.set('attr', attr) + expect(handlers.get('attr.title')).to.equal(attr) + expect(handlers.get('attr.style.color')).to.equal(attr) + }) + + it('get() returns undefined for an unknown handler', function () { + const handlers = new BindingHandlerObject() + expect(handlers.get('nope')).to.equal(undefined) + }) +}) diff --git a/packages/utils/spec/stringBehaviors.ts b/packages/utils/spec/stringBehaviors.ts new file mode 100644 index 000000000..8e14bcc0d --- /dev/null +++ b/packages/utils/spec/stringBehaviors.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai' + +import { parseJson } from '../dist' + +describe('parseJson', function () { + it('parses a valid JSON string into an object', function () { + expect(parseJson('{"a":1,"b":"two"}')).to.deep.equal({ a: 1, b: 'two' }) + }) + + it('parses primitives and arrays', function () { + expect(parseJson('42')).to.equal(42) + expect(parseJson('"hello"')).to.equal('hello') + expect(parseJson('true')).to.equal(true) + expect(parseJson('null')).to.equal(null) + expect(parseJson('[1,2,3]')).to.deep.equal([1, 2, 3]) + }) + + it('trims surrounding whitespace before parsing', function () { + expect(parseJson(' {"x":10} ')).to.deep.equal({ x: 10 }) + }) + + it('returns null for an empty string', function () { + expect(parseJson('')).to.equal(null) + }) + + it('returns null for a whitespace-only string', function () { + expect(parseJson(' \n\t ')).to.equal(null) + }) + + it('returns null when the input is not a string', function () { + // Inputs whose runtime type is not 'string' must short-circuit to null, + // not be coerced or thrown on. The signature is typed `string` but the + // function is callable from data-bind expressions where the runtime + // value can be anything. + expect(parseJson(undefined as unknown as string)).to.equal(null) + expect(parseJson(null as unknown as string)).to.equal(null) + expect(parseJson(42 as unknown as string)).to.equal(null) + expect(parseJson({} as unknown as string)).to.equal(null) + }) + + it('throws when the input is a non-empty malformed JSON string', function () { + // parseJson does not swallow JSON.parse errors — callers that want to + // tolerate bad input must wrap the call themselves. Pinning this + // behavior so a future "be helpful and return null" change is caught. + expect(() => parseJson('{not json')).to.throw() + expect(() => parseJson('undefined')).to.throw() + }) +}) diff --git a/plans/2026-04-22-coverage-gap-fill.md b/plans/2026-04-22-coverage-gap-fill.md new file mode 100644 index 000000000..106551480 --- /dev/null +++ b/plans/2026-04-22-coverage-gap-fill.md @@ -0,0 +1,228 @@ +# Plan: Fill Test-Coverage Gaps Identified by `bun run test:coverage` + +## Context + +PR #364 wired up `bun run test:coverage` (vitest + `@vitest/coverage-v8`, +chromium project, source-map remapped to TS). The first run produced +`coverage/` (HTML + `coverage-summary.json` + `lcov.info`). Overall: +**92.71% statements / 87.63% branches / 90.29% +functions / 92.82% lines** across the public `@tko/*` surface (Date:2026-04-22). + +That number hides a long tail of low-coverage files concentrated in the +provider stack and a few utils modules. Because TKO is a published low-level +framework with an unknown audience (AGENTS.md "Context for every agent"), gaps +in those layers are the riskiest: they're the substrate every binding sits on +and every consumer hits at startup. + +This plan closes the largest gaps first, in order of risk-adjusted return: +small files with simple uncovered branches, then the provider stack, then +DOM utilities. It is a coverage-driven plan, not a redesign — every commit +adds tests, no production code changes unless a test surfaces a real bug +(in which case the fix is a separate commit per AGENTS.md "Always Improve"). + +## Source of truth + +Regenerate with `bun run test:coverage` + +- `coverage/coverage-summary.json` — per-file totals (machine-readable) +- `coverage/lcov-report/index.html` — drill-down with uncovered line ranges + +Refresh before working on this plan: `bun run test:coverage` then re-read the +relevant `coverage/lcov-report/