Newer
Older
client_dwatch / lib / prechecks / __tests__ / edit-drafts.test.ts
/**
 * 対応状況・備考の「編集モード」の下書き(ユーザー指示 2026-10-06。D-165)の期待値表。
 *
 * ★期待値表 → テスト → 実装の順(`CLAUDE.md` §6-2)★。表の番号(P1〜P8 / D1〜D5 / S1〜S3 / H1〜H6)は仕様の表と同じ。
 *
 * 行 R = { id: "1", resolution_status: "unresolved", note: null }
 * 行 S = { id: "2", resolution_status: "in_progress", note: "abc" }
 */
import { describe, expect, it } from "vitest";
import {
  draftNote,
  draftStatus,
  patchRequestFromDraft,
  saveDrafts,
  setDraftNote,
  setDraftStatus,
  showsResolvedTime,
  type EditableRow,
  type ResolutionDraft,
  type ResolutionDrafts,
} from "@/lib/prechecks/edit-drafts";
import type { PrecheckStatusUpdateRequest } from "@/lib/types";

const R: EditableRow = { id: "1", resolution_status: "unresolved", note: null };
const S: EditableRow = { id: "2", resolution_status: "in_progress", note: "abc" };
const T: EditableRow = { id: "3", resolution_status: "resolved", note: "t" };

const EMPTY: ResolutionDrafts = new Map<string, ResolutionDraft>();

describe("patchRequestFromDraft(P1〜P8)", () => {
  const cases: [string, EditableRow, ResolutionDraft | undefined, PrecheckStatusUpdateRequest | null][] = [
    ["P1 R・下書きなし → null", R, undefined, null],
    ["P2 R・{ status: unresolved } → null(同じ値)", R, { status: "unresolved" }, null],
    ["P3 R・{ status: resolved } → { status: resolved }(note キーなし)", R, { status: "resolved" }, { status: "resolved" }],
    ["P4 S・{ note: xyz } → { status: in_progress, note: xyz }", S, { note: "xyz" }, { status: "in_progress", note: "xyz" }],
    ["P5 S・{ note: abc } → null", S, { note: "abc" }, null],
    [
      "P6 R・{ status: in_progress, note: memo } → 両方",
      R,
      { status: "in_progress", note: "memo" },
      { status: "in_progress", note: "memo" },
    ],
    ["P7 R・{ note: '' } → null(null と '' は同じ)", R, { note: "" }, null],
    ["P8 S・{ note: '' } → { status: in_progress, note: '' }(備考を消す)", S, { note: "" }, { status: "in_progress", note: "" }],
  ];
  it.each(cases)("%s", (_label, row, draft, expected) => {
    const actual = patchRequestFromDraft(row, draft);
    expect(actual).toStrictEqual(expected);
    if (actual && expected && !("note" in expected)) expect("note" in actual).toBe(false);
  });
});

describe("setDraftStatus / setDraftNote / draftNote(D1〜D5)", () => {
  it("D1 resolved にしてから unresolved に戻すと空の Map(エントリが消える)", () => {
    const once = setDraftStatus(EMPTY, R, "resolved");
    expect(once.get("1")).toStrictEqual({ status: "resolved" });
    const back = setDraftStatus(once, R, "unresolved");
    expect(back.size).toBe(0);
  });

  it("D2 備考 x → 対応状況 resolved で { note: x, status: resolved }", () => {
    const d2 = setDraftStatus(setDraftNote(EMPTY, S, "x"), S, "resolved");
    expect(d2.size).toBe(1);
    expect(d2.get("2")).toStrictEqual({ note: "x", status: "resolved" });
  });

  it("D3 D2 の結果に備考を元の abc に戻すと { status: resolved } だけ残る", () => {
    const d2 = setDraftStatus(setDraftNote(EMPTY, S, "x"), S, "resolved");
    const d3 = setDraftNote(d2, S, "abc");
    expect(d3.get("2")).toStrictEqual({ status: "resolved" });
  });

  it("D4 setDraft* は入力の Map を書き換えない", () => {
    const input = new Map<string, ResolutionDraft>([["2", { note: "x" }]]);
    const snapshot = structuredClone(input);
    const a = setDraftStatus(input, S, "resolved");
    const b = setDraftNote(input, S, "abc");
    const c = setDraftNote(input, R, "memo");
    expect(input).toStrictEqual(snapshot);
    expect(input.get("2")).toStrictEqual({ note: "x" });
    expect(a).not.toBe(input);
    expect(b).not.toBe(input);
    expect(c).not.toBe(input);
  });

  it("D5 draftNote(R, undefined) は ''", () => {
    expect(draftNote(R, undefined)).toBe("");
  });

  it("(補足)draftStatus / draftNote は下書きがあればそれ、無ければ行の値", () => {
    expect(draftStatus(S, undefined)).toBe("in_progress");
    expect(draftStatus(S, { status: "resolved" })).toBe("resolved");
    expect(draftNote(S, undefined)).toBe("abc");
    expect(draftNote(S, { note: "" })).toBe("");
  });
});

describe("showsResolvedTime(H1〜H6。指示書BA 項1・D-181)", () => {
  const cases: [string, EditableRow, ResolutionDraft | undefined, boolean][] = [
    ["H1 T(解消)・下書きなし → 出す", T, undefined, true],
    ["H2 T(解消)・下書きで未解消 → 出さない", T, { status: "unresolved" }, false],
    ["H3 T(解消)・下書きで対応中 → 出さない", T, { status: "in_progress" }, false],
    ["H4 T(解消)・備考だけ変えた → 出す", T, { note: "x" }, true],
    ["H5 R(未解消)・下書きで解消 → 出さない(日時はまだ無い)", R, { status: "resolved" }, false],
    ["H6 S(対応中)・下書きなし → 出さない", S, undefined, false],
  ];
  it.each(cases)("%s", (_label, row, draft, expected) => {
    expect(showsResolvedTime(row, draft)).toBe(expected);
  });
});

describe("saveDrafts(S1〜S3)", () => {
  it("S1 R 変更あり・S 変更なし・T 変更あり、T の send が例外", async () => {
    const drafts = new Map<string, ResolutionDraft>([
      ["1", { status: "resolved" }],
      ["3", { note: "changed" }],
    ]);
    const calls: [string, PrecheckStatusUpdateRequest][] = [];
    const tError = new Error("T failed");
    const result = await saveDrafts([R, S, T], drafts, async (row, request) => {
      calls.push([row.id, request]);
      if (row.id === "3") throw tError;
      return { ...row, resolution_status: request.status };
    });
    expect(calls).toStrictEqual([
      ["1", { status: "resolved" }],
      ["3", { status: "resolved", note: "changed" }],
    ]);
    expect([...result.updated.keys()]).toStrictEqual(["1"]);
    expect(result.updated.get("1")).toStrictEqual({ ...R, resolution_status: "resolved" });
    expect([...result.failed.keys()]).toStrictEqual(["3"]);
    expect(result.failed.get("3")).toBe(tError);
    expect(result.remaining).toStrictEqual(new Map([["3", { note: "changed" }]]));
  });

  it("S1(補足)1行ずつ順に送る(前の send が終わるまで次を呼ばない)", async () => {
    const drafts = new Map<string, ResolutionDraft>([
      ["1", { status: "resolved" }],
      ["3", { note: "changed" }],
    ]);
    let inFlight = 0;
    let maxInFlight = 0;
    await saveDrafts([R, S, T], drafts, async (row) => {
      inFlight += 1;
      maxInFlight = Math.max(maxInFlight, inFlight);
      await new Promise((resolve) => setTimeout(resolve, 1));
      inFlight -= 1;
      return row;
    });
    expect(maxInFlight).toBe(1);
  });

  it("S2 全部成功 → failed 空・remaining 空", async () => {
    const drafts = new Map<string, ResolutionDraft>([
      ["1", { status: "resolved" }],
      ["2", { note: "x" }],
    ]);
    const result = await saveDrafts([R, S, T], drafts, async (row) => row);
    expect(result.updated.size).toBe(2);
    expect(result.failed.size).toBe(0);
    expect(result.remaining.size).toBe(0);
  });

  it("S3 onProgress は (1,2), (2,2) の順に呼ばれる(送る行の数で数える)", async () => {
    const drafts = new Map<string, ResolutionDraft>([
      ["1", { status: "resolved" }],
      ["3", { note: "changed" }],
    ]);
    const progress: [number, number][] = [];
    await saveDrafts(
      [R, S, T],
      drafts,
      async (row) => {
        if (row.id === "3") throw new Error("x");
        return row;
      },
      (done, total) => progress.push([done, total])
    );
    expect(progress).toStrictEqual([
      [1, 2],
      [2, 2],
    ]);
  });
});