test(web): cover multi-view resolution and scheduling interactions
This commit is contained in:
parent
afab67a38b
commit
b2c946b08d
@ -147,6 +147,7 @@ describe("Workspace Objects API", () => {
|
||||
},
|
||||
],
|
||||
activeView: "Important",
|
||||
viewSettings: undefined,
|
||||
});
|
||||
|
||||
let queryCall = 0;
|
||||
|
||||
156
apps/web/app/components/workspace/object-calendar.test.ts
Normal file
156
apps/web/app/components/workspace/object-calendar.test.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, format,
|
||||
parseISO, addDays,
|
||||
} from "date-fns";
|
||||
|
||||
/**
|
||||
* Pure calendar grid computation extracted for testing.
|
||||
* These functions mirror the logic inside ObjectCalendar's MonthView.
|
||||
*/
|
||||
|
||||
function buildMonthGrid(date: Date): { days: Date[]; weekCount: number } {
|
||||
const monthStart = startOfMonth(date);
|
||||
const monthEnd = endOfMonth(date);
|
||||
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
const days = eachDayOfInterval({ start: calStart, end: calEnd });
|
||||
return { days, weekCount: days.length / 7 };
|
||||
}
|
||||
|
||||
function groupEventsByDay(
|
||||
events: { id: string; date: Date; endDate?: Date }[],
|
||||
): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const ev of events) {
|
||||
const key = format(ev.date, "yyyy-MM-dd");
|
||||
if (!map.has(key)) {map.set(key, []);}
|
||||
map.get(key)!.push(ev.id);
|
||||
if (ev.endDate && format(ev.date, "yyyy-MM-dd") !== format(ev.endDate, "yyyy-MM-dd")) {
|
||||
const spanDays = eachDayOfInterval({ start: addDays(ev.date, 1), end: ev.endDate });
|
||||
for (const d of spanDays) {
|
||||
const dk = format(d, "yyyy-MM-dd");
|
||||
if (!map.has(dk)) {map.set(dk, []);}
|
||||
map.get(dk)!.push(ev.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Month grid generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildMonthGrid", () => {
|
||||
it("always produces a multiple of 7 days (grid must be rectangular)", () => {
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const { days } = buildMonthGrid(new Date(2026, m, 15));
|
||||
expect(days.length % 7).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes padding days from previous and next months (prevents empty cells)", () => {
|
||||
const { days } = buildMonthGrid(new Date(2026, 2, 15)); // March 2026
|
||||
const marchStart = startOfMonth(new Date(2026, 2, 15));
|
||||
const firstDay = days[0];
|
||||
const lastDay = days[days.length - 1];
|
||||
|
||||
expect(firstDay <= marchStart).toBe(true);
|
||||
expect(lastDay >= endOfMonth(new Date(2026, 2, 15))).toBe(true);
|
||||
});
|
||||
|
||||
it("starts grid on Monday (weekStartsOn: 1)", () => {
|
||||
const { days } = buildMonthGrid(new Date(2026, 0, 15)); // January 2026
|
||||
expect(format(days[0], "EEEE")).toBe("Monday");
|
||||
});
|
||||
|
||||
it("ends grid on Sunday", () => {
|
||||
const { days } = buildMonthGrid(new Date(2026, 5, 15)); // June 2026
|
||||
expect(format(days[days.length - 1], "EEEE")).toBe("Sunday");
|
||||
});
|
||||
|
||||
it("produces 4-6 weeks for any month (standard calendar range)", () => {
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const { weekCount } = buildMonthGrid(new Date(2026, m, 1));
|
||||
expect(weekCount).toBeGreaterThanOrEqual(4);
|
||||
expect(weekCount).toBeLessThanOrEqual(6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event grouping by day
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("groupEventsByDay", () => {
|
||||
it("groups single-day events by their date key", () => {
|
||||
const events = [
|
||||
{ id: "a", date: parseISO("2026-03-10") },
|
||||
{ id: "b", date: parseISO("2026-03-10") },
|
||||
{ id: "c", date: parseISO("2026-03-11") },
|
||||
];
|
||||
const grouped = groupEventsByDay(events);
|
||||
expect(grouped.get("2026-03-10")).toEqual(["a", "b"]);
|
||||
expect(grouped.get("2026-03-11")).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("spans multi-day events across all covered days (prevents disappearing events)", () => {
|
||||
const events = [
|
||||
{
|
||||
id: "multiday",
|
||||
date: parseISO("2026-03-10"),
|
||||
endDate: parseISO("2026-03-13"),
|
||||
},
|
||||
];
|
||||
const grouped = groupEventsByDay(events);
|
||||
expect(grouped.get("2026-03-10")).toContain("multiday");
|
||||
expect(grouped.get("2026-03-11")).toContain("multiday");
|
||||
expect(grouped.get("2026-03-12")).toContain("multiday");
|
||||
expect(grouped.get("2026-03-13")).toContain("multiday");
|
||||
});
|
||||
|
||||
it("does not span same-day events (endDate === date)", () => {
|
||||
const events = [
|
||||
{
|
||||
id: "sameday",
|
||||
date: parseISO("2026-03-10"),
|
||||
endDate: parseISO("2026-03-10"),
|
||||
},
|
||||
];
|
||||
const grouped = groupEventsByDay(events);
|
||||
expect(grouped.get("2026-03-10")).toEqual(["sameday"]);
|
||||
expect(grouped.has("2026-03-11")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty map for no events", () => {
|
||||
const grouped = groupEventsByDay([]);
|
||||
expect(grouped.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date parsing edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("calendar date parsing", () => {
|
||||
it("parseISO handles ISO date strings correctly", () => {
|
||||
const d = parseISO("2026-03-15");
|
||||
expect(d.getFullYear()).toBe(2026);
|
||||
expect(d.getMonth()).toBe(2); // 0-indexed
|
||||
expect(d.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it("parseISO handles datetime strings (calendar should use date portion only)", () => {
|
||||
const d = parseISO("2026-03-15T14:30:00Z");
|
||||
expect(d.getFullYear()).toBe(2026);
|
||||
expect(d.getMonth()).toBe(2);
|
||||
expect(d.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it("parseISO returns Invalid Date for garbage input (calendar should skip these)", () => {
|
||||
const d = parseISO("not-a-date");
|
||||
expect(Number.isNaN(d.getTime())).toBe(true);
|
||||
});
|
||||
});
|
||||
190
apps/web/app/components/workspace/object-timeline.test.ts
Normal file
190
apps/web/app/components/workspace/object-timeline.test.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseISO, differenceInDays, addDays, subMonths, addMonths,
|
||||
eachDayOfInterval, eachWeekOfInterval, eachMonthOfInterval,
|
||||
} from "date-fns";
|
||||
|
||||
/**
|
||||
* Pure timeline computation extracted for testing.
|
||||
* Mirrors the logic inside ObjectTimeline.
|
||||
*/
|
||||
|
||||
type TimelineZoom = "day" | "week" | "month" | "quarter";
|
||||
|
||||
function getTimelineBounds(
|
||||
items: { startDate: Date; endDate: Date }[],
|
||||
zoom: TimelineZoom,
|
||||
): { start: Date; end: Date } {
|
||||
if (items.length === 0) {
|
||||
const now = new Date();
|
||||
return { start: subMonths(now, 1), end: addMonths(now, 2) };
|
||||
}
|
||||
const earliest = new Date(Math.min(...items.map((i) => i.startDate.getTime())));
|
||||
const latest = new Date(Math.max(...items.map((i) => i.endDate.getTime())));
|
||||
const paddingDays = zoom === "day" ? 3 : zoom === "week" ? 7 : zoom === "month" ? 14 : 30;
|
||||
return {
|
||||
start: addDays(earliest, -paddingDays),
|
||||
end: addDays(latest, paddingDays),
|
||||
};
|
||||
}
|
||||
|
||||
function dateToX(date: Date, timelineStart: Date, dayWidth: number): number {
|
||||
return differenceInDays(date, timelineStart) * dayWidth;
|
||||
}
|
||||
|
||||
function getHeaderTicks(
|
||||
start: Date,
|
||||
end: Date,
|
||||
zoom: TimelineZoom,
|
||||
): Date[] {
|
||||
switch (zoom) {
|
||||
case "day":
|
||||
return eachDayOfInterval({ start, end });
|
||||
case "week":
|
||||
return eachWeekOfInterval({ start, end }, { weekStartsOn: 1 });
|
||||
case "month":
|
||||
return eachMonthOfInterval({ start, end });
|
||||
case "quarter":
|
||||
return eachMonthOfInterval({ start, end }).filter((d) => d.getMonth() % 3 === 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline bounds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getTimelineBounds", () => {
|
||||
it("provides sensible defaults for empty item list (prevents zero-width timeline)", () => {
|
||||
const { start, end } = getTimelineBounds([], "week");
|
||||
expect(end > start).toBe(true);
|
||||
const span = differenceInDays(end, start);
|
||||
expect(span).toBeGreaterThan(30);
|
||||
});
|
||||
|
||||
it("adds padding around the earliest and latest dates", () => {
|
||||
const items = [
|
||||
{ startDate: parseISO("2026-03-10"), endDate: parseISO("2026-03-20") },
|
||||
];
|
||||
const { start, end } = getTimelineBounds(items, "week");
|
||||
expect(start < items[0].startDate).toBe(true);
|
||||
expect(end > items[0].endDate).toBe(true);
|
||||
});
|
||||
|
||||
it("uses zoom-appropriate padding (day zoom = tight, quarter zoom = wide)", () => {
|
||||
const items = [
|
||||
{ startDate: parseISO("2026-06-01"), endDate: parseISO("2026-06-30") },
|
||||
];
|
||||
const dayBounds = getTimelineBounds(items, "day");
|
||||
const quarterBounds = getTimelineBounds(items, "quarter");
|
||||
const dayPadding = differenceInDays(items[0].startDate, dayBounds.start);
|
||||
const quarterPadding = differenceInDays(items[0].startDate, quarterBounds.start);
|
||||
expect(quarterPadding).toBeGreaterThan(dayPadding);
|
||||
});
|
||||
|
||||
it("handles single-day items (start === end causes zero-width bar without padding)", () => {
|
||||
const items = [
|
||||
{ startDate: parseISO("2026-03-15"), endDate: parseISO("2026-03-15") },
|
||||
];
|
||||
const { start, end } = getTimelineBounds(items, "week");
|
||||
expect(differenceInDays(end, start)).toBeGreaterThan(7);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date-to-pixel conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("dateToX", () => {
|
||||
const start = parseISO("2026-03-01");
|
||||
|
||||
it("returns 0 for the timeline start date", () => {
|
||||
expect(dateToX(start, start, 30)).toBe(0);
|
||||
});
|
||||
|
||||
it("scales linearly with day count", () => {
|
||||
expect(dateToX(addDays(start, 10), start, 30)).toBe(300);
|
||||
});
|
||||
|
||||
it("produces different widths at different zoom levels (zoom changes dayWidth)", () => {
|
||||
const d = addDays(start, 7);
|
||||
expect(dateToX(d, start, 80)).toBe(560); // day zoom
|
||||
expect(dateToX(d, start, 30)).toBe(210); // week zoom
|
||||
expect(dateToX(d, start, 10)).toBe(70); // month zoom
|
||||
expect(dateToX(d, start, 4)).toBe(28); // quarter zoom
|
||||
});
|
||||
|
||||
it("returns negative for dates before timeline start (used for clipping)", () => {
|
||||
const before = addDays(start, -5);
|
||||
expect(dateToX(before, start, 30)).toBe(-150);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header tick generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getHeaderTicks", () => {
|
||||
const start = parseISO("2026-03-01");
|
||||
const end = parseISO("2026-06-30");
|
||||
|
||||
it("'day' zoom produces one tick per day", () => {
|
||||
const shortEnd = addDays(start, 6);
|
||||
const ticks = getHeaderTicks(start, shortEnd, "day");
|
||||
expect(ticks.length).toBe(7);
|
||||
});
|
||||
|
||||
it("'week' zoom produces one tick per week", () => {
|
||||
const ticks = getHeaderTicks(start, end, "week");
|
||||
const expectedWeeks = Math.ceil(differenceInDays(end, start) / 7);
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(expectedWeeks - 1);
|
||||
expect(ticks.length).toBeLessThanOrEqual(expectedWeeks + 1);
|
||||
});
|
||||
|
||||
it("'month' zoom produces one tick per month", () => {
|
||||
const ticks = getHeaderTicks(start, end, "month");
|
||||
expect(ticks.length).toBe(4); // Mar, Apr, May, Jun
|
||||
});
|
||||
|
||||
it("'quarter' zoom only includes quarter-start months (Jan, Apr, Jul, Oct)", () => {
|
||||
const yearStart = parseISO("2026-01-01");
|
||||
const yearEnd = parseISO("2026-12-31");
|
||||
const ticks = getHeaderTicks(yearStart, yearEnd, "quarter");
|
||||
expect(ticks.length).toBe(4);
|
||||
for (const tick of ticks) {
|
||||
expect(tick.getMonth() % 3).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bar width computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("timeline bar positioning", () => {
|
||||
const timelineStart = parseISO("2026-03-01");
|
||||
const dayWidth = 30;
|
||||
|
||||
it("bar width equals (endDate - startDate) * dayWidth", () => {
|
||||
const item = {
|
||||
startDate: parseISO("2026-03-10"),
|
||||
endDate: parseISO("2026-03-15"),
|
||||
};
|
||||
const x = dateToX(item.startDate, timelineStart, dayWidth);
|
||||
const w = dateToX(item.endDate, timelineStart, dayWidth) - x;
|
||||
expect(x).toBe(9 * 30);
|
||||
expect(w).toBe(5 * 30);
|
||||
});
|
||||
|
||||
it("minimum bar width prevents invisible zero-width bars", () => {
|
||||
const item = {
|
||||
startDate: parseISO("2026-03-10"),
|
||||
endDate: parseISO("2026-03-10"), // same day
|
||||
};
|
||||
const x = dateToX(item.startDate, timelineStart, dayWidth);
|
||||
const rawW = dateToX(item.endDate, timelineStart, dayWidth) - x;
|
||||
const minW = dayWidth * 0.5;
|
||||
const w = Math.max(rawW, minW);
|
||||
expect(w).toBe(minW); // zero-width gets minimum
|
||||
expect(w).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@ -89,4 +89,112 @@ describe("resolveActiveViewSyncDecision", () => {
|
||||
expect(missingActive).toBeNull();
|
||||
expect(unknownActive).toBeNull();
|
||||
});
|
||||
|
||||
// --- New tests for viewType and settings ---
|
||||
|
||||
it("detects viewType change and triggers re-apply (prevents stale view mode after external edit)", () => {
|
||||
const kanbanView: SavedView = {
|
||||
name: "Board",
|
||||
view_type: "kanban",
|
||||
settings: { kanbanField: "Status" },
|
||||
};
|
||||
|
||||
const decision = resolveActiveViewSyncDecision({
|
||||
savedViews: [kanbanView],
|
||||
activeView: "Board",
|
||||
currentActiveViewName: "Board",
|
||||
currentFilters: emptyFilterGroup(),
|
||||
currentViewColumns: undefined,
|
||||
currentViewType: "table",
|
||||
currentSettings: undefined,
|
||||
});
|
||||
|
||||
expect(decision).not.toBeNull();
|
||||
expect(decision?.shouldApply).toBe(true);
|
||||
expect(decision?.nextViewType).toBe("kanban");
|
||||
expect(decision?.nextSettings).toEqual({ kanbanField: "Status" });
|
||||
});
|
||||
|
||||
it("detects settings change while name and filters stay the same (calendar mode changed externally)", () => {
|
||||
const calView: SavedView = {
|
||||
name: "Monthly",
|
||||
view_type: "calendar",
|
||||
settings: { calendarDateField: "Due Date", calendarMode: "month" },
|
||||
};
|
||||
|
||||
const decision = resolveActiveViewSyncDecision({
|
||||
savedViews: [calView],
|
||||
activeView: "Monthly",
|
||||
currentActiveViewName: "Monthly",
|
||||
currentFilters: emptyFilterGroup(),
|
||||
currentViewColumns: undefined,
|
||||
currentViewType: "calendar",
|
||||
currentSettings: { calendarDateField: "Due Date", calendarMode: "week" },
|
||||
});
|
||||
|
||||
expect(decision?.shouldApply).toBe(true);
|
||||
expect(decision?.nextSettings?.calendarMode).toBe("month");
|
||||
});
|
||||
|
||||
it("does not re-apply when viewType and settings are already aligned", () => {
|
||||
const view: SavedView = {
|
||||
name: "Timeline",
|
||||
view_type: "timeline",
|
||||
settings: { timelineStartField: "Start", timelineEndField: "End" },
|
||||
};
|
||||
|
||||
const decision = resolveActiveViewSyncDecision({
|
||||
savedViews: [view],
|
||||
activeView: "Timeline",
|
||||
currentActiveViewName: "Timeline",
|
||||
currentFilters: emptyFilterGroup(),
|
||||
currentViewColumns: undefined,
|
||||
currentViewType: "timeline",
|
||||
currentSettings: { timelineStartField: "Start", timelineEndField: "End" },
|
||||
});
|
||||
|
||||
expect(decision?.shouldApply).toBe(false);
|
||||
});
|
||||
|
||||
it("detects viewType-only change when settings are identical (prevents stuck view mode)", () => {
|
||||
const view: SavedView = {
|
||||
name: "Gallery",
|
||||
view_type: "gallery",
|
||||
settings: { galleryTitleField: "Name" },
|
||||
};
|
||||
|
||||
const decision = resolveActiveViewSyncDecision({
|
||||
savedViews: [view],
|
||||
activeView: "Gallery",
|
||||
currentActiveViewName: "Gallery",
|
||||
currentFilters: emptyFilterGroup(),
|
||||
currentViewColumns: undefined,
|
||||
currentViewType: "table",
|
||||
currentSettings: { galleryTitleField: "Name" },
|
||||
});
|
||||
|
||||
expect(decision?.shouldApply).toBe(true);
|
||||
expect(decision?.nextViewType).toBe("gallery");
|
||||
});
|
||||
|
||||
it("propagates undefined viewType without crashing (backwards compat with old saved views)", () => {
|
||||
const legacyView: SavedView = {
|
||||
name: "Legacy",
|
||||
filters: statusFilter("Active"),
|
||||
};
|
||||
|
||||
const decision = resolveActiveViewSyncDecision({
|
||||
savedViews: [legacyView],
|
||||
activeView: "Legacy",
|
||||
currentActiveViewName: undefined,
|
||||
currentFilters: emptyFilterGroup(),
|
||||
currentViewColumns: undefined,
|
||||
currentViewType: "table",
|
||||
currentSettings: {},
|
||||
});
|
||||
|
||||
expect(decision?.shouldApply).toBe(true);
|
||||
expect(decision?.nextViewType).toBeUndefined();
|
||||
expect(decision?.nextSettings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
142
apps/web/lib/object-view-types.test.ts
Normal file
142
apps/web/lib/object-view-types.test.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveViewType,
|
||||
resolveViewSettings,
|
||||
autoDetectViewField,
|
||||
type SavedView,
|
||||
type ViewType,
|
||||
type ViewTypeSettings,
|
||||
type FieldMeta,
|
||||
VIEW_TYPES,
|
||||
} from "./object-filters";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveViewType
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveViewType", () => {
|
||||
it("uses saved view's view_type when present (view overrides all defaults)", () => {
|
||||
const view: SavedView = { name: "Board", view_type: "kanban" };
|
||||
expect(resolveViewType(view, "table", "table")).toBe("kanban");
|
||||
});
|
||||
|
||||
it("falls back to currentViewType when saved view has no view_type", () => {
|
||||
const view: SavedView = { name: "Filtered" };
|
||||
expect(resolveViewType(view, "calendar", "table")).toBe("calendar");
|
||||
});
|
||||
|
||||
it("falls back to object default when both saved view and current are undefined", () => {
|
||||
expect(resolveViewType(undefined, undefined, "kanban")).toBe("kanban");
|
||||
});
|
||||
|
||||
it("falls back to 'table' when nothing is specified", () => {
|
||||
expect(resolveViewType(undefined, undefined, undefined)).toBe("table");
|
||||
});
|
||||
|
||||
it("rejects invalid view types in saved view and falls through (prevents garbage data from breaking UI)", () => {
|
||||
const view = { name: "Bad", view_type: "nonexistent" as ViewType };
|
||||
expect(resolveViewType(view, undefined, "kanban")).toBe("kanban");
|
||||
});
|
||||
|
||||
it("rejects invalid object default and falls to 'table' (prevents DB corruption from crashing)", () => {
|
||||
expect(resolveViewType(undefined, undefined, "invalid_type")).toBe("table");
|
||||
});
|
||||
|
||||
it("accepts every valid ViewType without falling through", () => {
|
||||
for (const vt of VIEW_TYPES) {
|
||||
const view: SavedView = { name: "Test", view_type: vt };
|
||||
expect(resolveViewType(view, undefined, undefined)).toBe(vt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveViewSettings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveViewSettings", () => {
|
||||
it("returns saved view settings when no object defaults exist", () => {
|
||||
const settings: ViewTypeSettings = { kanbanField: "Priority" };
|
||||
expect(resolveViewSettings(settings, undefined)).toEqual({ kanbanField: "Priority" });
|
||||
});
|
||||
|
||||
it("returns object defaults when no saved view settings exist", () => {
|
||||
const defaults: ViewTypeSettings = { calendarDateField: "Due Date" };
|
||||
expect(resolveViewSettings(undefined, defaults)).toEqual({ calendarDateField: "Due Date" });
|
||||
});
|
||||
|
||||
it("saved view settings override object defaults (per-view customization works)", () => {
|
||||
const defaults: ViewTypeSettings = { kanbanField: "Status", calendarDateField: "Date" };
|
||||
const override: ViewTypeSettings = { kanbanField: "Priority" };
|
||||
const result = resolveViewSettings(override, defaults);
|
||||
expect(result.kanbanField).toBe("Priority");
|
||||
expect(result.calendarDateField).toBe("Date");
|
||||
});
|
||||
|
||||
it("returns empty object when both inputs are undefined", () => {
|
||||
expect(resolveViewSettings(undefined, undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("does not override object defaults with undefined saved view values (partial override is safe)", () => {
|
||||
const defaults: ViewTypeSettings = {
|
||||
kanbanField: "Status",
|
||||
calendarDateField: "Due Date",
|
||||
timelineStartField: "Start",
|
||||
};
|
||||
const partial: ViewTypeSettings = { kanbanField: "Priority" };
|
||||
const result = resolveViewSettings(partial, defaults);
|
||||
expect(result.kanbanField).toBe("Priority");
|
||||
expect(result.calendarDateField).toBe("Due Date");
|
||||
expect(result.timelineStartField).toBe("Start");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// autoDetectViewField
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("autoDetectViewField", () => {
|
||||
const fields: FieldMeta[] = [
|
||||
{ name: "Title", type: "text" },
|
||||
{ name: "Description", type: "text" },
|
||||
{ name: "Status", type: "enum" },
|
||||
{ name: "Priority", type: "enum" },
|
||||
{ name: "Due Date", type: "date" },
|
||||
{ name: "Start Date", type: "date" },
|
||||
{ name: "End Date", type: "date" },
|
||||
{ name: "Active", type: "boolean" },
|
||||
];
|
||||
|
||||
it("detects kanbanField by preferring 'status' in name (correct heuristic for boards)", () => {
|
||||
expect(autoDetectViewField("kanban", "kanbanField", fields)).toBe("Status");
|
||||
});
|
||||
|
||||
it("falls back to first enum when no field contains 'status'", () => {
|
||||
const noStatus: FieldMeta[] = [
|
||||
{ name: "Priority", type: "enum" },
|
||||
{ name: "Category", type: "enum" },
|
||||
];
|
||||
expect(autoDetectViewField("kanban", "kanbanField", noStatus)).toBe("Priority");
|
||||
});
|
||||
|
||||
it("detects calendarDateField by preferring 'due/start/begin' in name", () => {
|
||||
expect(autoDetectViewField("calendar", "calendarDateField", fields)).toBe("Due Date");
|
||||
});
|
||||
|
||||
it("detects timelineEndField by preferring 'end/finish/close' in name", () => {
|
||||
expect(autoDetectViewField("timeline", "timelineEndField", fields)).toBe("End Date");
|
||||
});
|
||||
|
||||
it("detects galleryTitleField by preferring 'name/title' in name", () => {
|
||||
expect(autoDetectViewField("gallery", "galleryTitleField", fields)).toBe("Title");
|
||||
});
|
||||
|
||||
it("returns undefined for galleryCoverField (no cover detection)", () => {
|
||||
expect(autoDetectViewField("gallery", "galleryCoverField", fields)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when no matching field type exists (prevents crash on sparse schemas)", () => {
|
||||
const noDate: FieldMeta[] = [{ name: "Name", type: "text" }];
|
||||
expect(autoDetectViewField("calendar", "calendarDateField", noDate)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user