Newer
Older
client_dwatch / lib / errors / __tests__ / edit-drafts.test.ts
/**
 * エラー一覧の下書き(指示書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);
  });
});