Ctrl K
ComunidadGuías…

test/client-office-boards.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// @vitest-environment happy-dom
import { describe, it, expect, vi } from 'vitest';

// office.js only needs switchToSession from terminal.js, which pulls in xterm.
vi.mock('../public/modules/terminal.js', () => ({ switchToSession: vi.fn(), restartAgent: vi.fn(), restartPending: () => false }));
// The click wiring is the feature: a board click must reach showJobBoard(). The
// rest of jobs.js stays real so COLUMNS below is still the production list.
vi.mock('../public/modules/jobs.js', async (actual) => ({
  ...(await actual()),
  showJobBoard: vi.fn(),
}));

const { computeBoardLayout, BOARD_TITLES, BOARD_STATES, countBoardJobs, boardPostPlan, hitJobBoard, setupOfficeClick } =
  await import('../public/modules/office.js');
const { COLUMNS, showJobBoard } = await import('../public/modules/jobs.js');
const { JOB_STATES, STATE_LABELS } = await import('../lib/jobs.js');

const Z = 3;

// The board's drawn box, matching drawJobBoardEasels: the left edge is floored,
// and the drop shadow in drawWhiteboard reaches one Z-unit further right.
function drawnBox(section, board) {
  const left = section.centerX - Math.floor(board.boardW / 2);
  return { left, right: left + board.boardW, shadowRight: left + board.boardW + Z };
}

// The job boards stand in the three wall sections between the windows. Their
// geometry is pure arithmetic over the panel width, so it is checked here
// rather than by eyeballing the canvas.
describe('job board placement', () => {
  // Includes the band where the boards first appear and are at minimum size.
  const widths = [200, 240, 252, 270, 288, 306, 320, 400, 480, 640, 900, 1280, 2000];

  it('keeps every board, drop shadow included, inside its own wall section', () => {
    for (const w of widths) {
      const board = computeBoardLayout(w);
      if (!board.visible) continue;
      for (const section of board.sections) {
        const box = drawnBox(section, board);
        expect(box.left, `panel ${w}`).toBeGreaterThanOrEqual(section.left);
        expect(box.shadowRight, `panel ${w}`).toBeLessThanOrEqual(section.right);
      }
    }
  });

  it('centres the board frame on its stand', () => {
    for (const w of widths) {
      const board = computeBoardLayout(w);
      if (!board.visible) continue;
      for (const section of board.sections) {
        const box = drawnBox(section, board);
        // The stand's legs, brace and shadow are all centred on section.centerX.
        expect((box.left + box.right) / 2, `panel ${w}`).toBe(section.centerX);
      }
    }
  });

  it('stands almost against the wall, on the floor, clear of the desk grid', () => {
    for (const w of widths) {
      const board = computeBoardLayout(w);
      if (!board.visible) continue;
      // Feet on the floor (below the baseboard), no further out than the
      // plant pots' fronts (6 Z deep): the boards belong against the wall,
      // not wandering out into the room.
      expect(board.footY, `panel ${w}`).toBeGreaterThan(board.wallBottom);
      expect(board.footY - board.wallBottom, `panel ${w}`).toBeLessThanOrEqual(6 * Z);
      // The frame rises up the wall but never above the ceiling line.
      expect(board.boardTop, `panel ${w}`).toBeGreaterThanOrEqual(0);
      expect(board.footY, `panel ${w}`).toBeLessThan(board.floorTop);
      expect(board.frameBottom, `panel ${w}`).toBeLessThan(board.footY);
    }
  });

  it('draws nothing rather than smudges on a panel too narrow for a board', () => {
    expect(computeBoardLayout(160).visible).toBe(false);
    expect(computeBoardLayout(900).visible).toBe(true);
  });
});

// The three whiteboards are the real board's columns, in order. The column
// list exists three times (server states, board tab labels, canvas titles)
// and nothing imports across them, so this is what keeps them in step.
// `done` has no column (a merged card leaves the board), so no whiteboard.
describe('job board titles', () => {
  it('are the board tab column labels, shouted', () => {
    expect(BOARD_TITLES).toEqual(COLUMNS.map((c) => c.label.toUpperCase()));
  });

  it('cover every job state except done, in order', () => {
    expect(COLUMNS.map((c) => c.state)).toEqual(JOB_STATES.filter((s) => s !== 'done'));
  });

  it('read the same as the labels the server writes out', () => {
    // The browser cannot import lib/jobs.js (only public/ is served), so the
    // column labels exist twice: here for the board, and as STATE_LABELS for
    // whatever writes a card out as text — the MCP tools an agent reads.
    for (const c of COLUMNS) expect(STATE_LABELS[c.state]).toBe(c.label);
  });
});

// Each whiteboard pins one paper per real job in its column, so the canvas
// mirrors the board tab's job counts instead of a fixed decoration.
describe('job board post sync', () => {
  it('maps the same states as the board tab columns, in order', () => {
    expect(BOARD_STATES).toEqual(COLUMNS.map((c) => c.state));
  });

  it('counts jobs per column and ignores done, unknown, and malformed jobs', () => {
    const counts = countBoardJobs([
      { state: 'todo' }, { state: 'todo' },
      { state: 'in-progress' },
      { state: 'review' }, { state: 'review' }, { state: 'review' },
      { state: 'done' },
      { state: 'bogus' }, {}, null,
    ]);
    expect(counts).toEqual([2, 1, 3]);
  });

  it('accepts the production shape — a Map values() iterator', () => {
    const jobs = new Map([['job-1', { state: 'todo' }], ['job-2', { state: 'review' }]]);
    expect(countBoardJobs(jobs.values())).toEqual([1, 0, 1]);
  });

  it('handles an empty board', () => {
    expect(countBoardJobs([])).toEqual([0, 0, 0]);
    expect(boardPostPlan(0)).toEqual({ shown: 0, overflow: 0 });
  });

  it('caps posts at the pin slots and reports the overflow', () => {
    // The cap itself: shown saturates while overflow picks up the rest. The
    // slot count is asserted indirectly so adding a slot doesn't break this.
    const cap = boardPostPlan(100).shown;
    expect(cap).toBeGreaterThan(0);
    expect(boardPostPlan(3)).toEqual({ shown: 3, overflow: 0 });
    expect(boardPostPlan(cap)).toEqual({ shown: cap, overflow: 0 });
    expect(boardPostPlan(cap + 1)).toEqual({ shown: cap, overflow: 1 });
    expect(boardPostPlan(cap + 3)).toEqual({ shown: cap, overflow: 3 });
  });
});

describe('clicking a board opens the Jobs tab', () => {
  const W = 1200;
  const board = computeBoardLayout(W);

  it('hits every painted board, frame and stand', () => {
    for (const { centerX } of board.sections) {
      expect(hitJobBoard(centerX, board.boardTop + 1, W)).toBe(true);
      expect(hitJobBoard(centerX, board.footY - 1, W)).toBe(true);   // the stand
    }
  });

  it('misses the wall above, the floor below and the gaps between', () => {
    const { centerX } = board.sections[0];
    expect(hitJobBoard(centerX, board.boardTop - 1, W)).toBe(false);
    expect(hitJobBoard(centerX, board.footY + 1, W)).toBe(false);
    const gap = (board.sections[0].centerX + board.sections[1].centerX) / 2;
    expect(hitJobBoard(gap, board.boardTop + 1, W)).toBe(false);
  });

  it('never hits when the panel is too narrow to draw a board', () => {
    const narrow = 120;
    expect(computeBoardLayout(narrow).visible).toBe(false);
    expect(hitJobBoard(60, 60, narrow)).toBe(false);
  });
});

describe('the canvas click reaches the board', () => {
  const W = 1200, H = 800;

  // A stand-in for the office canvas: setupOfficeClick only needs to register a
  // listener and measure the element.
  function fakeCanvas() {
    let handler;
    const canvas = {
      addEventListener: (_type, fn) => { handler = fn; },
      getBoundingClientRect: () => ({ left: 0, top: 0, width: W, height: H }),
    };
    vi.spyOn(document, 'getElementById').mockReturnValue(canvas);
    setupOfficeClick();
    return (x, y) => handler({ clientX: x, clientY: y });
  }

  it('opens the Jobs tab on a board click and leaves it shut elsewhere', () => {
    showJobBoard.mockClear();
    const click = fakeCanvas();
    const board = computeBoardLayout(W);
    click(board.sections[1].centerX, board.boardTop + 4);
    expect(showJobBoard).toHaveBeenCalledTimes(1);
    click(board.sections[1].centerX, H - 4);   // empty floor, no agents
    expect(showJobBoard).toHaveBeenCalledTimes(1);
    document.getElementById.mockRestore();
  });
});