///
/// 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 { Component, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { WizardTestWrapper } from './wizard-test-wrapper';
import { StepChangingEvent, WizardComponent } from './wizard.component';
import { WizardModule } from './wizard.module';
interface StepDefinition {
title: string;
content?: string;
visited?: boolean;
}
/**
* Test navigation and steps display correctly
*/
@Component({
selector: 'wizard-test-app',
template: `
@for (step of steps; track step) {
{{ step.content }}
}
`,
imports: [WizardModule],
})
export class WizardTestComponent {
@ViewChild(WizardComponent) wizard: WizardComponent;
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('Wizard', () => {
let component: WizardTestComponent;
let fixture: ComponentFixture;
let wrapper: WizardTestWrapper;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [WizardModule, WizardTestComponent],
}).compileComponents();
});
beforeEach(async () => {
fixture = TestBed.createComponent(WizardTestComponent);
wrapper = new WizardTestWrapper(fixture);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
});
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 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]');
});
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 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.wizard.reset();
expect(component.step).toBe(0);
expect(component.steps[0].visited).toBe(true, 'steps[0]');
expect(component.steps[1].visited).toBe(false, 'steps[1]');
});
});
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);
});
});
});
@Component({
selector: 'wizard-async-validation-test-app',
template: `
First Step
Last Step
`,
imports: [WizardModule],
})
export class WizardAsyncValidationTestComponent {
firstStepValidator: () => boolean | Promise;
lastStepValidator: () => boolean | Promise;
// eslint-disable-next-line @typescript-eslint/no-empty-function
stepChanging(_: StepChangingEvent) {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
stepChange(_: number) {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onNext(_: number) {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onFinishing() {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onFinish() {}
}
describe('Wizard with validator', () => {
const ASYNC_DURATION = 100;
let component: WizardAsyncValidationTestComponent;
let fixture: ComponentFixture;
let wrapper: WizardTestWrapper;
let stepChanging: jasmine.Spy;
let stepChange: jasmine.Spy;
let onNext: jasmine.Spy;
let onFinishing: jasmine.Spy;
let onFinish: jasmine.Spy;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [WizardModule, WizardAsyncValidationTestComponent],
}).compileComponents();
});
beforeEach(async () => {
fixture = TestBed.createComponent(WizardAsyncValidationTestComponent);
wrapper = new WizardTestWrapper(fixture);
component = fixture.componentInstance;
stepChanging = spyOn(component, 'stepChanging');
stepChange = spyOn(component, 'stepChange');
onNext = spyOn(component, 'onNext');
onFinishing = spyOn(component, 'onFinishing');
onFinish = spyOn(component, 'onFinish');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
});
it('should allow proceeding to the next step when no validator has been provided', async () => {
expect(wrapper.getActiveStepHeader().innerText).toBe('First Step');
await wrapper.clickStepButton('Next');
expect(wrapper.getActiveStepHeader().innerText).toBe('Last Step');
expect(stepChanging).toHaveBeenCalled();
expect(stepChange).toHaveBeenCalled();
expect(onNext).toHaveBeenCalled();
});
it('should allow finishing the wizard when no validator has been provided', async () => {
await wrapper.clickStepButton('Next');
await wrapper.clickStepButton('Finish');
expect(wrapper.getActiveStepHeader().innerText).toBe('Last Step');
expect(stepChanging).toHaveBeenCalledTimes(1);
expect(stepChange).toHaveBeenCalledTimes(1);
expect(onNext).toHaveBeenCalledTimes(1);
expect(onFinishing).toHaveBeenCalledTimes(1);
expect(onFinish).toHaveBeenCalledTimes(1);
});
it('should allow proceeding to the next step when a validator function returns true', async () => {
component.firstStepValidator = () => {
return true;
};
fixture.detectChanges();
await wrapper.clickStepButton('Next');
expect(wrapper.getActiveStepHeader().innerText).toBe('Last Step');
expect(stepChanging).toHaveBeenCalledTimes(1);
expect(stepChange).toHaveBeenCalledTimes(1);
expect(onNext).toHaveBeenCalledTimes(1);
});
it('should prevent proceeding to the next step when a validator function returns false', async () => {
component.firstStepValidator = () => {
return false;
};
fixture.detectChanges();
await wrapper.clickStepButton('Next');
expect(wrapper.getActiveStepHeader().innerText).toBe('First Step');
expect(stepChanging).toHaveBeenCalledTimes(1);
expect(stepChange).not.toHaveBeenCalled();
expect(onNext).not.toHaveBeenCalled();
});
it('should allow finishing the wizard when a validator function returns true', async () => {
component.lastStepValidator = () => {
return true;
};
fixture.detectChanges();
await wrapper.clickStepButton('Next');
await wrapper.clickStepButton('Finish');
expect(onFinishing).toHaveBeenCalled();
expect(onFinish).toHaveBeenCalled();
});
it('should prevent finishing the wizard when a validator function returns false', async () => {
component.lastStepValidator = () => {
return false;
};
fixture.detectChanges();
await wrapper.clickStepButton('Next');
await wrapper.clickStepButton('Finish');
expect(onFinishing).toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
});
it('should allow proceeding to the next step when a validator function returns true in a promise', fakeAsync(() => {
component.firstStepValidator = () => {
return new Promise(resolve => {
setTimeout(() => {
resolve(true);
}, ASYNC_DURATION);
});
};
fixture.detectChanges();
wrapper.clickStepButton('Next');
tick(ASYNC_DURATION);
fixture.detectChanges();
expect(wrapper.getActiveStepHeader().innerText).toBe('Last Step');
expect(stepChanging).toHaveBeenCalled();
expect(stepChange).toHaveBeenCalled();
expect(onNext).toHaveBeenCalled();
}));
it('should prevent proceeding to the next step when a validator function returns false in a promise', fakeAsync(() => {
component.firstStepValidator = () => {
return new Promise(resolve => {
setTimeout(() => {
resolve(false);
}, ASYNC_DURATION);
});
};
fixture.detectChanges();
wrapper.clickStepButton('Next');
tick(ASYNC_DURATION);
expect(wrapper.getActiveStepHeader().innerText).toBe('First Step');
expect(stepChanging).toHaveBeenCalled();
expect(stepChange).not.toHaveBeenCalled();
expect(onNext).not.toHaveBeenCalled();
}));
it('should allow finishing the wizard when a validator function returns true in a promise', fakeAsync(() => {
component.lastStepValidator = () => {
return new Promise(resolve => {
setTimeout(() => {
resolve(true);
}, ASYNC_DURATION);
});
};
fixture.detectChanges();
wrapper.clickStepButton('Next');
tick(1);
wrapper.clickStepButton('Finish');
tick(ASYNC_DURATION);
expect(onFinishing).toHaveBeenCalled();
expect(onFinish).toHaveBeenCalled();
}));
it('should prevent finishing the wizard when a validator function returns false in a promise', fakeAsync(() => {
component.lastStepValidator = () => {
return new Promise(resolve => {
setTimeout(() => {
resolve(false);
}, ASYNC_DURATION);
});
};
fixture.detectChanges();
wrapper.clickStepButton('Next');
tick(1);
wrapper.clickStepButton('Finish');
tick(ASYNC_DURATION);
expect(onFinishing).toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
}));
});
/**
* 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: [WizardModule],
})
export class WizardValidationTestComponent {
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('Wizard with validation', () => {
let component: WizardValidationTestComponent;
let fixture: ComponentFixture;
let wrapper: WizardTestWrapper;
let visitedChanged: jasmine.Spy;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [WizardModule, WizardValidationTestComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(WizardValidationTestComponent);
wrapper = new WizardTestWrapper(fixture);
component = fixture.componentInstance;
visitedChanged = spyOn(fixture.componentInstance, 'visitedChanged');
fixture.detectChanges();
});
it('should remove visited state from later steps when valid = false', () => {
// has to be set manually as false as default
component.resetVisitedOnValidationError = true;
// 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);
});
it('should not have an invalid appearance when valid = false and visited = false', () => {
component.step1Visited = true;
component.step2Visited = false;
component.step2Valid = false;
fixture.detectChanges();
expect(wrapper.isStepValid(1)).toBe(true);
});
it('should have an invalid appearance when valid = false and visited = true', () => {
component.step1Visited = true;
component.step2Visited = true;
component.step2Valid = false;
fixture.detectChanges();
expect(wrapper.isStepValid(1)).toBe(false);
});
});