diff --git a/apps/desktop/src/components/generate/params/DateTimeParams.vue b/apps/desktop/src/components/generate/params/DateTimeParams.vue index 86dca5dfb9..67fd7b0212 100644 --- a/apps/desktop/src/components/generate/params/DateTimeParams.vue +++ b/apps/desktop/src/components/generate/params/DateTimeParams.vue @@ -4,6 +4,7 @@ import { useI18n } from "vue-i18n"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { RefreshCw } from "@lucide/vue"; +import { generateTimeInRange } from "@/lib/dataGrid/generatorTimeRange"; import type { GeneratorParams } from "@/lib/dataGrid/dataGenerate"; const props = defineProps<{ params: GeneratorParams }>(); @@ -30,12 +31,7 @@ const previewVal = computed(() => { if (!props.params.allDay) { return formatLocalDate(d); } - const sh = parseInt(props.params.startTime ?? "00") || 0; - const sm = parseInt(props.params.endTime ?? "23") || 23; - const h = Math.floor(Math.random() * (sm - sh + 1)) + sh; - const m = Math.floor(Math.random() * 60); - const s = Math.floor(Math.random() * 60); - return `${formatLocalDate(d)} ${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${formatLocalDate(d)} ${generateTimeInRange(props.params.startTime, props.params.endTime)}`; }); const weekdayLabels = computed(() => (["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const).map((d) => t(`dataGenerate.weekdayShort.${d}`))); diff --git a/apps/desktop/src/components/generate/params/TimeParams.vue b/apps/desktop/src/components/generate/params/TimeParams.vue index 8834da3eb3..3924d47a1d 100644 --- a/apps/desktop/src/components/generate/params/TimeParams.vue +++ b/apps/desktop/src/components/generate/params/TimeParams.vue @@ -4,6 +4,7 @@ import { useI18n } from "vue-i18n"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { RefreshCw } from "@lucide/vue"; +import { generateTimeInRange } from "@/lib/dataGrid/generatorTimeRange"; import type { GeneratorParams } from "@/lib/dataGrid/dataGenerate"; const props = defineProps<{ params: GeneratorParams }>(); @@ -12,17 +13,7 @@ const { t } = useI18n(); const previewVal = computed(() => { void previewKey.value; - let sh = 0, - sm = 23; - if (!props.params.allDay) { - sh = parseInt(props.params.startTime ?? "00") || 0; - sm = parseInt(props.params.endTime ?? "23") || 23; - } - const h = Math.floor(Math.random() * (sm - sh + 1)) + sh; - const m = Math.floor(Math.random() * 60); - const s = Math.floor(Math.random() * 60); - const pad = (n: number) => String(n).padStart(2, "0"); - return `${pad(h)}:${pad(m)}:${pad(s)}`; + return props.params.allDay ? generateTimeInRange() : generateTimeInRange(props.params.startTime, props.params.endTime); }); const previewKey = ref(0); diff --git a/apps/desktop/src/components/generate/params/__tests__/TimeRangePreview.spec.ts b/apps/desktop/src/components/generate/params/__tests__/TimeRangePreview.spec.ts new file mode 100644 index 0000000000..acde8e0ce8 --- /dev/null +++ b/apps/desktop/src/components/generate/params/__tests__/TimeRangePreview.spec.ts @@ -0,0 +1,48 @@ +// @vitest-environment happy-dom +import { createApp, h, nextTick, reactive } from "vue"; +import { createI18n } from "vue-i18n"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import TimeParams from "../TimeParams.vue"; +import DateTimeParams from "../DateTimeParams.vue"; +import { generateValue, type GeneratorParams } from "@/lib/dataGrid/dataGenerate"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); + vi.restoreAllMocks(); +}); + +describe.each(["time", "datetime"] as const)("%s range preview", (kind) => { + it("matches generation and updates when minute bounds are edited", async () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const params = reactive({ allDay: kind === "datetime", start: "2026-10-17", end: "2026-10-17", startTime: "09:15", endTime: "09:20" }); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp({ render: () => h(kind === "time" ? TimeParams : DateTimeParams, { params }) }); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + cleanups.push(() => { + app.unmount(); + host.remove(); + }); + const generated = () => String(generateValue("t", kind, kind, 1, params)); + expect(generated()).toContain("09:17:30"); + expect(host.textContent).toContain(generated()); + + const inputs = host.querySelectorAll('input[type="time"]'); + expect(inputs).toHaveLength(2); + for (const input of inputs) { + input.value = "00:00"; + input.dispatchEvent(new Event("change", { bubbles: true })); + } + await nextTick(); + expect(generated()).toContain("00:00:00"); + expect(host.textContent).toContain(generated()); + const random = vi.spyOn(Math, "random"); + random.mockClear(); + Array.from(host.querySelectorAll("button")).at(-1)?.click(); + await nextTick(); + expect(random).toHaveBeenCalled(); + expect(host.textContent).toContain(generated()); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/__tests__/dataGenerateTime.spec.ts b/apps/desktop/src/lib/dataGrid/__tests__/dataGenerateTime.spec.ts new file mode 100644 index 0000000000..7fdaade17b --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/__tests__/dataGenerateTime.spec.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { defaultGeneratorParams, generateValue } from "@/lib/dataGrid/dataGenerate"; + +// Exercise the production generator, not a copy of the time-range algorithm. +afterEach(() => vi.restoreAllMocks()); + +describe("time generation bounds", () => { + it.each([ + ["00:00", "00:00", "00:00:00", "00:00:00", "00:00:00"], + ["09:15", "09:20", "09:15:00", "09:17:30", "09:20:00"], + ["09:15:10", "09:15:12", "09:15:10", "09:15:11", "09:15:12"], + ["09", "09", "09:00:00", "09:30:00", "09:59:59"], + ["09:20", "09:15", "09:15:00", "09:17:30", "09:20:00"], + [undefined, undefined, "00:00:00", "12:00:00", "23:59:59"], + ["invalid", "25:90", "00:00:00", "12:00:00", "23:59:59"], + ])("honors %s..%s for time and daily datetime windows", (startTime, endTime, first, middle, last) => { + for (const [random, expected] of [ + [0, first], + [0.5, middle], + [1 - Number.EPSILON, last], + ] as const) { + vi.spyOn(Math, "random").mockReturnValue(random); + expect(generateValue("t", "time", "time", 1, { allDay: false, startTime, endTime })).toBe(expected); + expect(generateValue("t", "datetime", "datetime", 1, { allDay: true, start: "2026-10-17", end: "2026-10-17", startTime, endTime })).toBe(`2026-10-17 ${expected}`); + } + }); + + it("ignores custom bounds in the time generator's all-day mode", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + expect(generateValue("t", "time", "time", 1, { allDay: true, startTime: "09:15", endTime: "09:20" })).toBe("12:00:00"); + }); + + it("uses valid time input values for new configurations", () => { + const params = defaultGeneratorParams("t", { dataType: "time", columnDefault: null }, "time"); + expect(params.startTime).toBe("00:00"); + expect(params.endTime).toBe("23:59"); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGenerate.ts b/apps/desktop/src/lib/dataGrid/dataGenerate.ts index ece98d2b0a..7b6339c86c 100644 --- a/apps/desktop/src/lib/dataGrid/dataGenerate.ts +++ b/apps/desktop/src/lib/dataGrid/dataGenerate.ts @@ -1,5 +1,6 @@ import type { DatabaseType } from "@/types/database"; import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql"; +import { generateTimeInRange } from "@/lib/dataGrid/generatorTimeRange"; import { generateRegexValue } from "@/lib/dataGrid/regexGenerate"; export interface GeneratorNode { @@ -1434,8 +1435,8 @@ function buildDefaultGeneratorParams(_columnName: string, attrs: ColumnAttrs, ge } if (generatorKey === "time") { - params.startTime = "00"; - params.endTime = "23"; + params.startTime = "00:00"; + params.endTime = "23:59"; return params; } @@ -1649,13 +1650,7 @@ export function generateValue(columnName: string, dataType: string, generatorKey if (params?.allDay) { const [rangeStart, rangeEnd] = ensureRange(start, end); const d = new Date(rangeStart.getTime() + Math.random() * (rangeEnd.getTime() - rangeStart.getTime())); - const sh = parseInt(params.startTime ?? "00") || 0; - const sm = parseInt(params.endTime ?? "23") || 23; - const h = Math.floor(Math.random() * (sm - sh + 1)) + sh; - const m = Math.floor(Math.random() * 60); - const s = Math.floor(Math.random() * 60); - const pad = (n: number) => String(n).padStart(2, "0"); - return `${formatLocalDate(d)} ${pad(h)}:${pad(m)}:${pad(s)}`; + return `${formatLocalDate(d)} ${generateTimeInRange(params.startTime, params.endTime)}`; } return randTimestamp(start, end); } @@ -1665,17 +1660,7 @@ export function generateValue(columnName: string, dataType: string, generatorKey return randDate(start, end); } if (key === "time") { - let sh = 0, - sm = 23; - if (!params?.allDay) { - sh = parseInt(params?.startTime ?? "00") || 0; - sm = parseInt(params?.endTime ?? "23") || 23; - } - const h = Math.floor(Math.random() * (sm - sh + 1)) + sh; - const m = Math.floor(Math.random() * 60); - const s = Math.floor(Math.random() * 60); - const pad = (n: number) => String(n).padStart(2, "0"); - return `${pad(h)}:${pad(m)}:${pad(s)}`; + return params?.allDay ? generateTimeInRange() : generateTimeInRange(params?.startTime, params?.endTime); } if (key === "enum") { const vals = (params?.values ?? "") diff --git a/apps/desktop/src/lib/dataGrid/generatorTimeRange.ts b/apps/desktop/src/lib/dataGrid/generatorTimeRange.ts new file mode 100644 index 0000000000..ee4b30618f --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/generatorTimeRange.ts @@ -0,0 +1,23 @@ +const SECONDS_PER_DAY = 24 * 60 * 60; + +function timeBoundSeconds(value: string | undefined, upper: boolean): number { + const fallback = upper ? SECONDS_PER_DAY - 1 : 0; + const match = /^(\d{1,2})(?::(\d{2})(?::(\d{2}))?)?$/.exec(value?.trim() ?? ""); + if (!match) return fallback; + const hour = Number(match[1]); + // Older saved configurations contain hours only; retain their whole-hour + // upper bound. Values from the time inputs are exact clock-time bounds. + const minute = match[2] === undefined ? (upper ? 59 : 0) : Number(match[2]); + const second = match[2] === undefined ? (upper ? 59 : 0) : Number(match[3] ?? 0); + if (hour > 23 || minute > 59 || second > 59) return fallback; + return hour * 3600 + minute * 60 + second; +} + +export function generateTimeInRange(start?: string, end?: string): string { + const startSeconds = timeBoundSeconds(start, false); + const endSeconds = timeBoundSeconds(end, true); + const lower = Math.min(startSeconds, endSeconds); + const upper = Math.max(startSeconds, endSeconds); + const seconds = lower + Math.floor(Math.random() * (upper - lower + 1)); + return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), seconds % 60].map((part) => String(part).padStart(2, "0")).join(":"); +} diff --git a/docs/assets/issue-12553/README.md b/docs/assets/issue-12553/README.md new file mode 100644 index 0000000000..02a8d67cfd --- /dev/null +++ b/docs/assets/issue-12553/README.md @@ -0,0 +1,11 @@ +# Time-range generator regression preview + +`time-range-preview.png` shows the production `TimeParams.vue` and +`DateTimeParams.vue` components mounted in an isolated local Vite harness, with +sample rows produced by the production `generateValue` function alongside them. +It is a component preview, not a screenshot of a live database operation. + +The three examples cover midnight (`00:00` to `00:00`), a minute-level time +window (`09:15` to `09:20`), and the same daily window for datetime generation. +The automated generation and mounted-component tests cover input changes, +refresh, endpoint selection, legacy hour-only configurations, and all-day mode. diff --git a/docs/assets/issue-12553/time-range-preview.png b/docs/assets/issue-12553/time-range-preview.png new file mode 100644 index 0000000000..3293904531 Binary files /dev/null and b/docs/assets/issue-12553/time-range-preview.png differ