feat : excel in website
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
addExtraSheetColumn,
|
||||
addExtraSheetRow,
|
||||
createExtraSheet,
|
||||
deleteExtraSheetColumn,
|
||||
deleteExtraSheetRow,
|
||||
extraSheetSchema,
|
||||
extraSheetToMatrix,
|
||||
matrixToExtraSheet,
|
||||
moveExtraSheetColumn,
|
||||
moveExtraSheetRow,
|
||||
} from "@/lib/guides/extra-sheet";
|
||||
|
||||
function idFactory() {
|
||||
let index = 0;
|
||||
return (prefix: string) => `${prefix}-${++index}`;
|
||||
}
|
||||
|
||||
describe("Extra sheet", () => {
|
||||
it("creates a valid two-by-two sheet", () => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
expect(extraSheetSchema.safeParse(sheet).success).toBe(true);
|
||||
expect(sheet.columns.map((column) => column.label)).toEqual(["คอลัมน์ 1", "คอลัมน์ 2"]);
|
||||
expect(extraSheetToMatrix(sheet)).toEqual([
|
||||
[{ value: "" }, { value: "" }],
|
||||
[{ value: "" }, { value: "" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves cell identity while rows and columns move", () => {
|
||||
const createId = idFactory();
|
||||
const sheet = createExtraSheet(createId);
|
||||
const firstColumnId = sheet.columns[0].id;
|
||||
const secondColumnId = sheet.columns[1].id;
|
||||
sheet.rows[0].cells[firstColumnId] = "A";
|
||||
sheet.rows[0].cells[secondColumnId] = "B";
|
||||
sheet.rows[1].cells[firstColumnId] = "C";
|
||||
|
||||
const moved = moveExtraSheetRow(moveExtraSheetColumn(sheet, 0, 1), 0, 1);
|
||||
expect(extraSheetToMatrix(moved)).toEqual([
|
||||
[{ value: "" }, { value: "C" }],
|
||||
[{ value: "B" }, { value: "A" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds and removes rows and columns without orphaned cells", () => {
|
||||
const createId = idFactory();
|
||||
const initial = createExtraSheet(createId);
|
||||
const columnId = createId("column");
|
||||
const expanded = addExtraSheetRow(
|
||||
addExtraSheetColumn(initial, "Notes", columnId),
|
||||
createId("row"),
|
||||
);
|
||||
expect(expanded.rows).toHaveLength(3);
|
||||
expect(expanded.rows.every((row) => row.cells[columnId] === "")).toBe(true);
|
||||
|
||||
const reduced = deleteExtraSheetColumn(expanded, 2);
|
||||
expect(reduced.rows.every((row) => !(columnId in row.cells))).toBe(true);
|
||||
expect(deleteExtraSheetRow(reduced, 2).rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("expands rows and columns when a pasted matrix is larger", () => {
|
||||
const createId = idFactory();
|
||||
const initial = createExtraSheet(createId);
|
||||
const expanded = matrixToExtraSheet(initial, [
|
||||
[{ value: "1" }, { value: "2" }, { value: "3" }],
|
||||
[{ value: "4" }, { value: "5" }, { value: "6" }],
|
||||
[{ value: "7" }, { value: "8" }, { value: "=1+1" }],
|
||||
], createId);
|
||||
|
||||
expect(expanded.columns).toHaveLength(3);
|
||||
expect(expanded.rows).toHaveLength(3);
|
||||
expect(extraSheetToMatrix(expanded)[2][2]?.value).toBe("=1+1");
|
||||
expect(extraSheetSchema.safeParse(expanded).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate labels and cells for missing columns", () => {
|
||||
const sheet = createExtraSheet(idFactory());
|
||||
sheet.columns[1].label = sheet.columns[0].label.toUpperCase();
|
||||
sheet.rows[0].cells.missing = "orphan";
|
||||
const result = extraSheetSchema.safeParse(sheet);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.message)).toContain("ชื่อคอลัมน์ต้องไม่ซ้ำกัน");
|
||||
expect(result.error.issues.map((issue) => issue.message)).toContain("ไม่พบคอลัมน์ของเซลล์นี้");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as z from "zod";
|
||||
|
||||
const extraSheetId = z.string().min(1).max(128);
|
||||
|
||||
export const extraSheetSchema = z.strictObject({
|
||||
columns: z.array(z.strictObject({
|
||||
id: extraSheetId,
|
||||
label: z.string().trim().min(1).max(120),
|
||||
})).min(1),
|
||||
rows: z.array(z.strictObject({
|
||||
id: extraSheetId,
|
||||
cells: z.record(z.string(), z.string().max(10_000)),
|
||||
})).min(1),
|
||||
}).superRefine((sheet, context) => {
|
||||
const columnIds = new Set(sheet.columns.map((column) => column.id));
|
||||
if (columnIds.size !== sheet.columns.length) {
|
||||
context.addIssue({ code: "custom", path: ["columns"], message: "รหัสคอลัมน์ต้องไม่ซ้ำกัน" });
|
||||
}
|
||||
const columnLabels = new Set(sheet.columns.map((column) => column.label.toLocaleLowerCase()));
|
||||
if (columnLabels.size !== sheet.columns.length) {
|
||||
context.addIssue({ code: "custom", path: ["columns"], message: "ชื่อคอลัมน์ต้องไม่ซ้ำกัน" });
|
||||
}
|
||||
const rowIds = new Set(sheet.rows.map((row) => row.id));
|
||||
if (rowIds.size !== sheet.rows.length) {
|
||||
context.addIssue({ code: "custom", path: ["rows"], message: "รหัสแถวต้องไม่ซ้ำกัน" });
|
||||
}
|
||||
sheet.rows.forEach((row, rowIndex) => {
|
||||
Object.keys(row.cells).forEach((columnId) => {
|
||||
if (!columnIds.has(columnId)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["rows", rowIndex, "cells", columnId],
|
||||
message: "ไม่พบคอลัมน์ของเซลล์นี้",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export type ExtraSheet = z.output<typeof extraSheetSchema>;
|
||||
|
||||
export type ExtraSheetCell = { value: string };
|
||||
export type ExtraSheetMatrix = Array<Array<ExtraSheetCell | undefined>>;
|
||||
|
||||
export function nextExtraSheetColumnLabel(columns: ExtraSheet["columns"]): string {
|
||||
const labels = new Set(columns.map((column) => column.label.toLocaleLowerCase()));
|
||||
let index = columns.length + 1;
|
||||
while (labels.has(`คอลัมน์ ${index}`.toLocaleLowerCase())) index += 1;
|
||||
return `คอลัมน์ ${index}`;
|
||||
}
|
||||
|
||||
export function createExtraSheet(createId: (prefix: string) => string): ExtraSheet {
|
||||
const columns = [1, 2].map((index) => ({
|
||||
id: createId("extra-sheet-column"),
|
||||
label: `คอลัมน์ ${index}`,
|
||||
}));
|
||||
return {
|
||||
columns,
|
||||
rows: [1, 2].map(() => ({
|
||||
id: createId("extra-sheet-row"),
|
||||
cells: Object.fromEntries(columns.map((column) => [column.id, ""])),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function extraSheetToMatrix(sheet: ExtraSheet): ExtraSheetMatrix {
|
||||
return sheet.rows.map((row) =>
|
||||
sheet.columns.map((column) => ({ value: row.cells[column.id] ?? "" })),
|
||||
);
|
||||
}
|
||||
|
||||
export function matrixToExtraSheet(
|
||||
sheet: ExtraSheet,
|
||||
matrix: ExtraSheetMatrix,
|
||||
createId: (prefix: string) => string,
|
||||
): ExtraSheet {
|
||||
const columnCount = Math.max(
|
||||
sheet.columns.length,
|
||||
...matrix.map((row) => row.length),
|
||||
);
|
||||
const columns = [...sheet.columns];
|
||||
while (columns.length < columnCount) {
|
||||
columns.push({
|
||||
id: createId("extra-sheet-column"),
|
||||
label: nextExtraSheetColumnLabel(columns),
|
||||
});
|
||||
}
|
||||
const rowCount = Math.max(sheet.rows.length, matrix.length);
|
||||
const rows = Array.from({ length: rowCount }, (_, rowIndex) => ({
|
||||
id: sheet.rows[rowIndex]?.id ?? createId("extra-sheet-row"),
|
||||
cells: Object.fromEntries(columns.map((column, columnIndex) => [
|
||||
column.id,
|
||||
String(matrix[rowIndex]?.[columnIndex]?.value ?? ""),
|
||||
])),
|
||||
}));
|
||||
return { columns, rows };
|
||||
}
|
||||
|
||||
export function addExtraSheetColumn(
|
||||
sheet: ExtraSheet,
|
||||
label: string,
|
||||
id: string,
|
||||
): ExtraSheet {
|
||||
return {
|
||||
columns: [...sheet.columns, { id, label }],
|
||||
rows: sheet.rows.map((row) => ({ ...row, cells: { ...row.cells, [id]: "" } })),
|
||||
};
|
||||
}
|
||||
|
||||
export function addExtraSheetRow(sheet: ExtraSheet, id: string): ExtraSheet {
|
||||
return {
|
||||
...sheet,
|
||||
rows: [...sheet.rows, {
|
||||
id,
|
||||
cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function moveExtraSheetColumn(
|
||||
sheet: ExtraSheet,
|
||||
from: number,
|
||||
to: number,
|
||||
): ExtraSheet {
|
||||
if (to < 0 || to >= sheet.columns.length || from === to) return sheet;
|
||||
const columns = [...sheet.columns];
|
||||
const [column] = columns.splice(from, 1);
|
||||
columns.splice(to, 0, column);
|
||||
return { ...sheet, columns };
|
||||
}
|
||||
|
||||
export function moveExtraSheetRow(
|
||||
sheet: ExtraSheet,
|
||||
from: number,
|
||||
to: number,
|
||||
): ExtraSheet {
|
||||
if (to < 0 || to >= sheet.rows.length || from === to) return sheet;
|
||||
const rows = [...sheet.rows];
|
||||
const [row] = rows.splice(from, 1);
|
||||
rows.splice(to, 0, row);
|
||||
return { ...sheet, rows };
|
||||
}
|
||||
|
||||
export function deleteExtraSheetColumn(sheet: ExtraSheet, index: number): ExtraSheet {
|
||||
if (sheet.columns.length === 1) return sheet;
|
||||
const columnId = sheet.columns[index]?.id;
|
||||
if (!columnId) return sheet;
|
||||
return {
|
||||
columns: sheet.columns.filter((_, columnIndex) => columnIndex !== index),
|
||||
rows: sheet.rows.map((row) => ({
|
||||
...row,
|
||||
cells: Object.fromEntries(Object.entries(row.cells).filter(([key]) => key !== columnId)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteExtraSheetRow(sheet: ExtraSheet, index: number): ExtraSheet {
|
||||
if (sheet.rows.length === 1) return sheet;
|
||||
return { ...sheet, rows: sheet.rows.filter((_, rowIndex) => rowIndex !== index) };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { calculateTeamMetrics } from "@/lib/guides/calculations";
|
||||
import { createExtraSheet } from "@/lib/guides/extra-sheet";
|
||||
import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, extraSectionSchema, teamSectionSchema, updateGuideSchema, weaponSectionSchema } from "@/lib/guides/schemas";
|
||||
import {
|
||||
recommendedStatElement,
|
||||
@@ -207,6 +208,37 @@ describe("structured guide validation", () => {
|
||||
sections: [{ title: "Extra", rows: [{ ...row, catalogItems: [row.catalogItems[0], row.catalogItems[0]] }] }],
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates an optional Extra card sheet", () => {
|
||||
let id = 0;
|
||||
const sheet = createExtraSheet((prefix) => `${prefix}-${++id}`);
|
||||
sheet.rows[0].cells[sheet.columns[0].id] = "=1+1";
|
||||
const row = {
|
||||
mediaId: null,
|
||||
heading: "Stats",
|
||||
body: "",
|
||||
percent: null,
|
||||
backgroundColor: null,
|
||||
highlights: [],
|
||||
catalogItems: [],
|
||||
sheet,
|
||||
};
|
||||
const result = extraSectionSchema.safeParse({
|
||||
expectedVersion: 1,
|
||||
sections: [{ title: "Extra", rows: [row] }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("=1+1");
|
||||
}
|
||||
|
||||
const invalidSheet = structuredClone(sheet);
|
||||
invalidSheet.rows[0].cells.missing = "orphan";
|
||||
expect(extraSectionSchema.safeParse({
|
||||
expectedVersion: 1,
|
||||
sections: [{ title: "Extra", rows: [{ ...row, sheet: invalidSheet }] }],
|
||||
}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Team Calc", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as z from "zod";
|
||||
|
||||
import { targetStatOptions } from "@/lib/guides/stats";
|
||||
import { parseYouTubeUrl } from "@/lib/guides/youtube";
|
||||
import { extraSheetSchema } from "@/lib/guides/extra-sheet";
|
||||
import { normalizeExtraRichText } from "@/lib/rich-text";
|
||||
|
||||
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
|
||||
@@ -154,6 +155,7 @@ const extraRowSchema = z.strictObject({
|
||||
(highlights) => new Set(highlights.map((highlight) => highlight.text)).size === highlights.length,
|
||||
"คำหรือวลีที่ไฮไลต์ต้องไม่ซ้ำกัน",
|
||||
).default([]),
|
||||
sheet: extraSheetSchema.nullable().default(null),
|
||||
}).transform((row) => ({
|
||||
...row,
|
||||
body: normalizeExtraRichText(row.body, row.highlights),
|
||||
|
||||
Reference in New Issue
Block a user