import { expect, test, vi } from "vitest" import type { BaseInputorContext, BaseInputorControllerOptions, BaseInputorSchema, } from "#Source/form/index.ts" import { BaseInputorController } from "#Source/form/index.ts" const TEST_INPUTOR_TYPE_NAME = "test" as const type TestExternalValue = string | undefined type TestInternalValue = string | undefined interface TestInputorSchema extends BaseInputorSchema { inputorTypeName: typeof TEST_INPUTOR_TYPE_NAME } interface TestInputorContext extends BaseInputorContext {} interface TestInputorControllerOptions extends BaseInputorControllerOptions< TestExternalValue, TestInputorSchema > {} class TestInputorController extends BaseInputorController< TestExternalValue, TestInternalValue, TestInputorSchema, TestInputorContext > { declare schemaType: TestInputorSchema declare optionsType: TestInputorControllerOptions declare constructorType: typeof TestInputorController static inputorTypeName = TEST_INPUTOR_TYPE_NAME inputorTypeName = TEST_INPUTOR_TYPE_NAME protected voidExternalValue = undefined protected voidInternalValue = undefined protected override async constrainExternalValue( value: TestExternalValue, ): Promise { if (value === undefined) { return await Promise.resolve(undefined) } return await Promise.resolve(value.trim()) } protected override async constrainInternalValue( value: TestInternalValue, ): Promise { if (value === undefined) { return await Promise.resolve(undefined) } return await Promise.resolve(value.trim()) } } const createTestInputor = ( overrides: Partial = {}, ): TestInputorController => { return new TestInputorController({ id: "test-id", name: "test-name", placeholder: "test-placeholder", value: " initial ", ...overrides, }) } test("BaseInputorController.isReady and waitForReady initialize defaults and emit initial hooks", async () => { const valueChanges: Array<{ newValue: TestExternalValue; oldValue: TestExternalValue }> = [] const schemaChanges: Array<{ newSchema: TestInputorSchema; oldSchema: TestInputorSchema }> = [] const controller = createTestInputor({ onValueChange: (context) => { valueChanges.push(context) }, onSchemaChange: (context) => { schemaChanges.push(context) }, }) expect(controller.isReady()).toBe(false) await controller.waitForReady() expect(controller.isReady()).toBe(true) expect(await controller.getSchema()).toEqual({ inputorTypeName: TEST_INPUTOR_TYPE_NAME, id: "test-id", name: "test-name", placeholder: "test-placeholder", value: "initial", isVoid: false, isFocused: false, isTouched: false, isDirty: false, isDisabled: false, isDebugMode: false, }) expect(valueChanges).toEqual([ { newValue: "initial", oldValue: "initial", }, ]) expect(schemaChanges).toHaveLength(1) expect(schemaChanges[0]!.newSchema).toEqual(schemaChanges[0]!.oldSchema) expect(schemaChanges[0]!.newSchema.value).toBe("initial") }) test("BaseInputorController.getInternalValue, getContext, and isEqualSchema expose current state", async () => { const controller = createTestInputor({ value: " alpha ", }) await controller.waitForReady() expect(controller.getInternalValue()).toBe("alpha") expect(await controller.getContext()).toEqual({ inputorTypeName: TEST_INPUTOR_TYPE_NAME, id: "test-id", name: "test-name", placeholder: "test-placeholder", value: "alpha", isVoid: false, isFocused: false, isTouched: false, isDirty: false, isDisabled: false, isDebugMode: false, internalValue: "alpha", isReady: true, }) expect(await controller.isEqualSchema({ value: "alpha", placeholder: "test-placeholder" })).toBe( true, ) expect(await controller.isEqualSchema({ value: "beta" })).toBe(false) expect(await controller.isEqualSchema(null)).toBe(false) }) test("BaseInputorController.subscribeInputChange and updateInputValue emit input updates after debounce", async () => { vi.useFakeTimers() try { const controller = createTestInputor({ value: "start", inputDebounce: 20, }) const inputChanges: Array<{ newInput: TestExternalValue oldInput: TestExternalValue oldValue: TestExternalValue }> = [] await controller.waitForReady() controller.subscribeInputChange((context) => { inputChanges.push(context) }) await controller.updateInputValue(" next ") expect(inputChanges).toEqual([]) await vi.advanceTimersByTimeAsync(20) expect(inputChanges).toEqual([ { newInput: " next ", oldInput: "start", oldValue: "start", }, ]) } finally { vi.useRealTimers() } }) test("BaseInputorController.unsubscribeInputChange removes one input subscriber", async () => { vi.useFakeTimers() try { const controller = createTestInputor({ value: "start" }) let calls = 0 const subscriber = (): void => { calls = calls + 1 } await controller.waitForReady() controller.subscribeInputChange(subscriber) controller.unsubscribeInputChange(subscriber) await controller.updateInputValue("next") await vi.advanceTimersByTimeAsync(0) expect(calls).toBe(0) } finally { vi.useRealTimers() } }) test("BaseInputorController.clearInputChangeSubscribers removes all input subscribers", async () => { vi.useFakeTimers() try { const controller = createTestInputor({ value: "start" }) let calls = 0 await controller.waitForReady() controller.subscribeInputChange(() => { calls = calls + 1 }) controller.subscribeInputChange(() => { calls = calls + 1 }) controller.clearInputChangeSubscribers() await controller.updateInputValue("next") await vi.advanceTimersByTimeAsync(0) expect(calls).toBe(0) } finally { vi.useRealTimers() } }) test("BaseInputorController.subscribeValueChange, applyInputValue, and updateValue emit value changes", async () => { const controller = createTestInputor({ value: "start" }) const valueChanges: Array<{ newValue: TestExternalValue; oldValue: TestExternalValue }> = [] await controller.waitForReady() controller.subscribeValueChange((context) => { valueChanges.push(context) }) await controller.updateInputValue(" next ") await controller.applyInputValue() await controller.updateValue(" final ") expect(valueChanges).toEqual([ { newValue: "next", oldValue: "start", }, { newValue: "final", oldValue: "next", }, ]) }) test("BaseInputorController.unsubscribeValueChange removes one value subscriber", async () => { const controller = createTestInputor({ value: "start" }) let calls = 0 const subscriber = (): void => { calls = calls + 1 } await controller.waitForReady() controller.subscribeValueChange(subscriber) controller.unsubscribeValueChange(subscriber) await controller.updateValue("next") expect(calls).toBe(0) }) test("BaseInputorController.clearValueChangeSubscribers removes all value subscribers", async () => { const controller = createTestInputor({ value: "start" }) let calls = 0 await controller.waitForReady() controller.subscribeValueChange(() => { calls = calls + 1 }) controller.subscribeValueChange(() => { calls = calls + 1 }) controller.clearValueChangeSubscribers() await controller.updateValue("next") expect(calls).toBe(0) }) test("BaseInputorController.subscribeSchemaChange and updatePlaceholder emit schema changes", async () => { const controller = createTestInputor({ value: "start" }) const schemaChanges: Array<{ newSchema: TestInputorSchema; oldSchema: TestInputorSchema }> = [] await controller.waitForReady() await Promise.resolve() controller.subscribeSchemaChange((context) => { schemaChanges.push(context) }) await controller.updatePlaceholder("next-placeholder") expect(schemaChanges.length).toBeGreaterThanOrEqual(1) expect(schemaChanges.at(-1)!.oldSchema.placeholder).toBe("test-placeholder") expect(schemaChanges.at(-1)!.newSchema.placeholder).toBe("next-placeholder") }) test("BaseInputorController.unsubscribeSchemaChange removes one schema subscriber", async () => { const controller = createTestInputor({ value: "start" }) let calls = 0 const subscriber = (): void => { calls = calls + 1 } await controller.waitForReady() controller.subscribeSchemaChange(subscriber) controller.unsubscribeSchemaChange(subscriber) await controller.updatePlaceholder("next-placeholder") expect(calls).toBe(0) }) test("BaseInputorController.clearSchemaChangeSubscribers removes all schema subscribers", async () => { const controller = createTestInputor({ value: "start" }) let calls = 0 await controller.waitForReady() controller.subscribeSchemaChange(() => { calls = calls + 1 }) controller.subscribeSchemaChange(() => { calls = calls + 1 }) controller.clearSchemaChangeSubscribers() await controller.updatePlaceholder("next-placeholder") expect(calls).toBe(0) }) test("BaseInputorController.updateIsVoid resets the value to its void value", async () => { const controller = createTestInputor({ value: "start" }) await controller.waitForReady() await controller.updateIsVoid(true) expect(await controller.getSchema()).toMatchObject({ value: undefined, isVoid: true, isDirty: true, }) }) test("BaseInputorController.updateIsFocused also marks the controller as touched", async () => { const controller = createTestInputor({ value: "start" }) await controller.waitForReady() await controller.updateIsFocused(true) expect(await controller.getSchema()).toMatchObject({ isFocused: true, isTouched: true, }) }) test("BaseInputorController.updateIsTouched updates touched state directly", async () => { const controller = createTestInputor({ value: "start" }) await controller.waitForReady() await controller.updateIsTouched(true) expect(await controller.getSchema()).toMatchObject({ isTouched: true, }) }) test("BaseInputorController.updateIsDirty updates dirty state directly", async () => { const controller = createTestInputor({ value: "start" }) await controller.waitForReady() await controller.updateIsDirty(true) expect(await controller.getSchema()).toMatchObject({ isDirty: true, }) }) test("BaseInputorController.updateIsDisabled updates disabled state directly", async () => { const controller = createTestInputor({ value: "start" }) await controller.waitForReady() await controller.updateIsDisabled(true) expect(await controller.getSchema()).toMatchObject({ isDisabled: true, }) }) test("BaseInputorController.updateIsDebugMode updates debug mode directly", async () => { const controller = createTestInputor({ value: "start" }) await controller.waitForReady() await controller.updateIsDebugMode(true) expect(await controller.getSchema()).toMatchObject({ isDebugMode: true, }) }) test("BaseInputorController.updateSchema applies multiple schema fields in one pass", async () => { const controller = createTestInputor({ value: "start" }) await controller.waitForReady() await controller.updateSchema({ ...(await controller.getSchema()), placeholder: "schema-placeholder", value: " schema-value ", isFocused: true, isTouched: true, isDirty: true, isDisabled: true, isDebugMode: true, }) expect(await controller.getSchema()).toEqual({ inputorTypeName: TEST_INPUTOR_TYPE_NAME, id: "test-id", name: "test-name", placeholder: "schema-placeholder", value: "schema-value", isVoid: false, isFocused: true, isTouched: true, isDirty: true, isDisabled: true, isDebugMode: true, }) }) test("BaseInputorController.destroy clears every subscriber collection", async () => { vi.useFakeTimers() try { const controller = createTestInputor({ value: "start" }) let inputCalls = 0 let valueCalls = 0 let schemaCalls = 0 await controller.waitForReady() controller.subscribeInputChange(() => { inputCalls = inputCalls + 1 }) controller.subscribeValueChange(() => { valueCalls = valueCalls + 1 }) controller.subscribeSchemaChange(() => { schemaCalls = schemaCalls + 1 }) controller.destroy() await controller.updateInputValue("next") await vi.advanceTimersByTimeAsync(0) await controller.updateValue("final") await controller.updatePlaceholder("after-destroy") expect(inputCalls).toBe(0) expect(valueCalls).toBe(0) expect(schemaCalls).toBe(0) } finally { vi.useRealTimers() } })