diff --git a/src/ui-kit/experimental/actions-list/actions-list.spec.ts b/src/ui-kit/experimental/actions-list/actions-list.spec.ts new file mode 100644 index 000000000..b7f9430ed --- /dev/null +++ b/src/ui-kit/experimental/actions-list/actions-list.spec.ts @@ -0,0 +1,84 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { SimpleChanges } from "@angular/core"; +import { SamActionsListComponent, ToolbarItem } from "./actions-list.component"; +import { SamActionDropdownModule } from "../../components/actions/actions-dropdown"; + +describe("The Sam Actions List component", () => { + let component: SamActionsListComponent; + let fixture: ComponentFixture; + + const contentModel: ToolbarItem[] = [ + { label: "Download", icon: "fa-download" as const }, + { label: "Share", icon: "fa-share-alt" as const }, + { label: "Filter", icon: "fa-filter" as const, disabled: true }, + { + label: "More Item", + icon: "fa-chevron-circle-left" as const, + showMore: true, + }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamActionsListComponent], + imports: [SamActionDropdownModule], + }); + fixture = TestBed.createComponent(SamActionsListComponent); + component = fixture.componentInstance; + }); + + it("should render a button for each non-showMore content item", () => { + component.contentModel = contentModel; + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons.length).toBe(3); + }); + + it("should emit action when a non-disabled item is clicked", () => { + component.contentModel = contentModel; + fixture.detectChanges(); + const emitted: ToolbarItem[] = []; + component.action.subscribe((item: ToolbarItem) => emitted.push(item)); + component.actionClick(contentModel[0]); + expect(emitted).toEqual([contentModel[0]]); + }); + + it("should not emit action when a disabled item is clicked", () => { + component.contentModel = contentModel; + fixture.detectChanges(); + const emitted: ToolbarItem[] = []; + component.action.subscribe((item: ToolbarItem) => emitted.push(item)); + component.actionClick(contentModel[2]); + expect(emitted.length).toBe(0); + }); + + it("should collect showMore items into showMoreActions on ngOnChanges", () => { + component.contentModel = contentModel; + component.ngOnChanges({ contentModel: true } as unknown as SimpleChanges); + expect(component.showMoreActions.length).toBe(1); + expect(component.showMoreActions[0]).toEqual({ + name: "More Item", + label: "More Item", + icon: "fa fa-chevron-circle-left", + }); + }); + + it("should trigger actionClick for the matching item when the dropdown emits", () => { + component.contentModel = contentModel; + component.ngOnChanges({ contentModel: true } as unknown as SimpleChanges); + const emitted: ToolbarItem[] = []; + component.action.subscribe((item: ToolbarItem) => emitted.push(item)); + component.dropdownClick({ label: "More Item" }); + expect(emitted).toEqual([contentModel[3]]); + }); + + it("should not emit anything when the dropdown emits a non-matching item", () => { + component.contentModel = contentModel; + component.ngOnChanges({ contentModel: true } as unknown as SimpleChanges); + const emitted: ToolbarItem[] = []; + component.action.subscribe((item: ToolbarItem) => emitted.push(item)); + component.dropdownClick({ label: "Unknown" }); + expect(emitted.length).toBe(0); + }); +}); diff --git a/src/ui-kit/experimental/alert/alert.spec.ts b/src/ui-kit/experimental/alert/alert.spec.ts index 5cd17dc1f..0bb1bdb33 100755 --- a/src/ui-kit/experimental/alert/alert.spec.ts +++ b/src/ui-kit/experimental/alert/alert.spec.ts @@ -1,59 +1,53 @@ -// import { TestBed } from '@angular/core/testing'; -// import { RouterTestingModule } from '@angular/router/testing'; -// import { By } from '@angular/platform-browser'; -// import { SimpleChanges } from '@angular/core'; -// import { SamIconsModule } from '../icon/icon.module'; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { SamAlertNextComponent } from "./alert.component"; +import { SamIconsModule } from "../icon/icon.module"; -// // Load the implementations that should be tested -// import { SamAlertNextComponent } from './alert.component'; +describe("The Sam Alert component", () => { + let component: SamAlertNextComponent; + let fixture: ComponentFixture; -// const defaultConfig = { -// description: 'i-am-a-description', -// title: 'i-am-a-title', -// type: 'success', -// }; + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamAlertNextComponent], + imports: [SamIconsModule], + }); + fixture = TestBed.createComponent(SamAlertNextComponent); + component = fixture.componentInstance; + }); -// describe.skip('The Sam Alert component', () => { -// describe('isolated tests', () => { -// let component: SamAlertNextComponent; -// beforeEach(() => { -// component = new SamAlertNextComponent(); -// }); -// it('should check if type is defined', () => { -// component.type = 'notAValidType'; -// expect(component.typeNotDefined()).toBe(true); -// component.type = 'success'; -// expect(component.typeNotDefined()).toBe(false); -// component.ngOnInit(); -// expect(component.selectedType).toBe('sam-alert-success'); -// }); -// }); -// describe('rendered tests', () => { -// let component: SamAlertNextComponent; -// let fixture: any; + it("should default to the success type when no type is set", () => { + component.type = undefined; + fixture.detectChanges(); + expect(component.selectedType).toBe("sam-alert-success"); + expect(component.selectedIcon).toBe(component.selectedIconTypes.success); + }); -// beforeEach(() => { -// TestBed.configureTestingModule({ -// declarations: [SamAlertNextComponent], -// imports: [RouterTestingModule, SamIconsModule], -// }); + it("should apply the matching class and icon for a known type", () => { + component.type = "error"; + fixture.detectChanges(); + expect(component.selectedType).toBe("sam-alert-error"); + expect(component.selectedIcon).toBe(component.selectedIconTypes.error); + const wrapper = fixture.debugElement.query(By.css(".sam-alert-error")); + expect(wrapper).not.toBeNull(); + }); -// fixture = TestBed.createComponent(SamAlertNextComponent); -// component = fixture.componentInstance; -// component.type = defaultConfig.type; -// fixture.detectChanges(); + it("should keep the default success type when given an unknown type", () => { + component.type = "notAValidType"; + fixture.detectChanges(); + expect(component.typeNotDefined()).toBe(true); + expect(component.selectedType).toBe("sam-alert-success"); + }); -// }); -// it('type check', () => { -// fixture.detectChanges(); -// fixture.whenStable().then(() => { -// expect( -// fixture.debugElement.query( -// By.css('.sam-alert') -// ).nativeElement.className -// ) -// .toContain('sam-alert-success'); -// }); -// }); -// }); -// }); + it("should treat an empty string type as not defined", () => { + component.type = ""; + expect(component.typeNotDefined()).toBe(true); + }); + + it("should render the screen-reader text for the current type", () => { + component.type = "warning"; + fixture.detectChanges(); + const srText = fixture.debugElement.query(By.css(".sr-only")); + expect(srText.nativeElement.textContent.trim()).toBe("warning alert"); + }); +}); diff --git a/src/ui-kit/experimental/box/box.spec.ts b/src/ui-kit/experimental/box/box.spec.ts new file mode 100644 index 000000000..3ddf09b9a --- /dev/null +++ b/src/ui-kit/experimental/box/box.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { SamBoxComponent } from "./box.component"; + +describe("The Sam Box component", () => { + let component: SamBoxComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamBoxComponent], + }); + fixture = TestBed.createComponent(SamBoxComponent); + component = fixture.componentInstance; + }); + + it("should apply the base sam box class with no inputs set", () => { + fixture.detectChanges(); + expect(component.css_classes).toBe("sam box"); + }); + + it("should append the type and padded classes when both inputs are set", () => { + component.type = "success"; + component.padded = "large"; + fixture.detectChanges(); + expect(component.css_classes).toBe("sam box success large padded"); + }); +}); diff --git a/src/ui-kit/experimental/button-next/button.spec.ts b/src/ui-kit/experimental/button-next/button.spec.ts index d78eee196..59aab133c 100755 --- a/src/ui-kit/experimental/button-next/button.spec.ts +++ b/src/ui-kit/experimental/button-next/button.spec.ts @@ -1,13 +1,11 @@ -import { TestBed, waitForAsync } from "@angular/core/testing"; - -import { By } from "@angular/platform-browser"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; // Load the implementations that should be tested import { SamButtonNextComponent } from "./button.component"; describe("The Sam Button Next component", () => { let component: SamButtonNextComponent; - let fixture: any; + let fixture: ComponentFixture; const primaryBtnConfig = { buttonType: "primary", @@ -70,4 +68,53 @@ describe("The Sam Button Next component", () => { expect(component.btnClass).toContain("secondary"); expect(component.isDisabled).toBe(false); }); + + it("should append a size class when size matches a known size key", function () { + component.action = "primary"; + component.size = "small"; + fixture.detectChanges(); + expect(component.btnClass).toBe("primary small"); + }); + + it("should append the inverted class when theme is dark", function () { + component.action = "primary"; + component.theme = "dark"; + fixture.detectChanges(); + expect(component.btnClass).toBe("primary inverted"); + }); + + it("should append the disabled class when isDisabled is true", function () { + component.action = "primary"; + component.isDisabled = true; + fixture.detectChanges(); + expect(component.btnClass).toBe("primary disabled"); + }); + + it("should emit onClick when clicked and not disabled", function () { + component.action = "primary"; + fixture.detectChanges(); + const emitted: Event[] = []; + component.onClick.subscribe((event: Event) => emitted.push(event)); + const clickEvent = new Event("click"); + component.click(clickEvent); + expect(emitted).toEqual([clickEvent]); + }); + + it("should not emit onClick when clicked while disabled", function () { + component.action = "primary"; + component.isDisabled = true; + fixture.detectChanges(); + const emitted: Event[] = []; + component.onClick.subscribe((event: Event) => emitted.push(event)); + component.click(new Event("click")); + expect(emitted.length).toBe(0); + }); + + it("should render a submit-type button when action is submit", function () { + component.action = "submit"; + component.id = "submitBtn"; + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector("button"); + expect(button.getAttribute("type")).toBe("submit"); + }); }); diff --git a/src/ui-kit/experimental/container/container.spec.ts b/src/ui-kit/experimental/container/container.spec.ts new file mode 100644 index 000000000..629ab1548 --- /dev/null +++ b/src/ui-kit/experimental/container/container.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { SamContainerComponent } from "./container.component"; + +describe("The Sam Container component", () => { + let component: SamContainerComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamContainerComponent], + }); + fixture = TestBed.createComponent(SamContainerComponent); + component = fixture.componentInstance; + }); + + it("should apply the base sam container class with no inputs set", () => { + fixture.detectChanges(); + expect(component.css_classes).toBe("sam container"); + }); + + it("should append the size and weight classes when both inputs are set", () => { + component.size = "small"; + component.weight = "bold"; + fixture.detectChanges(); + expect(component.css_classes).toBe("sam container small bold"); + }); +}); diff --git a/src/ui-kit/experimental/date-range-v2/datepicker/picker.component.ts b/src/ui-kit/experimental/date-range-v2/datepicker/picker.component.ts index 53fb2cf53..9eaaf93b2 100755 --- a/src/ui-kit/experimental/date-range-v2/datepicker/picker.component.ts +++ b/src/ui-kit/experimental/date-range-v2/datepicker/picker.component.ts @@ -129,7 +129,7 @@ export class DatepickerComponent _focusableString: string = 'a[href], area, button, select, textarea, *[tabindex], \ input:not([type="hidden"])'; - @ViewChild("calendarpopup", { static: true }) calendarpopup: ElementRef; + @ViewChild("calendarpopup") calendarpopup: ElementRef; @ViewChild("calendarButton", { static: true }) calendarButton: ElementRef; static dateValidation() { diff --git a/src/ui-kit/experimental/date-range-v2/datepicker/picker.spec.ts b/src/ui-kit/experimental/date-range-v2/datepicker/picker.spec.ts index d498ad458..119487368 100755 --- a/src/ui-kit/experimental/date-range-v2/datepicker/picker.spec.ts +++ b/src/ui-kit/experimental/date-range-v2/datepicker/picker.spec.ts @@ -1,8 +1,20 @@ -import { TestBed } from "@angular/core/testing"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ChangeDetectorRef } from "@angular/core"; +import { FormControl } from "@angular/forms"; import { DatepickerComponent } from "./picker.component"; -import { By } from "@angular/platform-browser"; -import { ChangeDetectorRef, Renderer2, ElementRef } from "@angular/core"; import { SamFormService } from "../../../form-service"; +import { LabelWrapper } from "../../../wrappers/label-wrapper/label-wrapper.component"; +import { SamInputMaskModule } from "../../input-mask"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; + +interface DatepickerInternals { + updateDayNames(): void; +} + +function asInternals(component: DatepickerComponent): DatepickerInternals { + return component as unknown as DatepickerInternals; +} describe("The picker component", () => { describe("isolated tests", () => { @@ -22,5 +34,264 @@ describe("The picker component", () => { expect(component.dayNamesOrdered[5]).toBe("F"); expect(component.dayNamesOrdered[6]).toBe("S"); }); + + it("should throw when weekStart is out of range", () => { + component.weekStart = 10; + expect(() => asInternals(component).updateDayNames()).toThrow( + /not in range/ + ); + }); + + it("should return false from yearValidator for a non-numeric or too-early year", () => { + const control = new FormControl(1969); + expect(component.yearValidator(control)).toEqual({ invalidYear: true }); + const nonNumeric = new FormControl("abc"); + expect(component.yearValidator(nonNumeric)).toEqual({ + invalidYear: true, + }); + }); + + it("should return null from yearValidator for a valid year", () => { + const control = new FormControl(2020); + expect(component.yearValidator(control)).toBeNull(); + }); + + it("should identify valid dates within the configured range", () => { + component.rangeStart = new Date(2020, 0, 1); + component.rangeEnd = new Date(2020, 11, 31); + expect(component.isDateValid(new Date(2020, 5, 15))).toBe(true); + expect(component.isDateValid(new Date(2019, 11, 31))).toBe(false); + expect(component.isDateValid(new Date(2021, 0, 1))).toBe(false); + }); + + it("should filter out invalid days into zeroes", () => { + component.rangeStart = new Date(2020, 0, 10); + component.rangeEnd = new Date(2020, 0, 20); + const days = [ + new Date(2020, 0, 5), + new Date(2020, 0, 15), + new Date(2020, 0, 25), + ]; + const filtered = component.filterInvalidDays(days as unknown as number[]); + expect(filtered).toEqual([0, days[1], 0]); + }); + + it("should identify the chosen day and current day", () => { + component.date = new Date(2020, 0, 15); + expect(component.isChosenDay(new Date(2020, 0, 15))).toBe(true); + expect(component.isChosenDay(new Date(2020, 0, 16))).toBe(false); + expect(component.isChosenDay(null)).toBe(false); + + expect(component.isCurrentDay(new Date())).toBe(true); + expect(component.isCurrentDay(new Date(2000, 0, 1))).toBe(false); + expect(component.isCurrentDay(null)).toBe(false); + }); + + it("should return the accent color for a chosen day and light grey for the current day", () => { + component.date = new Date(2020, 0, 15); + expect(component.getDayBackgroundColor(new Date(2020, 0, 15))).toBe( + component.accentColor + ); + expect(component.getDayBackgroundColor(new Date())).toBe( + component.colors["lightGrey"] + ); + expect(component.getDayBackgroundColor(new Date(2000, 0, 1))).toBe( + component.colors["white"] + ); + }); + + it("should return white font color except for the chosen day", () => { + component.date = new Date(2020, 0, 15); + expect(component.getDayFontColor(new Date(2020, 0, 15))).toBe( + component.colors["white"] + ); + expect(component.getDayFontColor(new Date(2000, 0, 1))).toBe( + component.colors["black"] + ); + }); + + it("should report a day as hovered only when it matches hoveredDay and is not chosen", () => { + const day = new Date(2020, 0, 15); + component.hoveredDay = day; + expect(component.isHoveredDay(day)).toBe(true); + component.date = day; + expect(component.isHoveredDay(day)).toBe(false); + }); + }); + + describe("rendered tests", () => { + let component: DatepickerComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + SamInputMaskModule, + NoopAnimationsModule, + ], + declarations: [DatepickerComponent, LabelWrapper], + providers: [SamFormService], + }); + fixture = TestBed.createComponent(DatepickerComponent); + component = fixture.componentInstance; + component.name = "start-date"; + fixture.detectChanges(); + }); + + it("should show today's month/year and calendar days on init", () => { + expect(component.calendarDays.length).toBeGreaterThan(0); + expect(component.currentMonth).toBe( + component.months[new Date().getMonth()] + ); + }); + + it("should toggle the calendar open/closed via onInputClick", () => { + expect(component.showCalendar).toBe(false); + component.onInputClick(); + expect(component.showCalendar).toBe(true); + component.onInputClick(); + expect(component.showCalendar).toBe(false); + }); + + it("should show the calendar via displayCalendar", () => { + component.displayCalendar(); + expect(component.showCalendar).toBe(true); + }); + + it("should close the calendar and re-focus the calendar button on cancel", () => { + component.displayCalendar(); + const focusSpy = vi.spyOn( + component.calendarButton.nativeElement, + "focus" + ); + component.onCancel(); + expect(component.showCalendar).toBe(false); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("should navigate to the previous and next month with onArrowClick", () => { + const initialMonth = component.currentMonthNumber; + component.onArrowClick("right"); + expect(component.currentMonthNumber).toBe( + initialMonth === 11 ? 0 : initialMonth + 1 + ); + component.onArrowClick("left"); + expect(component.currentMonthNumber).toBe(initialMonth); + }); + + it("should not navigate left past the rangeStart month", () => { + const now = new Date(); + component.rangeStart = new Date(now.getFullYear(), now.getMonth(), 1); + const initialMonth = component.currentMonthNumber; + component.onArrowClick("left"); + expect(component.currentMonthNumber).toBe(initialMonth); + }); + + it("should not navigate right past the rangeEnd month", () => { + const now = new Date(); + component.rangeEnd = new Date(now.getFullYear(), now.getMonth(), 28); + const initialMonth = component.currentMonthNumber; + component.onArrowClick("right"); + expect(component.currentMonthNumber).toBe(initialMonth); + }); + + it("should select a valid day, emit onSelect, and close the calendar", () => { + const emitted: Date[] = []; + component.onSelect.subscribe((d) => emitted.push(d)); + component.displayCalendar(); + const day = new Date(); + component.onSelectDay(day); + expect(component.date).toBe(day); + expect(emitted).toEqual([day]); + expect(component.showCalendar).toBe(false); + }); + + it("should not select an invalid day", () => { + component.rangeStart = new Date(2099, 0, 1); + component.rangeEnd = new Date(2099, 11, 31); + const day = new Date(2000, 0, 1); + component.onSelectDay(day); + expect(component.date).toBeUndefined(); + }); + + it("should update the year and refresh the month when a valid year is submitted", () => { + component.yearControl.setValue(2025); + component.onYearSubmit(); + expect(component.currentYear).toBe(2025); + }); + + it("should reset the year control when an invalid year is submitted", () => { + component.yearControl.setValue("abc"); + component.onYearSubmit(); + expect(component.yearControl.value).toBe(component.currentYear); + }); + + it("should sync the input text with the calendar for a valid 10-character date", () => { + component.inputText = "01/01/2020"; + component.syncInputWithCal(); + expect(component.date.getFullYear()).toBe(2020); + }); + + it("should call onChange with an empty string when inputText is cleared", () => { + const emitted: string[] = []; + component.registerOnChange((v: string) => emitted.push(v)); + component.inputText = ""; + component.syncInputWithCal(); + expect(emitted).toEqual([""]); + }); + + it("should close the calendar when clicking outside of it", () => { + component.displayCalendar(); + fixture.detectChanges(); + fixture.detectChanges(); + const outsideEl = document.createElement("div"); + document.body.appendChild(outsideEl); + component.handleGlobalClick({ + target: outsideEl, + } as unknown as MouseEvent); + expect(component.showCalendar).toBe(false); + document.body.removeChild(outsideEl); + }); + + it("should not close the calendar when clicking the calendar button", () => { + component.displayCalendar(); + fixture.detectChanges(); + component.handleGlobalClick({ + target: component.calendarButton.nativeElement, + } as unknown as MouseEvent); + expect(component.showCalendar).toBe(true); + }); + + it("should format the input text using a custom dateFormat function", () => { + component.dateFormat = (d: Date) => `custom-${d.getFullYear()}`; + component.date = new Date(2020, 0, 1); + component.syncVisualsWithDate(); + expect(component.inputText).toBe("custom-2020"); + }); + + it("should validate the static dateValidation for an invalid date string", () => { + const validatorFn = DatepickerComponent.dateValidation(); + const control = new FormControl("not-a-date"); + control.markAsDirty(); + const result = validatorFn(control); + expect(result.dateError.message).toBe("Invalid date"); + }); + + it("should validate the static dateValidation for a year below the minimum", () => { + const validatorFn = DatepickerComponent.dateValidation(); + const control = new FormControl("01/01/0999"); + control.markAsDirty(); + const result = validatorFn(control); + expect(result.dateError.message).toBe("Please enter 4 digit year"); + }); + + it("should pass dateValidation for a clean control", () => { + const validatorFn = DatepickerComponent.dateValidation(); + const control = new FormControl(""); + const result = validatorFn(control); + expect(result).toBeUndefined(); + }); }); }); diff --git a/src/ui-kit/experimental/dollar/dollar.spec.ts b/src/ui-kit/experimental/dollar/dollar.spec.ts index e69de29bb..30c6ebb59 100755 --- a/src/ui-kit/experimental/dollar/dollar.spec.ts +++ b/src/ui-kit/experimental/dollar/dollar.spec.ts @@ -0,0 +1,200 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { SamDollarComponent } from "./dollar.component"; +import { LabelWrapper } from "../../wrappers/label-wrapper/label-wrapper.component"; +import { FormsModule, FormControl } from "@angular/forms"; +import { SamFormService } from "../../form-service"; +import { ChangeDetectorRef } from "@angular/core"; + +describe("The Sam Dollar component", () => { + describe("isolated tests", () => { + let component: SamDollarComponent; + const cdr: ChangeDetectorRef = undefined; + + beforeEach(() => { + component = new SamDollarComponent(new SamFormService(), cdr); + }); + + it("should convert a dollar-formatted string to a plain number string", () => { + expect(component.dollarToStr("$1,234.56")).toBe("1234.56"); + expect(component.dollarToStr(null)).toBe(""); + }); + + it("should round and format a plain number as a dollar string", () => { + expect(component.strToDollar("1234.5")).toBe("$1,234.50"); + expect(component.strToDollar("")).toBe(""); + }); + + it("should insert thousands separators", () => { + expect(component.numberWithCommas("1234567.89")).toBe("1,234,567.89"); + }); + + it("should round to two decimal places for currency", () => { + expect(component.roundForCurrency(1234.005)).toBe("1234.01"); + expect(component.roundForCurrency(NaN)).toBe(""); + }); + }); + + describe("rendered tests", () => { + let component: SamDollarComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [FormsModule], + declarations: [SamDollarComponent, LabelWrapper], + providers: [SamFormService], + }); + + fixture = TestBed.createComponent(SamDollarComponent); + component = fixture.componentInstance; + component.label = "Award amount"; + component.name = "award-amount"; + component.id = "award-amount"; + }); + + it("should throw if id is not set", () => { + component.id = undefined; + expect(() => component.ngOnInit()).toThrow(/requires a \[id\] parameter/); + }); + + it("should switch to plain-number display and back on focus/blur", () => { + fixture.detectChanges(); + component.control = new FormControl(""); + component.ngOnInit(); + component.value = "$1,234.00"; + component.onFocus(); + expect(component.attrType).toBe("number"); + expect(component.value).toBe("1234.00"); + + component.onLoseFocus(); + expect(component.attrType).toBe("text"); + expect(component.value).toBe("$1,234.00"); + }); + + it("should do nothing on focus when value is empty or just a dollar sign", () => { + fixture.detectChanges(); + component.value = ""; + component.onFocus(); + expect(component.attrType).toBe("text"); + + component.value = "$"; + component.onFocus(); + expect(component.attrType).toBe("text"); + }); + + it("should emit an empty change and blur events when value is cleared on blur", () => { + fixture.detectChanges(); + component.control = new FormControl(""); + component.ngOnInit(); + component.value = ""; + const onBlurEmitted: boolean[] = []; + const blurEmitted: boolean[] = []; + component.onBlur.subscribe((v) => onBlurEmitted.push(v)); + component.blur.subscribe((v) => blurEmitted.push(v)); + + component.onLoseFocus(); + + expect(onBlurEmitted).toEqual([true]); + expect(blurEmitted).toEqual([true]); + }); + + it("should not emit blur events when blurDisabled is true", () => { + fixture.detectChanges(); + component.blurDisabled = true; + const onBlurEmitted: boolean[] = []; + component.onBlur.subscribe((v) => onBlurEmitted.push(v)); + + component.onLoseFocus(); + + expect(onBlurEmitted.length).toBe(0); + }); + + it("should block key input once the max length is exceeded for a digit key", () => { + fixture.detectChanges(); + component.value = "12345678901234567"; + const numericEvent = { + code: "Digit7", + key: "7", + } as unknown as KeyboardEvent; + expect(component.onKeyInput(numericEvent)).toBe(false); + }); + + it("should call setErrors with required/null based on the emitted value", () => { + fixture.detectChanges(); + const control = new FormControl(""); + component.control = control; + const setErrorsSpy = vi.spyOn(control, "setErrors"); + + component.emitChange(""); + expect(setErrorsSpy).toHaveBeenLastCalledWith({ required: true }); + + component.emitChange("$5.00"); + expect(setErrorsSpy).toHaveBeenLastCalledWith(null); + }); + + it("should work with a form control and format the input value via onLoseFocus", () => { + const c = new FormControl("", () => undefined); + component.control = c; + component.required = true; + component.ngOnInit(); + component.ngAfterViewInit(); + + component.value = "1500"; + component.onLoseFocus(); + + expect(component.value).toBe("$1,500.00"); + }); + + it("should show a hint message", () => { + const hint = "Enter the total contract award amount"; + component.hint = hint; + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML).toContain(hint); + }); + + it("should show an error message when the control's errors are formatted", () => { + const errorMessage = "Uh-oh, something went wrong"; + const control = new FormControl(""); + control.setErrors({ customError: { message: errorMessage } }); + control.markAsDirty(); + component.control = control; + component.ngOnInit(); + component.ngAfterViewInit(); + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML).toContain(errorMessage); + }); + + it("should show a label", () => { + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML).toContain("Award amount"); + }); + + it("should format errors via the SamFormService submit/reset events when useFormService is true", () => { + const samFormService = TestBed.inject(SamFormService); + component.useFormService = true; + const control = new FormControl(""); + component.control = control; + fixture.detectChanges(); + component.ngOnInit(); + component.ngAfterViewInit(); + + const formatErrorsSpy = vi.spyOn(component.wrapper, "formatErrors"); + const clearErrorSpy = vi.spyOn(component.wrapper, "clearError"); + + samFormService.fireSubmit(control); + expect(formatErrorsSpy).toHaveBeenCalledWith(control); + + samFormService.fireReset(control); + expect(clearErrorSpy).toHaveBeenCalled(); + }); + + it("should unsubscribe and detach the change detector on destroy", () => { + fixture.detectChanges(); + component.control = new FormControl(""); + component.ngOnInit(); + const detachSpy = vi.spyOn(component.cdr, "detach"); + expect(() => component.ngOnDestroy()).not.toThrow(); + expect(detachSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/ui-kit/experimental/hierarchical/hierarchical/hierarchical.component.spec.ts b/src/ui-kit/experimental/hierarchical/hierarchical/hierarchical.component.spec.ts index 10f893849..6297dfa67 100755 --- a/src/ui-kit/experimental/hierarchical/hierarchical/hierarchical.component.spec.ts +++ b/src/ui-kit/experimental/hierarchical/hierarchical/hierarchical.component.spec.ts @@ -93,7 +93,7 @@ describe("SamHierarchicalComponent", () => { }); it("Should set the model property when the value is written ", () => { - let model = new HierarchicalTreeSelectedItemModel(); + const model = new HierarchicalTreeSelectedItemModel(); component.writeValue(model); expect(component.model).toBe(model); }); @@ -105,7 +105,7 @@ describe("SamHierarchicalComponent", () => { expect(component.disabled).toBe(false); }); - it.skip("Should change to show mode when the modal is opened", () => { + it("Should change to show mode when the modal is opened", () => { component.onModalClick(); expect(component.modal.show).toBeTruthy(); }); @@ -116,9 +116,10 @@ describe("SamHierarchicalComponent", () => { expect(component.modal.show).toBe(false); }); - it.skip("Should change to not show mode when the modal is closed", () => { + it("Should change to not show mode when the modal is closed", () => { component.onModalClick(); expect(component.modal.show).toBeTruthy(); + component.hierarchicaltree.results = []; component.onModalSubmitClick(); expect(component.modal.show).toBe(false); }); diff --git a/src/ui-kit/experimental/hierarchical/models/SamHierarchicalTreeConfiguration.spec.ts b/src/ui-kit/experimental/hierarchical/models/SamHierarchicalTreeConfiguration.spec.ts new file mode 100644 index 000000000..cde374730 --- /dev/null +++ b/src/ui-kit/experimental/hierarchical/models/SamHierarchicalTreeConfiguration.spec.ts @@ -0,0 +1,32 @@ +import { SamHierarchicalTreeConfiguration } from "./SamHierarchicalTreeConfiguration"; + +describe("SamHierarchicalTreeConfiguration", () => { + it("should default navigateScreenReaderText and emptyResultText", () => { + const config = new SamHierarchicalTreeConfiguration(); + expect(config.navigateScreenReaderText).toBe("Go to"); + expect(config.emptyResultText).toBe( + "There are no results. Try again with another selection." + ); + }); + + it("should allow setting the remaining configuration fields", () => { + const config = new SamHierarchicalTreeConfiguration(); + config.minimumCharacterCountSearch = 3; + config.primaryKeyField = "id"; + config.primaryTextField = "name"; + config.gridColumnsDisplayed = [{ headerText: "Id", fieldName: "id" }]; + config.childCountField = "childCount"; + config.filterPlaceholderText = "Filter..."; + config.topLevelBreadcrumbText = "All"; + + expect(config.minimumCharacterCountSearch).toBe(3); + expect(config.primaryKeyField).toBe("id"); + expect(config.primaryTextField).toBe("name"); + expect(config.gridColumnsDisplayed).toEqual([ + { headerText: "Id", fieldName: "id" }, + ]); + expect(config.childCountField).toBe("childCount"); + expect(config.filterPlaceholderText).toBe("Filter..."); + expect(config.topLevelBreadcrumbText).toBe("All"); + }); +}); diff --git a/src/ui-kit/experimental/hierarchical/models/SamHierarchicalTreeGridConfiguration.spec.ts b/src/ui-kit/experimental/hierarchical/models/SamHierarchicalTreeGridConfiguration.spec.ts new file mode 100644 index 000000000..2c9b2da08 --- /dev/null +++ b/src/ui-kit/experimental/hierarchical/models/SamHierarchicalTreeGridConfiguration.spec.ts @@ -0,0 +1,26 @@ +import { SamHierarchicalTreeGridConfiguration } from "./SamHierarchicalTreeGridConfiguration"; + +describe("SamHierarchicalTreeGridConfiguration", () => { + it("should default navigateScreenReaderText and emptyResultText", () => { + const config = new SamHierarchicalTreeGridConfiguration(); + expect(config.navigateScreenReaderText).toBe("Go to"); + expect(config.emptyResultText).toBe( + "There are no results. Try again with another selection." + ); + }); + + it("should allow setting the remaining configuration fields", () => { + const config = new SamHierarchicalTreeGridConfiguration(); + config.primaryKeyField = "id"; + config.gridColumnsDisplayed = [{ headerText: "Id", fieldName: "id" }]; + config.childCountField = "childCount"; + config.primaryTextField = "name"; + + expect(config.primaryKeyField).toBe("id"); + expect(config.gridColumnsDisplayed).toEqual([ + { headerText: "Id", fieldName: "id" }, + ]); + expect(config.childCountField).toBe("childCount"); + expect(config.primaryTextField).toBe("name"); + }); +}); diff --git a/src/ui-kit/experimental/label/label.spec.ts b/src/ui-kit/experimental/label/label.spec.ts new file mode 100644 index 000000000..555572cae --- /dev/null +++ b/src/ui-kit/experimental/label/label.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { SamLabelNextComponent } from "./label.component"; + +describe("The Sam Label Next component", () => { + let component: SamLabelNextComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamLabelNextComponent], + }); + fixture = TestBed.createComponent(SamLabelNextComponent); + component = fixture.componentInstance; + }); + + it("should apply the base sam label class with no size set", () => { + fixture.detectChanges(); + expect(component.css_classes).toBe("sam label"); + }); + + it("should append the size class when the size input is set", () => { + component.size = "large"; + fixture.detectChanges(); + expect(component.css_classes).toBe("sam label large"); + }); +}); diff --git a/src/ui-kit/experimental/layout/layout.spec.ts b/src/ui-kit/experimental/layout/layout.spec.ts new file mode 100644 index 000000000..648b4b9e0 --- /dev/null +++ b/src/ui-kit/experimental/layout/layout.spec.ts @@ -0,0 +1,80 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { + SamLayoutComponent, + SamLayoutImgComponent, + SamLayoutContentComponent, +} from "./layout.component"; + +describe("The Sam Layout component", () => { + let component: SamLayoutComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamLayoutComponent], + }); + fixture = TestBed.createComponent(SamLayoutComponent); + component = fixture.componentInstance; + }); + + it("should apply the base sam layout class with no inputs set", () => { + fixture.detectChanges(); + expect(component.css_classes).toBe("sam layout"); + }); + + it("should append the pattern and margin classes when both inputs are set", () => { + component.pattern = "grid"; + component.margin = "large"; + fixture.detectChanges(); + expect(component.css_classes).toBe("sam layout pattern-grid margin large"); + }); +}); + +describe("The Sam Layout Img component", () => { + let component: SamLayoutImgComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamLayoutImgComponent], + }); + fixture = TestBed.createComponent(SamLayoutImgComponent); + component = fixture.componentInstance; + }); + + it("should apply the base img class with no aligned input set", () => { + fixture.detectChanges(); + expect(component.css_classes).toBe("img"); + }); + + it("should append the aligned class when the aligned input is set", () => { + component.aligned = "right"; + fixture.detectChanges(); + expect(component.css_classes).toBe("img right aligned"); + }); +}); + +describe("The Sam Layout Content component", () => { + let component: SamLayoutContentComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamLayoutContentComponent], + }); + fixture = TestBed.createComponent(SamLayoutContentComponent); + component = fixture.componentInstance; + }); + + it("should create with the base content class", () => { + fixture.detectChanges(); + expect(component.css_classes).toBe("content"); + }); + + it("should accept an align input without altering css_classes", () => { + component.align = "center"; + fixture.detectChanges(); + expect(component.align).toBe("center"); + expect(component.css_classes).toBe("content"); + }); +}); diff --git a/src/ui-kit/experimental/list/list.spec.ts b/src/ui-kit/experimental/list/list.spec.ts new file mode 100644 index 000000000..3442333e7 --- /dev/null +++ b/src/ui-kit/experimental/list/list.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { SamListComponent, SamListItemComponent } from "./list.component"; + +describe("The Sam List component", () => { + let component: SamListComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamListComponent], + }); + fixture = TestBed.createComponent(SamListComponent); + component = fixture.componentInstance; + }); + + it("should apply the base sam list class with no inputs set", () => { + fixture.detectChanges(); + expect(component.css_classes).toBe("sam list"); + }); + + it("should append bulleted, columns, orientation, and bullet classes when set", () => { + component.bulleted = true; + component.columns = "two"; + component.orientation = "horizontal"; + component.bullet = true; + fixture.detectChanges(); + expect(component.css_classes).toBe( + "sam list bulleted two columns horizontal true" + ); + }); +}); + +describe("The Sam List Item component", () => { + let component: SamListItemComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamListItemComponent], + }); + fixture = TestBed.createComponent(SamListItemComponent); + component = fixture.componentInstance; + }); + + it("should not render a bullet span by default", () => { + fixture.detectChanges(); + const bulletSpan = fixture.nativeElement.querySelector(".bullet"); + expect(bulletSpan).toBeNull(); + }); + + it("should render a bullet span when the bullet input is set", () => { + component.bullet = "round"; + fixture.detectChanges(); + const bulletSpan = fixture.nativeElement.querySelector(".bullet"); + expect(bulletSpan).not.toBeNull(); + }); +}); diff --git a/src/ui-kit/experimental/listbox/listbox.component.spec.ts b/src/ui-kit/experimental/listbox/listbox.component.spec.ts index 7eb5a223f..24118449a 100755 --- a/src/ui-kit/experimental/listbox/listbox.component.spec.ts +++ b/src/ui-kit/experimental/listbox/listbox.component.spec.ts @@ -6,6 +6,7 @@ import { } from "@angular/core/testing"; import { SamListBoxComponent } from "./listbox.component"; import { By } from "@angular/platform-browser"; +import { CommonModule } from "@angular/common"; import { SamWrapperModule } from "../../../ui-kit/wrappers"; const options = [ @@ -81,7 +82,7 @@ describe("SamListBoxComponent", () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [SamWrapperModule], + imports: [SamWrapperModule, CommonModule], declarations: [SamListBoxComponent], }); fixture = TestBed.createComponent(SamListBoxComponent); @@ -98,7 +99,7 @@ describe("SamListBoxComponent", () => { }); it("onCheck with single mode", () => { - let ev = { + const ev = { target: { checked: true, }, @@ -156,7 +157,7 @@ describe("SamListBoxComponent", () => { it("should implement controlvalueaccessor", () => { component.onChange(); component.onTouched(); - component.registerOnChange((_) => undefined); + component.registerOnChange(() => undefined); component.registerOnTouched(() => undefined); component.writeValue(["test"]); expect(component.model[0]).toBe("test"); @@ -177,7 +178,7 @@ describe("SamListBoxComponent", () => { }); it("onChecked checked/unchecked", () => { - let ev = { + const ev = { target: { checked: true, }, @@ -207,7 +208,6 @@ describe("SamListBoxComponent", () => { component.onKeyDown(downEvent); tick(); fixture.detectChanges(); - const list = fixture.debugElement.query(By.css(".checkbox-container")); expect(component.options[1]["highlighted"]).toBeTruthy(); const upEvent = { key: "Up", @@ -242,7 +242,6 @@ describe("SamListBoxComponent", () => { component.options = options; tick(); fixture.detectChanges(); - const list = fixture.debugElement.query(By.css(".checkbox-container")); expect(component.options[0]["highlighted"]).toBeTruthy(); component.onHover(component.options.length - 1); fixture.detectChanges(); @@ -261,8 +260,8 @@ describe("SamListBoxComponent", () => { expect(component.options[7]["highlighted"]).toBeTruthy(); })); - it("Should remove item from selected reuslts", fakeAsync(() => { - let ev = { + it("Should remove item from selected results", fakeAsync(() => { + const ev = { target: { checked: false, }, @@ -274,4 +273,32 @@ describe("SamListBoxComponent", () => { expect(component.model.length).toBe(0); expect(component.modelChange.emit).toHaveBeenCalledWith(component.model); })); + + it("should not select disabled options when writing a value via setSelectedItem", () => { + const optionsWithDisabled = options.map((o, i) => + i === 1 ? { ...o, disabled: true } : o + ); + component.options = optionsWithDisabled; + fixture.detectChanges(); + component.writeValue([ + optionsWithDisabled[6].value, + optionsWithDisabled[1].value, + ]); + expect(component.model).toEqual([optionsWithDisabled[6].value]); + }); + + it("should default to an empty model when writeValue is called without an array", () => { + component.options = options; + fixture.detectChanges(); + component.writeValue(undefined); + expect(component.model).toEqual([]); + }); + + it("should report whether a value is currently checked", () => { + component.options = options; + component.model = [options[2].value]; + fixture.detectChanges(); + expect(component.isChecked(options[2].value)).toBe(true); + expect(component.isChecked(options[3].value)).toBe(false); + }); }); diff --git a/src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts b/src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts new file mode 100644 index 000000000..20ace3215 --- /dev/null +++ b/src/ui-kit/experimental/patterns/layout/components/sidenav/sidenav.spec.ts @@ -0,0 +1,238 @@ +import { Component, ElementRef, ViewChild } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { A11yModule } from "@angular/cdk/a11y"; +import { CommonModule } from "@angular/common"; +import { MdSidenav, MdSidenavContainer } from "./sidenav"; + +interface SidenavInternals { + _elementRef: ElementRef; + _onTransitionEnd(event: TransitionEvent): void; +} + +interface SidenavContainerInternals { + _getMarginLeft(): number; + _getMarginRight(): number; + _getPositionOffset(): number; + _getStyles(): { marginLeft: string; marginRight: string; transform: string }; +} + +function asInternals(sidenav: MdSidenav): SidenavInternals { + return sidenav as unknown as SidenavInternals; +} + +function asContainerInternals( + container: MdSidenavContainer +): SidenavContainerInternals { + return container as unknown as SidenavContainerInternals; +} + +@Component({ + template: ` + + + Sidenav content + + + `, + standalone: false, +}) +class HostComponent { + mode: "over" | "push" | "side" = "over"; + align: "start" | "end" = "start"; + disableClose = false; + backdropClicked = false; + + @ViewChild(MdSidenav) sidenav: MdSidenav; + @ViewChild(MdSidenavContainer) container: MdSidenavContainer; + + onBackdrop() { + this.backdropClicked = true; + } +} + +function createFixture(): { + fixture: ComponentFixture; + host: HostComponent; +} { + TestBed.configureTestingModule({ + declarations: [HostComponent, MdSidenav, MdSidenavContainer], + imports: [CommonModule, A11yModule], + }); + const fixture = TestBed.createComponent(HostComponent); + const host = fixture.componentInstance; + fixture.detectChanges(); + return { fixture, host }; +} + +describe("The Sam Sidenav component", () => { + let fixture: ComponentFixture; + let host: HostComponent; + + beforeEach(() => { + ({ fixture, host } = createFixture()); + }); + + it("should start closed", () => { + expect(host.sidenav.opened).toBe(false); + expect(host.sidenav._isClosed).toBe(true); + }); + + it("should resolve open() with an open toggle result once the transition ends", async () => { + const openPromise = host.sidenav.open(); + expect(host.sidenav._isOpening).toBe(true); + asInternals(host.sidenav)._onTransitionEnd({ + target: asInternals(host.sidenav)._elementRef.nativeElement, + propertyName: "transform", + } as TransitionEvent); + const result = await openPromise; + expect(result.type).toBe("open"); + expect(result.animationFinished).toBe(true); + expect(host.sidenav.opened).toBe(true); + }); + + it("should resolve close() with a close toggle result once the transition ends", async () => { + const openPromise = host.sidenav.open(); + asInternals(host.sidenav)._onTransitionEnd({ + target: asInternals(host.sidenav)._elementRef.nativeElement, + propertyName: "transform", + } as TransitionEvent); + await openPromise; + + const closePromise = host.sidenav.close(); + asInternals(host.sidenav)._onTransitionEnd({ + target: asInternals(host.sidenav)._elementRef.nativeElement, + propertyName: "transform", + } as TransitionEvent); + const result = await closePromise; + expect(result.type).toBe("close"); + expect(host.sidenav.opened).toBe(false); + }); + + it("should resolve immediately with the current state when toggled to the same state", async () => { + const result = await host.sidenav.toggle(false); + expect(result.type).toBe("close"); + }); + + it("should close on Escape unless disableClose is set", () => { + const closeSpy = vi.spyOn(host.sidenav, "close"); + const event = { + keyCode: 27, + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + + host.sidenav.handleKeydown(event); + expect(closeSpy).toHaveBeenCalled(); + + closeSpy.mockClear(); + host.disableClose = true; + fixture.detectChanges(); + host.sidenav.handleKeydown(event); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it("should emit onAlignChanged when align changes", () => { + let emitCount = 0; + host.sidenav.onAlignChanged.subscribe(() => emitCount++); + host.align = "end"; + fixture.detectChanges(); + expect(emitCount).toBe(1); + expect(host.sidenav._isEnd).toBe(true); + }); + + it("should not emit onAlignChanged when align is set to the same value", () => { + let emitCount = 0; + host.sidenav.onAlignChanged.subscribe(() => emitCount++); + host.align = "start"; + fixture.detectChanges(); + expect(emitCount).toBe(0); + }); + + it("should expose mode-based class flags", () => { + expect(host.sidenav._modeOver).toBe(true); + host.mode = "side"; + fixture.detectChanges(); + expect(host.sidenav._modeSide).toBe(true); + host.mode = "push"; + fixture.detectChanges(); + expect(host.sidenav._modePush).toBe(true); + }); + + it("should call open and close on both container sidenavs via open()/close()", async () => { + const openSpy = vi.spyOn(host.sidenav, "open"); + const containerOpenPromise = host.container.open(); + asInternals(host.sidenav)._onTransitionEnd({ + target: asInternals(host.sidenav)._elementRef.nativeElement, + propertyName: "transform", + } as TransitionEvent); + await containerOpenPromise; + expect(openSpy).toHaveBeenCalled(); + + const closeSpy = vi.spyOn(host.sidenav, "close"); + const containerClosePromise = host.container.close(); + asInternals(host.sidenav)._onTransitionEnd({ + target: asInternals(host.sidenav)._elementRef.nativeElement, + propertyName: "transform", + } as TransitionEvent); + await containerClosePromise; + expect(closeSpy).toHaveBeenCalled(); + }); + + it("should emit backdropClick and close non-side sidenavs on backdrop click", async () => { + const openPromise = host.sidenav.open(); + asInternals(host.sidenav)._onTransitionEnd({ + target: asInternals(host.sidenav)._elementRef.nativeElement, + propertyName: "transform", + } as TransitionEvent); + await openPromise; + fixture.detectChanges(); + + host.container._onBackdropClicked(); + expect(host.backdropClicked).toBe(true); + expect(host.sidenav.opened).toBe(false); + }); + + it("should throw when two sidenavs share the same align value", () => { + TestBed.resetTestingModule(); + + @Component({ + template: ` + + One + Two + + `, + standalone: false, + }) + class DuplicateAlignHostComponent {} + + TestBed.configureTestingModule({ + declarations: [ + DuplicateAlignHostComponent, + MdSidenav, + MdSidenavContainer, + ], + imports: [CommonModule, A11yModule], + }); + const duplicateFixture = TestBed.createComponent( + DuplicateAlignHostComponent + ); + expect(() => duplicateFixture.detectChanges()).toThrow( + /already declared for 'align="start"'/ + ); + }); + + it("should compute margin and position styles based on side/push sidenavs", () => { + host.mode = "side"; + fixture.detectChanges(); + const container = asContainerInternals(host.container); + expect(typeof container._getMarginLeft()).toBe("number"); + expect(typeof container._getMarginRight()).toBe("number"); + expect(typeof container._getPositionOffset()).toBe("number"); + expect(container._getStyles().marginLeft).toContain("px"); + }); +}); diff --git a/src/ui-kit/experimental/picker/picker.spec.ts b/src/ui-kit/experimental/picker/picker.spec.ts new file mode 100644 index 000000000..82594e60f --- /dev/null +++ b/src/ui-kit/experimental/picker/picker.spec.ts @@ -0,0 +1,85 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { SamPickerComponent } from "./picker"; +import { SamPopoverComponent } from "./popover"; + +@Component({ + template: ` + + + +
+
+
A
+
B
+
+
+
+
+ `, + standalone: false, +}) +class HostComponent {} + +describe("The Sam Picker component", () => { + let fixture: ComponentFixture; + let picker: SamPickerComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [HostComponent, SamPickerComponent, SamPopoverComponent], + }); + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + picker = fixture.debugElement.children[0].componentInstance; + }); + + it("should wire up a combobox once the input and popover are available", () => { + expect(picker.combobox).toBeTruthy(); + }); + + it("should emit onSearch when the input dispatches an input event", () => { + const emitted: string[] = []; + picker.onSearch.subscribe((value) => emitted.push(value)); + const input: HTMLInputElement = picker.input.nativeElement; + input.value = "a"; + input.dispatchEvent(new Event("input")); + expect(emitted.length).toBe(1); + }); + + it("should emit onChange and update selected when a grid cell is clicked", () => { + const emitted: any[] = []; + picker.onChange.subscribe((cell) => emitted.push(cell)); + const cellEl: HTMLElement = + fixture.nativeElement.querySelector('[data-value="a"]'); + cellEl.click(); + expect(emitted.length).toBe(1); + expect(picker.selected).toBe(emitted[0]); + }); + + it("should clear the input value when clearInput is called", () => { + const input: HTMLInputElement = picker.input.nativeElement; + input.value = "something"; + picker.clearInput(); + expect(input.value).toBe(""); + }); +}); + +describe("The Sam Picker component without an input/popover", () => { + @Component({ + template: ``, + standalone: false, + }) + class EmptyHostComponent {} + + it("should not construct a combobox when input/popover are absent", () => { + TestBed.configureTestingModule({ + declarations: [EmptyHostComponent, SamPickerComponent], + }); + const fixture = TestBed.createComponent(EmptyHostComponent); + fixture.detectChanges(); + const picker: SamPickerComponent = + fixture.debugElement.children[0].componentInstance; + expect(picker.combobox).toBeUndefined(); + }); +}); diff --git a/src/ui-kit/experimental/picker/popover.spec.ts b/src/ui-kit/experimental/picker/popover.spec.ts new file mode 100644 index 000000000..99c611d5f --- /dev/null +++ b/src/ui-kit/experimental/picker/popover.spec.ts @@ -0,0 +1,36 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { SamPopoverComponent } from "./popover"; +import { AbstractGrid } from "../aria/abstract-grid/abstract-grid"; + +@Component({ + template: ` + +
+
+
A
+
+
+
+ `, + standalone: false, +}) +class HostComponent {} + +describe("The Sam Popover component", () => { + let fixture: ComponentFixture; + let popover: SamPopoverComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [HostComponent, SamPopoverComponent], + }); + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + popover = fixture.debugElement.children[0].componentInstance; + }); + + it("should build an AbstractGrid over its host element", () => { + expect(popover.grid).toBeInstanceOf(AbstractGrid); + }); +}); diff --git a/src/ui-kit/experimental/search/search.spec.ts b/src/ui-kit/experimental/search/search.spec.ts new file mode 100644 index 000000000..02eb67e43 --- /dev/null +++ b/src/ui-kit/experimental/search/search.spec.ts @@ -0,0 +1,118 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { RouterTestingModule } from "@angular/router/testing"; +import { SamSearchComponent } from "./search.component"; +import { SamIconsModule } from "../icon/icon.module"; + +interface SearchResult { + name: string; + domain: boolean; + description: string; +} + +function fakeTargetEvent(value: string): Event { + return { + target: { value }, + preventDefault: vi.fn(), + } as unknown as Event; +} + +describe("The Sam Search component", () => { + let component: SamSearchComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamSearchComponent], + imports: [NoopAnimationsModule, RouterTestingModule, SamIconsModule], + }); + fixture = TestBed.createComponent(SamSearchComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should populate results after a debounced search matching more than one character", fakeAsync(() => { + const input: HTMLInputElement = component.inputEl.nativeElement; + input.value = "Education"; + input.dispatchEvent(new Event("keyup")); + expect(component.loading).toBe(true); + + tick(400); + + expect(component.loading).toBe(false); + expect(component.results.length).toBeGreaterThan(0); + })); + + it("should not search when the input has one character or fewer", fakeAsync(() => { + const input: HTMLInputElement = component.inputEl.nativeElement; + input.value = "E"; + input.dispatchEvent(new Event("keyup")); + tick(400); + expect(component.loading).toBe(false); + expect(component.results.length).toBe(0); + })); + + it("should clear results and set the input value when closeAutocomplete is called", () => { + component.results = [ + { name: "x", domain: false, description: "x" }, + ] as SearchResult[]; + component.closeAutocomplete("Department of Education"); + expect(component.results.length).toBe(0); + expect(component.inputEl.nativeElement.value).toBe( + "Department of Education" + ); + }); + + it("should focus the input when inputFocus is called", () => { + const focusSpy = vi.spyOn(component.inputEl.nativeElement, "focus"); + component.inputFocus(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("should enter cfda tab-search mode and prevent default when tab is pressed with value cfda", () => { + const event = fakeTargetEvent("cfda"); + component.inputTab(event); + expect(component.tabSearch).toBe(true); + expect((event.target as HTMLInputElement).value).toBe(""); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it("should not enter cfda tab-search mode for other input values", () => { + const event = fakeTargetEvent("something else"); + component.inputTab(event); + expect(component.tabSearch).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("should exit cfda tab-search mode on backspace when the input is empty", () => { + component.tabSearch = true; + const event = fakeTargetEvent(""); + component.inputBackspace(event); + expect(component.tabSearch).toBe(false); + expect((event.target as HTMLInputElement).value).toBe("cfda"); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it("should clear results on backspace when not in tab-search mode and value is short", () => { + component.tabSearch = false; + component.results = [ + { name: "x", domain: false, description: "x" }, + ] as SearchResult[]; + const event = fakeTargetEvent("a"); + component.inputBackspace(event); + expect(component.results.length).toBe(0); + }); + + it("should set the selected option and focus the input on select change", () => { + const focusSpy = vi.spyOn(component.inputEl.nativeElement, "focus"); + const event = fakeTargetEvent(" Contracting "); + component.onSelectChange(event); + expect(component.selectedOption).toBe("Contracting"); + expect(focusSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/ui-kit/experimental/sideNavigationToolbar/sideNavigationToolbar/sideNavigationToolbar.component.spec.ts b/src/ui-kit/experimental/sideNavigationToolbar/sideNavigationToolbar/sideNavigationToolbar.component.spec.ts index c9e3a0cf9..c2677bb70 100755 --- a/src/ui-kit/experimental/sideNavigationToolbar/sideNavigationToolbar/sideNavigationToolbar.component.spec.ts +++ b/src/ui-kit/experimental/sideNavigationToolbar/sideNavigationToolbar/sideNavigationToolbar.component.spec.ts @@ -3,7 +3,29 @@ import { waitForAsync, ComponentFixture, TestBed } from "@angular/core/testing"; import { SamSideNavigationToolbarComponent } from "./sideNavigationToolbar.component"; import { SamSideNavigationToolbarItemComponent } from "../sideNavigationToolbarItem/sideNavigationToolbarItem.component"; import { CommonModule } from "@angular/common"; -import { By } from "@angular/platform-browser"; +import { Component, ViewChildren, QueryList } from "@angular/core"; + +@Component({ + template: ` + + + + `, + standalone: false, +}) +class HostComponent { + items = [ + { id: "item1", title: "First" }, + { id: "item2", title: "Second" }, + ]; + + @ViewChildren(SamSideNavigationToolbarItemComponent) + toolbarItems: QueryList; +} describe("SamSideNavigationToolbarComponent", () => { let component: SamSideNavigationToolbarComponent; @@ -30,3 +52,39 @@ describe("SamSideNavigationToolbarComponent", () => { expect(component).toBeTruthy(); }); }); + +describe("SamSideNavigationToolbarComponent accordion coordination", () => { + let hostFixture: ComponentFixture; + let host: HostComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + HostComponent, + SamSideNavigationToolbarComponent, + SamSideNavigationToolbarItemComponent, + ], + imports: [CommonModule], + }).compileComponents(); + })); + + beforeEach(() => { + hostFixture = TestBed.createComponent(HostComponent); + host = hostFixture.componentInstance; + hostFixture.detectChanges(); + }); + + it("should close all other items when one item is selected", () => { + const [first, second] = host.toolbarItems.toArray(); + + first.open(); + hostFixture.detectChanges(); + expect(first.showSection).toBe(true); + expect(second.showSection).toBe(false); + + second.open(); + hostFixture.detectChanges(); + expect(first.showSection).toBe(false); + expect(second.showSection).toBe(true); + }); +}); diff --git a/src/ui-kit/experimental/sideNavigationToolbar/sideNavigationToolbarItem/sideNavigationToolbarItem.component.spec.ts b/src/ui-kit/experimental/sideNavigationToolbar/sideNavigationToolbarItem/sideNavigationToolbarItem.component.spec.ts index 91955f681..5226c61e0 100755 --- a/src/ui-kit/experimental/sideNavigationToolbar/sideNavigationToolbarItem/sideNavigationToolbarItem.component.spec.ts +++ b/src/ui-kit/experimental/sideNavigationToolbar/sideNavigationToolbarItem/sideNavigationToolbarItem.component.spec.ts @@ -1,8 +1,8 @@ import { waitForAsync, ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { SamSideNavigationToolbarItemComponent } from "./sideNavigationToolbarItem.component"; import { CommonModule } from "@angular/common"; -import { By } from "@angular/platform-browser"; describe("SamSideNavigationToolbarItemComponent", () => { let component: SamSideNavigationToolbarItemComponent; @@ -19,10 +19,59 @@ describe("SamSideNavigationToolbarItemComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(SamSideNavigationToolbarItemComponent); component = fixture.componentInstance; + component.id = "item1"; + component.title = "First Item"; + component.icon = "fa fa-star"; fixture.detectChanges(); }); it("should create", () => { expect(component).toBeTruthy(); }); + + it("should start with the section closed", () => { + expect(component.showSection).toBe(false); + const button = fixture.debugElement.query(By.css("button")); + expect(button.attributes["aria-expanded"]).toBe("false"); + }); + + it("should emit sideNavigationToolbarItemSelected and remain closed until a parent opens it", () => { + const emitted: any[] = []; + component.sideNavigationToolbarItemSelected.subscribe((item) => + emitted.push(item) + ); + component.open(); + expect(emitted).toEqual([component]); + // showSection is only flipped by the parent accordion in response to + // the emitted selection event, not by open() itself. + expect(component.showSection).toBe(false); + }); + + it("should close the section when close is called", () => { + component.showSection = true; + fixture.detectChanges(); + component.close(); + expect(component.showSection).toBe(false); + }); + + it("should open the section when the trigger button is clicked", () => { + const emitted: any[] = []; + component.sideNavigationToolbarItemSelected.subscribe((item) => + emitted.push(item) + ); + const button = fixture.debugElement.query(By.css("button")); + button.nativeElement.click(); + expect(emitted).toEqual([component]); + }); + + it("should close the section when the close control is clicked", () => { + component.showSection = true; + fixture.detectChanges(); + const closeControl = fixture.debugElement.query( + By.css(".close [role=button]") + ); + closeControl.nativeElement.click(); + fixture.detectChanges(); + expect(component.showSection).toBe(false); + }); }); diff --git a/src/ui-kit/experimental/tabs/tab-body.spec.ts b/src/ui-kit/experimental/tabs/tab-body.spec.ts new file mode 100644 index 000000000..ad3faa8fe --- /dev/null +++ b/src/ui-kit/experimental/tabs/tab-body.spec.ts @@ -0,0 +1,174 @@ +import { + Component, + OnInit, + TemplateRef, + ViewChild, + ViewContainerRef, +} from "@angular/core"; +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { AnimationEvent } from "@angular/animations"; +import { PortalModule, TemplatePortal } from "@angular/cdk/portal"; +import { + MdTabBody, + MdTabBodyOriginState, + MdTabBodyPositionState, +} from "./tab-body"; + +interface TabBodyInternals { + _position: MdTabBodyPositionState; + _origin: MdTabBodyOriginState | undefined; +} + +function asInternals(tabBody: MdTabBody): TabBodyInternals { + return tabBody as unknown as TabBodyInternals; +} + +@Component({ + template: ` + tab content + + `, + standalone: false, +}) +class HostComponent implements OnInit { + position = 0; + origin: number | null = null; + + @ViewChild("contentTemplate", { static: true }) + contentTemplate: TemplateRef; + + content: TemplatePortal; + + constructor(public viewContainerRef: ViewContainerRef) {} + + ngOnInit() { + this.content = new TemplatePortal( + this.contentTemplate, + this.viewContainerRef + ); + } +} + +function createFixture(): ComponentFixture { + TestBed.configureTestingModule({ + declarations: [HostComponent, MdTabBody], + imports: [PortalModule, NoopAnimationsModule], + }); + return TestBed.createComponent(HostComponent); +} + +function getTabBody(fixture: ComponentFixture): MdTabBody { + return fixture.debugElement.query( + (de) => de.componentInstance instanceof MdTabBody + ).componentInstance; +} + +describe("The Sam Tab Body component", () => { + let fixture: ComponentFixture; + let host: HostComponent; + let tabBody: MdTabBody; + + beforeEach(() => { + fixture = createFixture(); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should set the position state to center when position is 0", () => { + tabBody = getTabBody(fixture); + expect(asInternals(tabBody)._position).toBe("center"); + }); + + it("should set the position state to left when position is negative", () => { + host.position = -1; + fixture.detectChanges(); + tabBody = getTabBody(fixture); + expect(asInternals(tabBody)._position).toBe("left"); + }); + + it("should set the position state to right when position is positive", () => { + host.position = 1; + fixture.detectChanges(); + tabBody = getTabBody(fixture); + expect(asInternals(tabBody)._position).toBe("right"); + }); + + it("should ignore a null origin", () => { + host.origin = null; + fixture.detectChanges(); + tabBody = getTabBody(fixture); + expect(asInternals(tabBody)._origin).toBeUndefined(); + }); + + it("should set the origin state to left when origin is 0 or less", () => { + host.origin = 0; + fixture.detectChanges(); + tabBody = getTabBody(fixture); + expect(asInternals(tabBody)._origin).toBe("left"); + }); + + it("should set the origin state to right when origin is positive", () => { + host.origin = 1; + fixture.detectChanges(); + tabBody = getTabBody(fixture); + expect(asInternals(tabBody)._origin).toBe("right"); + }); + + it("should emit onCentering when the translate animation starts moving to center", () => { + tabBody = getTabBody(fixture); + const emitted: number[] = []; + tabBody.onCentering.subscribe((height) => emitted.push(height)); + tabBody._onTranslateTabStarted({ toState: "center" } as AnimationEvent); + expect(emitted.length).toBe(1); + }); + + it("should not emit onCentering when the translate animation starts moving away from center", () => { + tabBody = getTabBody(fixture); + const emitted: number[] = []; + tabBody.onCentering.subscribe((height) => emitted.push(height)); + tabBody._onTranslateTabStarted({ toState: "left" } as AnimationEvent); + expect(emitted.length).toBe(0); + }); + + it("should emit onCentered when the translate animation completes at center", fakeAsync(() => { + tabBody = getTabBody(fixture); + asInternals(tabBody)._position = "center"; + const emitted: boolean[] = []; + tabBody.onCentered.subscribe(() => emitted.push(true)); + tabBody._onTranslateTabComplete({ toState: "center" } as AnimationEvent); + tick(); + expect(emitted.length).toBe(1); + })); +}); + +describe("The Sam Tab Body component with a left origin", () => { + it("should switch a centered position to left-origin-center on init", () => { + const fixture = createFixture(); + const host = fixture.componentInstance; + host.origin = 0; + fixture.detectChanges(); + const tabBody = getTabBody(fixture); + expect(asInternals(tabBody)._position).toBe("left-origin-center"); + }); +}); + +describe("The Sam Tab Body component with a right origin", () => { + it("should switch a centered position to right-origin-center on init", () => { + const fixture = createFixture(); + const host = fixture.componentInstance; + host.origin = 1; + fixture.detectChanges(); + const tabBody = getTabBody(fixture); + expect(asInternals(tabBody)._position).toBe("right-origin-center"); + }); +}); diff --git a/src/ui-kit/experimental/tabs/tab-group.spec.ts b/src/ui-kit/experimental/tabs/tab-group.spec.ts new file mode 100644 index 000000000..ea22d88a3 --- /dev/null +++ b/src/ui-kit/experimental/tabs/tab-group.spec.ts @@ -0,0 +1,117 @@ +import { Component, ViewChild } from "@angular/core"; +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { RIGHT_ARROW } from "@angular/cdk/keycodes"; +import { SamTabsNextModule, MdTabGroup, MdTabChangeEvent } from "./index"; + +@Component({ + template: ` + + Content One + Content Two + Content Three + + `, + standalone: false, +}) +class HostComponent { + selectedIndex = 0; + @ViewChild(MdTabGroup) tabGroup: MdTabGroup; +} + +describe("The Sam Tabs Next component", () => { + let fixture: ComponentFixture; + let host: HostComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [HostComponent], + imports: [SamTabsNextModule, NoopAnimationsModule], + }); + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + }); + + it("should render a label for each tab without throwing", () => { + expect(() => fixture.detectChanges()).not.toThrow(); + fixture.detectChanges(); + const labels = fixture.nativeElement.querySelectorAll(".mat-tab-label"); + expect(labels.length).toBe(3); + expect(host.tabGroup.selectedIndex).toBe(0); + }); + + it("should clamp an out-of-range selectedIndex to the last tab", () => { + host.selectedIndex = 10; + fixture.detectChanges(); + fixture.detectChanges(); + expect(host.tabGroup.selectedIndex).toBe(2); + }); + + it("should emit selectChange with the newly selected tab when selectedIndex changes", fakeAsync(() => { + fixture.detectChanges(); + fixture.detectChanges(); + const emitted: MdTabChangeEvent[] = []; + host.tabGroup.selectChange.subscribe((event) => emitted.push(event)); + host.selectedIndex = 1; + fixture.detectChanges(); + fixture.detectChanges(); + tick(); + expect(emitted.length).toBe(1); + expect(emitted[0].index).toBe(1); + })); + + it("should emit selectedIndexChange derived from selectChange", fakeAsync(() => { + fixture.detectChanges(); + fixture.detectChanges(); + const emitted: number[] = []; + host.tabGroup.selectedIndexChange.subscribe((index) => emitted.push(index)); + host.selectedIndex = 2; + fixture.detectChanges(); + fixture.detectChanges(); + tick(); + expect(emitted).toEqual([2]); + })); + + it("should emit focusChange when a label wrapper reports a focus change", () => { + fixture.detectChanges(); + fixture.detectChanges(); + const emitted: MdTabChangeEvent[] = []; + host.tabGroup.focusChange.subscribe((event) => emitted.push(event)); + + const tabListContainer = fixture.nativeElement.querySelector( + ".mat-tab-label-container" + ) as HTMLElement; + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "keyCode", { get: () => RIGHT_ARROW }); + tabListContainer.dispatchEvent(event); + + expect(emitted.length).toBe(1); + expect(emitted[0].index).toBe(1); + }); + + it("should build unique label and content ids per tab index, reflected on the rendered DOM", () => { + fixture.detectChanges(); + fixture.detectChanges(); + const labels = fixture.nativeElement.querySelectorAll(".mat-tab-label"); + expect(labels[0].id).not.toBe(labels[1].id); + expect(labels[0].getAttribute("aria-controls")).toContain( + "md-tab-content-" + ); + }); + + it("should coerce the dynamicHeight input to a boolean", () => { + fixture.detectChanges(); + host.tabGroup.dynamicHeight = "false" as unknown as boolean; + expect(host.tabGroup.dynamicHeight).toBe(false); + host.tabGroup.dynamicHeight = true; + expect(host.tabGroup.dynamicHeight).toBe(true); + }); +}); diff --git a/src/ui-kit/experimental/tabs/tab-group.ts b/src/ui-kit/experimental/tabs/tab-group.ts index 0a6496a5b..abc73b5f5 100755 --- a/src/ui-kit/experimental/tabs/tab-group.ts +++ b/src/ui-kit/experimental/tabs/tab-group.ts @@ -92,7 +92,7 @@ export class MdTabGroup { /** Output to enable support for two-way binding on `[(selectedIndex)]` */ @Output() get selectedIndexChange(): Observable { - return map.call(this.selectChange, (event) => event.index); + return this.selectChange.pipe(map((event) => event.index)); } /** Event emitted when focus has changed within a tab group. */ diff --git a/src/ui-kit/experimental/tabs/tab-header.spec.ts b/src/ui-kit/experimental/tabs/tab-header.spec.ts new file mode 100644 index 000000000..bc308429d --- /dev/null +++ b/src/ui-kit/experimental/tabs/tab-header.spec.ts @@ -0,0 +1,117 @@ +import { Component, ViewChild } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { CommonModule } from "@angular/common"; +import { RIGHT_ARROW, LEFT_ARROW, ENTER } from "@angular/cdk/keycodes"; +import { MdTabHeader, MdTabLabelWrapper } from "./index"; + +@Component({ + template: ` + +
+ {{ label }} +
+
+ `, + standalone: false, +}) +class HostComponent { + selectedIndex = 0; + labels = ["One", "Two", "Three"]; + disabledIndexes = new Set(); + + @ViewChild(MdTabHeader) tabHeader: MdTabHeader; +} + +function dispatchKeydown(target: Element, keyCode: number): void { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + }); + // jsdom's KeyboardEvent constructor does not honor a `keyCode` init + // property, so it must be defined explicitly to match what real browsers + // report for legacy `event.keyCode` consumers like `_handleKeydown`. + Object.defineProperty(event, "keyCode", { get: () => keyCode }); + target.dispatchEvent(event); +} + +describe("The Sam Tab Header component", () => { + let fixture: ComponentFixture; + let host: HostComponent; + let tabListContainer: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [HostComponent, MdTabHeader, MdTabLabelWrapper], + imports: [CommonModule], + }); + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + fixture.detectChanges(); + tabListContainer = fixture.nativeElement.querySelector( + ".mat-tab-label-container" + ); + }); + + it("should render a label wrapper for each label without throwing", () => { + const wrappers = fixture.nativeElement.querySelectorAll( + "[md-tab-label-wrapper]" + ); + expect(wrappers.length).toBe(3); + }); + + it("should move focus to the next valid tab on ArrowRight", () => { + dispatchKeydown(tabListContainer, RIGHT_ARROW); + expect(host.tabHeader.focusIndex).toBe(1); + }); + + it("should move focus to the previous valid tab on ArrowLeft", () => { + host.tabHeader.focusIndex = 1; + dispatchKeydown(tabListContainer, LEFT_ARROW); + expect(host.tabHeader.focusIndex).toBe(0); + }); + + it("should emit selectFocusedIndex when Enter is pressed", () => { + const emitted: number[] = []; + host.tabHeader.selectFocusedIndex.subscribe((index) => emitted.push(index)); + host.tabHeader.focusIndex = 2; + dispatchKeydown(tabListContainer, ENTER); + expect(emitted).toEqual([2]); + }); + + it("should skip disabled tabs when moving focus forward", () => { + host.disabledIndexes.add(1); + fixture.detectChanges(); + dispatchKeydown(tabListContainer, RIGHT_ARROW); + expect(host.tabHeader.focusIndex).toBe(2); + }); + + it("should not move focus past the last tab", () => { + host.tabHeader.focusIndex = 2; + dispatchKeydown(tabListContainer, RIGHT_ARROW); + expect(host.tabHeader.focusIndex).toBe(2); + }); + + it("should update selectedIndex when set via the selectedIndex input", () => { + host.selectedIndex = 2; + fixture.detectChanges(); + fixture.detectChanges(); + expect(host.tabHeader.selectedIndex).toBe(2); + }); + + it("should coerce disableRipple to a boolean", () => { + host.tabHeader.disableRipple = "false" as unknown as boolean; + expect(host.tabHeader.disableRipple).toBe(false); + host.tabHeader.disableRipple = true; + expect(host.tabHeader.disableRipple).toBe(true); + }); + + it("should treat all tabs as valid when label wrappers are not yet available", () => { + expect(host.tabHeader._isValidIndex(0)).toBe(true); + }); +}); diff --git a/src/ui-kit/experimental/tabs/tab-header.ts b/src/ui-kit/experimental/tabs/tab-header.ts index 3a940363a..d72134709 100755 --- a/src/ui-kit/experimental/tabs/tab-header.ts +++ b/src/ui-kit/experimental/tabs/tab-header.ts @@ -180,14 +180,16 @@ export class MdTabHeader */ ngAfterContentInit() { this._realignInkBar = this._ngZone.runOutsideAngular(() => { - let resize = + const resize = typeof window !== "undefined" - ? auditTime.call(fromEvent(window, "resize"), 10) + ? fromEvent(window, "resize").pipe(auditTime(10)) : observableOf(null); - return startWith.call(merge(resize), null).subscribe(() => { - this._updatePagination(); - }); + return merge(resize) + .pipe(startWith(null)) + .subscribe(() => { + this._updatePagination(); + }); }); } diff --git a/src/ui-kit/experimental/title/title.spec.ts b/src/ui-kit/experimental/title/title.spec.ts new file mode 100644 index 000000000..9ee086400 --- /dev/null +++ b/src/ui-kit/experimental/title/title.spec.ts @@ -0,0 +1,60 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { CommonModule } from "@angular/common"; +import { SamTitleComponent } from "./title.component"; + +describe("The Sam Title component", () => { + let component: SamTitleComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamTitleComponent], + imports: [CommonModule], + }); + fixture = TestBed.createComponent(SamTitleComponent); + component = fixture.componentInstance; + }); + + it("should render an h1 for highest importance", () => { + component.importance = "highest"; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("h1")).not.toBeNull(); + }); + + it("should render an h2 for high importance", () => { + component.importance = "high"; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("h2")).not.toBeNull(); + }); + + it("should render an h3 for normal importance", () => { + component.importance = "normal"; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("h3")).not.toBeNull(); + }); + + it("should render an h4 for low importance", () => { + component.importance = "low"; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("h4")).not.toBeNull(); + }); + + it("should return undefined from getTitleTag for an unrecognized importance", () => { + expect(component.getTitleTag("unknown")).toBeUndefined(); + }); + + it("should append aligned and weight classes to css_classes on init", () => { + component.aligned = "center"; + component.weight = "bold"; + component.ngOnInit(); + expect(component.css_classes).toBe("sam title center aligned bold"); + }); + + it("should apply the css_classes to the rendered tag", () => { + component.importance = "high"; + component.weight = "bold"; + fixture.detectChanges(); + const tag = fixture.nativeElement.querySelector("h2"); + expect(tag.className).toBe("sam title bold"); + }); +}); diff --git a/src/ui-kit/experimental/video-player/video-player.component.ts b/src/ui-kit/experimental/video-player/video-player.component.ts index 4d5fdd86d..56f085126 100755 --- a/src/ui-kit/experimental/video-player/video-player.component.ts +++ b/src/ui-kit/experimental/video-player/video-player.component.ts @@ -95,11 +95,14 @@ export class SamVideoPlayerComponent { } ngOnDestroy() { - let pxAnounce = document.getElementById("px-video-aria-announce"); - if (pxAnounce && typeof pxAnounce.remove === "function") { - pxAnounce.remove(); - } else { - pxAnounce.parentNode.removeChild(pxAnounce); + const pxAnnounceEl = document.getElementById("px-video-aria-announce"); + if (!pxAnnounceEl) { + return; + } + if (typeof pxAnnounceEl.remove === "function") { + pxAnnounceEl.remove(); + } else if (pxAnnounceEl.parentNode) { + pxAnnounceEl.parentNode.removeChild(pxAnnounceEl); } } diff --git a/src/ui-kit/experimental/video-player/video-player.spec.ts b/src/ui-kit/experimental/video-player/video-player.spec.ts new file mode 100644 index 000000000..936336261 --- /dev/null +++ b/src/ui-kit/experimental/video-player/video-player.spec.ts @@ -0,0 +1,132 @@ +import { Component, ViewChild } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { SamVideoPlayerComponent } from "./video-player.component"; + +@Component({ + template: ` + + + + + `, + standalone: false, +}) +class HostComponent { + videoId = "vid1"; + title = "Test Video"; + + @ViewChild(SamVideoPlayerComponent) player: SamVideoPlayerComponent; +} + +@Component({ + template: ` + + + + + `, + standalone: false, +}) +class EmptyHostComponent { + videoId = "vid2"; + + @ViewChild(SamVideoPlayerComponent) player: SamVideoPlayerComponent; +} + +describe("The Sam Video Player component", () => { + let initPxVideoSpy: ReturnType; + + beforeEach(() => { + initPxVideoSpy = vi.fn(); + ( + globalThis as unknown as { InitPxVideo: typeof initPxVideoSpy } + ).InitPxVideo = initPxVideoSpy; + }); + + afterEach(() => { + delete (globalThis as unknown as { InitPxVideo?: unknown }).InitPxVideo; + }); + + it("should initialize the player and set attributes on the progress/video elements", () => { + TestBed.configureTestingModule({ + declarations: [HostComponent, SamVideoPlayerComponent], + }); + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + expect(initPxVideoSpy).toHaveBeenCalledWith( + expect.objectContaining({ videoId: "vid1", videoTitle: "Test Video" }) + ); + + const videoEl = fixture.nativeElement.querySelector("video"); + expect(videoEl.getAttribute("name")).toBe("vid1"); + expect(videoEl.getAttribute("role")).toBe("presentation"); + }); + + it("should default the video title and seek interval when not provided", () => { + TestBed.configureTestingModule({ + declarations: [HostComponent, SamVideoPlayerComponent], + }); + const fixture = TestBed.createComponent(HostComponent); + const host = fixture.componentInstance; + host.title = ""; + fixture.detectChanges(); + + expect(initPxVideoSpy).toHaveBeenCalledWith( + expect.objectContaining({ + videoTitle: "Sam Video", + seekInterval: 10, + }) + ); + }); + + it("should emit onFullScreenChange when the document fullscreenchange event fires", () => { + TestBed.configureTestingModule({ + declarations: [HostComponent, SamVideoPlayerComponent], + }); + const fixture = TestBed.createComponent(HostComponent); + const host = fixture.componentInstance; + fixture.detectChanges(); + + const emitted: boolean[] = []; + host.player.onFullScreenChange.subscribe((val: boolean) => + emitted.push(val) + ); + host.player.onToggleFullScreen({} as Event); + + expect(emitted.length).toBe(1); + }); + + it("should log errors for each missing required content child on init", () => { + TestBed.configureTestingModule({ + declarations: [EmptyHostComponent, SamVideoPlayerComponent], + }); + const fixture = TestBed.createComponent(EmptyHostComponent); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + fixture.detectChanges(); + + expect(errorSpy).toHaveBeenCalledTimes(3); + errorSpy.mockRestore(); + }); + + it("should remove the aria-announce element on destroy when it exists", () => { + TestBed.configureTestingModule({ + declarations: [HostComponent, SamVideoPlayerComponent], + }); + const fixture = TestBed.createComponent(HostComponent); + const host = fixture.componentInstance; + fixture.detectChanges(); + + const announceEl = document.createElement("div"); + announceEl.id = "px-video-aria-announce"; + document.body.appendChild(announceEl); + + host.player.ngOnDestroy(); + + expect(document.getElementById("px-video-aria-announce")).toBeNull(); + }); +}); diff --git a/src/ui-kit/experimental/youtube/youtube.spec.ts b/src/ui-kit/experimental/youtube/youtube.spec.ts new file mode 100644 index 000000000..dafdc2830 --- /dev/null +++ b/src/ui-kit/experimental/youtube/youtube.spec.ts @@ -0,0 +1,40 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { SamYoutubeComponent } from "./youtube.component"; + +describe("The Sam Youtube component", () => { + let component: SamYoutubeComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SamYoutubeComponent], + }); + fixture = TestBed.createComponent(SamYoutubeComponent); + component = fixture.componentInstance; + }); + + it("should build the embed url from the id input on init", () => { + component.id = "dQw4w9WgXcQ"; + fixture.detectChanges(); + expect(component.YouTubeVideoUrl).toBe( + "https://www.youtube.com/embed/dQw4w9WgXcQ" + ); + }); + + it("should render an iframe pointed at the sanitized video url", () => { + component.id = "dQw4w9WgXcQ"; + fixture.detectChanges(); + const iframe = fixture.nativeElement.querySelector("iframe"); + expect(iframe.getAttribute("src")).toBe( + "https://www.youtube.com/embed/dQw4w9WgXcQ" + ); + }); + + it("should update the video url when updateVideoUrl is called directly", () => { + fixture.detectChanges(); + component.updateVideoUrl("anotherId"); + expect(component.YouTubeVideoUrl).toBe( + "https://www.youtube.com/embed/anotherId" + ); + }); +});