Files
pleno-vue/tests/unit/date-period-selector.spec.js
T

505 lines
16 KiB
JavaScript

// @vitest-environment jsdom
import { defineComponent } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
const sharedState = vi.hoisted(() => ({
width: null,
}));
vi.mock("@vueuse/core", async () => {
const { ref: vueRef } = await import("vue");
const width = vueRef(1024);
sharedState.width = width;
return {
useWindowSize: () => ({ width }),
};
});
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
functions: {
device: {
isMobile: () => false,
},
ucFirst: (value) => String(value).charAt(0).toUpperCase() + String(value).slice(1),
},
objects: {
global: {
language: {
text: {
today: "Today",
anytime: "Anytime",
yesterday: "Yesterday",
last_7_days: "Last 7 days",
this_week: "This week",
last_week: "Last week",
this_month: "This month",
last_month: "Last month",
other_month: "Other month",
same_week_last_year: "Same week last year",
same_month_last_year: "Same month last year",
date_from: "From",
date_to: "To",
month: "Month",
year: "Year",
},
},
},
},
},
}));
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
const messages = {
en: {
common: {
to: "To",
today: "Today",
yesterday: "Yesterday",
},
global: {
other: "Other",
text: {
this_week: "This week",
last_week: "Last week",
last_7_days: "Last 7 days",
this_month: "This month",
last_month: "Last month",
other_month: "Other month",
same_week_last_year: "Same week last year",
same_month_last_year: "Same month last year",
},
anytime: "Anytime",
},
date_period: {
labels: {
period: "Period",
select_period: "Select period",
advanced_period: "Advanced period",
select_month: "Select month",
update_selection: "Update selection",
to: "To",
no_date_selection: "No date selected",
},
messages: {
invalid_selection: "Invalid",
multi_month: "Multiple months",
partial_month_prefix: "Need a full month?",
partial_month_action: "Use the full selected month",
partial_month_tooltip: "Set the range to the first and last day of the selected month",
partial_month_suffix: "for reporting.",
},
shortcuts: {
anytime: "Anytime",
today: "Today",
yesterday: "Yesterday",
this_week: "This week",
last_week: "Last week",
last_seven_days: "Last 7 days",
this_month: "This month",
last_month: "Last month",
other_month: "Other month",
same_week_last_year: "Same week last year",
same_month_last_year: "Same month last year",
},
},
},
};
const dropdownStubs = {
"b-dropdown": defineComponent({
name: "BDropdownStub",
template: "<div><slot name='trigger' /><slot /></div>",
}),
"b-dropdown-item": defineComponent({
name: "BDropdownItemStub",
template: "<div><slot /></div>",
}),
BTooltip: defineComponent({
name: "BTooltipStub",
template: "<span><slot /></span>",
}),
};
const parseDateValue = (value) => {
if (!value) {
return null;
}
const [year, month, day] = String(value).split("-").map(Number);
return new Date(year, month - 1, day);
};
const formatDateValue = (value) => {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return "";
}
return formatLocalDate(value);
};
const BuefyDateFieldStub = defineComponent({
name: "BuefyDateField",
props: {
modelValue: {
type: Date,
default: null,
},
dataTestid: {
type: String,
default: "",
},
clearable: Boolean,
},
emits: ["update:modelValue", "change"],
methods: {
formatDateValue,
update(value) {
const nextDate = parseDateValue(value);
this.$emit("update:modelValue", nextDate);
this.$emit("change", nextDate);
},
},
template:
"<input :data-testid='dataTestid' :data-clearable='String(clearable)' :value='formatDateValue(modelValue)' @input='update($event.target.value)' />",
});
const BuefyMonthFieldStub = defineComponent({
name: "BuefyMonthField",
props: {
modelValue: {
type: Date,
default: null,
},
dataTestid: {
type: String,
default: "",
},
clearable: Boolean,
inline: Boolean,
},
emits: ["update:modelValue", "change"],
methods: {
update(value) {
if (!value) {
this.$emit("update:modelValue", null);
this.$emit("change", null);
return;
}
const [year, month] = String(value).split("-").map(Number);
const nextDate = new Date(year, month - 1, 1);
this.$emit("update:modelValue", nextDate);
this.$emit("change", nextDate);
},
format(value) {
return value instanceof Date ? `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}` : "";
},
},
template:
"<input :data-testid='dataTestid' :data-clearable='String(clearable)' :data-inline='String(inline)' :value='format(modelValue)' @input='update($event.target.value)' />",
});
const componentStubs = {
...dropdownStubs,
BuefyDateField: BuefyDateFieldStub,
BuefyMonthField: BuefyMonthFieldStub,
};
const formatLocalDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
describe("DatePeriodSelector mobile layout", () => {
beforeEach(() => {
sharedState.width.value = 480;
});
it("renders stacked mobile controls and hides advanced month and year selectors by default", async () => {
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
props: {
selection: {
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
},
onSelectionChange: vi.fn(),
visibility: {
showMonthSelector: true,
showYearSelector: true,
showUpdateButton: false,
},
},
global: {
stubs: componentStubs,
},
});
expect(wrapper.get("[data-testid='date-period-mobile-layout']").exists()).toBe(true);
expect(wrapper.get("[data-testid='date-period-shortcuts']").exists()).toBe(true);
expect(wrapper.get("[data-testid='date-period-start']").exists()).toBe(true);
expect(wrapper.get("[data-testid='date-period-end']").exists()).toBe(true);
expect(wrapper.findAll("select")).toHaveLength(1);
expect(wrapper.find("[data-testid='date-period-month']").exists()).toBe(false);
await wrapper.get("[data-testid='date-period-advanced-toggle']").trigger("click");
expect(wrapper.findAll("select")).toHaveLength(1);
expect(wrapper.get("[data-testid='date-period-month']").exists()).toBe(true);
expect(wrapper.get("[data-testid='date-period-month']").attributes("data-inline")).toBe("false");
});
it("preserves the update:selection and onSelectionChange contract on mobile", async () => {
const onSelectionChange = vi.fn();
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
props: {
selection: {
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
},
onSelectionChange,
visibility: {
showMonthSelector: true,
showYearSelector: true,
showUpdateButton: false,
},
},
global: {
stubs: componentStubs,
},
});
await wrapper.get("[data-testid='date-period-end']").setValue("2026-03-05");
const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0];
expect(formatLocalDate(emittedSelection.startDate)).toBe("2026-03-01");
expect(formatLocalDate(emittedSelection.endDate)).toBe("2026-03-05");
expect(onSelectionChange).toHaveBeenCalledTimes(1);
expect(formatLocalDate(onSelectionChange.mock.calls[0][0])).toBe("2026-03-01");
expect(formatLocalDate(onSelectionChange.mock.calls[0][1])).toBe("2026-03-05");
});
it("emits the exact clicked date for start and end inputs", async () => {
sharedState.width.value = 1024;
const onSelectionChange = vi.fn();
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
props: {
selection: {
startDate: new Date(2026, 2, 1, 0, 0, 0, 0),
endDate: new Date(2026, 2, 3, 23, 59, 59, 999),
},
onSelectionChange,
visibility: {
showUpdateButton: false,
},
},
global: {
stubs: componentStubs,
},
});
await wrapper.get("[data-testid='date-period-start']").setValue("2026-03-31");
await wrapper.get("[data-testid='date-period-end']").setValue("2026-04-02");
const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0];
expect(formatLocalDate(emittedSelection.startDate)).toBe("2026-03-31");
expect(formatLocalDate(emittedSelection.endDate)).toBe("2026-04-02");
expect(formatLocalDate(onSelectionChange.mock.calls.at(-1)[0])).toBe("2026-03-31");
expect(formatLocalDate(onSelectionChange.mock.calls.at(-1)[1])).toBe("2026-04-02");
});
});
describe("DatePeriodSelector month warning", () => {
beforeEach(() => {
sharedState.width.value = 1024;
});
it("sets the current month from its first day through its last day", async () => {
const onSelectionChange = vi.fn();
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
props: {
selection: {
startDate: new Date(2026, 4, 4, 12, 30, 0, 0),
endDate: new Date(2026, 4, 5, 15, 45, 0, 0),
},
onSelectionChange,
visibility: {
showUpdateButton: false,
},
},
global: {
stubs: componentStubs,
},
});
await wrapper.get("[data-testid='date-period-set-entire-month']").trigger("click");
const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0];
expect(emittedSelection).toBeTruthy();
expect(emittedSelection.startDate.getFullYear()).toBe(2026);
expect(emittedSelection.startDate.getMonth()).toBe(4);
expect(emittedSelection.startDate.getDate()).toBe(1);
expect(emittedSelection.startDate.getHours()).toBe(0);
expect(emittedSelection.startDate.getMinutes()).toBe(0);
expect(emittedSelection.startDate.getSeconds()).toBe(0);
expect(emittedSelection.startDate.getMilliseconds()).toBe(0);
expect(emittedSelection.endDate.getFullYear()).toBe(2026);
expect(emittedSelection.endDate.getMonth()).toBe(4);
expect(emittedSelection.endDate.getDate()).toBe(31);
expect(emittedSelection.endDate.getHours()).toBe(23);
expect(emittedSelection.endDate.getMinutes()).toBe(59);
expect(emittedSelection.endDate.getSeconds()).toBe(59);
expect(emittedSelection.endDate.getMilliseconds()).toBe(999);
expect(onSelectionChange).toHaveBeenCalledWith(emittedSelection.startDate, emittedSelection.endDate);
});
it("renders secondary shortcuts behind the Other dropdown", async () => {
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
props: {
selection: {
startDate: new Date(2026, 2, 1, 0, 0, 0, 0),
endDate: new Date(2026, 2, 3, 23, 59, 59, 999),
},
onSelectionChange: vi.fn(),
visibility: {
showUpdateButton: false,
},
},
global: {
stubs: componentStubs,
},
});
expect(wrapper.get("[data-testid='date-period-other-dropdown-trigger']").text()).toContain("Other");
expect(wrapper.get("[data-testid='date-period-other-this_month']").text()).toContain("This month");
expect(wrapper.get("[data-testid='date-period-other-same_month_last_year']").text()).toContain(
"Same month last year"
);
});
it("opens the month modal from Other month on mobile", async () => {
sharedState.width.value = 480;
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
props: {
selection: {
startDate: new Date(2026, 4, 4, 12, 30, 0, 0),
endDate: new Date(2026, 4, 5, 15, 45, 0, 0),
},
onSelectionChange: vi.fn(),
visibility: {
showUpdateButton: false,
showMonthSelector: true,
showYearSelector: true,
},
},
global: {
stubs: componentStubs,
},
});
await wrapper.get("[data-testid='date-period-other-dropdown-trigger']").trigger("click");
expect(wrapper.get("[data-testid='date-period-other-other_month']").text()).toContain("Other month");
expect(wrapper.find("[data-testid='date-period-month']").exists()).toBe(false);
await wrapper.get("[data-testid='date-period-other-other_month']").trigger("click");
expect(wrapper.get("[data-testid='date-period-other-month-modal']").exists()).toBe(true);
expect(wrapper.get("[data-testid='date-period-other-month-picker']").exists()).toBe(true);
expect(wrapper.get("[data-testid='date-period-other-month-picker']").attributes("data-inline")).toBe("true");
});
it("selects Anytime for empty ranges without highlighting Today", () => {
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
props: {
selection: {
startDate: null,
endDate: null,
},
onSelectionChange: vi.fn(),
allowEmptySelection: true,
visibility: {
showUpdateButton: false,
showStartDate: false,
showEndDate: false,
showToLabel: false,
showSelectionValidity: false,
showMonthSelector: false,
showYearSelector: false,
showMultipleMonthWarning: false,
},
},
global: {
stubs: componentStubs,
},
});
const buttons = wrapper.findAll("button").filter((button) => ["Anytime", "Today"].includes(button.text()));
const anytimeButton = buttons.find((button) => button.text() === "Anytime");
const todayButton = buttons.find((button) => button.text() === "Today");
expect(anytimeButton.classes()).toContain("is-info");
expect(todayButton.classes()).not.toContain("is-info");
expect(wrapper.find(".level").exists()).toBe(false);
expect(wrapper.get("[data-testid='date-period-shortcut-anytime']").exists()).toBe(true);
});
it("hides empty range controls unless the selector is optional", () => {
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
props: {
selection: {
startDate: null,
endDate: null,
},
onSelectionChange: vi.fn(),
visibility: {
showUpdateButton: false,
},
},
global: {
stubs: componentStubs,
},
});
expect(wrapper.text()).not.toContain("Anytime");
expect(wrapper.get("[data-testid='date-period-start']").attributes("data-clearable")).toBe("false");
expect(wrapper.get("[data-testid='date-period-end']").attributes("data-clearable")).toBe("false");
});
it("marks date fields as clearable when optional", () => {
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
props: {
selection: {
startDate: new Date(2026, 6, 1),
endDate: new Date(2026, 6, 5),
},
onSelectionChange: vi.fn(),
allowEmptySelection: true,
visibility: {
showUpdateButton: false,
},
},
global: {
stubs: componentStubs,
},
});
expect(wrapper.text()).toContain("Anytime");
expect(wrapper.get("[data-testid='date-period-start']").attributes("data-clearable")).toBe("true");
expect(wrapper.get("[data-testid='date-period-end']").attributes("data-clearable")).toBe("true");
});
});