diff --git a/src/ui-kit/form-controls/date-range/date-range.spec.ts b/src/ui-kit/form-controls/date-range/date-range.spec.ts index 6800b4879..d5743f1d4 100755 --- a/src/ui-kit/form-controls/date-range/date-range.spec.ts +++ b/src/ui-kit/form-controls/date-range/date-range.spec.ts @@ -1,4 +1,4 @@ -import { TestBed, waitForAsync } from "@angular/core/testing"; +import { TestBed, ComponentFixture } from "@angular/core/testing"; import { FormsModule, FormControl } from "@angular/forms"; // Load the implementations that should be tested @@ -8,7 +8,6 @@ import { SamTimeComponent } from "../time/time.component"; import { SamDateTimeComponent } from "../date-time/date-time.component"; import { SamFormService } from "../../form-service"; import { SamWrapperModule } from "../../wrappers"; -import { SamUIKitModule } from "../../index"; describe("The Sam Date Range component", () => { describe("isolated tests", () => { @@ -70,9 +69,97 @@ describe("The Sam Date Range component", () => { expect(returnVal.dateRangeError.message).toBe("Invalid To Date"); }); }); + + describe("static dateRangeValidation", () => { + it("returns undefined when neither start nor end date is set", () => { + const c = new FormControl({}); + expect(SamDateRangeComponent.dateRangeValidation(c)).toBe(undefined); + }); + + it("flags an end date before the start date", () => { + const c = new FormControl({ + startDate: "2020-06-01", + endDate: "2020-01-01", + }); + const result = SamDateRangeComponent.dateRangeValidation(c); + expect(result.dateRangeError.message).toBe("Invalid date range"); + }); + + it("passes a valid start/end pair", () => { + const c = new FormControl({ + startDate: "2020-01-01", + endDate: "2020-06-01", + }); + expect(SamDateRangeComponent.dateRangeValidation(c)).toBe(undefined); + }); + + it("flags an invalid start-only date", () => { + const c = new FormControl({ startDate: "2020-99-99" }); + const result = SamDateRangeComponent.dateRangeValidation(c); + expect(result.dateRangeError.message).toBe("Invalid From Date"); + }); + + it("skips validation when the start-only date is the sentinel 'Invalid date'", () => { + const c = new FormControl({ startDate: "Invalid date" }); + expect(SamDateRangeComponent.dateRangeValidation(c)).toBe(undefined); + }); + + it("flags an invalid end-only date", () => { + const c = new FormControl({ endDate: "2020-99-99" }); + const result = SamDateRangeComponent.dateRangeValidation(c); + expect(result.dateRangeError.message).toBe("Invalid To Date"); + }); + + it("skips validation when the end-only date is the sentinel 'Invalid date'", () => { + const c = new FormControl({ endDate: "Invalid date" }); + expect(SamDateRangeComponent.dateRangeValidation(c)).toBe(undefined); + }); + }); + + describe("static dateRangeRequired", () => { + let component: SamDateRangeComponent; + + beforeEach(() => { + component = new SamDateRangeComponent(new SamFormService()); + }); + + it("requires both dates when required is set and returns an error when missing focus", () => { + component.required = true; + component.hasFocus = false; + const c = new FormControl({ + startDate: "Invalid date", + endDate: "Invalid date", + }); + const result = SamDateRangeComponent.dateRangeRequired(component)(c); + expect(result.dateRangeError.message).toBe("This field is required"); + }); + + it("does not error while the control has focus", () => { + component.required = true; + component.hasFocus = true; + const c = new FormControl({ + startDate: "Invalid date", + endDate: "Invalid date", + }); + expect(SamDateRangeComponent.dateRangeRequired(component)(c)).toBe( + undefined + ); + }); + + it("does not error when neither fromRequired nor toRequired is set", () => { + component.required = false; + component.fromRequired = false; + component.toRequired = false; + const c = new FormControl({}); + expect(SamDateRangeComponent.dateRangeRequired(component)(c)).toBe( + undefined + ); + }); + }); + describe("rendered tests", () => { let component: SamDateRangeComponent; - let fixture: any; + let fixture: ComponentFixture; // provide our implementations or mocks to the dependency injector beforeEach(() => { @@ -141,5 +228,56 @@ describe("The Sam Date Range component", () => { component.focusHandler(); expect(true).toBe(true); }); + + it("emits both start and end time in the output when type is date-time", () => { + let emitted; + component.type = "date-time"; + component.valueChange.subscribe((v) => (emitted = v)); + component.writeValue({ + startDate: "2016-12-29", + startTime: "11:11", + endDate: "2017-04-01", + endTime: "14:09", + }); + component.ngOnChanges(); + component.dateChange(); + expect(emitted.startTime).toBe("11:11"); + expect(emitted.endTime).toBe("14:09"); + }); + + it("focuses the end date's month input after a 'year entered' blur in date mode", () => { + component.type = "date"; + component.endDateComp.month.nativeElement.focus = () => undefined; + expect(() => component.dateBlur("year entered")).not.toThrow(); + expect(component.hasFocus).toBe(false); + }); + + it("does not attempt to focus the end date on a plain blur", () => { + component.type = "date"; + expect(() => component.dateBlur(undefined)).not.toThrow(); + expect(component.hasFocus).toBe(false); + }); + + it("endDateBlur clears focus and re-emits the current date change", () => { + component.endDateBlur(); + expect(component.hasFocus).toBe(false); + }); + + it("formats errors when a control is provided without useFormService", () => { + const control = new FormControl(""); + component.control = control; + component.useFormService = false; + expect(() => component.ngOnInit()).not.toThrow(); + }); + + it("subscribes to SamFormService events when useFormService is true", () => { + const formService: SamFormService = TestBed.inject(SamFormService); + const control = new FormControl(""); + component.control = control; + component.useFormService = true; + component.ngOnInit(); + expect(() => formService.fireSubmit(control.root)).not.toThrow(); + expect(() => formService.fireReset(control.root)).not.toThrow(); + }); }); }); diff --git a/src/ui-kit/form-controls/date-time/date-time.spec.ts b/src/ui-kit/form-controls/date-time/date-time.spec.ts index 8c3bd095c..b5884cc16 100755 --- a/src/ui-kit/form-controls/date-time/date-time.spec.ts +++ b/src/ui-kit/form-controls/date-time/date-time.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from "@angular/core/testing"; +import { TestBed, ComponentFixture } from "@angular/core/testing"; import { FormsModule, FormControl } from "@angular/forms"; // Load the implementations that should be tested @@ -10,8 +10,9 @@ import { SamWrapperModule } from "../../wrappers"; describe("The Sam Date Time component", () => { let component: SamDateTimeComponent; - let fixture: any; + let fixture: ComponentFixture; + // provide our implementations or mocks to the dependency injector beforeEach(() => { TestBed.configureTestingModule({ imports: [SamWrapperModule, FormsModule], @@ -23,134 +24,131 @@ describe("The Sam Date Time component", () => { component = fixture.componentInstance; component.value = "2016-12-31T12:01"; component.name = "test"; - fixture.detectChanges(); }); it("Should compile", function () { expect(true).toBe(true); }); - it("should throw a 508-compliance error when no name is provided", () => { + it("throws when name is not provided, for 508 compliance", () => { component.name = undefined; - expect(() => component.ngOnInit()).toThrowError(/508 compliance/); + expect(() => component.ngOnInit()).toThrow(); }); - it("should parse an initial value into date and time parts", () => { - component.writeValue("2016-12-31T12:01"); + it("parses a valid value into date and time on init", () => { + fixture.detectChanges(); + component.parseValueString(); expect(component.date).toBe("2016-12-31"); expect(component.time).toBe("12:01"); }); - it("should reset date and time parts when written a falsy value", () => { - component.writeValue("2016-12-31T12:01"); - component.writeValue(undefined); - expect(component.value).toBe(""); - expect(component.date).toBe(""); - expect(component.time).toBe(""); - }); - - it("should log an error and leave date/time unset for an unparsable value", () => { - const errorSpy = vi - .spyOn(console, "error") - .mockImplementation(() => undefined); - component.date = undefined; - component.time = undefined; - component.value = "not a real date"; + it("logs an error and leaves date/time unset for an invalid value", () => { + fixture.detectChanges(); + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + component.value = "2016-99-99Tx"; component.parseValueString(); - expect(errorSpy).toHaveBeenCalledWith( - "[value] for sam-date-time is invalid" - ); - errorSpy.mockRestore(); - }); - - it("should emit the combined value through registered onChange", () => { - let emitted: string; - component.registerOnChange((val) => (emitted = val)); - component.emitChanges("2020-01-01T10:00"); - expect(component.value).toBe("2020-01-01T10:00"); - expect(emitted).toBe("2020-01-01T10:00"); + expect(spy).toHaveBeenCalledWith("[value] for sam-date-time is invalid"); + spy.mockRestore(); }); - it("should not throw when emitting changes without a registered onChange", () => { - component.onChange = undefined; - expect(() => component.emitChanges("2020-01-01T10:00")).not.toThrow(); + it("does nothing when there is no value to parse", () => { + fixture.detectChanges(); + component.value = undefined; + expect(() => component.parseValueString()).not.toThrow(); }); - it("should emit undefined when both date and time inputs are empty", () => { - let emitted: string | undefined = "not-called"; - component.registerOnChange((val) => (emitted = val)); - vi.spyOn(component.dateComponent, "isEmptyField").mockReturnValue(true); - vi.spyOn(component.timeComponent, "isEmptyField").mockReturnValue(true); - + it("emits undefined when both date and time are empty", () => { + fixture.detectChanges(); + let emitted; + component.registerOnChange((v) => (emitted = v)); + component.dateComponent.writeValue(undefined); + component.timeComponent.writeValue(undefined); component.onInputChange(); - - expect(emitted).toBeUndefined(); + expect(emitted).toBe(undefined); }); - it("should emit the combined date and time when both inputs are valid", () => { - let emitted: string | undefined; - component.registerOnChange((val) => (emitted = val)); - vi.spyOn(component.dateComponent, "isEmptyField").mockReturnValue(false); - vi.spyOn(component.timeComponent, "isEmptyField").mockReturnValue(false); - vi.spyOn(component.dateComponent, "isValid").mockReturnValue(true); - vi.spyOn(component.timeComponent, "isValid").mockReturnValue(true); - component.date = "2020-01-01"; - component.time = "10:00"; - + it("emits the combined date-time string when both fields are valid", async () => { + fixture.detectChanges(); + let emitted; + component.registerOnChange((v) => (emitted = v)); + component.dateComponent.writeValue("2016-12-31"); + component.timeComponent.writeValue("12:01"); + component.date = "2016-12-31"; + component.time = "12:01"; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); component.onInputChange(); - - expect(emitted).toBe("2020-01-01T10:00"); + expect(emitted).toBe("2016-12-31T12:01"); }); - it("should emit 'Invalid Date Time' when the inputs are non-empty but invalid", () => { - let emitted: string | undefined; - component.registerOnChange((val) => (emitted = val)); - vi.spyOn(component.dateComponent, "isEmptyField").mockReturnValue(false); - vi.spyOn(component.timeComponent, "isEmptyField").mockReturnValue(false); - vi.spyOn(component.dateComponent, "isValid").mockReturnValue(false); - vi.spyOn(component.timeComponent, "isValid").mockReturnValue(true); - + it("emits 'Invalid Date Time' when the fields are inconsistent", async () => { + fixture.detectChanges(); + let emitted; + component.registerOnChange((v) => (emitted = v)); + component.dateComponent.writeValue("2016-12-31"); + component.timeComponent.writeValue(undefined); + component.date = "2016-12-31"; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); component.onInputChange(); - expect(emitted).toBe("Invalid Date Time"); }); - it("should move focus to the time input's hour field on date blur", () => { - const focusSpy = vi.fn(); - component.timeComponent.hourV = { nativeElement: { focus: focusSpy } }; + it("does not throw when no onChange callback has been registered", () => { + fixture.detectChanges(); + expect(() => component.emitChanges("2016-12-31T12:01")).not.toThrow(); + }); - component.dateBlur(); + it("focuses the time component's hour input on date blur", () => { + fixture.detectChanges(); + component.timeComponent.hourV.nativeElement.focus = () => undefined; + expect(() => component.dateBlur()).not.toThrow(); + }); - expect(focusSpy).toHaveBeenCalled(); + it("implements ControlValueAccessor via writeValue/registerOnChange/registerOnTouched", () => { + fixture.detectChanges(); + let changed; + let touched = false; + component.registerOnChange((v) => (changed = v)); + component.registerOnTouched(() => (touched = true)); + component.setDisabledState(true); + component.writeValue("2016-12-31T12:01"); + expect(component.value).toBe("2016-12-31T12:01"); + expect(component.disabled).toBe(true); + component.onChange("2020-01-01T00:00"); + component.onTouched(); + expect(changed).toBe("2020-01-01T00:00"); + expect(touched).toBe(true); }); - it("should clear the date and time inputs on resetInput", () => { - component.date = "2020-01-01"; - component.time = "10:00"; - component.resetInput(); + it("resets date/time when written an empty value", () => { + fixture.detectChanges(); + component.writeValue("2016-12-31T12:01"); + component.writeValue(undefined); + expect(component.value).toBe(""); expect(component.date).toBe(""); expect(component.time).toBe(""); }); - it("should wire up a form control and format errors on status change without the form service", () => { - const control = new FormControl(""); - component.control = control; - component.useFormService = false; + describe("control wiring", () => { + it("formats errors immediately when a control is provided without useFormService", () => { + component.control = new FormControl(""); + component.useFormService = false; + fixture.detectChanges(); + expect(() => component.ngOnInit()).not.toThrow(); + }); - expect(() => { + it("subscribes to SamFormService events when useFormService is true", () => { + const formService: SamFormService = TestBed.inject(SamFormService); + const control = new FormControl(""); + component.control = control; + component.useFormService = true; + fixture.detectChanges(); component.ngOnInit(); - control.setValue("changed"); - }).not.toThrow(); - }); - - it("should format errors through the SamFormService when useFormService is set", () => { - const formService = TestBed.inject(SamFormService); - const control = new FormControl(""); - component.control = control; - component.useFormService = true; - component.ngOnInit(); - - expect(() => formService.fireSubmit(control.root)).not.toThrow(); - expect(() => formService.fireReset(control.root)).not.toThrow(); + expect(() => formService.fireSubmit(control.root)).not.toThrow(); + expect(() => formService.fireReset(control.root)).not.toThrow(); + }); }); }); diff --git a/src/ui-kit/form-controls/date/date.spec.ts b/src/ui-kit/form-controls/date/date.spec.ts index 565cc9deb..0625a0a26 100755 --- a/src/ui-kit/form-controls/date/date.spec.ts +++ b/src/ui-kit/form-controls/date/date.spec.ts @@ -1,6 +1,6 @@ -import { TestBed, tick, waitForAsync } from "@angular/core/testing"; +import { TestBed, waitForAsync, ComponentFixture } from "@angular/core/testing"; -import { FormsModule } from "@angular/forms"; +import { FormsModule, FormControl } from "@angular/forms"; import { ChangeDetectorRef } from "@angular/core"; import { By } from "@angular/platform-browser"; // Load the implementations that should be tested @@ -11,10 +11,7 @@ import { SamFormService } from "../../form-service"; describe("The Sam Date component", () => { describe("Isolated tests", () => { let component: SamDateComponent; - let fixture: any; - let monthEl; - let dayEl; - let yearEl; + let fixture: ComponentFixture; // provide our implementations or mocks to the dependency injector beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -31,9 +28,6 @@ describe("The Sam Date component", () => { value: "2016-12-29", }); fixture.detectChanges(); - monthEl = fixture.debugElement.query(By.css("input[name=date_month]")); - dayEl = fixture.debugElement.query(By.css("input[name=date_day]")); - yearEl = fixture.debugElement.query(By.css("input[name=date_year]")); })); it("Should date value to be empty when tab is pressed", function () { @@ -188,9 +182,55 @@ describe("The Sam Date component", () => { expect(component._isLeapYear("2017")).toBe(false); }); }); + + describe("static validators", () => { + it("dateRequired flags a dirty, empty control as required", () => { + const c = new FormControl(""); + c.markAsDirty(); + const result = SamDateComponent.dateRequired()(c); + expect(result.dateRequiredError.message).toBe("This field is required"); + }); + + it("dateRequired passes a dirty control with a value", () => { + const c = new FormControl("2016-12-29"); + c.markAsDirty(); + expect(SamDateComponent.dateRequired()(c)).toBe(undefined); + }); + + it("dateRequired passes a pristine, empty control", () => { + const c = new FormControl(""); + expect(SamDateComponent.dateRequired()(c)).toBe(undefined); + }); + + it("dateValidation flags an invalid date on a dirty control", () => { + const c = new FormControl("2016-99-99"); + c.markAsDirty(); + const result = SamDateComponent.dateValidation()(c); + expect(result.dateError.message).toBe("Invalid date"); + }); + + it("dateValidation flags a year below 1000 on a dirty control", () => { + const c = new FormControl("0999-01-01"); + c.markAsDirty(); + const result = SamDateComponent.dateValidation()(c); + expect(result.dateError.message).toBe("Please enter 4 digit year"); + }); + + it("dateValidation passes a dirty control with a valid 4-digit-year date", () => { + const c = new FormControl("2016-12-29"); + c.markAsDirty(); + expect(SamDateComponent.dateValidation()(c)).toBe(undefined); + }); + + it("dateValidation passes a pristine control regardless of value", () => { + const c = new FormControl("2016-99-99"); + expect(SamDateComponent.dateValidation()(c)).toBe(undefined); + }); + }); + describe("Rendered tests", () => { let component: SamDateComponent; - let fixture: any; + let fixture: ComponentFixture; let monthEl; let dayEl; let yearEl; @@ -307,4 +347,572 @@ describe("The Sam Date component", () => { expect(model.year).toBe("2000"); }); }); + + describe("paste handlers", () => { + let component: SamDateComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamDateComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamDateComponent); + component = fixture.componentInstance; + component.name = "paste-test"; + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + })); + + function pasteEvent(text: string) { + let prevented = false; + return { + clipboardData: { getData: () => text }, + preventDefault: () => { + prevented = true; + }, + wasPrevented: () => prevented, + }; + } + + it("allows a valid pasted month", () => { + const event = pasteEvent("11"); + component.onMonthPaste(event); + expect(event.wasPrevented()).toBe(false); + }); + + it("rejects a pasted month above the max", () => { + const event = pasteEvent("13"); + component.onMonthPaste(event); + expect(event.wasPrevented()).toBe(true); + }); + + it("rejects a pasted month longer than 2 characters", () => { + const event = pasteEvent("123"); + component.onMonthPaste(event); + expect(event.wasPrevented()).toBe(true); + }); + + it("allows a valid pasted day", () => { + const event = pasteEvent("15"); + component.onDayPaste(event); + expect(event.wasPrevented()).toBe(false); + }); + + it("rejects a pasted day above the max", () => { + const event = pasteEvent("32"); + component.onDayPaste(event); + expect(event.wasPrevented()).toBe(true); + }); + + it("allows a valid pasted year", () => { + const event = pasteEvent("2016"); + component.onYearPaste(event); + expect(event.wasPrevented()).toBe(false); + }); + + it("rejects a pasted year longer than 4 digits", () => { + const event = pasteEvent("20166"); + component.onYearPaste(event); + expect(event.wasPrevented()).toBe(true); + }); + }); + + describe("getMaxDate and getNumJumpThreshold", () => { + let component: SamDateComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamDateComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamDateComponent); + component = fixture.componentInstance; + component.name = "max-date-test"; + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + })); + + it("caps at 30 for thirty-day months", () => { + component.month.nativeElement.value = "4"; + expect(component.getMaxDate()).toBe(30); + }); + + it("caps at 29 for February in a leap year", () => { + component.month.nativeElement.value = "2"; + component.year.nativeElement.value = "2016"; + expect(component.getMaxDate()).toBe(29); + }); + + it("caps at 28 for February in a non-leap year", () => { + component.month.nativeElement.value = "2"; + component.year.nativeElement.value = "2017"; + expect(component.getMaxDate()).toBe(28); + }); + + it("caps at 31 for all other months", () => { + component.month.nativeElement.value = "7"; + expect(component.getMaxDate()).toBe(31); + }); + + it("uses a jump threshold of 2 for February", () => { + expect(component.getNumJumpThreshold(2)).toBe(2); + }); + + it("uses a jump threshold of 3 for other months", () => { + expect(component.getNumJumpThreshold(7)).toBe(3); + }); + }); + + describe("touch, blur and naming behaviors", () => { + let component: SamDateComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamDateComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamDateComponent); + component = fixture.componentInstance; + component.name = "touch-test"; + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + })); + + it("strips a leading zero from the month on touch", () => { + component.month.nativeElement.value = "05"; + component.triggerMonthTouch({ target: { value: "05" } }); + expect(component.month.nativeElement.value).toBe("5"); + expect(component.isMonthTouched).toBe(true); + }); + + it("strips a leading zero from the day on touch", () => { + component.day.nativeElement.value = "05"; + component.triggerDayTouch({ target: { value: "05" } }); + expect(component.day.nativeElement.value).toBe("5"); + expect(component.isDateTouched).toBe(true); + }); + + it("marks the year as touched", () => { + component.triggerTouch({}); + expect(component.isYearTouched).toBe(true); + }); + + it("emits blur once all fields have been touched and blurred", () => { + let blurred = false; + component.blur.subscribe(() => (blurred = true)); + component.isDateTouched = true; + component.isMonthTouched = true; + component.isYearTouched = true; + component.isMonthBlur = true; + component.isDayBlur = true; + component.isYearBlur = true; + component.dateBlurred(); + expect(blurred).toBe(true); + }); + + it("does not emit blur until every field has been touched and blurred", () => { + let blurred = false; + component.blur.subscribe(() => (blurred = true)); + component.dateBlurred(); + expect(blurred).toBe(false); + }); + + it("shows the required designation in field names when required", () => { + component.required = true; + expect(component.monthName()).toContain("month required."); + expect(component.dayName()).toContain("day required."); + expect(component.yearName()).toContain("year required."); + }); + + it("omits the required designation in field names when not required", () => { + component.required = false; + expect(component.monthName()).not.toContain("required"); + expect(component.dayName()).not.toContain("required"); + expect(component.yearName()).not.toContain("required"); + }); + + it("tracks selection state for each field", () => { + component.onMonthSelected(); + component.onDaySelected(); + component.onYearSelected(); + expect(component.isMonthSelected).toBe(true); + expect(component.isDaySelected).toBe(true); + expect(component.isYearSelected).toBe(true); + }); + + it("clears the '0' month on blur", () => { + component.month.nativeElement.value = "0"; + component.onMonthBlur({}); + expect(component.month.nativeElement.value).toBe(""); + }); + + it("clears the '0' day on blur", () => { + component.day.nativeElement.value = "0"; + component.onDayBlur({}); + expect(component.day.nativeElement.value).toBe(""); + }); + + it("clears the '0' year and an orphaned Feb 29 on blur in a non-leap year", () => { + component.year.nativeElement.value = "0"; + component.onYearBlur({}); + expect(component.year.nativeElement.value).toBe(""); + + component.month.nativeElement.value = "2"; + component.day.nativeElement.value = "29"; + component.year.nativeElement.value = "2017"; + component.onYearBlur({}); + expect(component.day.nativeElement.value).toBe(""); + }); + }); + + describe("ControlValueAccessor via writeValue", () => { + let component: SamDateComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamDateComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamDateComponent); + component = fixture.componentInstance; + component.name = "cva-test"; + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + })); + + it("parses a written value into the model", () => { + component.writeValue("2016-12-29"); + expect(component.model.month).toBe(12); + expect(component.model.day).toBe(29); + expect(component.model.year).toBe(2016); + }); + + it("resets the inputs when written an empty value", () => { + component.writeValue("2016-12-29"); + component.writeValue(undefined); + expect(component.month.nativeElement.value).toBe(""); + expect(component.day.nativeElement.value).toBe(""); + expect(component.year.nativeElement.value).toBe(""); + }); + + it("registers onChange/onTouched callbacks and disabled state", () => { + let changed; + let touched = false; + component.registerOnChange((v) => (changed = v)); + component.registerOnTouched(() => (touched = true)); + component.setDisabledState(true); + component.onChange("2016-01-01"); + component.onTouched(); + expect(changed).toBe("2016-01-01"); + expect(touched).toBe(true); + expect(component.disabled).toBe(true); + }); + }); + + describe("ngAfterViewInit control wiring", () => { + let component: SamDateComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamDateComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamDateComponent); + component = fixture.componentInstance; + component.name = "control-test"; + })); + + it("formats errors immediately when a control is provided without useFormService", () => { + const control = new FormControl(""); + component.control = control; + component.defaultValidations = true; + component.required = true; + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + component.ngAfterViewInit(); + expect(control.validator).toBeTruthy(); + }); + + it("defers to the form service when useFormService is true", () => { + const control = new FormControl(""); + component.control = control; + component.useFormService = true; + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + expect(() => component.ngAfterViewInit()).not.toThrow(); + }); + + it("does nothing when no control is provided", () => { + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + expect(() => component.ngAfterViewInit()).not.toThrow(); + }); + }); + + describe("typing digits into month/day/year", () => { + let component: SamDateComponent; + let fixture: ComponentFixture; + let monthEl; + let dayEl; + let yearEl; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamDateComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamDateComponent); + component = fixture.componentInstance; + component.name = "typing-test"; + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + monthEl = component.month.nativeElement; + dayEl = component.day.nativeElement; + yearEl = component.year.nativeElement; + })); + + function digitEvent(key: number, target) { + return { + key, + target, + preventDefault: () => undefined, + }; + } + + it("types a single-digit month and advances focus to day", () => { + monthEl.selectionStart = 0; + dayEl.focus = () => undefined; + component.onMonthInput(digitEvent(5, { value: "" })); + expect(monthEl.value).toBe("5"); + }); + + it("clears a conflicting day value when the month makes it invalid", () => { + dayEl.value = "31"; + monthEl.selectionStart = 0; + component.onMonthInput(digitEvent(4, { value: "" })); + expect(dayEl.value).toBe(""); + }); + + it("types a single-digit day and advances focus to year", () => { + monthEl.value = "3"; + dayEl.selectionStart = 0; + yearEl.focus = () => undefined; + component.onDayInput(digitEvent(5, { value: "" })); + expect(dayEl.value).toBe("5"); + }); + + it("emits blur once a 4-digit year has been entered", () => { + let blurred = false; + component.blur.subscribe(() => (blurred = true)); + yearEl.value = "20"; + yearEl.selectionStart = 2; + component.onYearInput(digitEvent(1, { value: "201" })); + expect(blurred).toBe(true); + }); + }); + + describe("remaining onChangeHandler branches", () => { + let component: SamDateComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamDateComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamDateComponent); + component = fixture.componentInstance; + component.name = "change-handler-test"; + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + })); + + function touchAll(comp: SamDateComponent) { + comp.isDateTouched = true; + comp.isMonthTouched = true; + comp.isYearTouched = true; + } + + it("emits null when all fields are touched but empty", () => { + let emitted; + component.valueChange.subscribe((v) => (emitted = v)); + touchAll(component); + component.onChangeHandler(); + expect(emitted).toBe(null); + }); + + it("emits Invalid Date when the year isn't 4 digits", () => { + let emitted; + component.valueChange.subscribe((v) => (emitted = v)); + component.month.nativeElement.value = "5"; + component.day.nativeElement.value = "10"; + component.year.nativeElement.value = "20"; + touchAll(component); + component.onChangeHandler(); + expect(emitted).toBe("Invalid Date"); + }); + + it("emits Invalid Date when the composed date is not valid", () => { + let emitted; + component.valueChange.subscribe((v) => (emitted = v)); + component.month.nativeElement.value = "13"; + component.day.nativeElement.value = "40"; + component.year.nativeElement.value = "2020"; + component.model = { month: 13, day: 40, year: 2020 }; + component.isDateTouched = true; + component.isMonthTouched = true; + component.isYearTouched = false; + component.onChangeHandler(); + expect(emitted).toBe("Invalid Date"); + }); + + it("emits a formatted date string for a valid, fully touched date", () => { + let emitted; + component.valueChange.subscribe((v) => (emitted = v)); + component.month.nativeElement.value = "5"; + component.day.nativeElement.value = "10"; + component.year.nativeElement.value = "2020"; + component.model = { month: 5, day: 10, year: 2020 }; + component.isDateTouched = true; + component.isMonthTouched = true; + component.isYearTouched = false; + component.onChangeHandler(); + expect(emitted).toBe("2020-05-10"); + }); + + it("removalKeyHandler feeds the current input model through onChangeHandler", () => { + let emitted; + component.valueChange.subscribe((v) => (emitted = v)); + component.month.nativeElement.value = "5"; + component.day.nativeElement.value = "10"; + component.year.nativeElement.value = "2020"; + touchAll(component); + component.removalKeyHandler(); + expect(emitted).toBe("2020-05-10"); + }); + + it("touchHandler triggers a change once every field is touched", () => { + let emitted; + component.valueChange.subscribe((v) => (emitted = v)); + component.month.nativeElement.value = "5"; + component.day.nativeElement.value = "10"; + component.year.nativeElement.value = "2020"; + touchAll(component); + component.touchHandler(); + expect(emitted).toBe("2020-05-10"); + }); + + it("touchHandler does nothing until every field is touched", () => { + let emitted; + component.valueChange.subscribe((v) => (emitted = v)); + component.touchHandler(); + expect(emitted).toBeUndefined(); + }); + + it("isEmptyField honors an override argument", () => { + expect(component.isEmptyField({ day: "", month: "", year: "" })).toBe( + true + ); + expect( + component.isEmptyField({ day: "1", month: "2", year: "2020" }) + ).toBe(false); + }); + }); + + describe("private helpers used by public flows", () => { + let component: SamDateComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamDateComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamDateComponent); + component = fixture.componentInstance; + component.name = "helper-test"; + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + })); + + it("falls back to window.clipboardData when the event has none", () => { + const win = window as unknown as { + clipboardData?: { getData: (t: string) => string }; + }; + const originalClipboardData = win.clipboardData; + const getData = vi.fn(() => "07"); + win.clipboardData = { getData }; + const preventDefault = vi.fn(); + const event = { preventDefault }; + component.onMonthPaste(event); + win.clipboardData = originalClipboardData; + expect(getData).toHaveBeenCalledWith("text"); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("ignores 'c' and 'v' key presses to avoid interfering with copy/paste", () => { + const before = component.month.nativeElement.value; + component.onMonthInput({ key: "c", preventDefault: () => undefined }); + expect(component.month.nativeElement.value).toBe(before); + component.onDayInput({ key: "v", preventDefault: () => undefined }); + expect(component.day.nativeElement.value).toBe(before); + component.onYearInput({ key: "c", preventDefault: () => undefined }); + expect(component.year.nativeElement.value).toBe(before); + }); + }); + + describe("ngAfterViewInit with the SamFormService submit/reset events", () => { + let component: SamDateComponent; + let fixture: ComponentFixture; + let formService: SamFormService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamDateComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamDateComponent); + component = fixture.componentInstance; + component.name = "form-service-events-test"; + formService = TestBed.inject(SamFormService); + component.control = new FormControl(""); + component.useFormService = true; + component.ngOnChanges({ value: undefined }); + fixture.detectChanges(); + component.ngAfterViewInit(); + })); + + it("formats errors on a submit event matching the control's root", () => { + expect(() => + formService.fireSubmit(component.control.root) + ).not.toThrow(); + }); + + it("clears errors on a reset event matching the control's root", () => { + expect(() => formService.fireReset(component.control.root)).not.toThrow(); + }); + }); }); diff --git a/src/ui-kit/form-controls/time/time.spec.ts b/src/ui-kit/form-controls/time/time.spec.ts index d6b3282b9..a79fd92cf 100755 --- a/src/ui-kit/form-controls/time/time.spec.ts +++ b/src/ui-kit/form-controls/time/time.spec.ts @@ -1,10 +1,9 @@ -import { TestBed } from "@angular/core/testing"; -import { FormsModule } from "@angular/forms"; +import { TestBed, ComponentFixture } from "@angular/core/testing"; +import { FormsModule, FormControl } from "@angular/forms"; import { By } from "@angular/platform-browser"; // Load the implementations that should be tested import { SamTimeComponent } from "./time.component"; -import { SamUIKitModule } from "../../index"; import { SamFormService } from "../../form-service"; import { SamWrapperModule } from "../../wrappers"; @@ -49,11 +48,62 @@ describe("The Sam Time component", () => { component.writeValue("12:12"); expect(true).toBe(true); }); + + it("resets to an empty value when written undefined", () => { + component.hourV = { nativeElement: { value: "12" } }; + component.minuteV = { nativeElement: { value: "30" } }; + component.ampmV = { nativeElement: { value: "pm" } }; + component.writeValue(undefined); + expect(component.value).toBe(""); + expect(component.hourV.nativeElement.value).toBe(""); + expect(component.minuteV.nativeElement.value).toBe(""); + expect(component.ampmV.nativeElement.value).toBe("am"); + }); + + it("does not parse an invalid time string", () => { + component.value = "not a time"; + expect(() => component.parseValueString()).not.toThrow(); + expect(component.hours).toBeUndefined(); + }); + + it("converts a 24-hour PM time to 12-hour am/pm", () => { + component.value = "14:44"; + component.parseValueString(); + expect(component.hours).toBe(2); + expect(component.minutes).toBe(44); + expect(component.amPm).toBe("pm"); + }); + + it("converts midnight (00:xx) to 12 am", () => { + component.value = "00:05"; + component.parseValueString(); + expect(component.hours).toBe(12); + expect(component.minutes).toBe(5); + expect(component.amPm).toBe("am"); + }); + + it("formats hours for pm, converting 12 to 0 before adding 12", () => { + component.amPm = "pm"; + expect(component.formatHours(12)).toBe(12); + expect(component.formatHours(3)).toBe(15); + }); + + it("formats hours for am, leaving them unconverted", () => { + component.amPm = "am"; + expect(component.formatHours(9)).toBe(9); + }); + + it("names the hour, minute and am/pm fields from the component name", () => { + component.name = "my-time"; + expect(component.hourName()).toBe("my-time_hour"); + expect(component.minuteName()).toBe("my-time_minute"); + expect(component.amPmName()).toBe("my-time_am_pm"); + }); }); describe("rendered test", () => { let component: SamTimeComponent; - let fixture: any; + let fixture: ComponentFixture; // provide our implementations or mocks to the dependency injector beforeEach(() => { @@ -142,4 +192,197 @@ describe("The Sam Time component", () => { expect(time).toBe("11:11"); }); }); + + describe("typing hours and minutes", () => { + let component: SamTimeComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamTimeComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamTimeComponent); + component = fixture.componentInstance; + component.name = "typing-test"; + fixture.detectChanges(); + component.hourV.nativeElement.focus = () => undefined; + component.minuteV.nativeElement.focus = () => undefined; + component.ampmV.nativeElement.focus = () => undefined; + }); + + function press(key: string, target = { value: "" }) { + return { key, target, preventDefault: () => undefined }; + } + + it("ignores 'c'/'v' key presses on hours and minutes", () => { + const beforeHour = component.hourV.nativeElement.value; + const beforeMinute = component.minuteV.nativeElement.value; + component.hoursPress(press("c")); + expect(component.hourV.nativeElement.value).toBe(beforeHour); + component.minutesPress(press("v")); + expect(component.minuteV.nativeElement.value).toBe(beforeMinute); + }); + + it("rejects an hour above 12", () => { + component.hourV.nativeElement.value = "1"; + component.hoursPress(press("3")); + expect(component.hourV.nativeElement.value).toBe("1"); + }); + + it("types a single-digit hour and advances focus to minutes", () => { + component.hoursPress(press("5")); + expect(component.hourV.nativeElement.value).toBe("5"); + }); + + it("rejects a minute above 59", () => { + component.minuteV.nativeElement.value = "6"; + component.minutesPress(press("5")); + expect(component.minuteV.nativeElement.value).toBe("6"); + }); + + it("types a single-digit minute and advances focus to am/pm", () => { + component.minutesPress(press("5")); + expect(component.minuteV.nativeElement.value).toBe("5"); + }); + + it("strips a leading zero from the hour on touch", () => { + component.hourV.nativeElement.value = "05"; + component.hourTouched({ srcElement: { value: "05" } }); + expect(component.hourV.nativeElement.value).toBe("5"); + }); + + it("strips a leading zero from the minute on touch", () => { + component.minuteV.nativeElement.value = "05"; + component.minuteTouched({ srcElement: { value: "05" } }); + expect(component.minuteV.nativeElement.value).toBe("5"); + }); + + it("selectChange formats the current hour/minute into an output string", () => { + let changed; + component.registerOnChange((v) => (changed = v)); + component.amPm = "pm"; + component.hourV.nativeElement.value = "5"; + component.minuteV.nativeElement.value = "30"; + component.selectChange(); + expect(changed).toBe("17:30"); + }); + + it("onInputChange falls back to 'Invalid Time' when given a falsy value", () => { + let changed; + component.registerOnChange((v) => (changed = v)); + component.onInputChange(undefined); + expect(changed).toBe("Invalid Time"); + }); + + it("isValid checks that hours and minutes are within range", () => { + component.hourV.nativeElement.value = "5"; + component.minuteV.nativeElement.value = "30"; + expect(component.isValid()).toBe(true); + + component.hourV.nativeElement.value = "13"; + expect(component.isValid()).toBe(false); + }); + + it("getTime returns undefined for an invalid time", () => { + component.hourV.nativeElement.value = ""; + component.minuteV.nativeElement.value = ""; + expect(component.getTime()).toBeUndefined(); + }); + + it("getTime leaves a string '12' hour unconverted (nativeElement.value is a string)", () => { + component.amPm = "am"; + component.hourV.nativeElement.value = "12"; + component.minuteV.nativeElement.value = "30"; + const time = component.getTime(); + expect(time.format(component.OUTPUT_FORMAT)).toBe("12:30"); + }); + + it("getTime produces an invalid moment for pm string hours (string concatenation)", () => { + component.amPm = "pm"; + component.hourV.nativeElement.value = "5"; + component.minuteV.nativeElement.value = "30"; + const time = component.getTime(); + expect(time.format(component.OUTPUT_FORMAT)).toBe("Invalid date"); + }); + + it("removalKeyHandler feeds the formatted time through onChange", () => { + let changed; + component.registerOnChange((v) => (changed = v)); + component.amPm = "am"; + component.hourV.nativeElement.value = 5; + component.minuteV.nativeElement.value = 30; + component.removalKeyHandler(); + expect(changed).toBe("05:30"); + }); + + it("resetInput clears the hour/minute fields and resets am/pm", () => { + component.hourV.nativeElement.value = "5"; + component.minuteV.nativeElement.value = "30"; + component.ampmV.nativeElement.value = "pm"; + component.resetInput(); + expect(component.hourV.nativeElement.value).toBe(""); + expect(component.minuteV.nativeElement.value).toBe(""); + expect(component.ampmV.nativeElement.value).toBe("am"); + }); + + it("writeValue resets input when written an empty value", () => { + component.hourV.nativeElement.value = "5"; + component.minuteV.nativeElement.value = "30"; + component.writeValue(undefined); + expect(component.value).toBe(""); + expect(component.hourV.nativeElement.value).toBe(""); + }); + }); + + describe("control wiring", () => { + let component: SamTimeComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SamWrapperModule, FormsModule], + declarations: [SamTimeComponent], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamTimeComponent); + component = fixture.componentInstance; + component.name = "control-test"; + }); + + it("formats errors immediately when a control is provided without useFormService", () => { + component.control = new FormControl(""); + component.useFormService = false; + fixture.detectChanges(); + expect(() => component.ngOnInit()).not.toThrow(); + }); + + it("subscribes to SamFormService events when useFormService is true", () => { + const formService: SamFormService = TestBed.inject(SamFormService); + const control = new FormControl(""); + component.control = control; + component.useFormService = true; + fixture.detectChanges(); + component.ngOnInit(); + expect(() => formService.fireSubmit(control.root)).not.toThrow(); + expect(() => formService.fireReset(control.root)).not.toThrow(); + }); + + it("parses the value on ngOnChanges when the value input changes", () => { + component.value = "14:44"; + fixture.detectChanges(); + expect(() => component.ngOnChanges({ value: "14:44" })).not.toThrow(); + expect(component.hours).toBe(2); + }); + + it("does not reparse when ngOnChanges receives no value change", () => { + fixture.detectChanges(); + component.hours = 99; + component.ngOnChanges({}); + expect(component.hours).toBe(99); + }); + }); });