///
/// Copyright 2015-2026 Micro Focus or one of its affiliates.
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { ChangeDetectorRef, Component, OnDestroy, ViewChild, inject } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { StepChangingEvent } from '../wizard';
import { MarqueeWizardTestWrapper } from './marquee-wizard-test-wrapper';
import { MarqueeWizardComponent } from './marquee-wizard.component';
import { MarqueeWizardModule } from './marquee-wizard.module';
interface StepDefinition {
title: string;
content?: string;
visited?: boolean;
completed?: boolean;
}
/**
* Test navigation and steps display correctly
**/
@Component({
selector: 'marquee-wizard-app',
template: `
Marquee Wizard
@for (step of steps; track step) {
{{ step.content }}
}
`,
imports: [MarqueeWizardModule],
})
export class MarqueeWizardTestComponent {
@ViewChild(MarqueeWizardComponent) marqueeWizard: MarqueeWizardComponent;
step: number = 0;
steps: StepDefinition[] = [
{ title: 'First Step', content: 'Content of first step' },
{ title: 'Second Step', content: 'Content of second step' },
];
cancelVisible = false;
// eslint-disable-next-line @typescript-eslint/no-empty-function
onStepChanging(_: StepChangingEvent): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onStepChange(_: number): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onNext(_: number): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onPrevious(_: number): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onFinishing(): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onFinish(): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onCancel(): void {}
}
describe('Marquee Wizard', () => {
let component: MarqueeWizardTestComponent;
let fixture: ComponentFixture;
let wrapper: MarqueeWizardTestWrapper;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [MarqueeWizardModule, MarqueeWizardTestComponent],
}).compileComponents();
});
beforeEach(async () => {
fixture = TestBed.createComponent(MarqueeWizardTestComponent);
wrapper = new MarqueeWizardTestWrapper(fixture);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
});
it('should display an icon in the side pane', () => {
const icon = wrapper.nativeElement.querySelector('.marquee-wizard-description-container img');
expect(icon).toBeTruthy();
});
it('should display a title in the side pane', () => {
const title = wrapper.nativeElement.querySelector('.marquee-title');
expect(title.innerText).toBe('Marquee Wizard');
});
it('should generate an id for each step', () => {
const steps = wrapper.getStepHeaders();
steps.forEach((step, index) => {
expect(step.id).toMatch(`ux-wizard-[0-9]+-step-${index}-label`);
});
});
it('should display the step headers in the side pane', () => {
const stepHeaders = wrapper.getStepHeaders();
expect(stepHeaders.length).toBe(2);
expect(stepHeaders[0].classList).toContain('active', 'stepHeaders[0]');
expect(stepHeaders[0].classList).toContain('visited', 'stepHeaders[0]');
expect(stepHeaders[0].innerText).toBe('First Step', 'stepHeaders[0]');
expect(stepHeaders[1].classList).not.toContain('active', 'stepHeaders[1]');
expect(stepHeaders[1].classList).not.toContain('visited', 'stepHeaders[1]');
expect(stepHeaders[1].innerText).toBe('Second Step', 'stepHeaders[1]');
});
it('should display step content', () => {
expect(wrapper.getContentText()).toBe('Content of first step');
});
it('should set visited = true on the first step initially', () => {
expect(component.steps[0].visited).toBe(true);
expect(component.steps[1].visited).toBeUndefined();
});
it('should have a "Next" button on the first step', () => {
const buttons = wrapper.getStepButtons();
expect(buttons.length).toBe(1);
expect(buttons[0].classList).toContain('button-primary');
expect(buttons[0].innerText.toUpperCase()).toBe('NEXT');
});
it('should change to the second step when "Next" is clicked', async () => {
await wrapper.clickStepButton('Next');
expect(component.step).toBe(1);
expect(wrapper.getActiveStepHeader().innerText).toBe('Second Step');
expect(wrapper.getContentText()).toBe('Content of second step');
});
it('should emit stepChanging, stepChange, and onNext when "Next" is clicked', async () => {
spyOn(component, 'onStepChanging');
spyOn(component, 'onStepChange');
spyOn(component, 'onNext');
await wrapper.clickStepButton('Next');
expect(component.onStepChanging).toHaveBeenCalledWith(new StepChangingEvent(0, 1));
expect(component.onStepChange).toHaveBeenCalledWith(1);
expect(component.onNext).toHaveBeenCalledWith(1);
});
it('should only emit stepChange once when "Next" is clicked', async () => {
spyOn(component, 'onStepChange');
await wrapper.clickStepButton('Next');
expect(component.onStepChange).toHaveBeenCalledTimes(1);
});
it('should set visited = true on the second step when "Next" is clicked', async () => {
await wrapper.clickStepButton('Next');
expect(component.steps[0].visited).toBe(true, 'steps[0]');
expect(component.steps[1].visited).toBe(true, 'steps[1]');
});
it('should set completed = true on the first step when "Next" is clicked', async () => {
await wrapper.clickStepButton('Next');
expect(component.steps[0].completed).toBe(true);
expect(component.steps[1].completed).toBeUndefined();
});
it('should have an identifying class present on the next button', () => {
const button = wrapper.getStepButton('Next');
expect(button.classList).toContain('marquee-wizard-next-button');
});
describe('on the last step', () => {
beforeEach(() => {
component.step = 1;
fixture.detectChanges();
});
it('should display step content', () => {
expect(wrapper.getContentText()).toBe('Content of second step');
});
it('should display the step as active in the side pane', () => {
expect(wrapper.getActiveStepHeader().innerText).toBe('Second Step');
});
it('should have a "Previous" and "Finish" button', () => {
const buttons = wrapper.getStepButtons();
expect(buttons.length).toBe(2);
expect(buttons[0].classList).toContain('button-secondary');
expect(buttons[0].innerText.toUpperCase()).toBe('PREVIOUS');
expect(buttons[1].classList).toContain('button-primary');
expect(buttons[1].innerText.toUpperCase()).toBe('FINISH');
});
it('should set visited = true on the step', () => {
expect(component.steps[1].visited).toBe(true);
});
it('should emit onFinishing and onFinish when "Finish" button is clicked', async () => {
spyOn(component, 'onFinishing');
spyOn(component, 'onFinish');
await wrapper.clickStepButton('Finish');
expect(component.onFinishing).toHaveBeenCalledTimes(1);
expect(component.onFinish).toHaveBeenCalledTimes(1);
});
it('should set completed = true when "Finish" is clicked', async () => {
await wrapper.clickStepButton('Finish');
expect(component.steps[1].completed).toBe(true);
});
it('should change to the first step when "Previous" is clicked', async () => {
await wrapper.clickStepButton('Previous');
expect(component.step).toBe(0);
expect(wrapper.getActiveStepHeader().innerText).toBe('First Step');
expect(wrapper.getContentText()).toBe('Content of first step');
expect(component.steps[0].visited).toBe(true);
});
it('should emit stepChanging, stepChange, and onPrevious when "Previous" is clicked', async () => {
spyOn(component, 'onStepChanging');
spyOn(component, 'onStepChange');
spyOn(component, 'onPrevious');
await wrapper.clickStepButton('Previous');
expect(component.onStepChanging).toHaveBeenCalledWith(new StepChangingEvent(1, 0));
expect(component.onStepChange).toHaveBeenCalledWith(0);
expect(component.onPrevious).toHaveBeenCalledWith(0);
});
it('should set the active step to 0 and reset visited state when reset() is called', () => {
component.marqueeWizard.reset();
expect(component.step).toBe(0);
expect(component.steps[0].visited).toBe(true, 'steps[0]');
expect(component.steps[1].visited).toBe(false, 'steps[1]');
});
it('should have an identifying class present on the previous button', () => {
const button = wrapper.getStepButton('Previous');
expect(button.classList).toContain('marquee-wizard-previous-button');
});
it('should have an identifying class present on the finish button', () => {
const button = wrapper.getStepButton('Finish');
expect(button.classList).toContain('marquee-wizard-finish-button');
});
});
describe('with cancel button', () => {
beforeEach(() => {
component.cancelVisible = true;
fixture.detectChanges();
});
it('should have a cancel button on the first step', () => {
const buttons = wrapper.getStepButtons();
expect(buttons.length).toBe(2);
expect(buttons[0].classList).toContain('button-primary');
expect(buttons[0].innerText.toUpperCase()).toBe('NEXT');
expect(buttons[1].classList).toContain('button-secondary');
expect(buttons[1].innerText.toUpperCase()).toBe('CANCEL');
});
it('should emit onCancel when the "Cancel" button is clicked', async () => {
spyOn(component, 'onCancel');
await wrapper.clickStepButton('Cancel');
expect(component.onCancel).toHaveBeenCalledTimes(1);
});
it('should have an identifying class present on the cancel button', () => {
const button = wrapper.getStepButton('Cancel');
expect(button.classList).toContain('marquee-wizard-cancel-button');
});
});
});
/**
* Test for EL-3790 where an error can occur if steps rely on request response
*/
@Component({
selector: 'marquee-wizard-ngfor-app',
template: `
@for (step of steps; track step) {
{{ step.content }}
}
`,
imports: [MarqueeWizardModule],
})
export class MarqueeWizardAsyncTestComponent implements OnDestroy {
step = 0;
steps: StepDefinition[] = [];
valid: boolean = true;
disableNextWhenInvalid: boolean;
private readonly _timeout: number;
constructor() {
const changeDetector = inject(ChangeDetectorRef);
this._timeout = window.setTimeout(() => {
this.steps = [
{ title: 'First Step', content: 'Content of first step' },
{ title: 'Second Step', content: 'Content of second step' },
];
changeDetector.detectChanges();
}, 100);
}
ngOnDestroy(): void {
clearTimeout(this._timeout);
}
}
describe('Marquee wizard with delayed step creation', () => {
let component: MarqueeWizardAsyncTestComponent;
let fixture: ComponentFixture;
let wrapper: MarqueeWizardTestWrapper;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [MarqueeWizardModule, MarqueeWizardAsyncTestComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(MarqueeWizardAsyncTestComponent);
wrapper = new MarqueeWizardTestWrapper(fixture);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should have no steps initially', () => {
const steps = wrapper.getStepHeaders();
expect(steps.length).toBe(0);
});
it('should display steps after asynchronous load', fakeAsync(async () => {
await whenStepsLoaded();
const stepHeaders = wrapper.getStepHeaders();
expect(stepHeaders.length).toBe(2);
expect(stepHeaders[0].innerText).toBe('First Step');
expect(stepHeaders[1].innerText).toBe('Second Step');
expect(component.step).toBe(0);
expect(wrapper.getContentText()).toBe('Content of first step');
}));
it('should navigate to the second step when clicking "Next"', fakeAsync(async () => {
await whenStepsLoaded();
await wrapper.clickStepButton('Next');
expect(component.step).toBe(1);
expect(wrapper.getContentText()).toBe('Content of second step');
}));
describe('with disableNextWhenInvalid = true', () => {
beforeEach(() => {
component.disableNextWhenInvalid = true;
component.valid = false;
fixture.detectChanges();
});
it('should disable the "Next" button when the step is invalid', fakeAsync(async () => {
await whenStepsLoaded();
const button = wrapper.getStepButton('Next');
expect(button.hasAttribute('disabled')).toBe(true);
}));
});
async function whenStepsLoaded(): Promise {
tick(200);
fixture.detectChanges();
await fixture.whenStable();
// tick() operator requires a second round of change detection
fixture.detectChanges();
}
});
/**
* Test resetVisitedOnValidationError flag working as expected and visitedChange is hit when required.
**/
@Component({
selector: 'marquee-wizard-validation-app',
template: `
Step One Content
Step Two Content
Step Three Content
`,
imports: [MarqueeWizardModule],
})
export class MarqueeWizardValidationTestComponent {
currentStep: number;
resetVisitedOnValidationError = true;
// step 1 values
step1Valid = true;
step1Visited: boolean;
step1Completed: boolean;
// step 2 values
step2Valid: boolean = true;
step2Visited: boolean;
step2Completed: boolean;
// step 3 values
step3Valid: boolean = true;
step3Visited: boolean;
step3Completed: boolean;
// eslint-disable-next-line @typescript-eslint/no-empty-function
visitedChanged(index: number, value: boolean): void {}
}
describe('Marquee wizard with validation', () => {
let component: MarqueeWizardValidationTestComponent;
let fixture: ComponentFixture;
let wrapper: MarqueeWizardTestWrapper;
let visitedChanged: jasmine.Spy;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [MarqueeWizardModule, MarqueeWizardValidationTestComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(MarqueeWizardValidationTestComponent);
wrapper = new MarqueeWizardTestWrapper(fixture);
component = fixture.componentInstance;
visitedChanged = spyOn(fixture.componentInstance, 'visitedChanged');
fixture.detectChanges();
});
it('should remove visited state from later steps when valid = false', () => {
// 3 steps and number 2 gets set invalid - 1 should stay the same, 3 should be valid but no longer be visited
component.step1Visited = true;
component.step2Visited = true;
component.step3Visited = true;
fixture.detectChanges();
expect(wrapper.isStepValid(0)).toBe(true);
expect(wrapper.isStepValid(1)).toBe(true);
expect(wrapper.isStepValid(2)).toBe(true);
expect(wrapper.isStepVisited(0)).toBe(true);
expect(wrapper.isStepVisited(1)).toBe(true);
expect(wrapper.isStepVisited(2)).toBe(true);
component.step2Valid = false;
fixture.detectChanges();
expect(wrapper.isStepValid(0)).toBe(true);
expect(wrapper.isStepValid(1)).toBe(false);
expect(wrapper.isStepValid(2)).toBe(true);
expect(wrapper.isStepVisited(0)).toBe(true);
expect(wrapper.isStepVisited(1)).toBe(true);
expect(wrapper.isStepVisited(2)).toBe(false);
expect(component.step1Visited).toBe(true);
expect(component.step2Visited).toBe(true);
expect(component.step3Visited).toBe(false);
// check visitedChange was emitted 1 time only
expect(visitedChanged.calls.all().length).toBe(1);
});
it('should not remove visited state from later steps when valid = false and resetVisitedOnValidationError = false', () => {
component.resetVisitedOnValidationError = false;
component.step1Visited = true;
component.step2Visited = true;
component.step3Visited = true;
fixture.detectChanges();
expect(wrapper.isStepValid(0)).toBe(true);
expect(wrapper.isStepValid(1)).toBe(true);
expect(wrapper.isStepValid(2)).toBe(true);
expect(wrapper.isStepVisited(0)).toBe(true);
expect(wrapper.isStepVisited(1)).toBe(true);
expect(wrapper.isStepVisited(2)).toBe(true);
component.step2Valid = false;
fixture.detectChanges();
expect(wrapper.isStepValid(0)).toBe(true);
expect(wrapper.isStepValid(1)).toBe(false);
expect(wrapper.isStepValid(2)).toBe(true);
expect(wrapper.isStepVisited(0)).toBe(true);
expect(wrapper.isStepVisited(1)).toBe(true);
expect(wrapper.isStepVisited(2)).toBe(true);
expect(component.step1Visited).toBe(true);
expect(component.step2Visited).toBe(true);
expect(component.step3Visited).toBe(true);
// check visitedChange was not emitted
expect(visitedChanged.calls.all().length).toBe(0);
});
});
/**
* Custom step template
*/
@Component({
selector: 'marquee-wizard-custom-step',
template: `
{{ index }}. {{ step.header }} ({{ context.count }})
`,
imports: [MarqueeWizardModule],
})
export class MarqueeWizardCustomStepTemplateComponent {}
describe('Marquee wizard with custom step template', () => {
let component: MarqueeWizardCustomStepTemplateComponent;
let fixture: ComponentFixture;
let nativeElement: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [MarqueeWizardModule, MarqueeWizardCustomStepTemplateComponent],
}).compileComponents();
fixture = TestBed.createComponent(MarqueeWizardCustomStepTemplateComponent);
component = fixture.componentInstance;
nativeElement = fixture.nativeElement;
fixture.detectChanges();
});
it('should display the custom step template with the correct context', () => {
const steps = nativeElement.querySelectorAll('.marquee-wizard-step');
expect(steps.item(0).textContent.trim()).toBe('0. Step One (123)');
expect(steps.item(1).textContent.trim()).toBe('1. Step Two (456)');
});
});
@Component({
selector: 'marquee-wizard-step-navigation-app',
template: `
Step One Content
Step Two Content
Step Three Content
`,
imports: [MarqueeWizardModule],
})
export class MarqueeWizardStepNavigationComponent {
currentStep: number;
sequential: boolean = true;
// eslint-disable-next-line @typescript-eslint/no-empty-function
onStepChange(_: number): void {}
// step 1 values
step1Valid: boolean = true;
step1Visited: boolean = true;
step1Completed: boolean = false;
// step 2 values
step2Valid: boolean = true;
step2Visited: boolean = true;
step2Completed: boolean = false;
// step 3 values
step3Valid: boolean = true;
step3Visited: boolean = true;
step3Completed: boolean = false;
// eslint-disable-next-line @typescript-eslint/no-empty-function
visitedChanged(index: number, value: boolean): void {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
completedChange(index: number, value: boolean): void {}
}
describe('Marquee wizard with step navigation', () => {
let component: MarqueeWizardStepNavigationComponent;
let fixture: ComponentFixture;
let nativeElement: HTMLElement;
let visitedChanged: jasmine.Spy;
let completedChange: jasmine.Spy;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [MarqueeWizardModule, MarqueeWizardStepNavigationComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(MarqueeWizardStepNavigationComponent);
component = fixture.componentInstance;
nativeElement = fixture.nativeElement;
visitedChanged = spyOn(fixture.componentInstance, 'visitedChanged');
completedChange = spyOn(fixture.componentInstance, 'completedChange');
fixture.detectChanges();
});
describe('[sequential]=false', () => {
it('should allow navigation to any step and emit stepChange on click', async () => {
component.sequential = false;
component.step1Visited = true;
component.step2Visited = false;
component.step3Visited = false;
expect(isStepVisited(0)).toBe(true);
spyOn(component, 'onStepChange');
fixture.detectChanges();
await clickStep(2);
expect(component.step1Visited).toBe(true);
expect(component.step2Visited).toBe(false);
expect(isStepVisited(2)).toBe(true);
expect(component.onStepChange).toHaveBeenCalledWith(2);
expect(component.step1Completed).toBe(true);
expect(component.step2Completed).toBe(false);
expect(component.step3Completed).toBe(false);
});
it('should mark a step as visited when navigating to a step', async () => {
component.sequential = false;
component.step1Visited = false;
component.step2Visited = true;
component.step3Visited = false;
expect(isStepVisited(1)).toBe(true);
fixture.detectChanges();
await clickStep(0);
expect(component.step1Visited).toBe(true);
expect(component.step2Visited).toBe(true);
expect(component.step3Visited).toBe(false);
});
it('should allow navigation away from an invalid step', async () => {
component.sequential = false;
component.step1Visited = true;
component.step3Visited = true;
component.step3Valid = false;
fixture.detectChanges();
expect(isStepValid(2)).toBe(false);
await clickStep(1);
expect(component.step2Visited).toBe(true);
});
it('should emit visitedChange when navigating to a step', async () => {
component.sequential = false;
component.step1Valid = true;
component.step1Visited = true;
component.step1Completed = true;
component.step3Visited = false;
fixture.detectChanges();
await clickStep(2);
expect(visitedChanged).toHaveBeenCalled();
});
it('should emit completedChange when step is completed', async () => {
component.sequential = false;
component.step1Valid = true;
component.step1Visited = true;
component.step1Completed = true;
fixture.detectChanges();
await clickStep(1);
expect(completedChange).toHaveBeenCalled();
});
});
function getSteps(): HTMLElement[] {
return Array.from(nativeElement.querySelectorAll('.marquee-wizard-step'));
}
async function clickStep(index: number) {
const steps = getSteps();
steps[index].click();
fixture.detectChanges();
await fixture.whenStable();
}
function isStepVisited(index: number): boolean {
const stepElements = nativeElement.querySelectorAll('.marquee-wizard-step');
return stepElements[index].classList.contains('visited');
}
function isStepValid(index: number): boolean {
const stepElements = nativeElement.querySelectorAll('.marquee-wizard-step');
return !stepElements[index].classList.contains('invalid');
}
});