/**
* エラー一覧の下書き(指示書BE §5 表E・§6)。
* アラート側の P/D/S/H は `lib/prechecks/__tests__/edit-drafts.test.ts` が守る。
*/
import { describe, expect, it } from "vitest";
import {
NOTE_REQUIRED_ROW_MESSAGE,
blocksSaveForMissingNote,
errorPatchRequestFromDraft,
requiresNote,
saveErrorDraftsWithCheck,
setErrorDraftErrorListId,
setErrorDraftNote,
setErrorDraftStatus,
type ErrorEditableRow,
type ErrorResolutionDrafts,
} from "@/lib/errors/edit-drafts";
import { patchRequestFromDraft } from "@/lib/prechecks/edit-drafts";
import type { ErrorListItem, ErrorStatusUpdateRequest } from "@/lib/types";
function row(partial: Partial<ErrorEditableRow> & Pick<ErrorEditableRow, "id">): ErrorEditableRow {
return {
resolution_status: "unresolved",
note: null,
error_list_id: null,
error_list: null,
error_list_selectable: true,
...partial,
};
}
function list(partial: Partial<ErrorListItem> & Pick<ErrorListItem, "id">): ErrorListItem {
return {
error_category_main_en: "OTHER ERROR",
error_category_main_ja: "その他(該当なし)",
error_category_sub_en: "Other",
error_category_sub_ja: "その他",
error_detail_en: "There are no errors.",
error_detail_ja: "該当するエラーはありません",
note_required: partial.id === 131,
...partial,
};
}
const stop = list({
id: 40,
note_required: false,
error_detail_ja: "非常停止",
error_detail_en: "E-stop",
});
const other = list({ id: 131 });
const catalog = [stop, other];
const R = row({ id: "R" });
const EMPTY: ErrorResolutionDrafts = new Map();
describe("期待値表 E(指示書BE §5)", () => {
it("E1 エラー内容だけ変更 → 本文に error_list_id。対応状況は行の値。note は無い", () => {
const drafts = setErrorDraftErrorListId(EMPTY, R, 40);
expect(drafts.size).toBe(1);
const request = errorPatchRequestFromDraft(R, drafts.get("R"));
expect(request).toEqual({ status: "unresolved", error_list_id: 40 });
expect(request && "note" in request).toBe(false);
});
it("E2 行と同じ id に戻すと下書きが消える", () => {
const saved = row({ id: "R", error_list_id: 40, error_list: stop });
const drafts = setErrorDraftErrorListId(EMPTY, saved, 40);
expect(drafts.size).toBe(0);
expect(errorPatchRequestFromDraft(saved, undefined)).toBeNull();
});
it("E3 対応状況と同時に変更", () => {
const drafts = setErrorDraftStatus(setErrorDraftErrorListId(EMPTY, R, 40), R, "in_progress");
expect(errorPatchRequestFromDraft(R, drafts.get("R"))).toEqual({
status: "in_progress",
error_list_id: 40,
});
});
it("E4 備考と同時に変更", () => {
const drafts = setErrorDraftNote(setErrorDraftErrorListId(EMPTY, R, 40), R, "現場確認");
expect(errorPatchRequestFromDraft(R, drafts.get("R"))).toEqual({
status: "unresolved",
note: "現場確認",
error_list_id: 40,
});
});
it("E5 下書きなし → null。共通側の本文にも error_list_id は無い", () => {
expect(errorPatchRequestFromDraft(R, undefined)).toBeNull();
const shared = patchRequestFromDraft(R, { status: "resolved" });
expect(shared).toEqual({ status: "resolved" });
expect(shared && "error_list_id" in shared).toBe(false);
});
it("対応状況を変えたあと、エラー内容を行の値に戻しても対応状況の下書きは残る", () => {
const saved = row({ id: "R", error_list_id: 40, error_list: stop });
const both = setErrorDraftStatus(
setErrorDraftErrorListId(EMPTY, saved, 131),
saved,
"resolved"
);
expect(both.get("R")).toEqual({ status: "resolved", error_list_id: 131 });
const onlyStatus = setErrorDraftErrorListId(both, saved, 40);
expect(onlyStatus.get("R")).toEqual({ status: "resolved" });
expect(errorPatchRequestFromDraft(saved, onlyStatus.get("R"))).toEqual({
status: "resolved",
});
});
});
describe("その他は備考必須(指示書BE §6)", () => {
it("その他+備考なし → 送らない", () => {
const drafts = setErrorDraftErrorListId(EMPTY, R, 131);
expect(blocksSaveForMissingNote(R, drafts.get("R"), catalog)).toBe(true);
});
it("その他+空白だけ → 送らない", () => {
const drafts = setErrorDraftNote(setErrorDraftErrorListId(EMPTY, R, 131), R, " ");
expect(blocksSaveForMissingNote(R, drafts.get("R"), catalog)).toBe(true);
});
it("その他+備考あり → 送る", () => {
const drafts = setErrorDraftNote(setErrorDraftErrorListId(EMPTY, R, 131), R, "現場");
expect(blocksSaveForMissingNote(R, drafts.get("R"), catalog)).toBe(false);
expect(errorPatchRequestFromDraft(R, drafts.get("R"))?.error_list_id).toBe(131);
});
it("既にその他の行で備考を消す → 送らない", () => {
const saved = row({
id: "R",
error_list_id: 131,
error_list: other,
note: "あった",
});
const drafts = setErrorDraftNote(EMPTY, saved, "");
expect(blocksSaveForMissingNote(saved, drafts.get("R"), catalog)).toBe(true);
});
it("既にその他で備考が空の行は、対応状況だけなら送る", () => {
const saved = row({
id: "R",
error_list_id: 131,
error_list: other,
note: null,
});
const drafts = setErrorDraftStatus(EMPTY, saved, "in_progress");
expect(blocksSaveForMissingNote(saved, drafts.get("R"), catalog)).toBe(false);
expect(errorPatchRequestFromDraft(saved, drafts.get("R"))).toEqual({ status: "in_progress" });
});
it("選べない行(error_list_selectable が false)は、131 を持っていて備考を消しても送る(サーバーも掛けない)", () => {
const saved = row({
id: "R",
error_list_id: 131,
error_list: other,
error_list_selectable: false,
note: "あった",
});
const drafts = setErrorDraftNote(EMPTY, saved, "");
expect(blocksSaveForMissingNote(saved, drafts.get("R"), catalog)).toBe(false);
// 画面の placeholder・警告枠も同じ関数で決まる
expect(requiresNote(saved, drafts.get("R"), catalog)).toBe(false);
expect(errorPatchRequestFromDraft(saved, drafts.get("R"))).toEqual({
status: "unresolved",
note: "",
});
});
it("requiresNote: 選べる行は適用後のエラー内容で決まる(下書き優先)", () => {
const saved = row({ id: "R", error_list_id: 131, error_list: other });
expect(requiresNote(saved, undefined, catalog)).toBe(true);
expect(requiresNote(saved, { error_list_id: 40 }, catalog)).toBe(false);
expect(requiresNote(R, { error_list_id: 131 }, catalog)).toBe(true);
expect(requiresNote(R, undefined, catalog)).toBe(false);
});
});
describe("saveErrorDraftsWithCheck(指示書BE §6 の保存時の振り分け)", () => {
type Sent = { id: string; request: ErrorStatusUpdateRequest };
function recorder(failIds: readonly string[] = []) {
const sent: Sent[] = [];
const send = async (item: ErrorEditableRow, request: ErrorStatusUpdateRequest) => {
sent.push({ id: item.id, request });
if (failIds.includes(item.id)) throw new Error(`fail ${item.id}`);
return { ...item, resolution_status: request.status };
};
return { sent, send };
}
const blockedRow = row({ id: "blocked" });
const okRow = row({ id: "ok" });
const failRow = row({ id: "fail" });
const statusOnlyRow = row({
id: "status-only",
error_list_id: 131,
error_list: other,
note: null,
});
const rows = [blockedRow, okRow, failRow, statusOnlyRow];
function buildDrafts() {
let d = setErrorDraftErrorListId(EMPTY, blockedRow, 131); // その他+備考なし
d = setErrorDraftErrorListId(d, okRow, 40);
d = setErrorDraftStatus(d, failRow, "resolved");
d = setErrorDraftStatus(d, statusOnlyRow, "in_progress"); // 既にその他・備考なし・対応状況だけ
return d;
}
it("当たった行は送らず、下書きはそのまま remaining に残り、blocked に文言が入る。ほかの行は送る", async () => {
const drafts = buildDrafts();
const blockedDraft = drafts.get("blocked");
const { sent, send } = recorder();
const result = await saveErrorDraftsWithCheck(rows, drafts, catalog, send);
expect(sent.map((s) => s.id)).toEqual(["ok", "fail", "status-only"]);
expect(sent.find((s) => s.id === "ok")?.request).toEqual({
status: "unresolved",
error_list_id: 40,
});
expect(result.blocked).toEqual(new Map([["blocked", NOTE_REQUIRED_ROW_MESSAGE]]));
expect(result.remaining.get("blocked")).toBe(blockedDraft);
expect(result.remaining.get("blocked")).toEqual({ error_list_id: 131 });
expect(Array.from(result.remaining.keys())).toEqual(["blocked"]);
expect(Array.from(result.updated.keys())).toEqual(["ok", "fail", "status-only"]);
expect(result.failed.size).toBe(0);
});
it("送信の失敗と送らなかった行が両方出る。失敗した行の下書きも remaining に残る", async () => {
const drafts = buildDrafts();
const { sent, send } = recorder(["fail"]);
const result = await saveErrorDraftsWithCheck(rows, drafts, catalog, send);
expect(sent.map((s) => s.id)).toEqual(["ok", "fail", "status-only"]);
expect(Array.from(result.failed.keys())).toEqual(["fail"]);
expect(result.failed.get("fail")).toBeInstanceOf(Error);
expect(Array.from(result.blocked.keys())).toEqual(["blocked"]);
expect(new Set(result.remaining.keys())).toEqual(new Set(["fail", "blocked"]));
expect(result.remaining.get("fail")).toEqual({ status: "resolved" });
expect(result.remaining.get("blocked")).toEqual({ error_list_id: 131 });
expect(Array.from(result.updated.keys())).toEqual(["ok", "status-only"]);
});
it("既にその他で備考が空の行の、対応状況だけの変更は送る(blocked に入らない)", async () => {
const drafts = setErrorDraftStatus(EMPTY, statusOnlyRow, "in_progress");
const { sent, send } = recorder();
const result = await saveErrorDraftsWithCheck([statusOnlyRow], drafts, catalog, send);
expect(sent).toEqual([{ id: "status-only", request: { status: "in_progress" } }]);
expect(result.blocked.size).toBe(0);
expect(result.remaining.size).toBe(0);
expect(result.updated.get("status-only")?.resolution_status).toBe("in_progress");
});
it("選べない行は 131+備考を消しても送る", async () => {
const unselectable = row({
id: "u",
error_list_id: 131,
error_list: other,
error_list_selectable: false,
note: "あった",
});
const drafts = setErrorDraftNote(EMPTY, unselectable, "");
const { sent, send } = recorder();
const result = await saveErrorDraftsWithCheck([unselectable], drafts, catalog, send);
expect(sent.map((s) => s.id)).toEqual(["u"]);
expect(result.blocked.size).toBe(0);
});
it("onProgress は送った行だけを数える", async () => {
const drafts = buildDrafts();
const { send } = recorder(["fail"]);
const calls: [number, number][] = [];
await saveErrorDraftsWithCheck(rows, drafts, catalog, send, (done, total) =>
calls.push([done, total])
);
expect(calls).toEqual([
[1, 3],
[2, 3],
[3, 3],
]);
});
it("全行が当たったら何も送らない", async () => {
const drafts = setErrorDraftErrorListId(EMPTY, blockedRow, 131);
const { sent, send } = recorder();
const calls: [number, number][] = [];
const result = await saveErrorDraftsWithCheck([blockedRow], drafts, catalog, send, (d, t) =>
calls.push([d, t])
);
expect(sent).toEqual([]);
expect(calls).toEqual([]);
expect(result.remaining.get("blocked")).toEqual({ error_list_id: 131 });
expect(result.blocked.get("blocked")).toBe(NOTE_REQUIRED_ROW_MESSAGE);
});
});