diff --git a/src/constants/endpoints.js b/src/constants/endpoints.js
index 4196cbf19..3035b4ad7 100644
--- a/src/constants/endpoints.js
+++ b/src/constants/endpoints.js
@@ -112,10 +112,18 @@ const endpoints = {
url: "/schedule/api/v1/schedule/shop-time-slots",
method: "POST",
},
+ GetShopTimeSlotsV2: {
+ url: "/schedule/api/v2/schedule/shop-time-slots",
+ method: "POST",
+ },
GetMobileTimeSlots: {
url: "/schedule/api/v1/schedule/mobile-time-slots",
method: "POST",
},
+ GetMobileTimeSlotsV2: {
+ url: "/schedule/api/v2/schedule/mobile-time-slots",
+ method: "POST",
+ },
GetMobilePremiumFee: {
url: "/parts/api/v1/parts/mobile-premium-fee",
method: "GET",
diff --git a/src/constants/error-messages.js b/src/constants/error-messages.js
index 1b0dfb724..2f49e2f1b 100644
--- a/src/constants/error-messages.js
+++ b/src/constants/error-messages.js
@@ -41,6 +41,9 @@ const errorMessages = {
INSURANCE_COMPANY_REQUIRED: "Please select an insurance company",
INSURANCE_COMPANY_NAME_REQUIRED: "Please enter your insurance company name",
POLICY_NUMBER_REQUIRED: "Please enter your policy number",
+ POLICY_NUMBER_FORMAT: "Please enter a valid policy number",
+ CLAIM_NUMBER_VALID: "Please enter a valid claim number",
+ CLAIM_NUMBER_FORMAT: "Claim number must be 6 to 8 digits",
DATE_OF_LOSS_REQUIRED: "Please enter your date of damage",
DAMAGE_TYPES_REQUIRED: "How damage occurred is required",
ZIP_CODE_REQUIRED: "Please enter your ZIP code",
diff --git a/src/constants/store-actions.js b/src/constants/store-actions.js
index 7e136fefa..cb4c6ff54 100644
--- a/src/constants/store-actions.js
+++ b/src/constants/store-actions.js
@@ -35,7 +35,9 @@ const storeActions = {
GET_PRICING_BY_DAY_PART: "getPricingByDayPart",
GET_SERVICEABILITY_DETAILS: "getServiceabilityDetails",
GET_SHOP_TIME_SLOTS: "getShopTimeSlots",
+ GET_SHOP_TIME_SLOTS_V2: "getShopTimeSlotsV2",
GET_MOBILE_TIME_SLOTS: "getMobileTimeSlots",
+ GET_MOBILE_TIME_SLOTS_V2: "getMobileTimeSlotsV2",
GET_PROVIDERS: "getProviders",
GET_MOBILE_PREMIUM_FEE: "getMobilePremiumFee",
SAVE_SESSION: "saveSession",
diff --git a/src/digital-components/textbox-question/textbox-question.spec.js b/src/digital-components/textbox-question/textbox-question.spec.js
index d83eb6509..02fc6a768 100644
--- a/src/digital-components/textbox-question/textbox-question.spec.js
+++ b/src/digital-components/textbox-question/textbox-question.spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from "@vue/test-utils";
+import { defineRule } from "vee-validate";
import textboxQuestion from "./textbox-question";
// Mock CMS content
@@ -12,6 +13,11 @@ const mockMixin = {
};
const maska = jest.fn();
+// Real rules registered with vee-validate so the watcher's pre-check (which calls the
+// standalone `validate` export, not `this.validate`) exercises genuine valid/invalid paths.
+defineRule("spec-always-valid", () => true);
+defineRule("spec-always-invalid", () => "invalid");
+
describe("textboxQuestion.vue", () => {
it("Should coerce non-string modelValue to empty string for v-model.trim", async () => {
const wrapper = shallowMount(textboxQuestion, {
@@ -148,7 +154,7 @@ describe("textboxQuestion.vue", () => {
expect(wrapper.emitted()).toHaveProperty("change");
});
- it("Should call this.handleChange with new value when the value is changed and the new value is valid", async () => {
+ it("Should call this.handleChange with new value and shouldValidate=true when the value is changed and the new value is valid", async () => {
// Arrange
const wrapper = shallowMount(textboxQuestion, {
global: {
@@ -157,22 +163,43 @@ describe("textboxQuestion.vue", () => {
},
},
propsData: {
- options: {},
modelValue: "foo",
+ validationRules: "spec-always-valid",
},
mixins: [mockMixin],
});
wrapper.vm.handleChange = jest.fn().mockImplementation(() => {});
- wrapper.vm.validate = jest.fn().mockImplementation(() => {
- return true;
- });
// Act
- wrapper.vm.$options.watch.value.call(wrapper.vm, "bar");
+ await wrapper.vm.$options.watch.value.call(wrapper.vm, "bar");
// Assert
- expect(wrapper.vm.handleChange).toHaveBeenCalled;
+ expect(wrapper.vm.handleChange).toHaveBeenCalledWith("bar", true);
+ });
+
+ it("Should still call this.handleChange with shouldValidate=false when the new value is invalid, so the field stays in sync for later validation", async () => {
+ // Arrange
+ const wrapper = shallowMount(textboxQuestion, {
+ global: {
+ directives: {
+ maska: maska,
+ },
+ },
+ propsData: {
+ modelValue: "foo",
+ validationRules: "spec-always-invalid",
+ },
+ mixins: [mockMixin],
+ });
+
+ wrapper.vm.handleChange = jest.fn().mockImplementation(() => {});
+
+ // Act
+ await wrapper.vm.$options.watch.value.call(wrapper.vm, "bar");
+
+ // Assert
+ expect(wrapper.vm.handleChange).toHaveBeenCalledWith("bar", false);
});
it("Should not display camera or spinner icon when disabled", () => {
diff --git a/src/digital-components/textbox-question/textbox-question.vue b/src/digital-components/textbox-question/textbox-question.vue
index 0bc635feb..041af416c 100644
--- a/src/digital-components/textbox-question/textbox-question.vue
+++ b/src/digital-components/textbox-question/textbox-question.vue
@@ -252,9 +252,15 @@ export default {
}
const result = await validate(newValue, this.validationRules); // do a test validation check, without triggering full validation
- if (result.valid) {
- this.handleChange(newValue); // trigger full validation on this field only
- }
+ // Always sync vee-validate's internal field value with what the user actually
+ // typed. Previously this only happened when the pre-check passed, so a field
+ // that never reached a "valid" intermediate state while typing (e.g. an
+ // 8-character alpha-only policy number) would never sync/validate at all,
+ // silently swallowing its error message on blur/submit. Passing result.valid
+ // as the shouldValidate flag keeps the no-flicker-while-typing behavior (errors
+ // aren't displayed until the value is actually valid or a full validation runs),
+ // while still keeping meta/value accurate for that later validation.
+ this.handleChange(newValue, result.valid);
},
},
components: {
diff --git a/src/layouts/coverage-statement/coverage-statement.spec.js b/src/layouts/coverage-statement/coverage-statement.spec.js
index a54591f34..ca8ebee2e 100644
--- a/src/layouts/coverage-statement/coverage-statement.spec.js
+++ b/src/layouts/coverage-statement/coverage-statement.spec.js
@@ -3,6 +3,9 @@ import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
+import store from "@/store";
+import { applicationConfig } from "@/constants/application-config";
+import { coverageStatus } from "@/constants/insurance";
// Mock our module for promises.
jest.mock("@/helpers/layout-helper.js", () => ({
@@ -37,14 +40,78 @@ describe("coverage-statement.vue", () => {
});
});
- describe("methods", () => {
- test("arePagePrerequisitesValid returns true", () => {
+ describe("arePagePrerequisitesValid", () => {
+ test("returns true when all prerequisites are valid", () => {
const { wrapper } = setupMocks();
+
expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true);
});
+
+ test("returns true when claim and coverage is selected, regardless of other prerequisites", () => {
+ const { wrapper } = setupMocks();
+ store.getters.order.payment.isClaimAndCoverage = true;
+ store.getters.order.serviceLocation.zipCode = null;
+
+ expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true);
+ });
+
+ test("returns false when service zip info is missing", () => {
+ const { wrapper } = setupMocks();
+ store.getters.order.serviceLocation.zipCode = null;
+
+ expect(wrapper.vm.arePagePrerequisitesValid()).toBe(false);
+ });
+
+ test("returns false when glass parts or repair info is missing", () => {
+ const { wrapper } = setupMocks();
+ store.getters.order.damage.isRepair = false;
+ store.getters.order.lineItems.glassParts = [];
+
+ expect(wrapper.vm.arePagePrerequisitesValid()).toBe(false);
+ });
+
+ test("returns false when insurance info is invalid", () => {
+ const { wrapper } = setupMocks();
+ store.getters.order.payment.isInsurance = null;
+
+ expect(wrapper.vm.arePagePrerequisitesValid()).toBe(false);
+ });
});
});
+function createValidOrderForCoverageStatement() {
+ return {
+ payment: {
+ isClaimAndCoverage: false,
+ isInsurance: false,
+ parentAccountNumber: applicationConfig.CASH_PARENT_ACCOUNT_NUMBER,
+ insuranceCoverage: {
+ isVerified: true,
+ coverageStatus: coverageStatus.VERIFIED,
+ },
+ },
+ policy: {
+ currentDeductible: 1000,
+ policyNumber: "POL123",
+ isItac: false,
+ isNoComp: false,
+ },
+ serviceLocation: {
+ zipCode: "00000",
+ zipCodeCtu: "00000",
+ },
+ damage: {
+ isRepair: false,
+ },
+ lineItems: {
+ glassParts: [{ id: "part1", partType: "WINDSHIELD" }],
+ promos: [],
+ supportingItems: [],
+ vaps: [],
+ },
+ };
+}
+
function setupMocks() {
const mockCmsContent = {
FunnelHeaderWidget: { Text: "Header" },
@@ -55,6 +122,10 @@ function setupMocks() {
fetchCmsContentForPage.mockResolvedValue(mockCmsContent);
settleAllPromises.mockResolvedValue({ cmsContent: mockCmsContent });
+ store.getters = {
+ order: createValidOrderForCoverageStatement(),
+ };
+
const mountOptions = getMountOptions({
route: { name: "duplicate-check", query: {}, params: {} },
router: {
@@ -63,19 +134,7 @@ function setupMocks() {
navigateWithoutSaving: jest.fn(),
},
store: {
- getters: {
- order: {
- policy: {
- currentDeductible: 1000,
- },
- lineItems: {
- glassParts: [],
- promos: [],
- supportingItems: [],
- vaps: [],
- },
- },
- },
+ getters: store.getters,
},
mixins: [
{
diff --git a/src/layouts/coverage-statement/coverage-statement.vue b/src/layouts/coverage-statement/coverage-statement.vue
index eec23bfee..e39f4a65b 100644
--- a/src/layouts/coverage-statement/coverage-statement.vue
+++ b/src/layouts/coverage-statement/coverage-statement.vue
@@ -87,6 +87,12 @@ import contentGroupModal from "@/fmg-components/content-group-modal/content-grou
import textBlock from "@/digital-components/text-block/text-block.vue";
import { debugLog } from "@/helpers/debug-log-helper";
import { loadSessionIfPresent } from "@/helpers/heritage-integration/order-helper";
+import {
+ flushPagePrereqsLogs,
+ hasServiceZipInfo,
+ hasInsuranceInfo,
+ hasGlassPartsOrRepairInfo,
+} from "@/helpers/page-prerequisites-helper.js";
export default {
name: "coverage-statement",
mixins: [],
@@ -199,7 +205,20 @@ export default {
);
},
arePagePrerequisitesValid() {
- return true;
+ const order = store.getters.order;
+ if (order.payment.isClaimAndCoverage) {
+ return true; //TODO: Future card when C&C flow is implemented
+ }
+
+ const logQueue = [];
+
+ const serviceZip = hasServiceZipInfo(order, logQueue);
+ const glassPartsOrRepair = hasGlassPartsOrRepairInfo(order, logQueue);
+ const insuranceInfo = hasInsuranceInfo(order, logQueue);
+ const preReqResult = serviceZip && glassPartsOrRepair && insuranceInfo;
+
+ flushPagePrereqsLogs("coverage-statement.vue", preReqResult, logQueue);
+ return preReqResult;
},
openModalAction(modalName) {
this.$refs[modalName]?.openModal();
diff --git a/src/layouts/insurance-details/insurance-details.vue b/src/layouts/insurance-details/insurance-details.vue
index 6414d33de..6142b86d1 100644
--- a/src/layouts/insurance-details/insurance-details.vue
+++ b/src/layouts/insurance-details/insurance-details.vue
@@ -46,11 +46,12 @@
+ inputId="claimNumber"
+ maxLength="8"
+ validationRules="claim-number-numeric|claim-number-format" />
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
-import { parentAccountNumbers } from "@/constants/insurance";
import buttonMain from "@/ux-components/button-main/button-main";
import { Form } from "vee-validate";
@@ -98,9 +97,6 @@ export default {
displayDisclaimerText() {
return this.disclaimerText !== null && this.disclaimerText !== "";
},
- parentAccountNumbers() {
- return parentAccountNumbers;
- },
},
methods: {
diff --git a/src/layouts/policy-info/policy-info.spec.js b/src/layouts/policy-info/policy-info.spec.js
index b86cce714..03929395d 100644
--- a/src/layouts/policy-info/policy-info.spec.js
+++ b/src/layouts/policy-info/policy-info.spec.js
@@ -1,8 +1,11 @@
import policyInfo from "@/layouts/policy-info/policy-info.vue";
-import { shallowMount } from "@vue/test-utils";
+import { shallowMount, mount, flushPromises } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
+import { validate, Form } from "vee-validate";
+import { errorMessages } from "@/constants/error-messages";
+import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
// Mock our module for promises.
jest.mock("@/helpers/layout-helper.js", () => ({
@@ -43,6 +46,391 @@ describe("policy-info.vue", () => {
expect(wrapper.vm.arePagePrerequisitesValid()).toBe(true);
});
});
+
+ describe("forwardButtonAction", () => {
+ // Regression coverage: clicking Continue must explicitly (re)validate the form
+ // rather than assuming meta.valid/isForwardActionDisabled already caught everything,
+ // since that was the actual gap behind "I typed an invalid policy number, clicked
+ // Continue, and the message never appeared".
+ test("does not save or navigate when the form is invalid", async () => {
+ const { wrapper } = setupMocks();
+ wrapper.vm.isFormValid = jest.fn().mockResolvedValue(false);
+
+ await wrapper.vm.forwardButtonAction();
+
+ expect(wrapper.vm.isFormValid).toHaveBeenCalledWith(wrapper.vm.$refs.theForm);
+ expect(wrapper.vm.dispatchStoreAction).not.toHaveBeenCalled();
+ expect(wrapper.vm.$router.navigateWithSaving).not.toHaveBeenCalled();
+ });
+
+ test("saves and navigates when the form is valid", async () => {
+ const { wrapper } = setupMocks();
+ wrapper.vm.isFormValid = jest.fn().mockResolvedValue(true);
+
+ await wrapper.vm.forwardButtonAction();
+
+ expect(wrapper.vm.dispatchStoreAction).toHaveBeenCalled();
+ expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled();
+ });
+ });
+
+ describe("policy-number-format validation rule", () => {
+ test("passes when no PolicyNumberValidationWidget config is returned", async () => {
+ const { wrapper } = await setupMocksWithPolicyNumberValidationWidget(undefined);
+
+ const result = await validate("AB1", "policy-number-format");
+
+ expect(result.valid).toBe(true);
+ expect(wrapper.vm).toBeTruthy();
+ });
+
+ test("passes when field is empty (required rule handles that case)", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ });
+
+ const result = await validate("", "policy-number-format");
+
+ expect(result.valid).toBe(true);
+ });
+
+ test("fails when value is shorter than minimumLength", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ });
+
+ const result = await validate("ABCDEF", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ });
+
+ test("fails when value is longer than maximumLength", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ });
+
+ const result = await validate("ABCDEFGHIJ", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ });
+
+ test("passes for a valid Alpha value within length bounds", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ });
+
+ const result = await validate("ABCDEFGH", "policy-number-format");
+
+ expect(result.valid).toBe(true);
+ });
+
+ test("fails for an Alpha rule when value contains digits", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ });
+
+ const result = await validate("ABCDEFG1", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ });
+
+ test("passes for a valid numeric value", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 6,
+ maximumLength: 10,
+ validationRuleType: "numeric",
+ });
+
+ const result = await validate("123456", "policy-number-format");
+
+ expect(result.valid).toBe(true);
+ });
+
+ test("fails for a numeric rule when value contains letters", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 6,
+ maximumLength: 10,
+ validationRuleType: "numeric",
+ });
+
+ const result = await validate("12345A", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ });
+
+ test("passes for a valid AlphaNumeric value", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 9,
+ maximumLength: 12,
+ validationRuleType: "AlphaNumeric",
+ });
+
+ const result = await validate("123456789a", "policy-number-format");
+
+ expect(result.valid).toBe(true);
+ });
+
+ test("fails for an AlphaNumeric rule when value contains a space", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 9,
+ maximumLength: 12,
+ validationRuleType: "AlphaNumeric",
+ });
+
+ const result = await validate("12345678 a", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ });
+
+ test("fails for an AlphaNumeric rule when value contains a dash", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 9,
+ maximumLength: 12,
+ validationRuleType: "AlphaNumeric",
+ });
+
+ const result = await validate("12345678-a", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ });
+
+ test("passes for a valid AlphaNumericWithSpaces value", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 6,
+ maximumLength: 12,
+ validationRuleType: "AlphaNumericWithSpaces",
+ });
+
+ const result = await validate("ABC 123", "policy-number-format");
+
+ expect(result.valid).toBe(true);
+ });
+
+ test("fails for an AlphaNumericWithSpaces rule when value contains other symbols", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 6,
+ maximumLength: 12,
+ validationRuleType: "AlphaNumericWithSpaces",
+ });
+
+ const result = await validate("ABC-123", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ });
+
+ test("uses the CMS-provided minValidationMessage when value is shorter than minimumLength", async () => {
+ const minValidationMessage = "Policy number should be at least 8 characters.";
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ minValidationMessage,
+ maxValidationMessage: "Policy number must be 9 characters or less.",
+ });
+
+ const result = await validate("ABCDEF", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toEqual([minValidationMessage]);
+ });
+
+ test("uses the CMS-provided maxValidationMessage when value is longer than maximumLength", async () => {
+ const maxValidationMessage = "Policy number must be 9 characters or less.";
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ minValidationMessage: "Policy number should be at least 8 characters.",
+ maxValidationMessage,
+ });
+
+ const result = await validate("ABCDEFGHIJ", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toEqual([maxValidationMessage]);
+ });
+
+ test("falls back to the default error message when minValidationMessage is not provided", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ });
+
+ const result = await validate("ABCDEF", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toEqual([errorMessages.POLICY_NUMBER_FORMAT]);
+ });
+
+ test("falls back to the default error message when maxValidationMessage is not provided", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ });
+
+ const result = await validate("ABCDEFGHIJ", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toEqual([errorMessages.POLICY_NUMBER_FORMAT]);
+ });
+
+ test("uses the CMS-provided invalidCharMessage for a pattern-only failure (value within length bounds)", async () => {
+ const invalidCharMessage = "Policy number may only contain letters.";
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ minValidationMessage: "Policy number should be at least 8 characters.",
+ maxValidationMessage: "Policy number must be 9 characters or less.",
+ invalidCharMessage,
+ });
+
+ const result = await validate("ABCDEFG1", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toEqual([invalidCharMessage]);
+ });
+
+ test("falls back to the default error message for a pattern-only failure when invalidCharMessage is not provided", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ minValidationMessage: "Policy number should be at least 8 characters.",
+ maxValidationMessage: "Policy number must be 9 characters or less.",
+ });
+
+ const result = await validate("ABCDEFG1", "policy-number-format");
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toEqual([errorMessages.POLICY_NUMBER_FORMAT]);
+ });
+ });
+
+ describe("policyNumber field end-to-end (via textbox-question)", () => {
+ // Regression test for a reported bug: typing a value that never passes through a
+ // "valid" intermediate state (e.g. "a@2" against an 8-9 char Alpha-only rule) left
+ // vee-validate's field `meta.valid` stuck at `true`, since textbox-question's value
+ // watcher only synced/validated the field when a pre-check said the value was valid.
+ // In the app this drives the Forward button's disabled state
+ // (:isForwardActionDisabled="!meta.valid"), so the bug meant a user could type an
+ // invalid policy number and the form would not flag it as invalid at all - it only
+ // "self-corrected" via the input's native `change` event on blur, which doesn't fire
+ // from keystrokes alone. This test dispatches only "input" events (like real typing)
+ // and intentionally does NOT fire a native "change"/blur event, to isolate the
+ // watcher's own behavior from that unrelated safety net.
+ test("marks the field invalid as soon as an unrecoverable value is typed, without waiting for blur", async () => {
+ await setupMocksWithPolicyNumberValidationWidget({
+ minimumLength: 8,
+ maximumLength: 9,
+ validationRuleType: "Alpha",
+ minValidationMessage: "Policy number should be at least 8 characters.",
+ maxValidationMessage: "Policy number must be 9 characters or less.",
+ });
+
+ const wrapper = mount(textboxQuestion, {
+ global: {
+ directives: { maska: jest.fn() },
+ mixins: [
+ {
+ methods: {
+ getCmsContent: jest.fn(() => ""),
+ },
+ },
+ ],
+ },
+ props: {
+ // Start from a value that already satisfies the rule, so meta.valid
+ // begins as true (isolating the watcher's behavior from the
+ // required-on-empty check, which is independently already true on
+ // mount regardless of this bug).
+ modelValue: "ABCDEFGH",
+ inputId: "policyNumber",
+ validationRules: "policy-number-required|policy-number-format",
+ "onUpdate:modelValue": (newValue) => wrapper.setProps({ modelValue: newValue }),
+ },
+ });
+ await flushPromises();
+ expect(wrapper.vm.meta.valid).toBe(true);
+
+ const input = wrapper.find("input");
+ input.element.value = "a@2";
+ await input.trigger("input"); // simulate typing only, no blur/change
+ await flushPromises();
+
+ expect(wrapper.vm.meta.valid).toBe(false);
+ });
+
+ // Reproduces "I typed a@2 and clicked Continue, and the message never appeared".
+ // Clicking Continue submits the real
+ `,
+ };
+
+ const wrapper = mount(harness, {
+ global: {
+ directives: { maska: jest.fn() },
+ mixins: [
+ {
+ methods: {
+ getCmsContent: jest.fn(() => ""),
+ },
+ },
+ ],
+ },
+ });
+ await flushPromises();
+
+ const input = wrapper.find("input");
+ input.element.value = "a@2";
+ await input.trigger("input"); // simulate typing only, no blur/change
+ await flushPromises();
+
+ // Mirrors clicking the real "Continue" button, which submits the