/* Headless smoke test: loads the real inline script from index.html behind a
   stub DOM, then exercises the parsing / date / sanitation / import logic. */
const fs = require("fs"), vm = require("vm"), assert = require("assert");

/* Every animation any stub element is asked to play, newest last, so a test can
   read back the keyframes the app handed the browser and settle them by hand. */
const anims = [];

function stubEl() {
  const cls = new Set();
  const el = {
    style: {}, dataset: {}, value: "", textContent: "", innerHTML: "", checked: false,
    disabled: false, files: [], _h: null,
    scrollLeft: 0, scrollTop: 0, scrollWidth: 4000, clientWidth: 800, scrollHeight: 600, clientHeight: 600,
    classList: {
      add(...c){ c.forEach(x => cls.add(x)); },
      remove(...c){ c.forEach(x => cls.delete(x)); },
      // second argument optional, as in the real DOM: without it, toggle flips
      toggle(c, on){ (on === undefined ? !cls.has(c) : on) ? cls.add(c) : cls.delete(c); },
      contains(c){ return cls.has(c); },
    },
    _on: {},
    addEventListener(type, fn){ (el._on[type] || (el._on[type] = [])).push(fn); },
    removeEventListener(){}, click(){}, focus(){ el._focused = true; }, select(){}, blur(){ el._focused = false; },
    /* Attributes, kept in a plain object so a test can read what the app
       announced. aria-expanded and friends are ordinary DOM, and a stub that
       throws on them pushes the app into avoiding them, which costs the
       screen-reader story for nothing. */
    _attr: {},
    setAttribute(k, v){ el._attr[k] = String(v); },
    getAttribute(k){ return Object.prototype.hasOwnProperty.call(el._attr, k) ? el._attr[k] : null; },
    removeAttribute(k){ delete el._attr[k]; },
    hasAttribute(k){ return Object.prototype.hasOwnProperty.call(el._attr, k); },
    contains(){ return false; }, closest(){ return null; },
    querySelector(){ return null; }, querySelectorAll(){ return []; },
    getBoundingClientRect(){ return {left:0, top:0}; },
    appendChild(c){ el._kids.push(c); }, scrollIntoView(){},
    _kids: [],
    /* A copy deep enough to be looked into: the same selector hands back the
       same stub every time, so a test can read what was written into the copy. */
    cloneNode() {
      const c = stubEl(), inner = {};
      c._copyOf = el;
      c.querySelector = sel => (inner[sel] || (inner[sel] = stubEl()));
      return c;
    },
    /* Web Animations, far enough to be inspected. `finished` is a thenable the
       test settles by hand rather than a real promise: the app only ever does
       .then(done, done) with it, and this keeps the tests synchronous. */
    animate(keyframes, opts) {
      const a = { el, keyframes, opts, _done: null };
      a.finished = { then(fn){ a._done = fn; return a.finished; } };
      a.settle = () => { if (a._done) a._done(); };
      anims.push(a);
      return a;
    },
  };
  /* 🔴 A <select>'s options, the way the real DOM offers them: derived from the
     innerHTML the app just wrote. Without this the stub could only say what a
     select was TOLD, never what it now HOLDS — and the app reads `.options`
     back in two places (the plan switcher and the filter chips), so those went
     untestable and one of them shipped a bug because of it (2026-08-22, the ▾
     that served a cached list). A test may still assign the array by hand, and
     an assigned array wins; that is the older habit and several tests use it. */
  let opts = null;
  Object.defineProperty(el, "options", {
    get() {
      if (opts) return opts;
      return [...String(el.innerHTML).matchAll(/<option\s+([^>]*)>([\s\S]*?)<\/option>/g)].map(m => {
        const attr = m[1], txt = m[2].replace(/<[^>]*>/g, "");
        const v = /value="([^"]*)"/.exec(attr);
        return { value: v ? v[1] : txt, textContent: txt, text: txt, selected: /\bselected\b/.test(attr) };
      });
    },
    set(v) { opts = v; },
  });
  return el;
}
const els = {};
const docOn = {};
const document = {
  getElementById: id => (els[id] || (els[id] = stubEl())),
  addEventListener(type, fn){ (docOn[type] || (docOn[type] = [])).push(fn); },
  querySelector(){ return null; }, querySelectorAll(){ return []; },
  /* Elements built rather than looked up. One caller: the toast's undo button
     (2026-08-21) — a message is never markup, so the button is appended as a
     node beside the text rather than written into innerHTML. */
  createElement(){ return stubEl(); },
  body: stubEl(),
  /* Serializing the page is how the snapshot copies the app. The stub hands
     back the real index.html instead of a rebuilt DOM: what buildSnapshot() is
     asked to do to that string is the part worth testing, and a faithful
     re-serialization of a stub DOM would prove nothing about it. */
  /* A real classList on it, because the theme is a class on <html>: the app
     toggles it, and a test has to be able to read back which theme is on.
     defineProperty rather than a literal, so outerHTML stays a *getter* — the
     `html` it reads is declared further down, and Object.assign would have run
     it here and thrown. */
  documentElement: (() => {
    const el = stubEl();
    Object.defineProperty(el, "outerHTML",
      { get(){ return html.replace(/^<!DOCTYPE html>\s*/i, ""); } });
    return el;
  })(),
};
// fire the handlers a real event would reach, so wiring can be tested too
const fire = (el, type, ev = {}) =>
  (el._on[type] || []).forEach(fn => fn({ target: el, preventDefault(){}, stopPropagation(){}, ...ev }));
const press = key =>
  (docOn.keydown || []).forEach(fn => fn({ key, ctrlKey: false, metaKey: false, preventDefault(){} }));
const winOn = {};
const ctx = {
  document, console, setTimeout, clearTimeout, Intl, Date, Math, JSON, Set, Map, Number, String, Array, Object, RegExp, Promise,
  /* Four more, and all four are the *browser's* own — the whitelist stays a
     statement that the app uses nothing but platform APIs. They are here so the
     licence check can be tested rather than mocked: Node's WebCrypto is the
     same WebCrypto, so a signature that verifies in this suite verifies in
     Edge, which is the whole compatibility gate. */
  crypto: require("crypto").webcrypto, TextEncoder, TextDecoder, Uint8Array,
  /* One more of the platform's own, and the same one in Node and the browser:
     the app uses it to name the HOST a failed Dropbox call went to, so a
     refusal can say where it happened without carrying a whole URL around. */
  URL,
  /* Four more of the platform's own, for the plan-in-a-link: deflate through
     CompressionStream is the same deflate in Node and in the browser, so a
     link this suite packs is a link Chrome unpacks — no mock, no library. */
  Blob, Response, CompressionStream, DecompressionStream,
  atob: s => Buffer.from(String(s), "base64").toString("binary"),
  /* `mediaMatches` is what the stub answers every media query with, so a test
     can stand in front of a dark-preferring machine — or one that has asked
     for less motion — by setting it and putting it back. */
  window: { addEventListener(type, fn){ (winOn[type] || (winOn[type] = [])).push(fn); }, innerWidth: 1200,
            mediaMatches: false,
            /* One flag answers every query, which is enough for the two the app
               asks about a theme — but not for a test that has to say "a coarse
               pointer AND no reduced motion". `mediaMatchMap` answers by query
               when it is set; without it, nothing changes. */
            matchMedia(q){ return { media: q, matches: ctx.window.mediaMatchMap
              ? !!ctx.window.mediaMatchMap[q] : !!ctx.window.mediaMatches }; } },
  /* What language this machine reads, which is where the app takes its own from
     on a first run. Tests set `languages` to stand somewhere else for a moment. */
  navigator: { languages: ["en"], language: "en" },

  /* A real store rather than an inert stub. It was `getItem: () => null`, which
     meant the "reopen the file you had open last" path could never be reached
     from a test at all — and that path is the one that decides whether opening
     a folder restores your plan or grabs the first one in the list. resetApp()
     clears it, so nothing leaks between tests. */
  localStorage: (() => {
    const m = new Map();
    return {
      getItem: k => (m.has(k) ? m.get(k) : null),
      setItem(k, v){ m.set(k, String(v)); },
      removeItem(k){ m.delete(k); },
      clear(){ m.clear(); },
    };
  })(),
  /* The address the app was launched with. Empty here: the boot-time read of it
     must not put the suite into read-only mode — parseLaunchParams is tested
     directly instead. */
  location: { search: "", hash: "" },
  indexedDB: { open(){ throw new Error("no idb in test"); } },
  prompt: () => null, confirm: () => true, alert(){},
  /* **The suite runs with a network that throws.** Every test in this file
     drives the app with this in place, so "local-folder mode makes no request"
     is not a claim but a thing that would fail loudly. The Dropbox tests swap in
     a recording fake for their own duration and put this back. */
  fetch: () => { throw new Error("the suite makes no network calls — a test that needs one swaps ctx.fetch"); },
};
ctx.globalThis = ctx;
vm.createContext(ctx);

const html = fs.readFileSync(__dirname + "/www/index.html", "utf8");
/* Non-greedy on purpose: a browser ends the script at the FIRST closing tag in
   its text, so this must too. A greedy match would happily run past one written
   by accident in a comment or a string and test code the browser never reaches
   — see the closing-tag check further down. */
const src = html.match(/<script>([\s\S]*?)<\/script>/)[1];
// expose the internals we want to poke at
vm.runInContext(src + "\n;globalThis.__api = {parseDate, fmtDate, normDate, normHour, addDays, daysBetween, dayStart, durationDays," +
  "parseCSV, sanitizeTasks, normKey, mapHeader, parseImport, ganttRange, state," +
  "markupText, xlText, parseSpreadsheetML, parseHtmlTable, parseMSPDI, htmlCellText, importGrid, IMPORT_FORMATS, LEVEL_COL," +
  "orderPeriod, fixModalPeriod, fixInlinePeriod, periodFixToast, cleanFileName, fileExists," +
  "addMonths, monthIndex, monthPos, daysInMonth, mainTasks, mainTaskOf, heliModel, recalcGroups, validateTask, renderHeli," +
  "monthSegments, subtasksInMonth, densityBand, HV_SHADES, spansADay, dayModel, renderDay, renderZoom," +
  "projectMonthBounds, milestoneLanes, drillToDay, backToHelicopter, monthAtX, monthAtCentre, hourModel, renderHour, drillToHour, backToDay, dayAtX, hourX, HOUR_WINDOW, renderTable, duplicateTask, laneBuckets, laneRowsMonth, laneRowsDay, laneModel, renderLane, laneKeyAtY, laneValue, laneFocus, setZoom, zoomUnit," +
  "markDirty, markViewDirty, undo, redo, resetHistory, history, HISTORY_MAX," +
  "normSearch, taskHit, searchHits, jumpToHit, clearSearch, updateSearchCount, looksLikeProjectFile, showImportPreview, removeFromGroup, addToGroup, taskById, groupOf, isGroup, saveFile," +
  "openAbout, closeAbout, aboutOpen, buildGroupLvlOptions, groupLvlLabel, depthOf, parentOf, ancestorsOf, subtreeHeight, descendantsOf, descendantRows, displayTasks, MAX_GROUP_DEPTH, GROUP_LEVELS, withinLevel, clampGroupLevel, rowClass, mainTaskOf, selectRow, clearSelection, sectionOf, sectionRows, addSelectionToGroup, removeSelectionFromGroup, renderGantt, render, renderHour, milestoneWhen, milestoneShiftLabel, shiftMilestoneDependents, downstreamOfMilestone, openModal, closeModal, saveModal, applyGroupDates, recalcPlan, renumberByTime, timeOrder, cmpTime, serializeCSV, NOTES_MAX," +
  "newTaskAnchor, anchorDates, startInline, cancelInline, saveInline," +
  "runRowAction, rowActions, selectionActions, openRowMenu, closeRowMenu, rowMenuOpen, placeRowMenu, setView," +
  "duplicateTasks, deleteTasks, shiftIdsUpFrom," +
  "COLUMNS, columnLayout, headRowHtml, runColAction, colMenuActions, openColMenu, columnByKey, parseViewLine, viewLines, viewValue, viewValues, parseCollapsed, inlineRowHtml, tombstoneLines, parseTombstones, recordDeletions, DEL_LINE, mergeSideOf, mergeDelCounts, mergeChoiceHtml," +
  "META_LINE, WRITER_ID, metaValue, metaLines, planReadOnlyOf, planWriterOf," +
  "FORMAT_VERSION, planFormatOf, parseMetaExtra," +
  "orderedColumns, moveColumn, resetColumnOrder, colOrderCustom, parseColOrder, manageKind, MANAGE_VIEWS," +
  "PLAN_EXT, PLAN_EXTS, isPlanFile, dropPlanExt, cleanFileName, listProjectFiles," +
  "buildMerge, mergeCounts, mergePlanned, mergeAll, applyMerge, renderMerge, openMergePicker, compareWith, closeMerge, mergeOpen, mergeRows, setMergeChoice," +
  "setStatus, markViewDirty," +
  "MERGE_FIELDS, MERGE_KINDS, mergeDiff, newerSide, mergeAdopt," +
  "myId, myName, setMyName, cleanMyName, loadMe, askWhoIAm, syncAboutMe, editMyName, meStamp, parseStamp, stampText," +
  "utcNow, recordEdits, rowSig, fmtHistoryBy, readHistoryBy," +
  "UID_RE, newUid, cleanUid, seedUids," +
  "registryOf, kindLabel, LIST_KINDS, MANAGE_TITLES, applyRename, renameEntry, renamePeopleValue, taskCountFor, loadLists, saveLists," +
  "teamOfPerson, setPersonTeam, assignPersonTeam, weekStartOf, isoWeekNum, teamMembers, teamScope, taskLoadIn," +
  "runsInWeek, teamWeekRows, teamLaterRows, teamBoard, renderTeam, refreshTeamControls, TEAM_LATER_MAX," +
  "SAVED_VIEW_KINDS, SAVED_VIEWS_MAX, VIEW_NAME_MAX, setViewRows, chartPane, savableView, fracHour, hourFrac, viewAxis," +
  "viewCentre, centreOn, topRowKey, scrollToRow, captureView, applyView, svEnc, svDec, cellEnc, cellDec, cleanViewName, savedViewCell," +
  "parseSavedViewCell, parseSavedViews, autoViewName, saveCurrentView, applySavedView, deleteSavedView, refreshSavedViews," +
  "MASS_FIELDS, openMassChange, applyMassChange, readMass, closeMass, massOpen," +
  "xmlEsc, xlCell, xlDate, xlRows, xlSheetName, spreadsheetML, XL_COLS, XL_LEVELS, xlCols," +
  "readOnly, planOpen, dispId, mergeTasks, openCombined, leaveCombined, syncCombinedUI, activeColumns," +
  "roReason, fsMode, blockedByReadOnly, setReadOnly, syncReadOnlyUI, parseLaunchParams, applyLaunchParams," +
  "openWantedFile, restoreSavedFolder, pickFolder, useRoot, openFile, withBusy, busyDepth, createFile, newFile, addListEntry, deleteListEntry," +
  "startImport, applyImport, renderManage, refreshFileList, idb, showCombined," +
  "buildSnapshot, appSource, snapshotFiles, snapshotName, exportSnapshot, openSnapshot, syncSnapshotUI, SNAPSHOT_HOSTS," +
  "encodePlanLink, decodePlanLink, openPlanLink," +
  "foldAll, isCollapsed, freeFileName, saveAsFile, openFirstProject, lastFileName, rememberLastFile," +
  "CFG_KEY, CFG_FILE, LANGS, MODES, isLang, cfgDefaults, cleanCfg, loadCfg, saveCfg, browserLang," +
  "myLang, dtFmt, appMode, restricted, setLang, exportCfg, applyCfg, loadCfgFile, syncCfgUI, syncModeUI, setMode, leaveEverything," +
  "LAST_PLAN_KEY, PLAN_MIME, planPickerOpts, rememberPlanHandle, lastPlanHandle, acceptPlanText," +
  "unsavedWork, offerToSave, I18N, trans, translateDom, I18N_HOSTS," +
  "THEME_KEY, THEMES, systemDark, storedTheme, themeNow, applyTheme, setTheme, toggleTheme, initTheme," +
  "syncMoreMenu, closeMoreMenu, moreMenuOpen," +
  "fileChangedUnderUs, handleConflict, rememberFileStamp, whoChanged, showMerge," +
  "openPlanManually, openPlanFromFile, saveRestricted, adoptPlan, downloadText," +
  "READ_TIMEOUT_MS, READ_WAITS, withTimeout, inspectPlanText, readPlanFile, readFailText, sayReadFailed," +
  "offlineStepsText, savePlanCopy, deviceTag, stampForName, closeConflict, conflictMerge, conflictSaveCopy, conflictOverwrite," +
  "LONG_PRESS_MS, LONG_PRESS_SLOP, onLongPress," +
  "CHECK_FAILED, okToSaveUnchecked, mergeValue," +
  "VIEW_PARAM, takeWantedView, planName, planRef, setOpenPlan," +
  "FsaAdapter, NativeAdapter, setKeyboardHeight, forceViewportRelayout, AI_SUFFIX, isAiPlan, showPlanName, projectTitle, refreshFileList," +
  "dragHasFile, openDroppedPlan," +
  "SPEC_URL, PUBLIC_KEY_B64, licence, licensedTo, parseLicence, checkLicence, licenceText, syncLicenceUI," +
  "LIC_KEY, storedLicence, storeLicence, forgetLicence, applyLicenceText, clearLicenceEntry," +
  "EDITION, isFree, freeFile, PRO_VIEWS, viewAllowed, applyEdition, SHARE_CREDIT_FREE," +
  "pinchWidth, touchGap, zoomUnit, setZoom, PHONE_W, onPhone, RES_LEFT_PHONE, loadLeftShut, saveLeftShut, toggleLeft, syncLeftShut, syncPlanName," +
  "COCK_SPANS, COCK_NOW_AT, COCK_POLL_MS, COCK_REBUILD_MS, COCK_STATES, cockSpan, cockWindow, cockTicks," +
  "COCK_NO_TEAM, setCockBy, toggleCockFocus, cockMonthAt, isCancelled, cancelledCls, HEALTH_TERMINAL, countsAsWork, stillAhead," +
  "dbxNet, dbxFetch, dbxFailText, dashCancelled, DASH_HEALTH," +
  "TRASH_EXT, isTrashFile, trashNameOf, untrashNameOf, deletePlan, restorePlan, destroyPlan, freeNameOn, copyPlanTo, copyPlansTo, withOtherStore, copyPlanAway, canCopyAway, copyAwayLabel, syncCopyAway, COPY_AWAY_WHY, syncTrashUI, syncDeleteBtn, openTrash, closeTrash, askConfirm, canDeletePlans," +
  "updateTaskSheet, setTaskDays, syncDurChips, syncStatusChips, syncTeamChips, syncTeamPlaceholder, STATUS_HEALTH_CLS, ADV_OPEN_KEY, advWasOpen, rememberAdvOpen," +
  "cockStateOf, cockLateBy, cockDelayText, cockBar, cockPack, cockLaneOf, cockpitModel," +
  "renderCockpit, cockSlide, cockWatching, cockWatchLabel, cockPoll, cockLoop, cockStart, cockStop, setCockSpan," +
  "isMainTask, isMainTaskIn, isReported, setReported, notStarted, resetDueDates, driftDays, baselineEndOf," +
  "projectTitle, setProjectName, renameProject, reportHeadHtml, PROJECT_NAME_MAX," +
  "REPORT_RULES, reportRule, reportRuleFor, reportOverall, leavesUnder, reportWeek, reportModel," +
  "reportSynthNote, reportSummaryCell, reportSummaryHtml, reportTableHtml, reportWeekHtml, renderReport, repList, repNames," +
  "listProjectFiles, openCombinePicker, closeCombine, combineOpen," +
  "ZOOM_LEVELS, ZOOM_MS, ZOOM_FAR, ZOOM_NEAR, zoomDirection, zoomAnim, zoomTo, captureZoomLayer, playZoomTransition, motionAllowed," +
  "STATUSES, DEFAULT_STATUS, STATUS_DOT, STATUS_ACT, STATUS_SEL_ACT, normStatus, today, parseHistory, fmtHistory," +
  "historyText, setTaskStatus, seedTask, changeStatus, statusItems, statusPillHtml, statusSelectHtml," +
  "buildStatusOptions, openStatusMenu, defaultHiddenCols, STATUS_STRENGTH, groupStatus," +
  "HEALTH, HEALTH_KEYS, healthByKey, healthAt, healthOf, healthCls, healthTip, healthLegendHtml, statusAt, bornAt," +
  "dashTasks, healthCounts, dashPct, milestoneHealth, MS_HEALTH, teamWorkload, DASH_TOP_TEAMS, DASH_WEEKS," +
  "progressAt, progressModel, trendModel, overdueBands, OVERDUE_BANDS, renderDash, dashKpiHtml, DASH_CARDS," +
  "donutHtml, svgPath, svgX, svgY, dashHealthBarHtml, dashMilestoneHtml, dashTeamHtml, dashFullHtml, DASH_HORIZON," +
  "CSV_COLS, HEADER, csvIndex, LEGACY_INDEX, parsePerson, parsePeople, fmtPerson, fmtPeople, normPeople, normAccountable," +
  "taskPeople, personHours, knownPeople, knownValues, onTask, samePerson, peopleCellHtml, normalizePeople, normalizeMilestone," +
  "DEFAULT_WORKLOAD_H, MAX_WORKLOAD_H, isWeekend, taskRunsOn, resourceDays, resourceModel, renderResources," +
  "HEALTH_SETS, HEALTH_FILTER_KEYS, healthPasses, RES_BANDS, workBand, RES_PX_MIN, RES_PX_MAX, RES_ROW_H, HV_ROW_H, refreshFilterSelects, refreshDatalists,FILTER_CHIPS, EXTRA_CHIPS, DASH_CHIPS, ZOOM_CHIPS, TEAM_CHIPS, PRES_CHIPS, COCK_CHIPS, COCK_AXIS_LABEL, cockAxis, cockDefaultBy, cockPersonOf, chipRowHtml, syncDashChips, syncZoomChips, syncCockpitChips, syncPresChips, chipBarClick, setFilter, filterChipOf, filterOn, filterChipText, syncFilterChips, openFilterSheet, closeFilterSheet, fSheetOpen, filterSheetHtml, placeFilterSheet, syncSearchChip, searchChipOpen, finePointer, FSHEET_FIND_AT," +
  "subtreeIds, shiftSubtree, canAddToGroup, tryAddToGroup, predecessorsViolated, successorsViolated," +
  "designMoveInTime, designResizeBy, designDropOnto, designUngroup, designOverHead, designRowAt," +
  "designStart, designMove, designEnd, barEditable, enterBarEditMode, exitBarEditMode, syncEditBar," +
  "designRowIndex, closestOf, ROW_H, HEAD_H, BAR_H, DESIGN_EDIT_H," +
  // events, and the tick that says a task's dates are still a guess
  "isEvent, isMilestone, msWord, msGlyph, eventEmoji, EVENT_EMOJI, EVENT_EMOJI_PRESETS, normEmoji, EMOJI_MAX," +
  "nameGlyphHtml, markCls, markLabel, markNameLen, sortedMilestones, updateMilestoneUI, buildEmojiPresets," +
  "canBeNotFinal, isNotFinal, finalCls, notFinalTip, setNotFinal, readModal, readInline, inlineNotes," +
  // the dashboard's scope, the folds a filter overrides, and what a bare name costs
  "topGroups, dashScope, refreshDashScope, dashScopeHtml, dashProgressHtml, dashProgressFigures," +
  "eventSpans, milestoneHtml, inlineRowHtml," +
  // what a drag in the Design view would cost the rest of the plan
  "settleShifts, planEndWith, scheduleImpact, impactText, signedDays," +
  "modalGroup, inlineGroup, reopenColMenu, DASH_ALERTS, dashAlert," +
  // typing a task's name where an ID is asked for
  "PRED_MIN_CHARS, PRED_MAX_HITS, predTerm, predKept, predIsSearch, predHits," +
  "predState, predPopOpen, closePredPop, renderPredPop, placePredPop," +
  "popTip, closePopTip, placePopTip, closeBarMenus, BAR_MENUS, closeMenusOnStateChange, vtWrapped, pageTop, touchBar, syncViewToggleMode," +
  "predInput, predAccept, predKey, syncPredNames," +
  // drift: the trail each end date leaves, and the baseline read out of it
  "DATE_HIST_MAX, DRIFT_MIN_W, fmtDateHist, parseDateHist, canDrift, baselineEndOf," +
  "driftDays, hasDrift, driftText, driftTip, recordDateMoves, recordEnd, drawDrift," +
  "driftBarHtml, fillDriftFields," +
  // labels: the plan's own words for a row, and the level filter beside them
  "LABEL_DELIM, parseLabels, fmtLabels, normLabels, labelsOf, sameLabel, hasLabel, labelsCellHtml, searchHay," +
  "ROW_LEVELS, LEVEL_LEAF, clampRowLevel, levelOnly, MASS_FIELDS," +
  // the folder asked for on the way in, when it could not be reconnected in silence
  "openPromptApplies, offerOpenDb, closeOpenPrompt, placeBarMenu, menuEscapes,"+
  "syncRepairReport, repairReportText, copyRepairReport, unlockFailureText,"+
  "syncDbxUnlockRow, syncDropboxKeepBox, dbxUnlockName, dbxSilentUnlock,"+
  "dbxFresh, dbxFreshOpen, dbxUnlockError, dropboxPasteCode, syncDbxPaste, placeMoreViews,"+
  "DBX_REDIRECT, dbxAuthCode, dropboxReturn, dbxRemembered, dbxRemember, syncDbxDiag, dbxAutoStart, setDropboxUnlock," +
  "showBooting, syncEmptyOffers, shareSnapshotNative," +
  "AI_PROVIDERS, AI_KEYS, aiProvider, aiFlow, aiPrompt, aiUrl, aiCopyPlan, syncAiPicker, aiConsent, openAiChat," +
  "aiPasteTarget, syncPasteButtons, readClipboard, unfencePlan, pastePlan, aiDraftPath," +
  "syncAiMenu, closeAiMenu, aiMenuOpen, openAiMenu, pasteFromAi, chooseAiProvider, askAiUrl," +
  "syncSavedViewBtn, savedViewMenuHtml, openSavedViews, closeSavedViews, savedViewsOpen, syncZoomTip," +
  "refreshPlanList, syncMirrors, syncPlanOps, closePlanOps, planOpsOpen, planOpsAllowed, planMenuHtml, openPlanMenu, closePlanMenu, planMenuOpen, syncPlanLabel, openPlanOpsFor," +
  "dbxShouldAsk, dbxAskKeep, dbxKeepYes, dropboxDone," +
  "askText, askSubmit, askCancel," +
  "CTX_BAR_KEY, loadCtxBar, syncCtxBarToggle," +
  // the presentation view: bands, lanes and the bars stacked on them
  "PRES_LINE_H, PRES_BAR_H, PRES_LANE_PAD, PRES_BAND_GAP, PRES_PX_MIN, PRES_PX_MAX, PRES_LEFT_PHONE," +
  "PRES_STARTS, presStartLabel, buildPresStartOptions, clampPresStart, childrenOf," +
  "packLane, presModel, renderPres, presGridHtml, PRES_DAY_GRID, presMarkersHtml, presDrillTo, matchesFilters," +
  "anyFilterOn, isCollapsed, WORKDAY_H," +
  // Dropbox: the way in (P2)
  "DBX_APP_KEY, DBX_AUTH_URL, DBX_TOKEN_URL, DBX_API_URL, DBX_SCOPES, DBX_REFRESH_AHEAD_MS, dbxTokenStore, dbxTokenStorePassword, dbxTokenStoreNative, setDbxTokenStore, b64url, formBody," +
  "b64urlBytes, DBX_STORE_KEY, DBX_KDF_ITER, DBX_PASS_MIN, dropboxKeep, dropboxUnlock, dropboxForgetKept," +
  "DropboxAuth, dropboxAllowed, openDropbox, closeDropbox, dropboxOpen, syncDropboxUI, dropboxBegin, dropboxConnect, dropboxDisconnect," +
  // the Dropbox store (P3)
  "DropboxAdapter, FsaAdapter, setStorage, useLocalFolder, useDropboxAgain, dbxArg, dbxNotFound, dbxConflict, DBX_CONTENT_URL, CHECK_FAILED, fileChangedUnderUs, saveFileNow, handleConflict, closeConflict," +
  // choosing the folder and switching the store (P4)
  "syncSourceUI, fillDropboxFolders, dropboxOwnFolder, useDropboxFolder, leaveDropbox, pickFolder, folderOpen," +
  "pickDbxFolder, updateDbxOpenBtn, dbxPathPreview, syncDropboxKeepBox, dbxKeepToggle," +
  // write throttling (P5)
  "saveFile, savePending, flushSave, DBX_SAVE_DELAY_MS, dbxRetrying, DBX_RETRY_MAX, DBX_RETRY_DEFAULT_MS, leaveEverything," +
  // offline (P6)
  "noteOffline, retryOffline, backOnline, DBX_OFFLINE_RETRY_MS, readFailText, openAbout," +
  // where to open from at start
  "OPENS, startupOpen, offerOpenChoice, chooseOpen, setDefaultOpen, cleanCfg, syncCfgUI, closeOpenPrompt};", ctx);
const A = ctx.__api;

/* askWhoIAm() is fire-and-forget from markDirty() and, unlike the old
   prompt() it replaced, opens an async modal that nothing here drives forward
   unless a test says so — a stray call before any test has set a name would
   leave askOverlay stuck open for the rest of the run. Marking the session
   "already asked" up front keeps that guard closed by default, the same way
   the old stub's `prompt: () => null` did nothing worse than return null. */
A.state.me.asked = true;

/* Which plan is open is one object now — `{name, ref}` — and the ref is the
   adapter's business. These set one half and keep the other, because that is
   what the two fields they replace used to do, and a fixture that quietly blanked
   the name while setting a handle would be testing a state the app cannot reach. */
const useRef = ref => A.setOpenPlan(A.planName(), ref);
const useName = name => A.setOpenPlan(name, A.planRef());

let pass = 0;
const eqArr = (a, b, msg) => assert.strictEqual(JSON.stringify([...a]), JSON.stringify(b), msg);
const t = (name, fn) => { fn(); console.log("  ok  " + name); pass++; };

console.log("\n-- dates --");
t("canonical YYYY-MM-DD", () => assert.strictEqual(A.normDate("2026-07-13"), "2026-07-13"));
t("dotted YYYY.MM.DD", () => assert.strictEqual(A.normDate("2026.07.13"), "2026-07-13"));
t("DD/MM/YYYY", () => assert.strictEqual(A.normDate("13/07/2026"), "2026-07-13"));
t("hungarian short 'K 26.12.15.'", () => assert.strictEqual(A.normDate("K 26.12.15."), "2026-12-15"));
t("'26.12.15' short form", () => assert.strictEqual(A.normDate("26.12.15"), "2026-12-15"));
t("impossible date rejected", () => assert.strictEqual(A.normDate("2026-02-30"), ""));
t("month 13 rejected", () => assert.strictEqual(A.normDate("2026-13-01"), ""));
t("garbage rejected", () => assert.strictEqual(A.normDate("soon"), ""));
t("hour 9:0 -> 09:00", () => assert.strictEqual(A.normHour("9:0"), "09:00"));
t("hour 25:00 rejected", () => assert.strictEqual(A.normHour("25:00"), ""));

console.log("\n-- DST-safe day arithmetic (the Gantt bug) --");
t("addDays across Oct fall-back keeps the calendar date", () => {
  // 2026-10-25 is the EU DST end; stepping with +86400000ms lands on 23:00 of the previous day
  const start = new Date(2026, 9, 20);
  for (let i = 0; i < 20; i++) {
    const naive = new Date(start.getTime() + i * 86400000);
    const good = A.addDays(start, i);
    assert.strictEqual(A.daysBetween(start, good), i, "daysBetween must be exact at i=" + i);
    if (i > 5) assert.strictEqual(good.getHours(), 0, "addDays must stay at midnight at i=" + i);
    void naive;
  }
  // and the naive stepping really is broken, i.e. the test is meaningful
  const naiveLate = new Date(start.getTime() + 10 * 86400000);
  assert.notStrictEqual(naiveLate.getDate(), A.addDays(start, 10).getDate());
});
t("durationDays across DST", () =>
  assert.strictEqual(A.durationDays({start:"2026-10-24", end:"2026-10-26"}), 3));

console.log("\n-- sanitizeTasks (hand-edited file repair) --");
/* Groups nest MAX_GROUP_DEPTH levels: group -> subgroup -> … -> task. A tree
   exactly that deep is the point of the feature and must survive untouched; one
   level further is cut, and the over-deep row is detached rather than deleted so
   nothing disappears from the file. Both trees are built from the constant, so
   adding a level of grouping needs no edit here. */
const chainCsv = n => [
  "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone",
  ...Array.from({ length: n }, (_, i) =>
    `${i + 1}|L${i}||2026-01-01||2026-01-05|||||${i < n - 1 ? i + 2 : ""}|no`),
].join("\n");

t("a tree nested to the limit is left alone", () => {
  const rows = A.MAX_GROUP_DEPTH + 1;            // rows in a chain = levels + 1
  const tasks = A.parseCSV(chainCsv(rows));
  const w = A.sanitizeTasks(tasks);
  for (let i = 1; i < rows; i++)
    eqArr(tasks.find(x => x.id === i).group, [i + 1], `level ${i - 1} lost its member`);
  assert.strictEqual(A.depthOf(rows, tasks), A.MAX_GROUP_DEPTH, "the chain is not as deep as it should be");
  eqArr(w, [], "a legal tree needs no repair");
});
t("one level too deep is cut, and the row is kept", () => {
  const rows = A.MAX_GROUP_DEPTH + 2;
  const tasks = A.parseCSV(chainCsv(rows));
  const w = A.sanitizeTasks(tasks);
  assert.strictEqual(tasks.length, rows, "no row is dropped");
  assert(tasks.every(x => A.depthOf(x.id, tasks) <= A.MAX_GROUP_DEPTH),
    "nothing deeper than " + A.MAX_GROUP_DEPTH + " levels survives");
  assert(w.some(x => /nest at most/.test(x)), "must warn: " + JSON.stringify(w));
});
t("a membership loop is broken without losing a row", () => {
  const tasks = A.parseCSV([
    "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone",
    "1|A||2026-01-01||2026-01-05|||||2|no",
    "2|B||2026-01-01||2026-01-05|||||1|no",
  ].join("\n"));
  const w = A.sanitizeTasks(tasks);
  assert.strictEqual(tasks.length, 2);
  assert(tasks.every(x => A.depthOf(x.id, tasks) <= 2), "no cycle left");
  assert(w.some(x => /loop/.test(x)), "must warn: " + JSON.stringify(w));
});
/* 🔴 The shape ChatGPT wrote on 2026-08-22 (gantora-ai-teszt-leletek §8/2): the
   Group column holds a row's CHILDREN, and it put the PARENT's id in each
   child's row. Nothing said so — the plan simply loaded upside down. */
t("a hierarchy written the other way up is turned around, and said out loud", () => {
  const tasks = A.parseCSV([
    "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone",
    "5|Csomagolás||2026-03-02||2026-03-06|||||||no",           // the summary row, no children named
    "6|Dobozok||2026-03-02||2026-03-03|||||5|no",              // …named by its children instead
    "7|Címkézés||2026-03-04||2026-03-05|||||5|no",
  ].join("\n"));
  const w = A.sanitizeTasks(tasks);
  const by = id => tasks.find(t => t.id === id);
  eqArr(by(5).group, [6, 7], "the summary row is the group now");
  eqArr(by(6).group, [], "and the leaves hold nobody");
  eqArr(by(7).group, []);
  assert(w.some(x => /other way up/.test(x)), "and it is reported: " + JSON.stringify(w));
});

/* …but only when the file leaves no doubt. ONE row naming a longer row is the
   ordinary shape of a plan whose group dates have not been rolled up yet, and
   the two cannot be told apart — so nothing is turned around on a guess. */
t("a single row naming a longer row is left exactly as it is", () => {
  const tasks = A.parseCSV([
    "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone",
    "1|Group with stale dates||2026-03-02||2026-03-03|||||2|no",
    "2|Its one child||2026-03-02||2026-03-09|||||||no",
  ].join("\n"));
  const w = A.sanitizeTasks(tasks);
  eqArr(tasks[0].group, [2], "🔴 a correct plan was turned upside down on a guess");
  assert(!w.some(x => /other way up/.test(x)), "and nothing was claimed: " + JSON.stringify(w));
});

t("self-referencing group is broken", () => {
  const tasks = A.parseCSV("1|G||2026-01-01||2026-01-02|||||1|no");
  A.sanitizeTasks(tasks);
  eqArr(tasks[0].group, []);
});
t("duplicate IDs are renumbered, not merged", () => {
  const tasks = A.parseCSV(["3|A||2026-01-01||2026-01-02||||||no",
                            "3|B||2026-01-03||2026-01-04||||||no"].join("\n"));
  const w = A.sanitizeTasks(tasks);
  assert.strictEqual(tasks.length, 2, "no row may be dropped");
  assert.notStrictEqual(tasks[0].id, tasks[1].id);
  assert(w.some(x => /Duplicate ID 3/.test(x)));
});
t("dangling predecessor dropped", () => {
  const tasks = A.parseCSV("1|A||2026-01-01||2026-01-02|99|||||no".replace("|99|", "|99|"));
  const t2 = A.parseCSV("1|A|| 2026-01-01|| 2026-01-02||99||||no");
  A.sanitizeTasks(t2);
  eqArr(t2[0].preds, []);
  void tasks;
});
t("milestone cannot be a group member", () => {
  const tasks = A.parseCSV(["1|G||2026-01-01||2026-01-02|||||2|no",
                            "2|MS||2026-01-02||2026-01-02||||||yes"].join("\n"));
  A.sanitizeTasks(tasks);
  eqArr(tasks[0].group, []);
});
t("stored '2026.07.13' round-trips to the date input format", () => {
  const tasks = A.parseCSV("1|A||2026.07.13|9:0|2026.07.14|17:30||||| no");
  assert.strictEqual(tasks[0].start, "2026-07-13");
  assert.strictEqual(tasks[0].hourStart, "09:00");
  assert.strictEqual(tasks[0].hourEnd, "17:30");
});

console.log("\n-- Excel readers: the two markup formats --");

/* The fixtures on disk are still delimited text: they are the real plans the
   header mapping was built against, and their value is their headers, not their
   punctuation. So the suite turns them into Excel sheets on the way in — which
   is also what a person does with them now that the importer reads Excel.
   Hence this little splitter: it is scaffolding for building fixtures, not a
   reader the app has (the app's CSV reader was removed with the CSV import). */
const splitRows = (text, delim) => text.replace(/^﻿/, "").split(/\r?\n/)
  .filter(l => l.trim() !== "")
  .map(l => {
    const cells = []; let cell = "", quoted = false;
    for (let i = 0; i < l.length; i++) {
      const c = l[i];
      if (quoted) { if (c === '"' && l[i + 1] === '"') { cell += '"'; i++; } else if (c === '"') quoted = false; else cell += c; }
      else if (c === '"' && cell === "") quoted = true;
      else if (c === delim) { cells.push(cell); cell = ""; }
      else cell += c;
    }
    cells.push(cell);
    return cells;
  });

const esc = s => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

// the same grid as the two things the importer reads
const asXml = grid => `<?xml version="1.0"?><?mso-application progid="Excel.Sheet"?>
<Workbook xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"><Worksheet ss:Name="S"><Table>` +
  grid.map(r => "<Row>" + r.map(c =>
    String(c) === "" ? "<Cell/>" : `<Cell><Data ss:Type="String">${esc(c)}</Data></Cell>`).join("") + "</Row>").join("") +
  `</Table></Worksheet></Workbook>`;

const asHtml = grid => `<html><body><table>` +
  grid.map(r => "<tr>" + r.map(c => `<td>${esc(c)}</td>`).join("") + "</tr>").join("") +
  `</table></body></html>`;

// a delimited fixture, imported the way it now has to be
const impXml = (text, delim = ";", name = "x.xml") => A.parseImport(asXml(splitRows(text, delim)), name);
const impHtml = (text, delim = ";", name = "x.html") => A.parseImport(asHtml(splitRows(text, delim)), name);

t("an XML Spreadsheet is recognised and read", () => {
  const g = A.importGrid(asXml([["ID", "Task"], ["1", "Build"]]));
  assert.strictEqual(g.kind, "xml");
  eqArr(g.rows[0], ["ID", "Task"]);
  eqArr(g.rows[1], ["1", "Build"]);
});

t("an HTML table is recognised and read", () => {
  const g = A.importGrid("<table><tr><th>ID</th><th>Task</th></tr><tr><td>1</td><td>Build</td></tr></table>");
  assert.strictEqual(g.kind, "html");
  eqArr(g.rows[0], ["ID", "Task"]);
  eqArr(g.rows[1], ["1", "Build"]);
});

t("anything that is neither comes back with no kind, and says so", () => {
  const g = A.importGrid("ID;Task\n1;Build\n");
  assert.strictEqual(g.kind, "");
  assert.strictEqual(g.rows.length, 0);
  const r = A.parseImport("ID;Task\n1;Build\n", "old.csv");
  assert.strictEqual(r.rows.length, 0);
  /* The sentence names all three readers since MSPDI joined them (2026-08-23):
     a person holding a Project file must not be told the app reads Excel only. */
  assert(/not a file this app can read/i.test(r.warn[0]), "the reason must be the one shown: " + r.warn[0]);
  assert(/Microsoft Project/i.test(r.warn[0]), "…and it must name the third format too: " + r.warn[0]);
});

t("empty cells keep the columns lined up, however Excel wrote them", () => {
  // ss:Index is how Excel skips a run of empty cells instead of writing them
  const xml = `<Workbook><Worksheet><Table>
    <Row><Cell><Data ss:Type="String">A</Data></Cell><Cell><Data ss:Type="String">B</Data></Cell><Cell><Data ss:Type="String">C</Data></Cell><Cell><Data ss:Type="String">D</Data></Cell></Row>
    <Row><Cell><Data ss:Type="String">1</Data></Cell><Cell ss:Index="4"><Data ss:Type="String">4</Data></Cell></Row>
    <Row><Cell/><Cell ss:MergeAcross="1"><Data ss:Type="String">wide</Data></Cell><Cell><Data ss:Type="String">last</Data></Cell></Row>
    </Table></Worksheet></Workbook>`;
  const rows = A.parseSpreadsheetML(xml);
  eqArr(rows[1], ["1", "", "", "4"], "ss:Index must be filled back in as blanks");
  eqArr(rows[2], ["", "wide", "", "last"], "ss:MergeAcross must be filled back in as blanks");
});

t("a typed date arrives as the day, and rich text as its text", () => {
  const xml = `<Workbook><Row><Cell><Data ss:Type="DateTime">2026-03-02T00:00:00.000</Data></Cell>` +
              `<Cell><Data ss:Type="String"><B>Bold</B> bit</Data></Cell>` +
              `<Cell><Data ss:Type="String">R&amp;D &lt;core&gt;</Data></Cell></Row>`;
  eqArr(A.parseSpreadsheetML(xml)[0], ["2026-03-02", "Bold bit", "R&D <core>"]);
});

t("colspan, line breaks and entities in an HTML table", () => {
  const rows = A.parseHtmlTable(
    `<table><tr><td colspan="2">wide</td><td>last</td></tr>` +
    `<tr><td>a<br>b</td><td>&nbsp;</td><td>R&amp;D</td></tr></table>`);
  eqArr(rows[0], ["wide", "", "last"]);
  eqArr(rows[1], ["a b", "", "R&D"]);
});

t("only the first table is read — a second sheet is not stapled on", () => {
  const rows = A.parseHtmlTable("<table><tr><td>one</td></tr></table><table><tr><td>two</td></tr></table>");
  assert.strictEqual(rows.length, 1);
  eqArr(rows[0], ["one"]);
});

t("an escaped entity is not unescaped twice", () => {
  assert.strictEqual(A.markupText("&amp;lt;"), "&lt;", "'&amp;lt;' is the four characters somebody typed");
});

console.log("\n-- Excel import --");
/* The foreign-sheet fixture, written here rather than kept as DB/TesztImport.csv.
   Three lines are not worth a file, and that file was **shipped in the download**
   — every reader unpacked a stranger's test data into their own plans folder,
   naming a real customer system. What the tests need is the *shape*: comma
   delimited, Hungarian headers, a weekday-prefixed date, a "MILESTONE:" name
   prefix and a Technical resource. The BOM is written as an escape on purpose;
   a literal one is invisible to every tool that has to edit this line later. */
const impText = "\uFEFF" + [
  "Number,Task,Kezdés,Hour (Start),Befejezés,Hour (End),Dependency,Resources,System,Notes",
  "1,PIN Process Change,,,,,,,,",
  "2,MILESTONE: Latest Release Day,K 26.12.15.,,K 26.12.15.,,,Technical,Technical,",
].join("\r\n") + "\r\n";
t("localised headers map", () => {
  const { map } = A.mapHeader(["Number","Task","Kezdés","Hour (Start)","Befejezés","Hour (End)","Dependency","Resources","System","Notes"]);
  assert.strictEqual(map.id, 0);
  assert.strictEqual(map.name, 1);
  assert.strictEqual(map.start, 2);
  assert.strictEqual(map.hourStart, 3);
  assert.strictEqual(map.end, 4);
  assert.strictEqual(map.preds, 6);
  assert.strictEqual(map.team, 7);
  assert.strictEqual(map.system, 8);
});

const imp = impXml(impText, ",", "foreign-sheet.xml");
t("both data rows imported", () => assert.strictEqual(imp.rows.filter(r => !r.skip).length, 2));
t("row 1 is a plain, date-less task", () => {
  const r = imp.rows[0];
  assert.strictEqual(r.name, "PIN Process Change");
  assert.strictEqual(r.milestone, false);
  assert.strictEqual(r.start, "");
});
t("row 2 detected as a milestone (name prefix AND Technical resource)", () => {
  const r = imp.rows[1];
  assert.strictEqual(r.milestone, true);
  assert.strictEqual(r.name, "Latest Release Day", "MILESTONE: prefix must be stripped");
  assert.strictEqual(r.start, "2026-12-15");
  assert.strictEqual(r.end, "2026-12-15", "a milestone's end mirrors its start");
  assert.strictEqual(r.team, "", "milestones carry no team");
});
t("TECHNICAL team alone marks a milestone", () => {
  const r = impXml("Number;Task;Resources\n1;Plain thing;technical\n");
  assert.strictEqual(r.rows[0].milestone, true);
});
t("MILESTONE prefix alone marks a milestone", () => {
  const r = impXml("Number;Task;Resources\n1;MILESTONE Go live;Dev Team A\n");
  assert.strictEqual(r.rows[0].milestone, true);
  assert.strictEqual(r.rows[0].name, "Go live");
});
t("ordinary rows are not milestones", () => {
  const r = impXml("Number;Task;Resources\n1;Technical design;Architecture\n");
  assert.strictEqual(r.rows[0].milestone, false, "'Technical design' is a name, not a TECHNICAL resource");
});
t("unnamed rows are skipped, not silently mangled", () => {
  const r = impXml("Number;Task\n1;\n2;Real\n");
  assert.strictEqual(r.rows[0].skip, true);
  assert.strictEqual(r.rows.filter(x => !x.skip).length, 1);
});
t("dependencies parsed from mixed separators", () => {
  const r = impXml("Number;Task;Dependency\n1;A;\n2;B;1\n3;C;1, 2\n");
  eqArr(r.rows[2].preds, [1, 2]);
});
/* Notes used to be dropped and reported as an unused column; it is a real field
   now, so the importer claims it instead of warning about it. */
t("a Notes column is imported, not discarded", () => {
  assert.strictEqual(imp.map.notes, 9, "the Notes column is recognised");
  assert(!imp.warn.some(w => /Notes/.test(w)), "and no longer reported unused: " + JSON.stringify(imp.warn));
});
t("note text survives the import", () => {
  const r = impXml("Number;Task;Notes\n1;A;Ring the vendor first\n");
  assert.strictEqual(r.rows[0].notes, "Ring the vendor first");
});
t("an over-long note is cut to the field's limit on import", () => {
  const r = impXml("Number;Task;Notes\n1;A;" + "x".repeat(700) + "\n");
  assert.strictEqual(r.rows[0].notes.length, 500);
});

/* Headers carry qualifiers and Hungarian inflections no alias list can enumerate.
   "Tevékenység neve" and "Megelőzők" used to fall through: the name column was
   only found by the positional fallback (which warned), and the predecessors were
   dropped entirely — every dependency in the file silently vanished. */
console.log("\n-- inflected / qualified headers --");
t("'Megelőzők' maps to predecessors", () => {
  const { map } = A.mapHeader(["ID","Tevékenység neve","Kezdés","Megelőzők"]);
  assert.strictEqual(map.preds, 3);
});
t("'Tevékenység neve' maps to the name column", () => {
  const { map } = A.mapHeader(["ID","Tevékenység neve","Kezdés","Megelőzők"]);
  assert.strictEqual(map.name, 1);
});
t("qualified date headers map", () => {
  const { map } = A.mapHeader(["Start Date (plan)","Kezdés dátuma","Befejezés dátuma"]);
  assert.strictEqual(map.start, 0, "first exact-ish hit wins");
  assert.strictEqual(map.end, 2);
});
t("an exact hit still beats a loose one", () => {
  const { map } = A.mapHeader(["Tevékenység neve","Name"]);
  assert.strictEqual(map.name, 1, "the exact 'Name' column must win over the inflected one");
});
t("short aliases do not swallow unrelated columns", () => {
  const { map, unmapped } = A.mapHeader(["Task","Nordic region","Identity check","Total cost"]);
  assert.strictEqual(map.name, 0);
  assert.strictEqual(map.id, undefined, "'Identity check' is not an ID column");
  eqArr(unmapped, ["Nordic region","Identity check","Total cost"]);
});
t("a Duration column stays quietly unused", () => {
  const { unmapped } = A.mapHeader(["Task","Időtartam (nap)","Duration"]);
  eqArr(unmapped, [], "duration is derived from start/end — not worth a warning");
});

/* Team "TECHNICAL" marks marker rows, but plans also use it for real technical
   work. Honouring it on a row that spans days collapsed the end date onto the
   start and threw away its team/system. */
console.log("\n-- TECHNICAL is a hint, not a verdict --");
const HDR = "ID,Tevékenység neve,Kezdés,Befejezés,Megelőzők,Team,System\n";
t("a single-day TECHNICAL row is still a milestone", () => {
  const r = impXml(HDR + "1,Release day,K 26.12.15.,K 26.12.15.,,Technical,Technical\n", ",");
  assert.strictEqual(r.rows[0].milestone, true);
});
t("a multi-day TECHNICAL row is real work", () => {
  const r = impXml(HDR + "1,Second day,Szo 26.12.19.,V 26.12.20.,,Technical,Technical\n", ",");
  const row = r.rows[0];
  assert.strictEqual(row.milestone, false);
  assert.strictEqual(row.end, "2026-12-20", "its end date must survive");
  assert.strictEqual(row.team, "Technical", "its team must survive");
});
t("an explicit MILESTONE prefix still collapses a multi-day row", () => {
  const r = impXml(HDR + "1,MILESTONE: Go live,Szo 26.12.19.,V 26.12.20.,,Technical,Technical\n", ",");
  assert.strictEqual(r.rows[0].milestone, true);
  assert.strictEqual(r.rows[0].end, "2026-12-19");
});

/* ---- Microsoft Project XML (MSPDI) ---------------------------------------
   The format MS Project writes on "Save as… XML", and the road off it. Read
   into a GRID and then through the importer everybody else uses, so what is
   tested here is the reader — the mapping, the repairs and the preview are
   already covered above and are not duplicated.

   The fixture is a plan with the shape that breaks readers: a project summary
   row at outline level 0, groups at two depths, an entity in a name, a note,
   links that name UIDs rather than row numbers, and a milestone at the end. */
console.log("\n-- Microsoft Project XML (MSPDI) --");
const mspdiSrc = fs.readFileSync(__dirname + "/DB/_fixture-mspdi.xml", "utf8");

t("an MS Project export is recognised as itself, not as an Excel sheet", () => {
  const g = A.importGrid(mspdiSrc);
  assert.strictEqual(g.kind, "mspdi", "both formats are .xml — the namespace is what tells them apart");
  assert.strictEqual(A.IMPORT_FORMATS.mspdi, "Microsoft Project XML (.xml)");
  // and the older reader must not claim it
  assert.notStrictEqual(A.importGrid(asXml([["ID", "Task"], ["1", "Build"]])).kind, "mspdi");
});

t("the grid it hands over is a sheet the importer already knows how to read", () => {
  const rows = A.parseMSPDI(mspdiSrc);
  eqArr(rows[0], ["ID", "Level1", "Level2", "Level3", "Start", "End", "Predecessors", "Milestone", "Notes"],
    "three name columns because the file is three deep, and headers the alias list knows");
  /* The project's own row is not a task: MS Project keeps the plan's name and
     its whole span at outline level 0, and importing it would wrap every other
     row in a group nobody wrote. */
  assert(!rows.some(r => r.slice(1, 4).join("") === "Terasz felújítás"), "the project summary row is left out");
  assert.strictEqual(rows.length, 10, "nine tasks and the header");
});

t("depth, dates, links and the milestone all arrive", () => {
  const rows = A.parseMSPDI(mspdiSrc);
  const at = name => rows.find(r => r.slice(1, 4).includes(name));
  const levelOf = r => r.slice(1, 4).findIndex(c => c !== "") + 1;

  assert.strictEqual(levelOf(at("Bontás")), 1, "a top-level group writes into Level1");
  assert.strictEqual(levelOf(at("Aljzat vizsgálat")), 2);
  assert.strictEqual(levelOf(at("Gerendák")), 3, "…and the deepest into Level3");

  const dig = at("Régi burkolat & szegély");
  assert(dig, "an entity in a name comes back as the character");
  assert.strictEqual(dig[4], "2026-09-01", "the date part of an MSPDI timestamp");
  assert.strictEqual(dig[5], "2026-09-02", "…and MS Project's 17:00 finish is still that day");
  assert.strictEqual(dig[8], "Konténer a ház elé, hétfő reggel", "the note travels");

  /* 🔴 The links name PredecessorUID, and the ID column carries the UID for
     exactly that reason — a link that pointed at MS Project's row number would
     break the moment anybody reordered anything there. */
  assert.strictEqual(at("Aljzat vizsgálat")[6], "2", "the link is the predecessor's UID");
  assert.strictEqual(at("Deszkázás")[6], "6");
  assert.strictEqual(at("Átadás")[7], "yes", "Milestone 1 is a milestone");
  assert.strictEqual(at("Gerendák")[7], "no");
});

t("through the importer: names, nesting and dependencies land in the plan", () => {
  const r = A.parseImport(mspdiSrc, "Terasz.xml");
  assert.strictEqual(r.kind, "mspdi");
  /* One line, and it is the one that says where the hierarchy came from —
     the importer's own notice for a sheet whose nesting is in Level columns
     rather than in a Group cell. That IS the mechanism this reader leans on,
     so the right assertion is that it says so and says nothing else. */
  assert.strictEqual(r.warn.length, 1, "one notice, no faults: " + JSON.stringify(r.warn));
  assert(/nesting was read from the Level columns/.test(r.warn[0]), r.warn[0]);
  const rows = r.rows.filter(x => !x.skip);
  assert.strictEqual(rows.length, 9);

  const by = name => rows.find(x => x.name === name);
  assert.strictEqual(by("Régi burkolat & szegély").start, "2026-09-01");
  assert.strictEqual(by("Átadás").milestone, true);
  assert.strictEqual(by("Átadás").end, "2026-09-18", "a milestone is one point in time");

  // the nesting the Level columns carried, as the importer reconstructs it
  assert.strictEqual(by("Bontás").level, 0, "level is zero-based on the row");
  assert.strictEqual(by("Aljzat vizsgálat").level, 1);
  assert.strictEqual(by("Gerendák").level, 2);

  // and the dependencies, still naming the source's UIDs for the remap to resolve
  eqArr(by("Aljzat vizsgálat").preds, [2]);
  eqArr(by("Deszkázás").preds, [6]);
  eqArr(by("Átadás").preds, [8]);
  assert.strictEqual(rows.every(x => x.preds.every(p => rows.some(y => y.srcId === p))), true,
    "every link points at a row that came in with it — nothing dangles");
});

/* End to end over the real file the dependency loss was reported against. Kept
   under the "_" prefix: that keeps it out of the app's project list, so importing
   it again cannot archive or rewrite the copy this suite reads. */
console.log("\n-- foreign import fixture (Hungarian headers) --");
const cofi = impXml(fs.readFileSync(__dirname + "/DB/_fixture-cofi-import.csv", "utf8"), ",", "cofi.xml");
t("every column is recognised — no spurious warnings", () => {
  eqArr(cofi.warn, [], "the preview must show a clean warning box");
});
t("its dependencies survive the import", () => {
  const byId = new Map(cofi.rows.map(r => [r.srcId, r]));
  eqArr(byId.get(4).preds, [2]);
  eqArr(byId.get(13).preds, [12]);
  eqArr(byId.get(20).preds, [19]);
  eqArr(byId.get(22).preds, [21]);
  assert.strictEqual(cofi.rows.filter(r => r.preds.length).length, 14);
});
t("only the two marked rows are milestones", () => {
  eqArr(cofi.rows.filter(r => r.milestone).map(r => r.srcId), [2, 12]);
});
t("remapped predecessors point at the right rows after applying", () => {
  // applyImport's remap, reproduced: source IDs become fresh IDs offset by base
  const rows = cofi.rows.filter(r => !r.skip), base = 100;
  const remap = new Map();
  rows.forEach((r, i) => { if (r.srcId != null && !remap.has(r.srcId)) remap.set(r.srcId, base + i + 1); });
  const t22 = rows.find(r => r.srcId === 22);
  eqArr(t22.preds.map(p => remap.get(p)), [remap.get(21)]);
  assert.strictEqual(rows.every(r => r.preds.every(p => remap.has(p))), true, "no predecessor may be dropped as dangling");
});

console.log("\n-- our own Excel export comes back in --");

/* The claim the feature rests on: a plan exported to Excel from here can be
   imported here again with nothing lost. Run over the real sample project, so
   it is exercised against groups nested several deep, milestones, workloads and
   dependencies rather than a fixture built to pass. */
const roundTrip = (() => {
  const before = A.state.tasks;
  A.state.tasks = A.parseCSV(fs.readFileSync(__dirname + "/DB/_fixture-sample-project.csv", "utf8"));
  A.sanitizeTasks(A.state.tasks);
  A.recalcPlan();
  const tasks = A.state.tasks;
  const xml = A.spreadsheetML(A.xlRows(), "sample-project");
  const back = A.parseImport(xml, "sample-project.xml");
  A.state.tasks = before;
  return { tasks, xml, back };
})();

t("the export is read as an XML Spreadsheet, with every column recognised", () => {
  assert.strictEqual(A.importGrid(roundTrip.xml).kind, "xml");
  assert(!roundTrip.back.warn.some(w => /not used/.test(w)),
         "a column of our own must never come back unrecognised: " + JSON.stringify(roundTrip.back.warn));
});

t("every task comes back, by name", () => {
  const got = roundTrip.back.rows.filter(r => !r.skip);
  assert.strictEqual(got.length, roundTrip.tasks.length,
                     `${roundTrip.tasks.length} went out, ${got.length} came back`);
  const names = new Set(got.map(r => r.name));
  for (const t of roundTrip.tasks)
    assert(names.has(t.name), `"${t.name}" did not survive the round trip`);
});

t("the name is read out of whichever Level column the row filled", () => {
  const heads = A.xlCols().map(c => c.head).filter(h => /^Level \d+$/.test(h));
  assert(heads.length > 1, "the export writes one name column per level");
  const { map, nameCols } = A.mapHeader(A.xlCols().map(c => c.head));
  eqArr(nameCols.map(i => A.xlCols()[i].head), heads, "all of them are the name column");
  assert.strictEqual(map.name, nameCols[0], "and the first is the name column proper");
});

t("dates, hours, dependencies and groups survive", () => {
  const byName = new Map(roundTrip.back.rows.map(r => [r.name, r]));
  for (const t of roundTrip.tasks) {
    const r = byName.get(t.name);
    assert.strictEqual(r.start, t.start, `${t.name}: start`);
    assert.strictEqual(r.end, t.end, `${t.name}: end`);
    assert.strictEqual(r.hourStart, t.hourStart, `${t.name}: start hour`);
    assert.strictEqual(r.milestone, !!t.milestone, `${t.name}: milestone flag`);
    assert.strictEqual(r.preds.length, t.preds.length, `${t.name}: predecessor count`);
    assert.strictEqual(r.group.length, (t.group || []).length, `${t.name}: group members`);
  }
  assert(roundTrip.tasks.some(t => t.preds.length), "the fixture has to carry dependencies");
  assert(roundTrip.tasks.some(t => (t.group || []).length), "and groups");
});

t("the people come back too — Accountable and Responsible are their own columns", () => {
  const byName = new Map(roundTrip.back.rows.map(r => [r.name, r]));
  let checked = 0;
  for (const t of roundTrip.tasks) {
    if (!t.accountable && !t.responsible) continue;
    const r = byName.get(t.name);
    assert.strictEqual(r.accountable, t.accountable || "", `${t.name}: accountable`);
    assert.strictEqual(r.responsible, t.responsible || "", `${t.name}: responsible`);
    checked++;
  }
  assert(checked > 3, "the fixture has to name people on several tasks, got " + checked);
});

t("a Responsible column is people now, not a team name", () => {
  const { map } = A.mapHeader(["Task", "Team", "Accountable", "Responsible"]);
  assert.strictEqual(map.team, 1);
  assert.strictEqual(map.accountable, 2);
  assert.strictEqual(map.responsible, 3);
  // "Felelős" is how Hungarian plans title the owning team, and stays there
  assert.strictEqual(A.mapHeader(["Feladat", "Felelős"]).map.team, 1);
});

t("the combined view's DB File column is skipped without a warning", () => {
  const { unmapped } = A.mapHeader(["ID", "Task", "Notes", "DB File"]);
  eqArr(unmapped, [], "it belongs to no single plan, so it is not data we lost");
});

t("the same sheet as an HTML table reads identically", () => {
  /* Which is the point of reading both: the format a plan travels in is
     whatever the person's Excel happened to save, and it must not change what
     arrives. */
  const grid = A.parseSpreadsheetML(roundTrip.xml);
  const html = "<table>" + grid.map(r =>
    "<tr>" + r.map(c => "<td>" + String(c).replace(/&/g, "&amp;").replace(/</g, "&lt;") + "</td>").join("") + "</tr>").join("") + "</table>";
  const back = A.parseImport(html, "sample.html");
  assert.strictEqual(back.kind, "html");
  eqArr(back.rows.filter(r => !r.skip).map(r => r.name),
        roundTrip.back.rows.filter(r => !r.skip).map(r => r.name));
});

/* A sheet in our own column layout whose Group column does not speak for every
   row — written by hand, written by a tool that indents rather than references,
   or an export of ours with rows added underneath. The nesting of those rows is
   still in the sheet, in the Level columns. */
console.log("\n-- levels imply grouping where the Group column is silent --");

const LVL_HDR = "ID,Level 1,Level 2,Level 3,Level 4,Group\n";
const lvlSheet = impXml(LVL_HDR +
  "1,Phase one,,,,\n" +
  "2,,Design,,,\n" +
  "3,,,Wireframes,,\n" +
  "4,,,,Header,\n" +
  "5,,,,Footer,\n" +
  "6,,,Review,,\n" +
  "7,,Build,,,\n" +
  "8,,,,Straight to four,\n" +
  "9,Phase two,,,,\n", ",");

// the rows a row holds, by name, as the Level columns implied them
const heldBy = (res, name) => {
  const byLine = new Map(res.rows.map(r => [r.line, r]));
  const r = res.rows.find(x => x.name === name);
  assert(r, "no row named " + name);
  return (r.groupLines || []).map(l => byLine.get(l).name);
};

t("each row's level is read out of the column it filled", () => {
  eqArr(lvlSheet.rows.map(r => r.level), [0, 1, 2, 3, 3, 2, 1, 3, 0]);
});
t("a row joins the nearest row above it at a shallower level", () => {
  eqArr(heldBy(lvlSheet, "Phase one"), ["Design", "Build"]);
  eqArr(heldBy(lvlSheet, "Design"), ["Wireframes", "Review"]);
  eqArr(heldBy(lvlSheet, "Wireframes"), ["Header", "Footer"]);
});
t("with no level 3 above it, a level-4 row joins the level 2", () => {
  eqArr(heldBy(lvlSheet, "Build"), ["Straight to four"]);
});
t("a row back at level 1 starts again at the top", () => {
  eqArr(heldBy(lvlSheet, "Phase two"), [], "and holds only what comes after it");
  const held = new Set(lvlSheet.rows.flatMap(r => heldBy(lvlSheet, r.name)));
  assert(!held.has("Phase two"), "a level-1 row belongs to nobody");
  assert(!held.has("Phase one"), "the first row least of all");
});
t("the first row is never grouped, however deep it starts", () => {
  const r = impXml(LVL_HDR + "1,,,,Deep first,\n2,,,,Deep second,\n3,Phase,,,,\n4,,Under it,,,\n", ",");
  const held = new Set(r.rows.flatMap(x => heldBy(r, x.name)));
  assert(!held.has("Deep first"), "nothing stands above it to join");
  assert(!held.has("Deep second"), "and a row at the same level is a sibling, not a parent");
  eqArr(heldBy(r, "Phase"), ["Under it"], "the sheet still nests from there on");
});
t("the preview says where the nesting came from", () => {
  assert(lvlSheet.warn.some(w => /Level columns/.test(w)), JSON.stringify(lvlSheet.warn));
});

t("a row the Group column already claims keeps the parent the IDs gave it", () => {
  const r = impXml(LVL_HDR + "1,Phase one,,,,2\n2,,Design,,,\n", ",");
  eqArr(r.rows[0].group, [2], "the ID the sheet gave");
  eqArr(heldBy(r, "Phase one"), [], "and the levels do not place it a second time");
  assert(!r.warn.some(w => /Level columns/.test(w)), JSON.stringify(r.warn));
});

/* The case this was reported against: an export of ours opened in Excel and
   extended by hand. The first rows still carry the Group IDs the app wrote; the
   rows typed in underneath carry a level and nothing else, and their IDs were
   made up afresh. Deciding per sheet, one filled Group cell flattened all of
   them. */
t("one filled Group cell does not switch the levels off for the rest of the sheet", () => {
  const r = impXml(LVL_HDR +
    "1,Release,,,,2\n" +
    "2,,Pilot,,,3\n" +
    "3,,,Partner tests,,\n" +
    "100,,,,Redirection,\n" +
    "101,,,,Identification,\n", ",");
  const byName = n => r.rows.find(x => x.name === n);
  eqArr(byName("Release").group, [2], "what the IDs say still stands");
  eqArr(byName("Pilot").group, [3]);
  eqArr(heldBy(r, "Partner tests"), ["Redirection", "Identification"],
        "and the hand-typed rows keep the nesting they were drawn with");
  const held = new Set(r.rows.flatMap(x => heldBy(r, x.name)));
  assert(!held.has("Partner tests"), "a row the IDs placed is not placed twice");
  assert(r.warn.some(w => /Level columns/.test(w)), JSON.stringify(r.warn));
});

/* Excel writes what it was given: a sheet whose first columns were emptied (or
   an export pasted to the right of something) starts its header at ss:Index 8.
   The columns still have to line up, or every field lands one place over. */
t("leading empty columns do not shift the sheet", () => {
  const pad = ",,,,,,,";
  const r = impXml(pad + "ID,Level 1,Level 2,Group\n" +
                   pad + "1,Phase,,\n" +
                   pad + "2,,Under it,\n", ",");
  assert.strictEqual(r.rows.length, 2, JSON.stringify(r.warn));
  assert.strictEqual(r.rows[0].srcId, 1, "the ID column is found where it actually is");
  eqArr(heldBy(r, "Phase"), ["Under it"]);
});

t("a milestone opens no branch — it can hold nothing", () => {
  const r = impXml(LVL_HDR + "1,Phase one,,,,\n2,,MILESTONE: Go live,,,\n3,,,Cleanup,,\n", ",");
  eqArr(heldBy(r, "Go live"), [], "a milestone is a point in time");
  eqArr(heldBy(r, "Phase one"), ["Go live", "Cleanup"]);
});

t("our own export is untouched by this — its Group column carries the nesting", () => {
  assert(roundTrip.back.rows.every(r => !r.groupLines),
         "a round trip must go on being decided by the IDs alone");
});

t("the implied nesting becomes real group membership when applied", () => {
  // applyImport's mapping, reproduced: kept rows take fresh IDs, and membership
  // read off the levels points at lines rather than at source IDs
  const rows = lvlSheet.rows.filter(r => !r.skip), base = 100;
  const byLine = new Map(rows.map((r, i) => [r.line, base + i + 1]));
  const tasks = rows.map((r, i) => ({
    id: base + i + 1, name: r.name, start: "", end: "", hourStart: "", hourEnd: "",
    preds: [], team: "", system: "", accountable: "", responsible: "", notes: "",
    milestone: r.milestone,
    group: (r.groupLines || []).map(l => byLine.get(l)),
  }));
  const warn = A.sanitizeTasks(tasks);
  eqArr(warn, [], "the tree the levels describe must need no repair");
  const byName = n => tasks.find(t => t.name === n);
  const names = ids => ids.map(id => tasks.find(t => t.id === id).name);
  eqArr(names(byName("Phase one").group), ["Design", "Build"]);
  eqArr(names(byName("Design").group), ["Wireframes", "Review"]);
  eqArr(names(byName("Wireframes").group), ["Header", "Footer"]);
  assert.strictEqual(A.depthOf(byName("Header").id, tasks), 3, "four levels deep, as the sheet drew it");
});

console.log("\n-- zoom view: shared model --");

// load the real sample project into the app's state
const sample = fs.readFileSync(__dirname + "/DB/_fixture-sample-project.csv", "utf8");
A.state.tasks = A.parseCSV(sample);
const sampleWarn = A.sanitizeTasks(A.state.tasks);
A.recalcGroups();

const byName = n => { const x = A.state.tasks.find(y => y.name === n); assert(x, "no task named " + n); return x; };
const idOf = n => byName(n).id;

t("the sample project is clean (no repairs needed)", () =>
  assert.strictEqual(sampleWarn.length, 0, "unexpected repairs: " + JSON.stringify(sampleWarn)));
t("the sample project passes the editor's own validation", () => {
  for (const task of A.state.tasks) {
    const errs = A.validateTask(task, task.id, A.state.tasks);
    assert.strictEqual(errs.length, 0, `task ${task.id} "${task.name}": ${errs.join(" / ")}`);
  }
});
t("group dates are derived from members", () => {
  assert.strictEqual(byName("Analysis & Design").start, "2026-07-13");
  assert.strictEqual(byName("Analysis & Design").end,   "2026-09-04");
  assert.strictEqual(byName("Go/No-Go decision").start, "2027-02-12");
  assert.strictEqual(byName("Go/No-Go decision").end,   "2027-02-12");
});
t("the X axis spans whole months, project start to project end", () => {
  const b = A.projectMonthBounds();
  assert.strictEqual(A.fmtDate(b.firstDay), "2026-07-01");
  assert.strictEqual(A.fmtDate(b.lastDay),  "2027-04-30");
  assert.strictEqual(b.months, 10, "July 2026 .. April 2027");
});
t("spansADay is calendar-based", () => {
  assert.strictEqual(A.spansADay({start:"2026-07-13", end:"2026-07-14"}), true);
  assert.strictEqual(A.spansADay({start:"2026-07-13", end:"2026-07-13"}), false, "same day is not longer than a day");
  assert.strictEqual(A.spansADay(byName("Kickoff & scope approval")), false, "09:00-12:00 is under a day");
  assert.strictEqual(A.spansADay(byName("Vendor contract signed")), true);
});

console.log("\n-- level 1: helicopter --");
const H = A.heliModel();
/* The fixture is deliberately flat — one level of grouping only. Subgroups are
   exercised against purpose-built fixtures further down, so this one keeps
   covering the ordinary case. */
const MAINS = ["Kickoff & scope approval","Analysis & Design","Vendor contract signed","Contract kick-off call",
  "Infrastructure build-out","Security & Compliance Review","Development","Data cleansing sprint",
  "Change Management & Comms","Interface Partner Onboarding","Data Migration","Training programme",
  "Budget re-approval","Steering committee review","Testing","Documentation & Handover","Cutover rehearsal",
  "Go/No-Go decision","Go-live weekend","Hypercare & Stabilisation"];

t("the fixture stays flat, so it keeps covering the single-level case", () => {
  assert(A.state.tasks.every(x => A.depthOf(x.id, A.state.tasks) <= 1),
    "nothing in the sample project is nested two deep");
});

t("only level-0 tasks are rows; members and milestones are not", () => {
  eqArr(A.mainTasks().map(x => x.id).sort((a,b) => a-b), MAINS.map(idOf).sort((a,b) => a-b));
});
t("EVERY main task gets a row, however short", () => {
  eqArr(H.rows.map(r => r.t.id).sort((a,b) => a-b), MAINS.map(idOf).sort((a,b) => a-b));
  assert.strictEqual(H.rows.length, MAINS.length);
  assert(H.rows.some(r => r.t.id === idOf("Kickoff & scope approval")), "the 3-hour kickoff still has a row");
});
t("milestones are lines, not rows", () => {
  eqArr(H.milestones.map(x => x.name),
    ["Design frozen","Security sign-off","Code complete","Data migration signed off","Go-live done","Hypercare ended","Project closed"]);
  assert(!H.rows.some(r => r.t.milestone));
});
t("a bar is split at month boundaries and covers exactly its span", () => {
  const dev = H.rows.find(r => r.t.id === idOf("Development"));   // 2026-09-07 -> 2026-12-18
  eqArr(dev.segs.map(s => s.off), [2, 3, 4, 5], "Sep, Oct, Nov, Dec 2026");
  assert.strictEqual(A.fmtDate(dev.segs[0].from), "2026-09-07", "first segment starts on the task start");
  assert.strictEqual(A.fmtDate(dev.segs[0].to),   "2026-09-30", "and stops at the month end");
  assert.strictEqual(A.fmtDate(dev.segs[1].from), "2026-10-01", "next segment picks up on the 1st");
  assert.strictEqual(A.fmtDate(dev.segs[3].to),   "2026-12-18", "last segment stops on the task end");
});
t("a task inside one month is a single segment", () => {
  const k = H.rows.find(r => r.t.id === idOf("Kickoff & scope approval"));
  assert.strictEqual(k.segs.length, 1);
  assert.strictEqual(A.fmtDate(k.segs[0].from), "2026-07-13");
  assert.strictEqual(A.fmtDate(k.segs[0].to),   "2026-07-13");
});
t("a segment counts the subtasks active in that month", () => {
  const dev = H.rows.find(r => r.t.id === idOf("Development"));
  eqArr(dev.segs.map(s => s.count), [4, 5, 3, 2], "Sep=4, Oct=5, Nov=3, Dec=2");
});
t("subtasksInMonth counts overlap, not just the start month", () => {
  const oct = A.monthIndex(new Date(2026, 9, 1));
  assert.strictEqual(A.subtasksInMonth(byName("Development"), oct), 5);
});
t("a standalone main task counts 0 in every month and draws lightest", () => {
  const infra = H.rows.find(r => r.t.id === idOf("Infrastructure build-out"));
  assert(infra.segs.length > 1);
  assert(infra.segs.every(s => s.count === 0 && A.densityBand(s.count, H.max) === 0));
});
t("max is the busiest (main task x month) pair in the whole project", () => {
  let seen = 0;
  for (const r of H.rows) for (const s of r.segs) seen = Math.max(seen, s.count);
  assert.strictEqual(H.max, seen);
  assert.strictEqual(H.max, 5, "Development in October 2026");
});
t("shading uses one scale shared by every row", () => {
  const dev = H.rows.find(r => r.t.id === idOf("Development"));
  eqArr(dev.segs.map(s => A.densityBand(s.count, H.max)), [4, 4, 3, 2]);
});
t("densityBand splits 0..max into five bands", () => {
  assert.strictEqual(A.HV_SHADES, 5);
  eqArr([0,1,2,3,4,5,6,7,8,9,10].map(c => A.densityBand(c, 10)), [0,0,1,1,2,2,3,3,4,4,4]);
  assert.strictEqual(A.densityBand(0, 0), 0, "no groups anywhere must not divide by zero");
  assert.strictEqual(A.densityBand(3, 3), 4, "the busiest month gets the darkest band");
});

console.log("\n-- level 2: day view --");
const D = A.dayModel();
const rowNames = D.rows.map(r => r.t.name);
const rowOf = n => D.rows.find(r => r.t.id === idOf(n));

t("rows are tasks longer than a day - main tasks AND subtasks", () => {
  assert(rowNames.includes("Development"), "main task");
  assert(rowNames.includes("Development – interfaces"), "its subtask");
  assert(rowNames.includes("Trial load 1"), "a subtask of another group");
  assert.strictEqual(rowOf("Development").level, 0);
  assert.strictEqual(rowOf("Development – interfaces").level, 1);
});
t("a subtask shorter than a day gets no row, it is counted on its main task", () => {
  assert(!rowNames.includes("Scope sign-off meeting"), "07-24 09:00-16:00 must not be a row");
  assert(!rowNames.includes("Code freeze checkpoint"), "12-18 09:00-11:00 must not be a row");
  assert.strictEqual(rowOf("Analysis & Design").shortSubs, 1);
  assert.strictEqual(rowOf("Development").shortSubs, 1);
  assert.strictEqual(rowOf("Data Migration").shortSubs, 0, "all its members span days");
});
t("a main task shorter than a day drops into Additional Tasks", () => {
  const extraNames = D.extra.map(e => e.t.name).sort();
  eqArr(extraNames, ["Contract kick-off call", "Go/No-Go decision", "Kickoff & scope approval"]);
  for (const n of extraNames) assert(!rowNames.includes(n), n + " must not have its own row");
});
t("the Additional Tasks count includes those main tasks AND their subtasks", () => {
  // 3 short main tasks, one of which (Go/No-Go decision) carries 2 subtasks
  assert.strictEqual(D.extraCount, 5);
  const gng = D.extra.find(e => e.t.name === "Go/No-Go decision");
  assert.strictEqual(gng.subs.length, 2);
  for (const s of gng.subs) assert(!rowNames.includes(s.name), s.name + " is folded into Additional Tasks");
});
t("every task is represented by something that is drawn", () => {
  for (const task of A.state.tasks) {
    if (task.milestone) continue;
    assert(D.rep.has(task.id), `task ${task.id} "${task.name}" has nothing representing it`);
  }
  assert.strictEqual(D.rep.get(idOf("Scope sign-off meeting")), "t:" + idOf("Analysis & Design"));
  assert.strictEqual(D.rep.get(idOf("Go/No-Go call")), "x:" + idOf("Go/No-Go decision"));
});
t("the day axis is the same whole-month span as the helicopter", () => {
  assert.strictEqual(A.fmtDate(D.min), "2026-07-01");
  assert.strictEqual(A.fmtDate(D.max), "2027-04-30");
  assert.strictEqual(D.days, A.daysBetween(D.min, D.max) + 1);
  assert.strictEqual(D.days, 304);
});
t("milestones are lines here too", () => {
  assert.strictEqual(D.milestones.length, 7);
  assert(!rowNames.some(n => byName(n).milestone));
});

console.log("\n-- zoom rendering --");
A.state.zoomLevel = "month";
A.renderZoom();
const hvBody = els.hvBody.innerHTML, hvLeft = els.hvLeftBody.innerHTML;
const count = (hay, needle) => hay.split(needle).length - 1;

t("helicopter: one segment per month of every main task", () => {
  const expected = H.rows.reduce((n, r) => n + r.segs.length, 0);
  assert.strictEqual(count(hvBody, 'class="hvSeg'), expected);
});
t("helicopter: all five blue bands are used", () => {
  for (let i = 0; i < 5; i++) assert(hvBody.includes(`class="hvSeg d${i}"`), "band d" + i + " unused");
});
t("helicopter: segments carry the month they drill into", () => {
  const b = A.projectMonthBounds();
  assert(hvBody.includes(`data-month="${b.minM}"`), "July 2026 segment");
  assert(hvBody.includes(`data-month="${b.minM + 9}"`), "April 2027 segment");
});
t("helicopter: bar ends are rounded, joins are not", () => {
  assert(hvBody.includes("border-radius:4px 0px 0px 4px"), "left cap");
  assert(hvBody.includes("border-radius:0px 4px 4px 0px"), "right cap");
  assert(hvBody.includes("border-radius:4px 4px 4px 4px"), "single-month task");
});
t("helicopter: legend shows five swatches and the max", () => {
  const lg = els.hvLegend.innerHTML;
  for (let i = 0; i < 5; i++) assert(lg.includes(`lgSw d${i}`), "missing swatch d" + i);
  assert(lg.includes(">" + H.max + "<"));
});
t("helicopter: left panel lists main tasks only", () => {
  for (const n of ["Analysis &amp; Design", "Go/No-Go decision", "Hypercare &amp; Stabilisation"])
    assert(hvLeft.includes(n), "missing row: " + n);
  for (const n of ["Requirements workshop", "Trial load 1"])
    assert(!hvLeft.includes(n), "member must not be a row: " + n);
});
t("helicopter: crumb offers the drill-down", () => {
  assert(els.hvCrumb.innerHTML.includes("Helicopter"));
  assert(!els.hvCrumb.innerHTML.includes("crumbBack"), "no back button at the top level");
});

A.state.zoomLevel = "day";
A.renderZoom();
const dBody = els.hvBody.innerHTML, dLeft = els.hvLeftBody.innerHTML;

t("day: one bar per row plus one per Additional Tasks entry", () => {
  // "<div class=" so the count pills (spans of class barCount) are not counted as bars
  assert.strictEqual(count(dBody, '<div class="bar'), D.rows.length + D.extra.length);
  // the class list may carry a status tone after it, so match the prefix only
  assert.strictEqual(count(dBody, 'class="bar extraBar'), 3);
});
t("day: subtasks appear as indented rows, sub-day ones do not", () => {
  assert(dLeft.includes("Development – interfaces"));
  assert(dLeft.includes("└ "), "members are indented");
  assert(!dLeft.includes("Scope sign-off meeting"));
  assert(!dLeft.includes("Code freeze checkpoint"));
});
t("day: the folded sub-day count rides on the main task's bar", () => {
  assert(dBody.includes('<span class="subCount">+1</span>'), "Analysis & Design / Development carry +1");
  assert(dLeft.includes('<span class="subCount" title="1 subtask(s) shorter than a day'), "and in the left panel");
});
t("day: Additional Tasks row shows the combined count", () => {
  assert(dLeft.includes("Additional Tasks"));
  assert(dLeft.includes(`<span class="subCount">${D.extraCount}</span>`), "total of " + D.extraCount);
  assert(dBody.includes('<span class="subCount">+2</span>'), "Go/No-Go decision shows its 2 subtasks");
});
/* Every sub-day main task shares the one Additional Tasks row, and ".lbl" trails
   the full name to the right of a marker only a few pixels wide — so the names
   used to pile up on each other and on the neighbouring markers. */
t("day: Additional Tasks markers carry no trailing name label", () => {
  const extras = dBody.match(/<div class="bar extraBar[^"]*"[\s\S]*?<\/div>/g) || [];
  assert.strictEqual(extras.length, 3);
  assert.strictEqual(extras.filter(s => /class="lbl"/.test(s)).length, 0,
    "a marker that wide cannot show a name without overlapping its neighbours");
  assert(extras.every(s => /title="#\d+ [^"]+ — under a day/.test(s)),
    "the name must still be reachable in the tooltip");
});
t("day: ordinary task bars keep their labels", () => {
  assert(count(dBody, 'class="lbl') > 0, "only the Additional Tasks row loses its labels");
});
t("day: milestones are lines with name over date, coloured by their health", () => {
  assert.strictEqual(count(dBody, 'class="hvMsLine'), 7);
  assert.strictEqual(count(dBody, 'class="hvMsFoot'), 7);
  assert(dBody.includes('<span class="n">Design frozen</span><span class="d">2026-09-04</span>'));
});
t("day: today line is drawn", () => assert.strictEqual(count(dBody, 'class="todayLine"'), 1));
t("day: dependency arrows are drawn, collapsed tasks lifted to their representative", () => {
  assert(dBody.includes('class="depSvg"'));
  assert(count(dBody, "<polygon") > 20, "one head per edge, got " + count(dBody, "<polygon"));
});
t("day: weekend shading and the month/day header bands are present", () => {
  assert(count(dBody, 'class="weekend"') > 50, "weekends across 10 months");
  assert(els.hvHeadUpper.innerHTML.includes("Sep 2026"), "month band");
  assert(els.hvHeadLower.innerHTML.length > 0, "day band");
});
t("day: crumb offers the way back up", () => {
  assert(els.hvCrumb.innerHTML.includes("crumbHeli"), "a button back to the helicopter");
  assert(els.hvCrumb.innerHTML.includes("Day view"));
});
t("output is escaped, not raw", () => {
  A.state.tasks.push({ id: 999, name: '<img src=x onerror=alert(1)>', start: "2026-07-01", hourStart: "",
                       end: "2026-12-01", hourEnd: "", preds: [], team: "", system: "", group: [], milestone: false });
  for (const lvl of ["month", "day"]) {
    A.state.zoomLevel = lvl;
    A.renderZoom();
    assert(!els.hvLeftBody.innerHTML.includes("<img"), "left panel unescaped at level " + lvl);
    assert(!els.hvBody.innerHTML.includes("<img"), "chart unescaped at level " + lvl);
  }
  A.state.tasks.pop();
});

console.log("\n-- drag to pan --");
const fireEl  = (el, type, ev) => (el._on[type] || []).forEach(fn => fn(ev));
const fireWin = (type, ev) => (winOn[type] || []).forEach(fn => fn(ev));
const mouse = (x, y, extra) => Object.assign(
  { button: 0, clientX: x, clientY: y, ctrlKey: false, shiftKey: false, deltaX: 0, deltaY: 0,
    preventDefault(){ this._pd = true; }, stopPropagation(){ this._sp = true; } }, extra || {});

for (const pane of ["hvRight", "ganttRight"]) {
  t(pane + ": is wired for panning", () =>
    assert(els[pane].classList.contains("panPane"), pane + " never got the panPane class"));

  t(pane + ": dragging scrolls the chart the other way", () => {
    const p = els[pane];
    p.scrollLeft = 300; p.scrollTop = 40;
    fireEl(p, "mousedown", mouse(500, 300));
    fireWin("mousemove", mouse(380, 285));            // dragged 120 left, 15 up
    assert.strictEqual(p.scrollLeft, 420, "content follows the cursor");
    assert.strictEqual(p.scrollTop, 55);
    assert(p.classList.contains("panning"), "grabbing cursor while panning");
    fireWin("mouseup", mouse(380, 285));
    assert(!p.classList.contains("panning"), "cursor restored on release");
  });

  t(pane + ": the click ending a pan is swallowed", () => {
    const p = els[pane];
    fireEl(p, "mousedown", mouse(500, 300));
    fireWin("mousemove", mouse(400, 300));
    fireWin("mouseup", mouse(400, 300));
    const ev = mouse(400, 300);
    fireEl(p, "click", ev);
    assert(ev._sp && ev._pd, "a pan must not also drill down or open the editor");
  });

  t(pane + ": a click that did not move still goes through", () => {
    const p = els[pane];
    fireEl(p, "mousedown", mouse(500, 300));
    fireWin("mousemove", mouse(502, 301));            // 3px: inside the shake tolerance
    assert(!p.classList.contains("panning"), "a shaky click is not a pan");
    fireWin("mouseup", mouse(502, 301));
    const ev = mouse(502, 301);
    fireEl(p, "click", ev);
    assert(!ev._sp && !ev._pd, "the click must reach the bar");
  });

}

t("gantt: the wheel scrolls sideways only when there is no vertical overflow", () => {
  const p = els.ganttRight;
  p.scrollLeft = 0;
  p.scrollHeight = 2000; p.clientHeight = 600;      // rows overflow -> leave the wheel alone
  fireEl(p, "wheel", mouse(0, 0, { deltaY: 120 }));
  assert.strictEqual(p.scrollLeft, 0, "vertical scrolling must not be stolen");

  p.scrollHeight = 400; p.clientHeight = 600;       // nothing to scroll vertically
  fireEl(p, "wheel", mouse(0, 0, { deltaY: 120 }));
  assert.strictEqual(p.scrollLeft, 120, "the wheel is given to the horizontal axis");
  p.scrollHeight = 600; p.clientHeight = 600;
});

t("gantt: Shift + wheel is the zoom, and it keeps the middle of the pane", () => {
  const p = els.ganttRight, was = A.state.view;
  A.state.view = "gantt"; A.state.pxPerDay = 28;
  A.renderGantt();
  p.scrollLeft = 600;
  const centreDay = (p.scrollLeft + p.clientWidth / 2) / A.state.pxPerDay;
  fireEl(p, "wheel", mouse(0, 0, { deltaY: -120, shiftKey: true }));
  assert(A.state.pxPerDay > 28, "days got wider");
  assert(Math.abs((p.scrollLeft + p.clientWidth / 2) / A.state.pxPerDay - centreDay) < 0.02,
    "the same day is still in the middle");
  A.state.pxPerDay = 28; A.state.view = was; p.scrollLeft = 0;
});

console.log("\n-- zoom view: Shift + wheel changes level, the plain wheel scrolls --");
const hv = els.hvRight;
A.state.view = "zoom";   // the zoom pane is what is on screen
const HEAVY = 300;       // wide months, so a mid-project month can actually be centred
const SEP = A.monthIndex(new Date(2026, 8, 1));      // September 2026
// the level gesture: Shift + wheel. Plain wheels are the browser's to scroll.
const wheelAt = (x, dy, extra) =>
  fireEl(hv, "wheel", mouse(x, 0, Object.assign({ deltaY: dy, shiftKey: true }, extra || {})));

/* The level used to hang off the plain wheel, taking over once the pane had run
   out of room to scroll — so reading to the bottom of a long chart dropped you
   into another level uninvited. The plain wheel is now left alone completely. */
t("the plain wheel is left to the browser, whatever the scroll position", () => {
  for (const [scrollTop, dy, where] of [[90, 400, "mid-pane"], [0, -400, "pinned to the top"],
                                        [1400, 400, "pinned to the bottom"]]) {
    A.state.zoomLevel = "month"; A.renderZoom();
    hv.scrollHeight = 2000; hv.clientHeight = 600;   // a chart taller than its pane
    hv.scrollTop = scrollTop;
    const ev = mouse(0, 0, { deltaY: dy });
    fireEl(hv, "wheel", ev);
    assert(!ev._pd, "the browser must be left to scroll — " + where);
    assert.strictEqual(A.state.zoomLevel, "month", "the level must not change — " + where);
  }
  hv.scrollHeight = 600; hv.clientHeight = 600; hv.scrollTop = 0;
});

t("a chart taller than its pane still changes level on Shift + wheel", () => {
  A.state.zoomLevel = "month"; A.renderZoom();
  hv.scrollHeight = 2000; hv.clientHeight = 600;
  hv.scrollTop = 900;                                // nowhere near either end
  const ev = mouse(0, 0, { deltaY: -400, shiftKey: true });
  fireEl(hv, "wheel", ev);
  assert(ev._pd, "the gesture belongs to the view, not to the scrollbar");
  assert.strictEqual(A.state.zoomLevel, "day", "no overscroll needed any more");
  hv.scrollHeight = 600; hv.clientHeight = 600; hv.scrollTop = 0;
});

/* Shift + wheel is horizontal scrolling to some browsers and trackpads, which
   report the notch on deltaX. Either axis has to work. */
t("Shift + wheel arriving as deltaX works too", () => {
  A.state.zoomLevel = "month"; A.renderZoom();
  fireEl(hv, "wheel", mouse(0, 0, { deltaX: -400, shiftKey: true }));
  assert.strictEqual(A.state.zoomLevel, "day");
});

t("one notch up over September opens the day view on September", () => {
  A.state.zoomLevel = "month";
  A.state.pxPerMonth = 130;
  A.renderZoom();
  hv.scrollLeft = 0;
  const xOverSeptember = (SEP - A.projectMonthBounds().minM + 0.5) * A.state.pxPerMonth;
  wheelAt(xOverSeptember, -120);
  assert.strictEqual(A.state.zoomLevel, "day", "should have dropped a level");
  assert.strictEqual(A.state.zoomMonth, SEP, "aimed at the month under the pointer");
});

t("…and September lands in the centre of the pane", () => {
  const { firstDay } = A.projectMonthBounds();
  const s = new Date(2026, 8, 1), e = new Date(2026, 8, 30);
  const mid = (A.daysBetween(firstDay, s) + A.daysBetween(firstDay, e) + 1) / 2 * A.state.pxPerDayZoom;
  assert.strictEqual(hv.scrollLeft, Math.max(0, mid - hv.clientWidth / 2));
  // the month really is centred, not merely near the left edge
  const centre = hv.scrollLeft + hv.clientWidth / 2;
  const day = A.addDays(firstDay, Math.round(centre / A.state.pxPerDayZoom));
  assert.strictEqual(day.getMonth(), 8, "the middle of the viewport is in September");
});

t("a nudge does not flip the level; a full notch does", () => {
  A.state.zoomLevel = "day"; A.renderZoom();
  wheelAt(500, 40);
  assert.strictEqual(A.state.zoomLevel, "day", "40 < one notch");
  wheelAt(500, 40);
  wheelAt(500, 40);                                  // 120 accumulated
  assert.strictEqual(A.state.zoomLevel, "month", "back up once a notch is reached");
});

t("reversing direction resets the accumulator", () => {
  A.state.zoomLevel = "day"; A.renderZoom();
  wheelAt(500, 80);
  wheelAt(500, -80);                                 // opposite way: must not total 160
  assert.strictEqual(A.state.zoomLevel, "day", "no level change from cancelling nudges");
});

t("wheel out at the helicopter does nothing — it is the top level", () => {
  A.state.zoomLevel = "month"; A.renderZoom();
  wheelAt(300, 400);
  assert.strictEqual(A.state.zoomLevel, "month", "nothing above the helicopter");
});

t("Ctrl + wheel still trims the bar width inside the level", () => {
  A.state.zoomLevel = "month"; A.renderZoom();
  const before = A.state.pxPerMonth;
  wheelAt(300, -120, { ctrlKey: true, shiftKey: false });
  assert(A.state.pxPerMonth > before, "months got wider");
  assert.strictEqual(A.state.zoomLevel, "month", "and the level did not change");
  A.state.pxPerMonth = 130;
});

/* Widening the bars must not walk the view off towards the start of the
   project: whatever moment is in the middle of the pane stays in the middle and
   the chart stretches away from it both ways. */
console.log("\n-- changing the width keeps the middle of the pane put --");
const centreUnitOf = () => {
  const u = A.zoomUnit();
  return (hv.scrollLeft + hv.clientWidth / 2) / u.get();
};

t("Ctrl + wheel keeps the centre, whatever the pointer is over", () => {
  A.state.zoomLevel = "month"; A.state.pxPerMonth = HEAVY; A.renderZoom();
  hv.scrollLeft = 1200;
  const before = centreUnitOf();
  wheelAt(0, -120, { ctrlKey: true, shiftKey: false });   // pointer at the far left edge
  assert(A.state.pxPerMonth > HEAVY, "months got wider");
  assert(Math.abs(centreUnitOf() - before) < 0.02,
    `the centre moved: ${before} -> ${centreUnitOf()}`);
});

t("the slider keeps the centre too", () => {
  for (const [lvl, key, from, to] of [["month", "pxPerMonth", 200, 320],
                                      ["day",   "pxPerDayZoom", 24, 60],
                                      ["hour",  "pxPerHour", 6, 18]]) {
    A.state.zoomLevel = lvl; A.state[key] = from; A.renderZoom();
    hv.scrollLeft = 900;
    const before = centreUnitOf();
    A.setZoom(to);
    assert.strictEqual(A.state[key], to, lvl + ": the slider value took");
    assert(Math.abs(centreUnitOf() - before) < 0.02, lvl + ": the centre moved");
    assert(hv.scrollLeft > 0, lvl + ": zooming in must not jump back to the start");
  }
});

t("zooming out at the very start stays at the start rather than going negative", () => {
  A.state.zoomLevel = "day"; A.state.pxPerDayZoom = 60; A.renderZoom();
  hv.scrollLeft = 0;
  A.setZoom(8);
  assert.strictEqual(hv.scrollLeft, 0, "clamped, not scrolled off the left edge");
});
// back to the defaults the rest of the file assumes
A.state.pxPerMonth = 130; A.state.pxPerDayZoom = 24; A.state.pxPerHour = 6;
A.state.zoomLevel = "month"; hv.scrollLeft = 0;

t("going back up returns to the month you were looking at", () => {
  A.state.zoomLevel = "month"; A.state.pxPerMonth = 130; A.renderZoom();
  hv.scrollLeft = 0;
  A.drillToDay(SEP);
  A.backToHelicopter();                              // no argument: use what is centred
  assert.strictEqual(A.state.zoomLevel, "month");
  assert.strictEqual(A.state.zoomMonth, SEP, "it found September from the day view's scroll position");
  // centring is clamped at the ends: September's midpoint is under half a viewport in
  const { minM } = A.projectMonthBounds();
  const ideal = (SEP - minM + 0.5) * A.state.pxPerMonth;
  assert.strictEqual(hv.scrollLeft, Math.max(0, ideal - hv.clientWidth / 2));
  assert(ideal >= hv.scrollLeft && ideal <= hv.scrollLeft + hv.clientWidth, "September is on screen");
});

t("a month in the middle of a long project really is centred", () => {
  const DEC = A.monthIndex(new Date(2026, 11, 1));
  A.state.zoomLevel = "month"; A.state.pxPerMonth = HEAVY; A.renderZoom();
  A.drillToDay(DEC);
  A.backToHelicopter();
  const { minM } = A.projectMonthBounds();
  const centre = hv.scrollLeft + hv.clientWidth / 2;
  assert.strictEqual(minM + Math.floor(centre / A.state.pxPerMonth), DEC,
    "December sits under the middle of the pane");
  A.state.pxPerMonth = 130;
});


console.log("\n-- level 3: hour view --");
// 2026-10-20 sits in the busiest stretch: Development, Data Migration,
// Infrastructure and the partner work all overlap there.
A.state.zoomDay = "2026-10-20";
A.state.zoomLevel = "hour";
const HM = A.hourModel();

t("the window is the chosen day plus or minus five days", () => {
  assert.strictEqual(A.HOUR_WINDOW, 5);
  assert.strictEqual(HM.days, 11);
  assert.strictEqual(A.fmtDate(HM.win), "2026-10-15");
  assert.strictEqual(A.fmtDate(A.addDays(HM.winEnd, -1)), "2026-10-25");
  assert.strictEqual(HM.totalHours, 264);
});

t("every task active in the window gets a row, and nothing else", () => {
  const shown = new Set(HM.rows.map(r => r.t.id));
  for (const task of A.state.tasks) {
    if (task.milestone || !task.start) continue;
    const a = A.parseDate(task.start), b = A.parseDate(task.end) || a;
    const overlaps = A.addDays(b, 1) > HM.win && a < HM.winEnd;
    assert.strictEqual(shown.has(task.id), overlaps,
      `#${task.id} "${task.name}" (${task.start}..${task.end}) should ${overlaps ? "" : "not "}be shown`);
  }
  assert(HM.rows.length > 3, "the sample really does overlap here, got " + HM.rows.length);
});

t("both main tasks and subtasks appear - no grouping at this level", () => {
  const nameOf = id => A.state.tasks.find(x => x.id === id).name;
  const mains = HM.rows.filter(r => r.isMain).map(r => nameOf(r.t.id));
  const subs  = HM.rows.filter(r => !r.isMain).map(r => nameOf(r.t.id));
  assert(mains.includes("Development"), "the group itself is a main task: " + mains.join(", "));
  assert(mains.includes("Infrastructure build-out"), "a standalone main task");
  assert(subs.includes("Data migration tooling"), "a subtask: " + subs.join(", "));
  assert(subs.length > 0 && mains.length > 0);
});

t("a task running out of the window is clipped and flagged on that edge", () => {
  const dev = HM.rows.find(r => r.t.name === "Development");                 // Sep 7 -> Dec 18
  assert(dev.clipL && dev.clipR, "it covers the whole window on both sides");
  const tooling = HM.rows.find(r => r.t.name === "Data migration tooling");  // Oct 19 -> Nov 27
  assert(!tooling.clipL, "it starts inside the window");
  assert(tooling.clipR, "but runs out the right-hand side");
});

t("hourX places a datetime by the hour", () => {
  const win = HM.win;
  assert.strictEqual(A.hourX(A.parseDate("2026-10-15"), "", win, false), 0, "window start");
  assert.strictEqual(A.hourX(A.parseDate("2026-10-15"), "06:00", win, false), 6);
  assert.strictEqual(A.hourX(A.parseDate("2026-10-16"), "", win, false), 24, "next midnight");
  assert.strictEqual(A.hourX(A.parseDate("2026-10-15"), "", win, true), 24, "a blank end hour is end of day");
  assert.strictEqual(A.hourX(A.parseDate("2026-10-15"), "12:30", win, false), 12.5);
});

t("only milestones inside the window are shown", () => {
  for (const ms of A.state.tasks.filter(x => x.milestone)) {
    const d = A.parseDate(ms.start);
    const inWindow = d >= HM.win && d < HM.winEnd;
    assert.strictEqual(HM.milestones.includes(ms), inWindow, ms.name + " visibility");
  }
  assert.strictEqual(HM.milestones.length, 0, "this window happens to contain none");
});

t("a window containing a milestone shows it", () => {
  A.state.zoomDay = "2026-12-18";                      // Code complete falls on this day
  eqArr(A.hourModel().milestones.map(x => x.name), ["Code complete"]);
  A.state.zoomDay = "2026-10-20";
});

console.log("\n-- hour rendering --");
A.state.zoomLevel = "hour";
A.renderZoom();
const hBody = els.hvBody.innerHTML, hLeft = els.hvLeftBody.innerHTML;

t("one bar per active task, marked main vs subtask", () => {
  // the health bucket follows the main/sub class, so match the prefix
  assert.strictEqual(count(hBody, 'class="hrBar '), HM.rows.length);
  assert(hBody.includes('class="hrBar main'), "main tasks are marked");
  assert(hBody.includes('class="hrBar sub'), "and subtasks");
  assert.strictEqual(count(hBody, 'class="hrBar main'), HM.rows.filter(r => r.isMain).length);
});

t("clipped bars carry the real start / end date on the edge", () => {
  assert(hBody.includes('<span class="edge l">« 2026-09-07</span>'), "Development's real start");
  assert(hBody.includes('<span class="edge r">2026-12-18 »</span>'), "and its real end");
});

t("a bar clipped on one side only gets one date", () => {
  const tooling = HM.rows.find(r => r.t.name === "Data migration tooling");
  assert(hBody.includes('<span class="edge r">2026-11-27 »</span>'), "its end, which is off-window");
  assert(!tooling.clipL, "and no start chip, because you can see where it begins");
});

t("bars are clipped to the window, never drawn outside it", () => {
  const w = HM.totalHours * A.state.pxPerHour;
  let seen = 0;
  for (const mm of hBody.matchAll(/class="hrBar [^"]+" data-id="\d+" style="left:([\d.-]+)px;top:[\d.]+px;width:([\d.]+)px"/g)) {
    seen++;
    const left = +mm[1], width = +mm[2];
    assert(left >= 0, "a bar started left of the window at " + left);
    assert(left + width <= w + 0.5, "a bar ran past the window end");
  }
  assert.strictEqual(seen, HM.rows.length, "matched every bar");
});

t("day and hour header bands are drawn", () => {
  assert.strictEqual(count(els.hvHeadUpper.innerHTML, 'class="hCell"'), 11, "one cell per day");
  assert(els.hvHeadLower.innerHTML.length > 0, "hour band");
});

/* The hour view is the hour view: its blocks are one hour wide, not the 3, 6 or
   12 they used to merge into. Labels are separate elements from blocks because
   .hCell clips its own text once the cell is only a few pixels wide — so the
   grid stays hourly while the numbers thin out to whatever will fit. */
t("hour blocks are one hour wide at every usable zoom", () => {
  const before = A.state.pxPerHour;
  for (const px of [6, 10, 22, 40]) {
    A.state.pxPerHour = px;
    A.renderHour();
    const lower = els.hvHeadLower.innerHTML, body = els.hvBody.innerHTML;
    assert.strictEqual(count(lower, 'class="hCell"'), 11 * 24, `px=${px}: 24 blocks per day`);
    assert.strictEqual(count(body, 'class="gridLine"'), 11 * 23, `px=${px}: an hourly gridline between them`);
  }
  A.state.pxPerHour = before;
});

t("hour numbers thin out only when they would not fit", () => {
  const before = A.state.pxPerHour;
  const labelsPerDay = px => { A.state.pxPerHour = px; A.renderHour();
                               return count(els.hvHeadLower.innerHTML, 'class="hLbl"') / 11; };
  assert.strictEqual(labelsPerDay(22), 24, "wide enough for every hour");
  assert.strictEqual(labelsPerDay(10), 8, "every 3rd hour");
  assert.strictEqual(labelsPerDay(6), 4, "every 6th hour");
  A.state.pxPerHour = before;
});

t("the now line appears only when now falls inside the window", () => {
  const now = new Date();
  const inWindow = now >= HM.win && now < HM.winEnd;
  assert.strictEqual(count(hBody, 'class="todayLine"'), inWindow ? 1 : 0);
});

t("dependency arrows are drawn between the visible tasks", () => {
  assert(hBody.includes('class="depSvg"'));
  const ids = new Set(HM.rows.map(r => r.t.id));
  let expected = 0;
  for (const task of A.state.tasks) {
    if (task.milestone || !ids.has(task.id)) continue;
    for (const p of task.preds) if (ids.has(p)) expected++;
  }
  assert.strictEqual(count(hBody, "<polygon"), expected,
    "one head per dependency with both ends on screen");
});

t("left panel lists the active tasks only", () => {
  assert(hLeft.includes("Development"));
  assert(hLeft.includes("Data migration tooling"));
  assert(!hLeft.includes("Kickoff"), "July's kickoff is nowhere near this window");
});

t("crumb shows the full trail with both ways back", () => {
  const c = els.hvCrumb.innerHTML;
  assert(c.includes("crumbHeli"), "back to the helicopter");
  assert(c.includes("crumbDay"), "back to the day view");
  assert(c.includes("Hour view"));
  assert(c.includes("2026-10-20"));
});

console.log("\n-- drilling in and out of the hour view --");
t("a wheel notch up in the day view opens the hour view on the day under the pointer", () => {
  A.state.view = "zoom";
  A.state.zoomLevel = "day"; A.state.pxPerDayZoom = 24; A.renderZoom();
  hv.scrollLeft = 0;
  const { firstDay } = A.projectMonthBounds();
  const target = A.parseDate("2026-10-20");
  wheelAt((A.daysBetween(firstDay, target) + 0.5) * A.state.pxPerDayZoom, -120);
  assert.strictEqual(A.state.zoomLevel, "hour");
  assert.strictEqual(A.state.zoomDay, "2026-10-20", "aimed at the day under the pointer");
});

t("the chosen day lands in the middle of the window", () => {
  const mid = (A.HOUR_WINDOW * 24 + 12) * A.state.pxPerHour;
  assert.strictEqual(hv.scrollLeft, Math.max(0, mid - hv.clientWidth / 2));
});

t("a wheel notch down goes back to the day view, centred on that day", () => {
  wheelAt(400, 120);
  assert.strictEqual(A.state.zoomLevel, "day");
  const { firstDay } = A.projectMonthBounds();
  const mid = (A.daysBetween(firstDay, A.parseDate("2026-10-20")) + 0.5) * A.state.pxPerDayZoom;
  assert.strictEqual(hv.scrollLeft, Math.max(0, mid - hv.clientWidth / 2));
});

t("wheel in at the hour view does nothing - it is the deepest level", () => {
  A.state.zoomLevel = "hour"; A.renderZoom();
  wheelAt(400, -400);
  assert.strictEqual(A.state.zoomLevel, "hour");
});

t("the three levels chain both ways", () => {
  A.state.zoomLevel = "month"; A.renderZoom();
  wheelAt(300, -120); assert.strictEqual(A.state.zoomLevel, "day");
  wheelAt(300, -120); assert.strictEqual(A.state.zoomLevel, "hour");
  wheelAt(300, 120);  assert.strictEqual(A.state.zoomLevel, "day");
  wheelAt(300, 120);  assert.strictEqual(A.state.zoomLevel, "month");
  wheelAt(300, 120);  assert.strictEqual(A.state.zoomLevel, "month", "and stops at the top");
});

t("Ctrl + wheel sets the hour width inside the hour view", () => {
  A.state.zoomLevel = "hour"; A.state.pxPerHour = 6; A.renderZoom();
  wheelAt(300, -120, { ctrlKey: true, shiftKey: false });
  assert(A.state.pxPerHour > 6, "hours got wider");
  assert.strictEqual(A.state.zoomLevel, "hour", "and the level did not change");
  A.state.pxPerHour = 6;
});

t("hour view output is escaped", () => {
  A.state.tasks.push({ id: 998, name: '<img src=x onerror=alert(1)>', start: "2026-10-16", hourStart: "",
                       end: "2026-10-22", hourEnd: "", preds: [], team: "", system: "", group: [], milestone: false });
  A.state.zoomLevel = "hour"; A.state.zoomDay = "2026-10-20";
  A.renderZoom();
  assert(!els.hvBody.innerHTML.includes("<img"), "chart unescaped");
  assert(!els.hvLeftBody.innerHTML.includes("<img"), "left panel unescaped");
  A.state.tasks.pop();
});

/* Changing level is drawn as a change of scale: the level being left behind
   keeps travelling the way the eye is going while the one arriving comes from
   the other side of it, both on screen at once. */
console.log("\n-- changing zoom level is a change of scale --");

// scale factor out of a "scale(1.35)" keyframe, so the numbers can be compared
const scaleOf = kf => +/scale\(([\d.]+)\)/.exec(kf.transform)[1];
// the pair of animations one transition plays: the ghost first, then the live pane
const lastPair = () => ({ ghost: anims[anims.length - 2], live: anims[anims.length - 1] });
const zoomAt = lvl => {
  A.state.view = "zoom"; A.state.zoomLevel = lvl; A.renderZoom();
  els.hvGhost.innerHTML = ""; els.hvGhost._kids.length = 0;
  anims.length = 0;
};

t("the direction is which way through the levels the move goes", () => {
  eqArr(A.ZOOM_LEVELS, ["month", "day", "hour"], "outermost first");
  assert.strictEqual(A.zoomDirection("month", "day"), "in");
  assert.strictEqual(A.zoomDirection("day", "hour"), "in");
  assert.strictEqual(A.zoomDirection("month", "hour"), "in", "two levels at once is still inwards");
  assert.strictEqual(A.zoomDirection("hour", "day"), "out");
  assert.strictEqual(A.zoomDirection("day", "month"), "out");
  assert.strictEqual(A.zoomDirection("day", "day"), null, "standing still is not a direction");
  assert.strictEqual(A.zoomDirection("day", "nonsense"), null, "and neither is a level we do not have");
});

t("drilling in grows the old level past you and brings the new one up from small", () => {
  zoomAt("day");
  A.drillToHour(A.parseDate("2026-10-20"));
  assert.strictEqual(A.zoomAnim.dir, "in");
  const { ghost, live } = lastPair();
  assert.strictEqual(scaleOf(ghost.keyframes[0]), 1, "the old level starts where it is");
  assert.strictEqual(scaleOf(ghost.keyframes[1]), A.ZOOM_FAR, "and grows past you");
  assert.strictEqual(ghost.keyframes[0].opacity, 1);
  assert.strictEqual(ghost.keyframes[1].opacity, 0, "fading out as it goes");
  assert.strictEqual(scaleOf(live.keyframes[0]), A.ZOOM_NEAR, "the new level starts small");
  assert.strictEqual(scaleOf(live.keyframes[1]), 1, "and settles at its own size");
  assert.strictEqual(live.keyframes[0].opacity, 0);
  assert.strictEqual(live.keyframes[1].opacity, 1, "fading in as it comes");
});

t("drilling out is the same movement reversed", () => {
  zoomAt("hour");
  A.backToDay(A.parseDate("2026-10-20"));
  assert.strictEqual(A.zoomAnim.dir, "out");
  const { ghost, live } = lastPair();
  assert.strictEqual(scaleOf(ghost.keyframes[1]), A.ZOOM_NEAR, "the old level recedes instead");
  assert.strictEqual(ghost.keyframes[1].opacity, 0);
  assert.strictEqual(scaleOf(live.keyframes[0]), A.ZOOM_FAR, "and the new one comes in oversized");
  assert.strictEqual(scaleOf(live.keyframes[1]), 1);
  assert.strictEqual(live.keyframes[1].opacity, 1);
});

t("both ways round the helicopter too", () => {
  zoomAt("month");
  A.drillToDay(A.monthIndex(A.parseDate("2026-10-01")));
  assert.strictEqual(A.zoomAnim.dir, "in");
  assert.strictEqual(scaleOf(lastPair().live.keyframes[0]), A.ZOOM_NEAR);
  zoomAt("day");
  A.backToHelicopter(A.monthIndex(A.parseDate("2026-10-01")));
  assert.strictEqual(A.zoomAnim.dir, "out");
  assert.strictEqual(scaleOf(lastPair().live.keyframes[0]), A.ZOOM_FAR);
});

t("the two layers move together, over the same time", () => {
  zoomAt("day");
  A.drillToHour(A.parseDate("2026-10-20"));
  const { ghost, live } = lastPair();
  assert.strictEqual(anims.length, 2, "one transition is two animations, no more");
  assert.strictEqual(ghost.opts.duration, A.ZOOM_MS);
  assert.strictEqual(live.opts.duration, A.ZOOM_MS, "the same length, or they would not read as one move");
  assert.strictEqual(ghost.opts.easing, live.opts.easing);
  assert.strictEqual(ghost.opts.fill, "forwards", "the ghost stays gone until it is cleared away");
});

t("the copy is of the level that was on screen, scrolled where it was", () => {
  zoomAt("day");
  els.hvRight.scrollLeft = 640; els.hvRight.scrollTop = 120;
  els.hvLeftBody.scrollTop = 96;
  A.drillToHour(A.parseDate("2026-10-20"));
  const copy = els.hvGhost._kids[0];
  assert(copy, "the ghost box holds the copy");
  assert.strictEqual(copy._copyOf, els.hv, "and it is a copy of the live pane");
  assert.strictEqual(lastPair().ghost.el, copy, "it is the copy that is animated, not the live pane");
  /* A fresh copy reads as scrolled to its top left, which would jump it away
     from whatever the user was looking at. */
  assert.strictEqual(copy.querySelector("#hvRight").scrollLeft, 640, "the copy keeps the scroll");
  assert.strictEqual(copy.querySelector("#hvRight").scrollTop, 120);
  assert.strictEqual(copy.querySelector("#hvLeftBody").scrollTop, 96, "the name column too");
});

t("the ghost is cleared away when the animation ends", () => {
  zoomAt("day");
  A.drillToHour(A.parseDate("2026-10-20"));
  assert(els.hvGhost.classList.contains("on"), "the box is shown while it plays");
  lastPair().ghost.settle();
  assert(!els.hvGhost.classList.contains("on"), "and hidden again when it ends");
  assert.strictEqual(els.hvGhost.innerHTML, "", "with nothing left in it");
});

/* A wheel fast enough to start the next transition before this one has ended
   leaves an old clean-up pending; it must not tidy away the ghost now flying. */
t("a transition cut short by the next one does not clear its ghost", () => {
  zoomAt("day");
  A.drillToHour(A.parseDate("2026-10-20"));
  const first = lastPair().ghost;
  A.backToDay(A.parseDate("2026-10-20"));
  const second = lastPair().ghost;
  assert.notStrictEqual(first, second, "the second transition brought its own copy");
  first.settle();
  assert(els.hvGhost.classList.contains("on"), "the second ghost is still on screen");
  second.settle();
  assert(!els.hvGhost.classList.contains("on"), "and goes when it is its own turn");
});

t("staying on one level is not a transition at all", () => {
  zoomAt("day");
  A.state.view = "gantt";                       // the zoom view is not even on screen
  A.drillToHour(A.parseDate("2026-10-20"));
  assert.strictEqual(A.zoomAnim.dir, null);
  assert.strictEqual(anims.length, 0, "nothing to animate");
  A.state.view = "zoom";
});


console.log("\n-- list view: duplicate --");

// work on a private copy of the sample so the zoom tests above stay valid
// stand up the app state from CSV text — every fixture loader goes through here
/* The same steps openFile() takes, so a fixture arrives in the state a real file
   would: repaired, group dates rolled up, and numbered in time order. */
function loadTasks(text) {
  A.state.tasks = A.parseCSV(text);
  A.sanitizeTasks(A.state.tasks);
  A.recalcPlan();
  useRef(null);          // keep saveFile() a no-op
  /* As adoptPlan() does: the plan as loaded is the starting point. Without this
     the first markDirty() after a fixture swap diffs it against the *previous*
     fixture and writes a tombstone for every row of that one — which the app
     cannot do, since it never swaps a plan in without resetting, but a test
     helper can. */
  A.state.tombstones = A.parseTombstones(text);
  A.state.planReadOnly = A.planReadOnlyOf(text);
  A.resetHistory();
}
const loadSample = () => loadTasks(fs.readFileSync(__dirname + "/DB/_fixture-sample-project.csv", "utf8"));
const nameOfId = id => (A.state.tasks.find(x => x.id === id) || {}).name;
const idOfName = n => A.state.tasks.find(x => x.name === n).id;
/* IDs follow the time order, so any change can renumber the whole plan. A test
   that holds on to a number across a mutation is testing arithmetic; one that
   looks the row up by name again is testing the plan. Prefer row()/idOfName(). */
const row = n => A.taskById(idOfName(n));

t("the copy lands directly below the original", () => {
  loadSample();
  const src = idOfName("Infrastructure build-out");
  const before = A.state.tasks.length;
  A.duplicateTask(src);
  assert.strictEqual(A.state.tasks.length, before + 1);
  assert.strictEqual(nameOfId(src), "Infrastructure build-out", "the original keeps its ID");
  assert.strictEqual(nameOfId(src + 1), "Infrastructure build-out - duplicate");
});

t("the copy carries the dates, hours, team, system and predecessors", () => {
  loadSample();
  const src = idOfName("Go-live weekend");           // has hours on both ends and two preds
  const original = A.state.tasks.find(x => x.id === src);
  const snapshot = JSON.parse(JSON.stringify(original));
  A.duplicateTask(src);
  const copy = A.state.tasks.find(x => x.id === src + 1);
  assert.strictEqual(copy.start, snapshot.start);
  assert.strictEqual(copy.hourStart, snapshot.hourStart);
  assert.strictEqual(copy.end, snapshot.end);
  assert.strictEqual(copy.hourEnd, snapshot.hourEnd);
  assert.strictEqual(copy.team, snapshot.team);
  assert.strictEqual(copy.system, snapshot.system);
  eqArr(copy.preds, [...A.state.tasks.find(x => x.id === src).preds], "same predecessors as the original");
});

t("IDs above the copy shift up and every reference follows", () => {
  loadSample();
  const src = idOfName("Requirements workshop");
  // remember every task by name, with the names of its predecessors and members
  const before = A.state.tasks.map(x => ({
    name: x.name,
    preds: x.preds.map(nameOfId).sort(),
    group: x.group.map(nameOfId).sort(),
  }));
  A.duplicateTask(src);
  for (const b of before) {
    const now = A.state.tasks.find(x => x.name === b.name);
    assert(now, "task disappeared: " + b.name);
    if (b.name === "Analysis & Design") continue;    // it gained the duplicate as a member
    eqArr(now.preds.map(nameOfId).sort(), b.preds, b.name + ": predecessors must still point at the same tasks");
    eqArr(now.group.map(nameOfId).sort(), b.group, b.name + ": members must still be the same tasks");
  }
});

t("a duplicated subtask stays in its group", () => {
  loadSample();
  const src = idOfName("Requirements workshop");     // member of Analysis & Design
  A.duplicateTask(src);
  const owner = A.groupOf(src + 1);
  assert(owner, "the copy is not in any group");
  assert.strictEqual(owner.name, "Analysis & Design");
  assert(owner.group.includes(src), "and the original is still in it too");
});

t("a duplicated standalone task joins no group", () => {
  loadSample();
  const src = idOfName("Infrastructure build-out");
  A.duplicateTask(src);
  assert.strictEqual(A.groupOf(src + 1), undefined);
});

t("duplicating a group does not steal its members", () => {
  loadSample();
  const src = idOfName("Testing");
  const members = A.state.tasks.find(x => x.id === src).group.map(nameOfId);
  A.duplicateTask(src);
  const copy = A.state.tasks.find(x => x.id === src + 1);
  eqArr(copy.group, [], "the copy is a plain task, not a second owner");
  const original = A.state.tasks.find(x => x.name === "Testing");
  eqArr(original.group.map(nameOfId), members, "the original keeps all of them");
  assert(!A.isGroup(copy));
});

t("duplicating a milestone keeps it a milestone", () => {
  loadSample();
  const src = idOfName("Code complete");
  A.duplicateTask(src);
  const copy = A.state.tasks.find(x => x.id === src + 1);
  assert.strictEqual(copy.milestone, true);
  assert.strictEqual(copy.start, copy.end, "a milestone is still one point in time");
  assert.strictEqual(copy.name, "Code complete - duplicate");
});

t("the result still passes the editor's own validation", () => {
  loadSample();
  for (const n of ["Requirements workshop", "Infrastructure build-out", "Testing", "Trial load 1"])
    A.duplicateTask(idOfName(n));
  A.recalcGroups();
  eqArr(A.sanitizeTasks(A.state.tasks), [], "no repairs should be needed");
  for (const task of A.state.tasks) {
    const errs = A.validateTask(task, task.id, A.state.tasks);
    assert.strictEqual(errs.length, 0, `#${task.id} "${task.name}": ${errs.join(" / ")}`);
  }
});

t("names are capped at the editor's 200 characters", () => {
  loadSample();
  const src = idOfName("Infrastructure build-out");
  A.state.tasks.find(x => x.id === src).name = "x".repeat(198);
  A.duplicateTask(src);
  assert.strictEqual(A.state.tasks.find(x => x.id === src + 1).name.length, 200);
});

/* Whatever you do next — drag it into a group, duplicate it again, start the
   task after it — you mean the copy, not the row you copied. */
t("the copy takes the selection over from the original", () => {
  loadSample();
  const src = idOfName("Infrastructure build-out");
  A.selectRow(src, false);
  A.duplicateTask(src);
  eqArr([...A.state.selected], [src + 1], "the copy is selected, the original is not");
  assert.strictEqual(A.state.selAnchor, src + 1, "and a shift-click now measures from it");
  A.clearSelection();
});

t("duplicating from no selection selects the copy all the same", () => {
  loadSample();
  A.clearSelection();
  const src = idOfName("Infrastructure build-out");
  A.duplicateTask(src);
  eqArr([...A.state.selected], [src + 1]);
  A.clearSelection();
});

/* Creating a task is nearly always "and then this happens": the new row starts
   where the row you are on ends, and until you give it a length it ends there
   too. Both halves are prefill only — nothing here overrides a typed value. */
console.log("\n-- a new task starts where the one you are on ends --");

const chained = () => loadTasks(
  "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone\n" +
  "1|Alpha|2|2026-03-02|09:00|2026-03-03|17:30||Dev|Core||no\n" +
  "2|Beta|1|2026-03-04||2026-03-04|||Dev|Core||no\n" +
  "3|Gamma|2|2026-03-05||2026-03-06|||Ops|Core||no\n");

t("the anchor is the group, then the selection, then the button's row", () => {
  chained();
  A.clearSelection();
  assert.strictEqual(A.newTaskAnchor(null), null, "nothing to go on");
  assert.strictEqual(A.newTaskAnchor({ atId: 2 }).id, 2, "the row whose insert button was pressed");
  A.selectRow(1, false);
  assert.strictEqual(A.newTaskAnchor({ atId: 2 }).id, 1, "the row you picked wins over it");
  A.addToGroup(3, 2);
  assert.strictEqual(A.newTaskAnchor({ atId: 4, groupId: 2 }).id, 2,
    "and the group it is being added into wins over both");
  A.clearSelection();
});

t("the dialog opens on the selected row's end, hours included", () => {
  chained();
  A.selectRow(1, false);
  A.openModal({ type: "new", atId: null });
  assert.strictEqual(els.fStart.value, "2026-03-03", "start = the row's end");
  assert.strictEqual(els.fHourStart.value, "17:30", "including its hour");
  assert.strictEqual(els.fEnd.value, "2026-03-03", "and the end matches the start");
  assert.strictEqual(els.fHourEnd.value, "17:30");
  assert.strictEqual(els.fDuration.textContent, "1 day(s)", "the duration preview agrees");
  A.closeModal(); A.clearSelection();
});

t("adding into a group starts after everything already in it", () => {
  chained();
  A.addToGroup(3, 2);                       // 2 becomes a group holding 3
  A.clearSelection();
  A.openModal({ type: "new", atId: 4, groupId: 2 });
  assert.strictEqual(els.fStart.value, "2026-03-06", "a group ends when its last member does");
  A.closeModal();
});

t("with nothing to go on the dialog stays empty", () => {
  chained();
  A.clearSelection();
  A.openModal({ type: "new", atId: null });
  assert.strictEqual(els.fStart.value, "", "no anchor, no prefill");
  assert.strictEqual(els.fEnd.value, "");
  A.closeModal();
});

t("an anchor with no end date prefills nothing", () => {
  chained();
  A.taskById(1).end = ""; A.taskById(1).hourEnd = "";
  A.selectRow(1, false);
  A.openModal({ type: "new", atId: null });
  assert.strictEqual(els.fStart.value, "", "there is nothing to chain from");
  A.closeModal(); A.clearSelection();
});

t("editing an existing task is never prefilled from the selection", () => {
  chained();
  A.selectRow(1, false);
  A.openModal({ type: "edit", id: 3 });
  assert.strictEqual(els.fStart.value, "2026-03-05", "the task's own dates, not the selected row's");
  A.closeModal(); A.clearSelection();
});

t("on a new task the End follows the Start, hours included", () => {
  chained();
  A.clearSelection();
  A.openModal({ type: "new", atId: null });
  els.fStart.value = "2026-04-01"; els.fHourStart.value = "08:00";
  fire(els.fStart, "change");
  assert.strictEqual(els.fEnd.value, "2026-04-01", "the end came along");
  assert.strictEqual(els.fHourEnd.value, "08:00", "and so did the hour");
  assert.strictEqual(els.fDuration.textContent, "1 day(s)", "with the duration preview refreshed");
  A.closeModal();
});

t("an End typed by hand stops the mirroring", () => {
  chained();
  A.clearSelection();
  A.openModal({ type: "new", atId: null });
  els.fStart.value = "2026-04-01"; fire(els.fStart, "change");
  els.fEnd.value = "2026-04-10"; fire(els.fEnd, "input");
  els.fStart.value = "2026-04-02"; fire(els.fStart, "change");
  assert.strictEqual(els.fEnd.value, "2026-04-10",
    "a length entered by hand survives a corrected start");
  A.closeModal();
});

t("editing an existing task never moves its End", () => {
  chained();
  A.openModal({ type: "edit", id: 1 });
  els.fStart.value = "2026-03-01"; fire(els.fStart, "change");
  assert.strictEqual(els.fEnd.value, "2026-03-03", "an edit is a correction, not a fresh row");
  A.closeModal();
});

t("a milestone mirrors either way, new or edited", () => {
  chained();
  A.openModal({ type: "edit", id: 1 });
  els.fMilestone.checked = true;
  els.fStart.value = "2026-05-05"; els.fHourStart.value = "10:00";
  fire(els.fStart, "change");
  assert.strictEqual(els.fEnd.value, "2026-05-05");
  assert.strictEqual(els.fHourEnd.value, "10:00", "a milestone is one instant, hour included");
  els.fMilestone.checked = false;
  A.closeModal();
});

t("the inline add row is prefilled from the selected task too", () => {
  chained();
  A.state.view = "table"; useRef({});
  A.selectRow(1, false);
  A.startInline("new");
  const row = els.tableView.innerHTML.match(/<tr class="editingRow"[\s\S]*?<\/tr>/)[0];
  assert(/id="iStart"[^>]*value="2026-03-03"/.test(row), "start = the selected row's end: " + row);
  assert(/id="iHs"[^>]*value="17:30"/.test(row), "hours included");
  assert(/id="iEnd"[^>]*value="2026-03-03"/.test(row), "and the end matches");
  A.cancelInline(); A.clearSelection(); useRef(null);
});

t("editing a row inline still shows that row's own dates", () => {
  chained();
  A.state.view = "table"; useRef({});
  A.selectRow(1, false);
  A.startInline(3);
  const row = els.tableView.innerHTML.match(/<tr class="editingRow"[\s\S]*?<\/tr>/)[0];
  assert(/id="iStart"[^>]*value="2026-03-05"/.test(row), "not the selection's: " + row);
  A.cancelInline(); A.clearSelection(); useRef(null);
});

/* The Actions column sits at the far right of a wide table, so the same actions
   are offered at the pointer. Both routes go through runRowAction(), which is
   what keeps them from drifting apart. */
console.log("\n-- right-click menu on a list row --");

const menuActs = () => [...els.rowMenu.innerHTML.matchAll(/data-act="([a-zA-Z]+)"/g)].map(m => m[1]);

t("a plain top-level task offers the four actions that apply to it", () => {
  chained();
  A.openRowMenu(1, 100, 100);
  eqArr(menuActs(), ["edit", "dup", "insert", "del"], "no grouping items: it is in no group and holds none");
  assert(A.rowMenuOpen(), "and the menu is on screen");
  A.closeRowMenu();
});

t("a group offers adding a task into it", () => {
  chained();
  A.addToGroup(3, 2);                         // 2 becomes a group holding 3
  A.openRowMenu(2, 100, 100);
  assert(menuActs().includes("addto"), "the ⤵️➕ item: " + menuActs());
  assert(!menuActs().includes("ungroup"), "but it is not in a group itself");
  A.closeRowMenu();
});

t("a member offers being taken out of its group", () => {
  chained();
  A.addToGroup(3, 2);
  A.openRowMenu(3, 100, 100);
  assert(menuActs().includes("ungroup"), "the ➖ item: " + menuActs());
  assert(!menuActs().includes("addto"), "it holds nothing, so there is nothing to add into");
  A.closeRowMenu();
});

t("a subgroup offers both", () => {
  chained();
  A.addToGroup(3, 2);                         // 2 holds 3
  A.addToGroup(2, 1);                         // 1 holds 2, so 2 is a subgroup
  A.openRowMenu(2, 100, 100);
  assert(menuActs().includes("addto"), "it is a group");
  assert(menuActs().includes("ungroup"), "and it is in one");
  A.closeRowMenu();
});

t("delete is separated from the rest and marked", () => {
  chained();
  A.openRowMenu(1, 100, 100);
  assert(/<div class="mSep"><\/div><button data-act="del"/.test(els.rowMenu.innerHTML),
    "a separator keeps it away from the item above: " + els.rowMenu.innerHTML);
  assert(/data-act="del"[^>]*class="danger"/.test(els.rowMenu.innerHTML), "and it is marked danger");
  A.closeRowMenu();
});

t("the menu says which row it is about, escaped", () => {
  chained();
  A.taskById(1).name = '<img src=x onerror="alert(1)">';
  A.openRowMenu(1, 100, 100);
  const head = els.rowMenu.innerHTML.match(/<div class="mHead">([\s\S]*?)<\/div>/)[1];
  assert(head.startsWith("#1 "), "the ID leads: " + head);
  assert(!/<img/.test(els.rowMenu.innerHTML), "a hostile name cannot break out: " + head);
  assert(head.includes("&lt;img"), "it is shown, escaped");
  A.closeRowMenu();
});

/* The two routes must offer the same things — a menu that quietly lost an
   action, or kept one the row no longer allows, is worse than no menu. */
t("the menu and the Actions column offer exactly the same actions", () => {
  chained();
  A.addToGroup(3, 2);
  A.state.view = "table"; useRef({});
  A.renderTable();
  for (const id of [1, 2, 3]) {
    const row = els.tableView.innerHTML.match(new RegExp(`<tr class="[^"]*" data-id="${id}"[\\s\\S]*?</tr>`))[0];
    const inRow = [...row.matchAll(/data-act="([a-zA-Z]+)"/g)].map(m => m[1]).sort();
    A.openRowMenu(id, 10, 10);
    /* 🔴 Two items in the menu are not about the row at all: folding every
       group open or shut, which moved here from the list's bar on 2026-08-21.
       They are the plan's actions, so the Actions column rightly has no such
       button — and this comparison has to leave them out rather than being
       widened to accept anything. */
    eqArr(menuActs().filter(a => a !== "expandAll" && a !== "collapseAll").sort(), inRow, "row " + id);
    A.closeRowMenu();
  }
  useRef(null);
});

t("choosing an action runs the same code the buttons do", () => {
  chained();
  const before = A.state.tasks.length;
  A.runRowAction("dup", 1);
  assert.strictEqual(A.state.tasks.length, before + 1, "duplicate ran");
  assert.strictEqual(A.taskById(2).name, "Alpha - duplicate");
});

t("an action naming a row that is gone does nothing", () => {
  chained();
  const before = A.state.tasks.map(x => x.id).join(",");
  A.runRowAction("dup", 999);
  A.openRowMenu(999, 10, 10);
  assert.strictEqual(A.state.tasks.map(x => x.id).join(","), before, "nothing happened");
  assert(!A.rowMenuOpen(), "and no menu opened for a row that does not exist");
});

t("only a group can be added into, whichever route asks", () => {
  chained();
  A.closeModal();
  A.runRowAction("addto", 1);                 // 1 is a plain task
  assert.strictEqual(els.overlay.classList.contains("open"), false,
    "the dialog does not open in 'add into group' mode for a row that is not a group");
});

t("Escape closes the menu before anything else claims the key", () => {
  chained();
  A.selectRow(1, false);
  A.openRowMenu(1, 100, 100);
  press("Escape");
  assert(!A.rowMenuOpen(), "the menu went");
  eqArr([...A.state.selected], [1], "and the selection it belongs to is still there");
  press("Escape");
  eqArr([...A.state.selected], [], "a second Escape then drops the selection");
});

t("leaving the list view closes it", () => {
  chained();
  useRef({});
  A.openRowMenu(1, 100, 100);
  A.setView("gantt");
  assert(!A.rowMenuOpen(), "the menu belongs to a row of the list we left");
  A.setView("table"); useRef(null);
});

/* With several rows picked the menu is about the selection. Editing several
   tasks at once means nothing, so that item goes; the rest apply to all of them. */
const trio = () => loadTasks(
  "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone\n" +
  "1|Prep|1|2026-03-02||2026-03-02|||Dev|Core||no\n" +
  "2|Build|1|2026-03-03||2026-03-03||1|Dev|Core||no\n" +
  "3|Test|1|2026-03-04||2026-03-04||2|Dev|Core||no\n" +
  "4|After|1|2026-03-05||2026-03-05||3|Ops|Core||no\n");
const pickTwo = () => { trio(); A.selectRow(2, false); A.selectRow(3, true); };

t("a multi-row selection gets the selection's actions, not the row's", () => {
  pickTwo();
  A.openRowMenu(3, 10, 10);
  /* No Edit: a name, a predecessor list and a group membership belong to one
     row. Mass change is the item that does apply to a set — what the rows share
     is when the work happens and who is on it. */
  eqArr(menuActs(), ["massSel", "dupSel", "insert", "delSel"]);
  assert(!menuActs().includes("edit"), "the single-row editor was offered for two rows");
  assert(/2 tasks selected/.test(els.rowMenu.innerHTML), "and the header says what it is about");
  assert(/📋 Duplicate 2 tasks/.test(els.rowMenu.innerHTML), "the counts are in the labels");
  A.closeRowMenu(); A.clearSelection();
});

t("right-clicking outside the selection is about that row alone", () => {
  pickTwo();
  A.openRowMenu(1, 10, 10);                   // row 1 is not in the selection
  assert(menuActs().includes("edit"), "the single-row menu is back: " + menuActs());
  assert(!menuActs().includes("dupSel"));
  A.closeRowMenu(); A.clearSelection();
});

/* Every row and selection menu opens with the status block — a caption, the
   five statuses, a rule — so a test about which *actions* are offered has to
   say so. Built from the app's own list, so a sixth status needs no edit here. */
const statusBlock = pfx => ["sep", ...A.STATUSES.map(s => pfx + s), "sep"];

t("ungrouping is offered only when some of them are in a group", () => {
  pickTwo();
  eqArr(A.selectionActions([2, 3]).map(i => i.act || "sep"),
        [...statusBlock("sts:"), "massSel", "sep", "dupSel", "insert", "sep", "delSel"],
        "neither is in a group");
  A.addToGroup(3, 1);                         // 1 becomes a group holding 3
  assert(A.selectionActions([2, 3]).map(i => i.act).includes("ungroupSel"),
    "now one of them is");
  A.clearSelection();
});

t("the menu is pulled back inside the window at the edges", () => {
  chained();
  els.rowMenu.offsetWidth = 210; els.rowMenu.offsetHeight = 180;
  ctx.window.innerHeight = 900;
  A.placeRowMenu(400, 300);
  assert.strictEqual(els.rowMenu.style.left, "400px", "room to the right: it opens at the pointer");
  assert.strictEqual(els.rowMenu.style.top, "300px");
  A.placeRowMenu(1150, 800);                  // window is 1200 x 900 in the stub
  assert.strictEqual(els.rowMenu.style.left, "940px", "against the right edge it opens leftwards");
  assert.strictEqual(els.rowMenu.style.top, "620px", "and upwards at the bottom");
  els.rowMenu.offsetWidth = els.rowMenu.offsetHeight = 0;
});

console.log("\n-- duplicating a whole selection --");

/* The block lands after its sources by ID, but a copy shares its source's dates,
   so the numbering then puts each copy beside the row it came from. */
t("each copy is numbered next to the row it came from", () => {
  pickTwo();
  A.duplicateTasks([2, 3]);
  eqArr(A.state.tasks.map(x => x.id + ":" + x.name),
        ["1:Prep", "2:Build", "3:Build - duplicate", "4:Test", "5:Test - duplicate", "6:After"],
        "the copy of a March task belongs beside it, not at the end of the plan");
  A.clearSelection();
});

/* The point of copying a set of tasks is the pattern, not N loose rows still
   pointing back at the originals. */
t("a dependency between two copied rows is rewritten to run between the copies", () => {
  pickTwo();
  A.duplicateTasks([2, 3]);
  eqArr(A.taskById(idOfName("Test - duplicate")).preds, [idOfName("Build - duplicate")],
    "the copy of Test follows the copy of Build, not the original");
});

t("a dependency on a task outside the selection still points at that task", () => {
  pickTwo();
  A.duplicateTasks([2, 3]);
  eqArr(A.taskById(idOfName("Build - duplicate")).preds, [idOfName("Prep")],
    "the copy of Build still waits for the original Prep");
  eqArr(A.taskById(idOfName("After")).preds, [idOfName("Test")],
    "and the row that followed Test still does");
});

t("the copies take over the selection", () => {
  pickTwo();
  A.duplicateTasks([2, 3]);
  eqArr([...A.state.selected].sort((a, b) => a - b),
        [idOfName("Build - duplicate"), idOfName("Test - duplicate")].sort((a, b) => a - b),
        "what you go on to work with");
  A.clearSelection();
});

t("the fields a single duplicate copies are the ones a block copies", () => {
  pickTwo();
  A.duplicateTasks([2, 3]);
  const src = A.taskById(idOfName("Build")), copy = A.taskById(idOfName("Build - duplicate"));
  for (const f of ["start", "hourStart", "end", "hourEnd", "team", "system", "milestone"])
    assert.strictEqual(copy[f], src[f], f + " is carried over");
  assert.strictEqual(copy.name, "Build - duplicate");
  eqArr(copy.group, [], "and a copy holds nothing, even if its source did");
  A.clearSelection();
});

t("duplicating a group in a block does not steal its members", () => {
  trio();
  A.taskById(3).preds = [];                   // a member may not depend on its own group
  A.addToGroup(3, 2);                         // 2 becomes a group holding 3
  A.clearSelection();
  A.duplicateTasks([1, 2]);                   // 2 is a group, its member sits at 3
  const copy = A.state.tasks.find(x => x.name === "Build - duplicate");
  eqArr(copy.group, [], "the copy is an empty row");
  eqArr(A.taskById(idOfName("Build")).group, [idOfName("Test")], "and the original keeps its member");
  A.clearSelection();
});

t("one row through the block path is just the single duplicate", () => {
  trio();
  A.clearSelection();
  A.duplicateTasks([2]);
  assert.strictEqual(A.taskById(3).name, "Build - duplicate", "the copy sits directly below its original");
  eqArr([...A.state.selected], [3]);
  A.clearSelection();
});

t("the result still passes the editor's own validation", () => {
  pickTwo();
  A.duplicateTasks([2, 3]);
  for (const t2 of A.state.tasks)
    eqArr(A.validateTask(t2, t2.id, A.state.tasks), [], "task " + t2.id);
  A.clearSelection();
});

console.log("\n-- deleting a whole selection --");

t("one question covers them all, and every row goes", () => {
  pickTwo();
  A.deleteTasks([2, 3]);
  eqArr(A.state.tasks.map(x => x.name), ["Prep", "After"], "both rows are gone");
  eqArr(A.state.tasks.map(x => x.id), [1, 2], "IDs above them shifted down");
  eqArr(A.taskById(2).preds, [], "and the links into the deleted rows were cleaned up");
  eqArr([...A.state.selected], [], "the selection went with them");
});

t("saying no changes nothing", () => {
  pickTwo();
  const before = A.serializeCSV(A.state.tasks);
  const yes = ctx.confirm;
  ctx.confirm = () => false;
  A.deleteTasks([2, 3]);
  ctx.confirm = yes;
  assert.strictEqual(A.serializeCSV(A.state.tasks), before, "the plan is untouched");
  A.clearSelection();
});

t("members of a deleted group survive, ungrouped", () => {
  trio();
  A.taskById(3).preds = [];
  A.addToGroup(3, 2);                         // 2 holds 3
  A.clearSelection();
  A.deleteTasks([1, 2]);
  eqArr(A.state.tasks.map(x => x.name), ["Test", "After"], "the member stayed");
  assert.strictEqual(A.groupOf(1), undefined, "with no group above it any more");
  A.clearSelection();
});

t("one row through the block path is just the single delete", () => {
  trio();
  A.clearSelection();
  A.deleteTasks([2]);
  eqArr(A.state.tasks.map(x => x.name), ["Prep", "Test", "After"]);
});

console.log("\n-- list view: drop on the ID column ungroups --");

t("removing a member leaves the task alone but out of the group", () => {
  loadSample();
  const g = idOfName("Analysis & Design"), m = idOfName("Technical design");
  assert.strictEqual(A.groupOf(m).id, g);
  const count = A.state.tasks.length;
  A.removeFromGroup(m);
  assert.strictEqual(A.state.tasks.length, count, "the task itself is kept");
  // IDs follow the time order, so the rows are found by name again after a change
  const m2 = idOfName("Technical design"), g2 = idOfName("Analysis & Design");
  assert.strictEqual(A.groupOf(m2), undefined, "but it is no longer a member");
  assert(!A.taskById(g2).group.includes(m2));
});

t("the group's dates are recomputed from what is left", () => {
  loadSample();
  const g = idOfName("Analysis & Design");
  assert.strictEqual(A.state.tasks.find(x => x.id === g).end, "2026-09-04", "Architecture review sets the end");
  A.removeFromGroup(idOfName("Architecture review"));
  assert.strictEqual(A.state.tasks.find(x => x.id === g).end, "2026-08-21", "now Technical design does");
});

t("a task that is in no group is untouched", () => {
  loadSample();
  const free = idOfName("Infrastructure build-out");
  const before = JSON.stringify(A.state.tasks.find(x => x.id === free));
  A.removeFromGroup(free);
  assert.strictEqual(JSON.stringify(A.state.tasks.find(x => x.id === free)), before);
});

/* Names, not numbers: the IDs are renumbered by the change itself, so what has
   to survive is which *rows* point at which. */
t("ungrouping keeps dependencies intact", () => {
  loadSample();
  const m = idOfName("Technical design");
  const dependents = A.state.tasks.filter(x => x.preds.includes(m)).map(x => x.name).sort();
  const preds = A.taskById(m).preds.map(nameOfId).sort();
  A.removeFromGroup(m);
  const m2 = idOfName("Technical design");
  eqArr(A.taskById(m2).preds.map(nameOfId).sort(), preds, "its own predecessors survive");
  eqArr(A.state.tasks.filter(x => x.preds.includes(m2)).map(x => x.name).sort(), dependents,
    "and so do the tasks depending on it");
});

t("the ID column is marked as the ungroup drop zone", () => {
  loadSample();
  A.state.view = "table";
  useRef({});
  A.renderTable();
  const html = els.tableView.innerHTML;
  // draggable as well now: every header is a handle for moving its column
  assert(html.includes('<th data-col="id" draggable="true" data-ungroup="1"'), "the ID header");
  assert(html.includes('<td class="num" data-ungroup="1"'), "and every ID cell");
  assert(html.includes('data-act="dup"'), "the duplicate button is on the row");
  useRef(null);
});

/* Only rows that were already groups used to accept a drop, so in a project with
   no groups at all — anything freshly imported — the gesture did nothing at all:
   there was no way to make the first group by dragging. */
console.log("\n-- list view: drop on a row groups the task under it --");

// a project with zero groups, the state the gesture was dead in
const flat = "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone\n" +
  "1|Alpha|1|2026-12-15||2026-12-15|||Dev|Core||no\n" +
  "2|Beta|1|2026-12-16||2026-12-16||1|Dev|Core||no\n" +
  "3|Gamma|1|2026-12-17||2026-12-17|||Dev|Core||no\n" +
  "4|Release day|0|2026-12-18||2026-12-18||||||yes\n";
const loadFlat = () => loadTasks(flat);

t("every eligible row is a drop target, not just existing groups", () => {
  loadFlat();
  A.state.view = "table"; useRef({});
  A.renderTable();
  const html = els.tableView.innerHTML;
  useRef(null);
  assert.strictEqual(A.state.tasks.filter(A.isGroup).length, 0, "fixture has no groups");
  assert.strictEqual(count(html, 'data-drop="1"'), 3, "the three ordinary tasks accept a drop");
  // rows only: the headers are draggable too now, and they are columns, not tasks
  assert.strictEqual((html.match(/<tr[^>]*draggable="true"/g) || []).length, 3, "and are draggable");
  assert(!/data-id="4"[^>]*data-drop/.test(html), "the milestone is not a drop target");
});

t("dropping onto a plain task turns that task into a group", () => {
  loadFlat();
  A.addToGroup(3, 1);
  assert(A.isGroup(row("Alpha")), "the target became a group");
  eqArr(row("Alpha").group, [idOfName("Gamma")]);
  assert.strictEqual(A.groupOf(idOfName("Gamma")).id, idOfName("Alpha"));
});

t("the new group takes its dates from its member", () => {
  loadFlat();
  A.addToGroup(3, 1);                       // Gamma runs 12-17, Alpha ran 12-15
  assert.strictEqual(row("Alpha").end, "2026-12-17", "the group's span covers its member");
});

t("a task keeps only one group — dropping again moves it", () => {
  loadFlat();
  A.addToGroup(3, 1);
  A.addToGroup(idOfName("Gamma"), idOfName("Beta"));
  eqArr(row("Alpha").group, [], "left the first group");
  eqArr(row("Beta").group, [idOfName("Gamma")], "and joined the second");
});

t("the result is a state the validator accepts", () => {
  loadFlat();
  A.addToGroup(3, 2);                       // nothing depends on Beta, so nothing is pushed
  const errs = A.state.tasks.flatMap(x => A.validateTask(x, x.id, A.state.tasks));
  eqArr(errs, [], "a drop must never leave a row the editor would reject");
});

/* Absorbing a member widens the group to its members' extent, which can carry the
   group's end past the start of a task depending on it. Legal, but silent until
   you next open that row — so the drop says so. */
t("a drop that pushes a dependent out of order warns about it", () => {
  loadFlat();
  els.toast.textContent = "";
  A.addToGroup(3, 1);                       // Gamma ends 12-17; Beta depends on Alpha and starts 12-16
  assert.strictEqual(row("Alpha").end, "2026-12-17");
  assert(new RegExp("task\\(s\\) " + idOfName("Beta") + " start before it finishes").test(els.toast.textContent),
    "expected a warning, got: " + JSON.stringify(els.toast.textContent));
});

t("a drop that pushes nobody stays quiet", () => {
  loadFlat();
  els.toast.textContent = "";
  A.addToGroup(3, 2);
  assert(els.toast.textContent && !/Heads up/.test(els.toast.textContent),
    "no warning expected, got: " + JSON.stringify(els.toast.textContent));
});

/* Dropping onto a member is how a subgroup is made — two levels of them now. */
t("dropping onto a member turns it into a subgroup", () => {
  loadFlat();
  A.addToGroup(idOfName("Gamma"), idOfName("Alpha"));   // Alpha becomes a group, Gamma its member
  A.addToGroup(idOfName("Beta"), idOfName("Gamma"));    // Gamma becomes a subgroup holding Beta
  assert(A.isGroup(row("Gamma")), "the member became a subgroup");
  assert.strictEqual(A.groupOf(idOfName("Beta")).id, idOfName("Gamma"));
  assert.strictEqual(A.depthOf(idOfName("Beta"), A.state.tasks), 2, "and its task sits one level further in");
});

/* One plain task per level, plus one to try the drop that must be refused — no
   milestone in the way, and the dates ascend so the IDs stay 1, 2, 3, … */
const STACK_N = A.MAX_GROUP_DEPTH + 2;
const deepStack = () => loadTasks(
  "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone\n" +
  Array.from({ length: STACK_N }, (_, i) => {
    const d = 11 + i;
    return `${i + 1}|T${i + 1}|1|2026-12-${d}||2026-12-${d}|||Dev|Core||no`;
  }).join("\n") + "\n");
// 1 > 2 > … > MAX_GROUP_DEPTH+1, the deepest legal stack
const stackToTheLimit = () => {
  for (let i = 2; i <= A.MAX_GROUP_DEPTH + 1; i++) A.addToGroup(i, i - 1);
};

t("grouping can be dropped together all the way to the deepest level", () => {
  deepStack();
  stackToTheLimit();
  const deepest = A.MAX_GROUP_DEPTH + 1;
  assert.strictEqual(A.groupOf(deepest).id, deepest - 1, "the last drop went through");
  assert.strictEqual(A.depthOf(deepest, A.state.tasks), A.MAX_GROUP_DEPTH, "and lands at the deepest level");
});

t("a drop one level past the limit is refused", () => {
  deepStack();
  stackToTheLimit();
  const before = A.state.tasks.map(x => (x.group || []).join(",")).join("|");
  A.addToGroup(A.MAX_GROUP_DEPTH + 2, A.MAX_GROUP_DEPTH + 1);   // would sit one level too deep
  assert.strictEqual(A.state.tasks.map(x => (x.group || []).join(",")).join("|"), before,
    "nothing may change when the drop is refused");
  assert(A.state.tasks.every(x => A.depthOf(x.id, A.state.tasks) <= A.MAX_GROUP_DEPTH));
});

t("a group cannot be dropped into its own descendant", () => {
  loadFlat();
  A.addToGroup(idOfName("Gamma"), idOfName("Alpha"));   // Alpha holds Gamma
  A.addToGroup(idOfName("Alpha"), idOfName("Gamma"));   // putting Alpha inside Gamma would loop
  assert.strictEqual(A.groupOf(idOfName("Alpha")), undefined, "the outer group stayed at the top");
  assert.strictEqual(A.groupOf(idOfName("Gamma")).id, idOfName("Alpha"));
});

t("a milestone is neither dragged into nor turned into a group", () => {
  loadFlat();
  A.addToGroup(idOfName("Alpha"), idOfName("Release day"));   // onto the milestone
  assert(!A.isGroup(row("Release day")));
  A.addToGroup(idOfName("Release day"), idOfName("Alpha"));   // the milestone itself
  assert(!A.isGroup(row("Alpha")));
});

/* A member and its group must not also be linked by a dependency — validateTask
   rejects that pairing from both sides, so the drop has to refuse it up front. */
t("a drop that would clash with a dependency is refused", () => {
  loadFlat();
  A.addToGroup(idOfName("Beta"), idOfName("Alpha"));    // Beta depends on Alpha
  assert(!A.isGroup(row("Alpha")), "Beta depends on Alpha");
  A.addToGroup(idOfName("Alpha"), idOfName("Beta"));    // and the same pair the other way round
  assert(!A.isGroup(row("Beta")), "Beta is the dependent — still refused");
});

t("dropping onto an existing group still works", () => {
  loadSample();
  const g = idOfName("Analysis & Design"), free = idOfName("Infrastructure build-out");
  const before = A.taskById(g).group.length;
  A.addToGroup(free, g);
  assert.strictEqual(row("Analysis & Design").group.length, before + 1);
  assert.strictEqual(A.groupOf(idOfName("Infrastructure build-out")).id, idOfName("Analysis & Design"));
});


console.log("\n-- zoom view: Y dimension = team / system --");
loadSample();
A.state.view = "zoom";
A.state.zoomFocus = null;

t("a row per team actually used, containers and milestones left out", () => {
  const buckets = A.laneBuckets("team");
  const names = [...buckets.keys()];
  for (const n of ["Architecture", "Business Analysis", "Dev Team A", "PMO", "QA", "Security"])
    assert(names.includes(n), "missing team: " + n + " (got " + names.join(", ") + ")");
  for (const [, tasks] of buckets) {
    for (const task of tasks) {
      assert(!task.milestone, "a milestone was counted: " + task.name);
      assert(!A.isGroup(task), "a group row was counted as work: " + task.name);
    }
  }
  eqArr(names, [...names].sort((a, b) => a.localeCompare(b)), "rows are in name order");
});

t("every leaf task lands in exactly one team bucket", () => {
  const buckets = A.laneBuckets("team");
  const counted = new Set();
  let total = 0;
  for (const [, tasks] of buckets) for (const task of tasks) {
    assert(!counted.has(task.id), "task counted twice: " + task.name);
    counted.add(task.id); total++;
  }
  const expected = A.state.tasks.filter(x => !x.milestone && !A.isGroup(x) && x.start).length;
  assert.strictEqual(total, expected, "every leaf task is on some row");
});

t("systems work the same way", () => {
  const names = [...A.laneBuckets("system").keys()];
  for (const n of ["BI", "ERP", "N/A", "Servers"]) assert(names.includes(n), "missing system: " + n);
});

t("a month segment counts the team's tasks running in that month", () => {
  const rows = A.laneRowsMonth("team");
  const dev = rows.find(r => r.key === "Dev Team A");
  const oct = A.monthIndex(new Date(2026, 9, 1));
  const seg = dev.segs.find(s => s.abs === oct);
  // Dev Team A in Oct 2026: interfaces, migration tooling, Partner A, Partner B
  const byHand = dev.tasks.filter(x => {
    const a = A.parseDate(x.start), b = A.parseDate(x.end) || a;
    return a <= new Date(2026, 9, 31) && b >= new Date(2026, 9, 1);
  }).length;
  assert.strictEqual(seg.count, byHand);
  assert(seg.count > 1, "October really is a busy month for them");
});

t("months with no work produce no segment", () => {
  const rows = A.laneRowsMonth("team");
  const qa = rows.find(r => r.key === "QA");
  const months = qa.segs.map(s => s.abs);
  eqArr(months, [...months].sort((a, b) => a - b), "segments are in order");
  const jul = A.monthIndex(new Date(2026, 6, 1));
  assert(!months.includes(jul), "QA has nothing in July 2026");
});

t("segments span the real edges inside the month, not the whole month", () => {
  const sec = A.laneRowsMonth("team").find(r => r.key === "Security");
  const aug = sec.segs.find(s => s.abs === A.monthIndex(new Date(2026, 7, 1)));
  assert.strictEqual(A.fmtDate(aug.from), "2026-08-10", "threat modelling starts mid-month");
  assert.strictEqual(A.fmtDate(aug.to),   "2026-08-31", "and runs to the month end");
});

t("the day level counts per day and merges equal runs", () => {
  const rows = A.laneRowsDay("team");
  const { firstDay } = A.projectMonthBounds();
  const sec = rows.find(r => r.key === "Security");
  for (const s of sec.segs) {
    assert(s.i1 >= s.i0, "a run must not be inverted");
    assert(s.count > 0, "empty days must not produce a segment");
  }
  // spot-check one day against a hand count
  const probe = A.parseDate("2026-10-20");
  const i = A.daysBetween(firstDay, probe);
  const seg = sec.segs.find(s => s.i0 <= i && i <= s.i1);
  const byHand = sec.tasks.filter(x => {
    const a = A.parseDate(x.start), b = A.parseDate(x.end) || a;
    return a <= probe && b >= probe;
  }).length;
  assert.strictEqual(seg ? seg.count : 0, byHand, "20 Oct 2026 for Security");
});

t("adjacent runs always differ in count", () => {
  for (const r of A.laneRowsDay("team")) {
    for (let i = 1; i < r.segs.length; i++) {
      const prev = r.segs[i - 1], cur = r.segs[i];
      if (cur.i0 === prev.i1 + 1)
        assert.notStrictEqual(cur.count, prev.count, r.key + ": touching runs with the same count were not merged");
    }
  }
});

t("shading uses the same five-band scale over the busiest row x period", () => {
  const m = A.laneModel("team", "month");
  let seen = 0;
  for (const r of m.rows) for (const s of r.segs) seen = Math.max(seen, s.count);
  assert.strictEqual(m.max, seen);
  assert(m.max > 0);
  assert.strictEqual(A.densityBand(m.max, m.max), 4, "the busiest cell is the darkest");
  assert.strictEqual(A.densityBand(0, m.max), 0);
});

console.log("\n-- team / system rendering --");
A.state.zoomBy = "team";
A.state.zoomLevel = "month";
A.renderZoom();
const tBody = els.hvBody.innerHTML, tLeft = els.hvLeftBody.innerHTML;

t("helicopter: rows are teams, not tasks", () => {
  assert(tLeft.includes("Dev Team A"));
  assert(tLeft.includes("task(s)"), "the row subtitle counts its tasks");
  assert(!tLeft.includes("Requirements workshop"), "no task names at this level");
  const m = A.laneModel("team", "month");
  assert.strictEqual(count(tBody, 'class="hvSeg'), m.rows.reduce((n, r) => n + r.segs.length, 0));
});

t("helicopter: segments drill into the day view and carry their row", () => {
  assert(tBody.includes("data-month="), "month cells drill down");
  assert(tBody.includes('data-key="Dev Team A"'), "and remember which team they belong to");
});

t("helicopter: milestones and today are still drawn", () => {
  assert.strictEqual(count(tBody, 'class="hvMsLine'), 7);
  assert(tBody.includes('<span class="n">Design frozen</span><span class="d">2026-09-04</span>'));
});

t("the legend counts tasks per month here", () => {
  assert(els.hvLegend.innerHTML.includes("tasks / month"));
  assert(els.hvLegend.innerHTML.includes("lgSw d4"));
});

A.state.zoomLevel = "day";
A.renderZoom();
const tdBody = els.hvBody.innerHTML;

t("day level: still teams, one box per run of equal load", () => {
  const m = A.laneModel("team", "day");
  assert.strictEqual(count(tdBody, 'class="hvSeg'), m.rows.reduce((n, r) => n + r.segs.length, 0));
  assert(tdBody.includes("data-day="), "day cells drill into the hour view");
  assert(els.hvLegend.innerHTML.includes("tasks / day"));
});

/* Drilling in on a team is a close-up of that team: the day level shows the one
   row that was drilled from, not the whole board again. */
console.log("\n-- day level shows only the team drilled from --");
t("with a focus, the day level draws that row and nothing else", () => {
  A.state.zoomBy = "team"; A.state.zoomLevel = "day";
  A.state.zoomFocus = "Dev Team A";
  A.renderZoom();
  const m = A.laneModel("team", "day");
  const mine = m.rows.find(r => r.key === "Dev Team A");
  assert(m.rows.length > 1, "the sample has several teams to hide");
  assert.strictEqual(count(els.hvBody.innerHTML, 'class="hvSeg'), mine.segs.length,
    "only the focused row's boxes are drawn");
  assert(els.hvLeftBody.innerHTML.includes("Dev Team A"));
  for (const r of m.rows)
    if (r.key !== "Dev Team A")
      assert(!els.hvLeftBody.innerHTML.includes(r.key), "another row leaked in: " + r.key);
});

t("the shading scale stays the project's, so a dark cell means what it did", () => {
  const all = A.laneModel("team", "day").max;
  assert(els.hvLegend.innerHTML.includes(">" + all + "<"), "the legend still tops out at " + all);
});

t("the crumb names the focused team and offers the way back to all of them", () => {
  const c = els.hvCrumb.innerHTML;
  assert(c.includes("Dev Team A"), "the crumb names the team");
  assert(c.includes("crumbAllLanes"), "and a button brings every team back");
});

t("clearing the focus brings the whole board back", () => {
  A.state.zoomFocus = null;
  A.renderZoom();
  const m = A.laneModel("team", "day");
  assert.strictEqual(count(els.hvBody.innerHTML, 'class="hvSeg'),
    m.rows.reduce((n, r) => n + r.segs.length, 0), "every row is drawn again");
  assert(!els.hvCrumb.innerHTML.includes("crumbAllLanes"), "and there is nothing to clear");
});

t("the helicopter is always the whole project, focus or not", () => {
  A.state.zoomFocus = "Dev Team A"; A.state.zoomLevel = "month";
  A.renderZoom();
  const m = A.laneModel("team", "month");
  assert.strictEqual(count(els.hvBody.innerHTML, 'class="hvSeg'),
    m.rows.reduce((n, r) => n + r.segs.length, 0));
  assert.strictEqual(A.laneFocus("month", m.rows), null, "focus does not apply up here");
  A.state.zoomFocus = null;
});

t("a focus left over from another dimension falls away", () => {
  const rows = A.laneRowsDay("team");
  assert.strictEqual(A.laneFocus("day", rows), null, "no focus, no filter");
  A.state.zoomFocus = "a team that no longer exists";
  assert.strictEqual(A.laneFocus("day", rows), null, "an unmatched focus is ignored");
  A.state.zoomFocus = null;
});

t("laneKeyAtY counts off the rows that are on screen", () => {
  A.state.zoomBy = "team"; A.state.zoomLevel = "day"; A.state.zoomFocus = null;
  const rows = A.laneRowsDay("team");
  assert.strictEqual(A.laneKeyAtY(4), rows[0].key, "first row");
  assert.strictEqual(A.laneKeyAtY(44), rows[1].key, "second row, 40px down");
  A.state.zoomFocus = rows[1].key;                    // now only that one is drawn
  assert.strictEqual(A.laneKeyAtY(4), rows[1].key, "the focused row is the only row");
  A.state.zoomFocus = null;
});

t("Shift + wheel into the day view carries the team under the pointer", () => {
  A.state.zoomBy = "team"; A.state.zoomLevel = "month"; A.state.zoomFocus = null;
  A.state.pxPerMonth = 130; A.renderZoom();
  hv.scrollLeft = 0; hv.scrollTop = 0;
  const rows = A.laneRowsMonth("team");
  fireEl(hv, "wheel", mouse(300, 44, { deltaY: -120, shiftKey: true }));   // second row
  assert.strictEqual(A.state.zoomLevel, "day");
  assert.strictEqual(A.state.zoomFocus, rows[1].key, "the row under the pointer became the focus");
});

t("going back up to the helicopter drops the focus", () => {
  A.backToHelicopter();
  assert.strictEqual(A.state.zoomFocus, null);
  A.state.zoomBy = "task"; A.state.zoomLevel = "month"; A.renderZoom();
});

console.log("\n-- micro view shows only the chosen team --");
t("drilling from a team row limits the hour view to that team's tasks", () => {
  A.state.zoomBy = "team";
  A.state.zoomFocus = "Dev Team A";
  A.state.zoomDay = "2026-10-20";
  A.state.zoomLevel = "hour";
  const hm = A.hourModel();
  assert.strictEqual(hm.focus, "Dev Team A");
  assert(hm.rows.length > 0, "that team is working then");
  for (const r of hm.rows)
    assert.strictEqual(r.t.team, "Dev Team A", "a foreign task leaked in: " + r.t.name);
  const names = hm.rows.map(r => r.t.name);
  assert(names.includes("Data migration tooling"), names.join(", "));
  assert(!names.includes("Development"), "the group container is not that team's work");
});

t("clearing the focus brings everyone back", () => {
  A.state.zoomFocus = null;
  const all = A.hourModel();
  A.state.zoomFocus = "Dev Team A";
  const one = A.hourModel();
  assert(all.rows.length > one.rows.length, "the unfocused window is wider");
});

t("systems focus the same way", () => {
  A.state.zoomBy = "system";
  A.state.zoomFocus = "Servers";
  const hm = A.hourModel();
  for (const r of hm.rows) assert.strictEqual(r.t.system, "Servers", "leaked: " + r.t.name);
});

t("the hour view renders and names the focus in the crumb", () => {
  A.state.zoomBy = "team"; A.state.zoomFocus = "Dev Team A"; A.state.zoomLevel = "hour";
  A.renderZoom();
  assert(els.hvCrumb.innerHTML.includes("Dev Team A"), "crumb names the team");
  assert(els.hvBody.innerHTML.includes('class="hrBar'), "bars are drawn");
  assert(els.hvHeadTitle.innerHTML.includes("Dev Team A"));
});

t("in task mode the focus is ignored", () => {
  A.state.zoomBy = "task";
  A.state.zoomFocus = "Dev Team A";     // stale value from a previous dimension
  const hm = A.hourModel();
  assert.strictEqual(hm.focus, null);
  assert(hm.rows.some(r => r.t.team !== "Dev Team A"), "everything active is shown again");
});

t("lane output is escaped", () => {
  loadSample();
  A.state.tasks.push({ id: 997, name: "x", start: "2026-10-01", hourStart: "", end: "2026-10-20", hourEnd: "",
                       preds: [], team: '<img src=x onerror=alert(1)>', system: "", group: [], milestone: false });
  A.state.zoomBy = "team";
  for (const lvl of ["month", "day"]) {
    A.state.zoomLevel = lvl;
    A.renderZoom();
    assert(!els.hvLeftBody.innerHTML.includes("<img"), "left panel unescaped at " + lvl);
    assert(!els.hvBody.innerHTML.includes("<img"), "chart unescaped at " + lvl);
  }
  A.state.tasks.pop();
  A.state.zoomBy = "task"; A.state.zoomFocus = null; A.state.zoomLevel = "month";
});


console.log("\n-- foreign files are never adopted as projects --");

const APP_HEADER = "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone";

t("our own files are recognised", () => {
  assert(A.looksLikeProjectFile(APP_HEADER + "\r\n"), "a freshly created file is just the header");
  assert(A.looksLikeProjectFile(APP_HEADER + "\r\n1|A|1|2026-01-01||2026-01-01||||||no\r\n"));
  assert(A.looksLikeProjectFile(fs.readFileSync(__dirname + "/DB/_fixture-sample-project.csv", "utf8")));
  assert(A.looksLikeProjectFile(""), "an empty file is treated as a new project");
  assert(A.looksLikeProjectFile("1|A|1|2026-01-01||2026-01-02||||||no\n"), "header-less but valid rows");
});

t("the import fixture that got overwritten is now refused", () => {
  const foreign = impText;   // the same foreign sheet, now written into this file
  assert.strictEqual(A.looksLikeProjectFile(foreign), false,
    "this is exactly the file the app rewrote in its own format");
});

t("other foreign shapes are refused too", () => {
  assert.strictEqual(A.looksLikeProjectFile("Number;Task;Kezdés\n1;A;2026-01-01\n"), false, "semicolons");
  assert.strictEqual(A.looksLikeProjectFile("Number,Task,Start\n1,A,2026-01-01\n"), false, "commas");
  assert.strictEqual(A.looksLikeProjectFile("just some notes\nand another line\n"), false, "not a table at all");
  assert.strictEqual(A.looksLikeProjectFile("a|b|c\n1|2|3\n"), false, "pipes, but nothing like our columns");
});

/* A foreign .csv sitting in /DB used to be offered to the CSV importer. The
   importer reads Excel now, so there is nothing to offer: the file is refused
   and left exactly as it is, and the message says the way in. */
t("a refused file is left alone, and the importer will not take it either", () => {
  const foreign = impText;   // the same foreign sheet, now written into this file
  assert.strictEqual(A.looksLikeProjectFile(foreign), false);
  const parsed = A.parseImport(foreign, "foreign-sheet.csv");
  assert.strictEqual(parsed.kind, "", "a delimited file is not an Excel sheet");
  assert.strictEqual(parsed.rows.length, 0);
  /* The advice changed with MSPDI (2026-08-23): "save it as an Excel sheet"
     was the only road in until MS Project's own XML became readable, and the
     menu item is called "Import…" now rather than "Import Excel…" (Daniel).
     What the test still holds is the thing that matters — the refusal names
     the way in, and names what that way reads. */
  assert(html.includes('to bring its tasks in, use'), "the message has to say the way in");
  assert(html.includes('it reads Excel sheets and Microsoft Project XML'), "…and what it can read");
  assert(!/CSV importer/.test(html), "nothing may still offer the importer a CSV");
});

console.log("\n-- About dialog --");

t("the credits are the ones that were asked for", () => {
  /* The wordmark carries the name in the dialog now, so the heading is there
     for the dialog's accessible name rather than to be read off the screen. */
  assert(html.includes(">Gantora — Portable Project Planning</h1>"), "program name");
  assert(/id="aboutName" class="srOnly"/.test(html), "and it labels the dialog");
  assert(/aria-labelledby="aboutName"/.test(html), "which is what the dialog points at");
  /* 🔴 The number the release ships as, and the one place it lives: the site's
     build and check read it out of this very line, and so does
     scripts/make-release.sh when it names the archives. A version typed in two
     places is a download button pointing at a file nobody built. Bumped to 1.5
     on 2026-08-23. */
  assert(html.includes(">Version 1.5</div>"), "version");
  assert(html.includes("<dd>Daniel Szabo</dd>"), "owner");
  assert(html.includes("<dd>Online Factory</dd>"), "organisation");
  assert(/href="https:\/\/www\.onlinefactory\.org"/.test(html), "site address");
});

t("it starts closed and the toolbar button opens it", () => {
  assert(!A.aboutOpen());
  fire(els.btnAbout, "click");
  assert(A.aboutOpen());
  assert.strictEqual(els.aboutOverlay.style.display, "flex");
  assert.strictEqual(els.btnAboutClose._focused, true, "focus lands in the dialog");
  A.closeAbout();
});

t("Close, Escape and the backdrop all dismiss it", () => {
  A.openAbout(); fire(els.btnAboutClose, "click");
  assert(!A.aboutOpen(), "Close button");
  A.openAbout(); press("Escape");
  assert(!A.aboutOpen(), "Escape");
  A.openAbout(); fire(els.aboutOverlay, "mousedown", { target: els.aboutOverlay });
  assert(!A.aboutOpen(), "backdrop");
});

t("a click inside the dialog does not dismiss it", () => {
  A.openAbout();
  fire(els.aboutOverlay, "mousedown", { target: els.aboutModal });
  assert(A.aboutOpen(), "only the backdrop itself closes it");
  A.closeAbout();
});

/* Escape is shared with the task modal and the import preview — About is checked
   first, so dismissing it must not also tear down whatever is open behind it. */
t("Escape closes only About when it is on top", () => {
  A.openAbout();
  els.overlay.classList.add("open");
  press("Escape");
  assert(!A.aboutOpen(), "About closed");
  assert(els.overlay.classList.contains("open"), "the task modal behind it is untouched");
  els.overlay.classList.remove("open");
});

/* The app must stay a single offline file: no library, font, icon or asset is
   fetched. The rule is not "no <link>" — a data: favicon is fine — it is that
   nothing the browser loads may point outside the file. */
t("the app ships no external assets", () => {
  const markup = html.replace(/<!--[\s\S]*?-->/g, "");     // comments are not shipped
  assert(!/@import|fonts\.googleapis/i.test(markup), "no external stylesheet or font");
  assert(!/<script[^>]+src=/i.test(markup), "no external script");
  const loaded = [...markup.matchAll(/<(?:img|link|script|source|iframe)\b[^>]*\b(?:src|href)="([^"]*)"/gi)]
    .map(m => m[1]).filter(u => !u.startsWith("data:"));
  eqArr(loaded, [], "every loaded asset is inline; nothing is fetched");
  /* Until 2026-08-18 the owner's site link was the only URL in the file. The
     Dropbox package (P2) brings the first network calls the app has ever made —
     by the owner's decision — and the rule is now the guarded one: **the only
     other URLs are Dropbox's, they live inside the Dropbox block, and nothing
     outside it can reach the network at all.** The block itself is fenced by
     the test after this one. */
  const urls = [...markup.matchAll(/https?:\/\/[^\s"'<>)]+/g)].map(m => m[0]);
  const dbx = urls.filter(u => /^https:\/\/(www\.dropbox\.com|api\.dropboxapi\.com|content\.dropboxapi\.com)\//.test(u));
  /* The named exceptions, and what each one IS — because the distinction this
     fence guards is not "no URLs" but **no requests**:

     - the owner's site link in the About box: opened by the reader's click;
     - SPEC_URL and the skill/format links: TEXT. One is written into a #META
       line so the file carries its own manual; the others are written into a
       prompt the reader takes to their own assistant. The app never fetches
       either;
     - the four assistant addresses (AI button, 2026-08-21): opened in the
       reader's own browser, with their own account, by their own tap. Nothing
       is sent from here and nothing comes back here.

     The fetch fence beside this one is what actually holds the network to the
     Dropbox block, and it is untouched by any of these. Anything not on this
     list is a fail. */
  /* Unique, not every occurrence: the two documentation links appear once per
     prompt per language, which is nineteen copies of the same two addresses. */
  eqArr([...new Set(urls.filter(u => !dbx.includes(u)))].sort(),
    ["https://chatgpt.com/?q=", "https://claude.ai/new?q=", "https://gantora.net/format.md",
     "https://gantora.net/skill.md", "https://gemini.google.com/app?q=", "https://grok.com/?q=",
     "https://www.onlinefactory.org"],
    "the app's only non-Dropbox URLs are the About link, the spec text, and the assistants the reader chooses");
  for (const [name, url] of [["SPEC_URL", "https://gantora.net/format.md"], ["SKILL_URL", "https://gantora.net/skill.md"]])
    assert.strictEqual(markup.split(url).length, 2,
      name + " is written once, at its const — everything else reads the name, including the AI prompts");
  assert(dbx.length, "and the Dropbox endpoints are there");
  assert(/rel="noopener noreferrer"/.test(html), "and it opens safely");
});

/* The other half of the rule. `fetch` and `window.open` are the two ways this
   page can reach outside itself, and both must be found only inside the Dropbox
   block — so a future feature that "just fetches something" fails here rather
   than shipping. And the suite runs with a `fetch` that throws (see ctx), so
   local-folder mode making a request would fail wherever it happened.

   🔴 One exception, and the difference is the whole rule: **`fetch` is the app
   speaking; `window.open` can be the app SHOWING the reader something.** The
   AI button (2026-08-21) opens the assistant the reader chose, in their own
   browser and their own account, on their own tap — nothing is sent from here
   and nothing comes back here. So window.open has a second home, named and
   bounded like the first; `fetch` still has exactly one. */
const aiBlock = () => {
  const code = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const at = code.indexOf("async function openAiChat(");
  assert(at > 0, "the AI block is gone");
  const end = code.indexOf("/* ---- The repair report", at);
  assert(end > at, "the AI block is not closed where expected");
  return code.slice(at, end);
};
const dropboxBlock = () => {
  const code = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const at = code.indexOf("const DBX_APP_KEY = ");
  assert(at > 0, "the Dropbox block is gone");
  const end = code.indexOf("const listProjectFiles = ", at);
  assert(end > at, "the Dropbox block is not closed where expected");
  return { code, inside: code.slice(at, end) };
};

t("the network is reachable from the Dropbox block and nowhere else", () => {
  const { code, inside } = dropboxBlock();
  const strip = s => s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
  const all = strip(code), mine = strip(inside);
  for (const [op, what] of [[/\bfetch\s*\(/g, "fetch()"], [/window\.open\s*\(/g, "window.open()"], [/https:\/\/[a-z.]*dropbox[a-z.]*\.com/g, "a Dropbox URL"]]) {
    const total = (all.match(op) || []).length, here = (mine.match(op) || []).length;
    /* window.open's second home: the AI button, and only there. */
    const ai = what === "window.open()" ? (strip(aiBlock()).match(op) || []).length : 0;
    assert(here > 0, what + ": the Dropbox block does not use it");
    if (ai) assert(what === "window.open()", "only window.open may live outside the Dropbox block");
    assert.strictEqual(total, here + ai,
      what + ": " + (total - here - ai) + " place(s) outside the Dropbox block (and, for window.open, the AI button) use it");
  }
  assert.strictEqual((mine.match(/\bfetch\s*\(/g) || []).length, 1, "and inside the block, one function makes every request");
  assert(!/XMLHttpRequest|navigator\.sendBeacon|new WebSocket|EventSource/.test(all), "no other way out");
});

/* The tab icon is the Gantora mark, embedded so it survives offline use and
   costs no request. 64px rather than 32: the same image is the face of the
   About button, and a tab on a high-DPI screen asks for more than 32. */
t("the favicon is the embedded Gantora mark", () => {
  const m = html.match(/<link\b[^>]*rel="icon"[^>]*href="data:image\/png;base64,([A-Za-z0-9+/=]+)"/i);
  assert(m, "a data-URI png favicon is declared");
  const png = Buffer.from(m[1], "base64");
  assert.strictEqual(png.slice(1, 4).toString(), "PNG", "decodes to a real PNG");
  assert.strictEqual(png.readUInt32BE(16), 64, "64px wide");
  assert.strictEqual(png.readUInt32BE(20), 64, "64px tall");
});

/* Four wordmarks — light and dark, each plain and Free — all embedded rather
   than linked: an <img> pointing at a site would make opening the app a network
   request and show a broken logo offline, which is the whole point of one file.

   They are custom properties rather than tags because each URI then appears
   **once**. Eight tags would carry the same 140 KB twice in a file that is
   copied whole into every shared page — and because the choice is then pure
   CSS, no JavaScript sets a src, which is what stops a shared page being
   serialised showing the wrong one. */
/* Since 2026-08-21 the two square marks live under a name of their own
   (--mark-sm): the phone block and the compact chrome both read it, and a
   picture pasted twice would be a picture that can drift. */
const wordmarks = () => [...html.matchAll(/--(?:wordmark|mark-sm):url\("data:image\/png;base64,([A-Za-z0-9+/=]+)"\)/g)].map(m => m[1]);

t("all six brand images are embedded, never fetched", () => {
  const found = wordmarks();
  /* Four wide wordmarks for a desk, and two square marks for a phone — 84px of
     lettering in a bar that also holds the folder button, the mode chip, the
     theme and About is a logo nobody can read. */
  assert.strictEqual(found.length, 6, "four wordmarks and two square marks — got " + found.length);
  assert(/:root\{--mark-sm:url/.test(html) && /html\.free\{--mark-sm:url/.test(html),
    "the square marks are named tokens, not repeated pictures");
  assert(/--wordmark:var\(--mark-sm\)/.test(html), "and the phone block reads the token rather than a second copy");
  for (const [i, b64] of found.entries()) {
    const b = Buffer.from(b64, "base64");
    assert.strictEqual(b.slice(1, 4).toString(), "PNG", "image " + i + " decodes to a real PNG");
  }
  assert.strictEqual(new Set(found).size, 6, "six different images, not one pasted six times");

  const desk = found.slice(0, 4).map(b => Buffer.from(b, "base64"));
  for (const [i, b] of desk.entries()) {
    assert.strictEqual(b.readUInt32BE(16), 360, "wordmark " + i + " is 360px wide");
    assert.strictEqual(b.readUInt32BE(20), 120, "wordmark " + i + " keeps the 3:1 ratio");
  }
  const phone = found.slice(4).map(b => Buffer.from(b, "base64"));
  for (const [i, b] of phone.entries()) {
    assert.strictEqual(b.readUInt32BE(16), 64, "phone mark " + i + " is 64px wide");
    assert.strictEqual(b.readUInt32BE(20), 64, "and square, because that is what a narrow bar has room for");
  }
});

/* `html.dark` is (0,1,1) where `:root` is (0,1,0), so a `:root` rule inside the
   phone block would lose to the desktop dark rule *outside* it however late it
   was written — source order settles a tie, not a difference in weight. Both
   selectors of each pair have to be restated. */
t("the phone mark beats the desktop wordmark in dark mode too", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  /* The phone block's two lines now point at the --mark-sm token rather than
     carrying the picture — the restating is what this test is about, and that
     is unchanged: html.free.dark outweighs :root, so both pairs are said. */
  const at = css.indexOf(":root, html.dark{--wordmark:var(--mark-sm);}");
  assert(at > 0, "the phone block restates the dark selector, not just :root");
  assert(at > css.indexOf("html.free.dark{--wordmark:"), "and comes after all four desktop ones");
  const block = css.slice(at, at + 40000);
  assert(/html\.free, html\.free\.dark\{--wordmark:var\(--mark-sm\);\}/.test(block),
    "the Free pair is restated the same way");
  assert(/#brandMark\{width:40px; height:40px;\}/.test(block),
    "and the bar slot becomes square — at the size the other icons wear, not a size smaller");
});

/* The tab keeps the square mark and the bar carries the logo: two different
   jobs. A tab is 16 pixels of a letter — a wordmark shrunk into it is a grey
   smudge — and the app bar has room for the name. */
/* On a desk the two are different jobs: a tab is 16 pixels of a letter, where a
   wordmark shrinks to a smudge, and the bar has room for the name. On a phone
   the bar has no such room, so it shows the mark — and the mark it shows is
   the tab's own artwork at the same size, which is one image doing two jobs
   rather than two images that happen to look alike. */
t("the tab shows the mark, the desk bar the logo, the phone the mark again", () => {
  const fav = html.match(/<link\b[^>]*rel="icon"[^>]*href="data:image\/png;base64,([A-Za-z0-9+/=]+)"/i)[1];
  const all = wordmarks();
  for (const w of all.slice(0, 4)) assert.notStrictEqual(w, fav, "no wordmark is the tab icon");
  assert.strictEqual(all[4], fav, "and the phone's plain mark is exactly the tab icon");
  assert.notStrictEqual(all[5], fav, "the Free one wears its badge, so it is its own image");

  const desk = Buffer.from(all[0], "base64");
  assert(desk.readUInt32BE(16) > desk.readUInt32BE(20) * 2, "a logo is wide");
  const phone = Buffer.from(all[4], "base64");
  assert.strictEqual(phone.readUInt32BE(16), phone.readUInt32BE(20), "a mark is square");
});

/* Source order is the whole mechanism for a tie here: a dark free copy must not
   take whichever of html.dark and html.free happened to be written last. */
t("the free-and-dark wordmark is written after both it has to beat", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const at = s => css.indexOf(s + "{--wordmark:");
  assert(at(":root") >= 0, "there is a light default");
  assert(at("html.dark") > at(":root"));
  assert(at("html.free") > at(":root"));
  assert(at("html.free.dark") > at("html.dark"), "after the dark one");
  assert(at("html.free.dark") > at("html.free"), "and after the free one");
});

t("the About button is the logo itself, and still says what it is", () => {
  assert(/<button id="btnAbout"[^>]*aria-label="About Gantora"/.test(html),
    "an image button has to carry its own name");
  assert(/id="btnAbout"[^>]*>\s*<span id="brandMark" class="wordmark"/.test(html),
    "the glyph is gone, the logo is painted there");
  /* Both marks are decoration: the button carries its own aria-label and the
     dialog is labelled by its own heading, so announcing the image as well
     would say the name twice. */
  assert(/id="brandMark"[^>]*aria-hidden="true"/.test(html));
  assert(/id="aboutMark"[^>]*aria-hidden="true"/.test(html));
  assert(/<h1 id="aboutName" class="srOnly">/.test(html), "the dialog still has a real name");
});

/* The edition decides which pair of the four is in play, and the stylesheet
   reads it off the html element. */
t("a free copy marks itself on the html element for the stylesheet", () => {
  const body = html.slice(html.indexOf("function applyEdition()"));
  assert(/documentElement\.classList\.add\("free"\)/.test(body.slice(0, 700)),
    "the class is set as soon as the edition is applied");
});

/* ---------- fixtures built field-by-field ----------
   Hand-written pipe rows drop a column far too easily, and a short row silently
   shifts "yes" into the Group column, so a milestone stops being one.

   This header is deliberately the *pre-people* one — the layout every file
   written before Accountable and Responsible existed carries. Leaving it here
   means several hundred of the checks below load an old file on every run, so a
   reader that quietly went back to fixed column positions could not pass. The
   builder for the current layout is csvRowNow() further down. */
const CSV_HDR = "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone|Notes";
const csvRow = o => [o.id, o.name, "", o.start || "", o.hs || "", o.end || "", o.he || "",
                     (o.preds || []).join(";"), o.team || "", o.system || "",
                     (o.group || []).join(";"), o.ms ? "yes" : "no", o.notes || ""].join("|");
const loadRows = rows => loadTasks(CSV_HDR + "\n" + rows.map(csvRow).join("\n") + "\n");

/* The same fixture in the layout the app writes today, built from the column
   definition itself so inserting another column cannot leave it behind. */
const CSV_HDR_NOW = A.HEADER.join("|");
const csvRowNow = o => {
  const v = {
    id: o.id, name: o.name, duration: "", start: o.start || "", hourStart: o.hs || "",
    end: o.end || "", hourEnd: o.he || "", preds: (o.preds || []).join(";"),
    team: o.team || "", system: o.system || "", accountable: o.acc || "", responsible: o.resp || "",
    /* format v2: the modern row's 13th cell is the CHILD's Parent UID — a
       fixture that wants membership through this helper names its parent, or
       uses a v1 header (CSV_HDR) whose Group alias still reads. */
    parent: o.parent || "", milestone: o.ms || o.ev ? "yes" : "no", notes: o.notes || "",
    status: o.status || "", history: o.history || "",
    // an event is a milestone the file flavours; both were appended at the end
    event: o.ev ? "yes" : "no", emoji: o.emoji || "", notFinal: o.nf ? "yes" : "no",
    // appended after those: the overruled baseline, and the end date's own trail
    baselineEnd: o.base || "", dateHist: o.dh || "",
    // …and the row's own name, which a fixture leaves empty unless it is about it
    uid: o.uid || "",
    // the words the plan puts on the row, ";"-separated
    labels: o.labels || "",
  };
  return A.CSV_COLS.map(c => v[c.key]).join("|");
};
/* Format v2: membership travels child-side, as the parent's UID. A fixture
   still speaks the old parent-side voice (`group: [2, 3]`) because a hundred
   of them do — this helper translates: the parent gets a uid, each child gets
   `parent`, and the wire the suite exercises is the real v2 wire. */
const loadRowsNow = rows => {
  for (const r of rows) if (r.group && r.group.length) {
    r.uid = r.uid || "uid-" + r.id;      // UID_RE wants 4–40 chars — "u-1" is three
    for (const cid of r.group) { const c = rows.find(x => x.id === cid); if (c) c.parent = r.uid; }
  }
  return loadTasks(CSV_HDR_NOW + "\n" + rows.map(csvRowNow).join("\n") + "\n");
};
const span = id => A.taskById(id).start + ".." + A.taskById(id).end;
/* IDs follow the dates now, so a fixture's hand-written numbers are gone the
   moment it loads. Anything that is not *about* numbering names its row. */
const spanOf = n => span(idOfName(n));
const idsOf = (...names) => names.map(idOfName).sort((a, b) => a - b);

console.log("\n-- milestones carry their hour into the zoom views --");

t("the label and tooltip show the hour when the milestone has one", () => {
  loadRows([{ id: 1, name: "Go live", start: "2026-03-10", hs: "14:30", end: "2026-03-10", he: "14:30", ms: true },
            { id: 2, name: "Work", start: "2026-03-09", end: "2026-03-12", team: "Dev" }]);
  A.state.view = "zoom"; A.state.zoomLevel = "day"; A.state.zoomBy = "task";
  A.renderDay();
  const body = els.hvBody.innerHTML;
  assert(body.includes('<span class="d">2026-03-10 14:30</span>'), "foot label");
  // the status tone appends its own line after the date, so match the start only
  assert(body.includes('title="★ Go live — 2026-03-10 14:30'), "tooltip");
});

t("a milestone with no hour still shows just its date", () => {
  loadRows([{ id: 1, name: "Gate", start: "2026-03-10", end: "2026-03-10", ms: true },
            { id: 2, name: "Work", start: "2026-03-09", end: "2026-03-12", team: "Dev" }]);
  A.renderDay();
  const body = els.hvBody.innerHTML;
  assert(body.includes('<span class="d">2026-03-10</span>'), "date only, no stray separator");
  assert(!/class="d">2026-03-10 /.test(body));
});

t("the lane packer leaves room for the longer date+hour label", () => {
  const withHour = A.milestoneLanes([{ name: "M", start: "2026-03-10", hourStart: "14:30" }], () => 100);
  const without  = A.milestoneLanes([{ name: "M", start: "2026-03-10", hourStart: "" }], () => 100);
  assert(A.milestoneWhen({ start: "2026-03-10", hourStart: "14:30" }) === "2026-03-10 14:30");
  assert.strictEqual(withHour.marks.length, 1);
  assert.strictEqual(without.marks.length, 1);
});

console.log("\n-- a group's dates come from its subtasks --");

t("a group spans its members, not its own stored dates", () => {
  loadRows([{ id: 1, name: "Phase", start: "2026-01-01", end: "2026-01-01", group: [2, 3] },
            { id: 2, name: "A", start: "2026-03-10", end: "2026-03-12", team: "Dev" },
            { id: 3, name: "B", start: "2026-03-05", end: "2026-03-20", team: "Dev" }]);
  assert.strictEqual(span(1), "2026-03-05..2026-03-20");
});

t("editing a member moves the group with it", () => {
  A.taskById(3).end = "2026-04-30";
  A.recalcGroups();
  assert.strictEqual(span(1), "2026-03-05..2026-04-30");
});

t("the group takes the earliest start hour and the latest end hour", () => {
  loadRows([{ id: 1, name: "Phase", start: "2026-01-01", end: "2026-01-01", group: [2, 3] },
            { id: 2, name: "A", start: "2026-03-10", hs: "09:30", end: "2026-03-10", he: "11:00", team: "Dev" },
            { id: 3, name: "B", start: "2026-03-10", hs: "14:00", end: "2026-03-10", he: "16:45", team: "Dev" }]);
  const g = A.taskById(1);
  assert.strictEqual(g.hourStart, "09:30");
  assert.strictEqual(g.hourEnd, "16:45");
});

t("dropping a member re-derives the group from what is left", () => {
  loadRows([{ id: 1, name: "Phase", start: "2026-01-01", end: "2026-01-01", group: [2, 3] },
            { id: 2, name: "A", start: "2026-03-10", end: "2026-03-12", team: "Dev" },
            { id: 3, name: "B", start: "2026-03-05", end: "2026-03-20", team: "Dev" }]);
  A.removeFromGroup(idOfName("B"));
  assert.strictEqual(span(idOfName("Phase")), "2026-03-10..2026-03-12");
});

/* A milestone is an anchor date: slipping it slips the plan hanging off it by the
   same number of days, so the schedule keeps its shape instead of silently
   collapsing onto the new date. */
console.log("\n-- moving a milestone moves the work behind it --");

const cascadeFixture = () => loadRows([
  { id: 1, name: "Gate",      start: "2026-03-01", end: "2026-03-01", ms: true },
  { id: 2, name: "Phase",     start: "2026-03-02", end: "2026-03-02", preds: [1], group: [3, 4] },
  { id: 3, name: "Sub one",   start: "2026-03-02", end: "2026-03-03", team: "Dev" },
  { id: 4, name: "Sub two",   start: "2026-03-04", end: "2026-03-06", team: "Dev" },
  { id: 5, name: "Follow up", start: "2026-03-10", end: "2026-03-11", preds: [2], team: "Dev" },
  { id: 6, name: "Unrelated", start: "2026-03-02", end: "2026-03-02", team: "Dev" },
  { id: 7, name: "Other gate", start: "2026-03-20", end: "2026-03-20", ms: true },
]);

t("the fixture really is two milestones and five tasks", () => {
  cascadeFixture();
  eqArr(A.state.tasks.filter(x => x.milestone).map(x => x.id), [1, 7]);
});

t("dependents, their subtasks and the chain behind them all shift", () => {
  cascadeFixture();
  const gate = row("Gate");
  gate.start = "2026-03-11"; gate.end = "2026-03-11";
  eqArr(A.shiftMilestoneDependents(gate.id, "2026-03-01"),
        idsOf("Phase", "Sub one", "Sub two", "Follow up"));
  assert.strictEqual(spanOf("Sub one"), "2026-03-12..2026-03-13", "subtask moved +10");
  assert.strictEqual(spanOf("Sub two"), "2026-03-14..2026-03-16", "subtask moved +10");
  assert.strictEqual(spanOf("Follow up"), "2026-03-20..2026-03-21", "transitive dependent moved +10");
  assert.strictEqual(spanOf("Phase"), "2026-03-12..2026-03-16", "the group re-derives from its members");
});

t("tasks that do not hang off the milestone are left alone", () => {
  assert.strictEqual(spanOf("Unrelated"), "2026-03-02..2026-03-02", "unrelated task");
  assert.strictEqual(spanOf("Other gate"), "2026-03-20..2026-03-20",
                     "another milestone is an anchor, not a consequence");
});

t("the shift is symmetric — moving it back restores the plan", () => {
  const gate = row("Gate");
  gate.start = "2026-03-01"; gate.end = "2026-03-01";
  A.shiftMilestoneDependents(gate.id, "2026-03-11");
  assert.strictEqual(spanOf("Phase"), "2026-03-02..2026-03-06");
  assert.strictEqual(spanOf("Follow up"), "2026-03-10..2026-03-11");
});

t("nothing moves when the date did not change, or the row is not a milestone", () => {
  eqArr(A.shiftMilestoneDependents(idOfName("Gate"), "2026-03-01"), [], "same date");
  eqArr(A.shiftMilestoneDependents(idOfName("Unrelated"), "2026-01-01"), [],
        "an ordinary task is not a milestone");
});

t("the shift is reported in days, signed", () => {
  assert.strictEqual(A.milestoneShiftLabel("2026-03-01", "2026-03-11"), "10 days later");
  assert.strictEqual(A.milestoneShiftLabel("2026-03-11", "2026-03-10"), "1 day earlier");
});

/* The cascade has to fire from the editors the user actually types in, not just
   from the helper. */
t("the task modal triggers the cascade and reports it", () => {
  cascadeFixture();
  const gateId = idOfName("Gate");
  A.openModal({ type: "edit", id: gateId });
  Object.assign(els.fName, { value: "Gate" });
  els.fStart.value = "2026-03-11"; els.fEnd.value = "2026-03-11";
  els.fHourStart.value = ""; els.fHourEnd.value = "";
  // no fGroup: membership is carried off the task now, not typed into a box
  els.fPreds.value = ""; els.fTeam.value = ""; els.fSystem.value = "";
  els.fMilestone.checked = true;
  els.toast.textContent = "";
  A.saveModal();
  assert.strictEqual(els.modalErrors.textContent, "", "saved without errors");
  assert.strictEqual(spanOf("Sub one"), "2026-03-12..2026-03-13", "the plan moved");
  // the toast is written before the renumbering, so it names the ID the user saw
  assert(new RegExp(`Milestone ${gateId} moved 10 days later`).test(els.toast.textContent),
         els.toast.textContent);
});

t("editing an ordinary task does not cascade", () => {
  cascadeFixture();
  els.toast.textContent = "";
  A.openModal({ type: "edit", id: idOfName("Unrelated") });
  Object.assign(els.fName, { value: "Unrelated" });
  els.fStart.value = "2026-03-02"; els.fEnd.value = "2026-03-09";
  els.fHourStart.value = ""; els.fHourEnd.value = "";
  els.fPreds.value = ""; els.fTeam.value = "Dev"; els.fSystem.value = "";
  els.fMilestone.checked = false;
  A.saveModal();
  assert.strictEqual(spanOf("Gate"), "2026-03-01..2026-03-01", "the milestone never moved");
  assert(!/moved with it/.test(els.toast.textContent));
});

/* An ID is no longer a name a row keeps: it is the row's place in the plan, so
   the list always reads 1, 2, 3 down the page. That costs the number its
   permanence, and everything that refers to a task by number has to travel with
   it — predecessors, group membership, and the view's own bookkeeping. */
console.log("\n-- IDs follow the time order --");

const outOfOrder = () => loadRows([
  { id: 1, name: "Last",   start: "2026-04-01", end: "2026-04-02", team: "Dev" },
  { id: 2, name: "First",  start: "2026-01-01", end: "2026-01-02", team: "Dev" },
  { id: 3, name: "Middle", start: "2026-02-01", end: "2026-02-02", team: "Dev", preds: [2] },
]);

t("a file numbered out of order is renumbered on the way in", () => {
  outOfOrder();
  eqArr(A.state.tasks.map(x => x.id + " " + x.name), ["1 First", "2 Middle", "3 Last"]);
});

t("predecessor references travel with the rows", () => {
  outOfOrder();
  eqArr(row("Middle").preds, [idOfName("First")],
        "Middle still depends on First, by the number First has now");
});

t("a plan already in time order is left alone", () => {
  outOfOrder();
  assert.strictEqual(A.renumberByTime(), false, "nothing to move the second time");
});

/* The group's own number is what makes the list read as an outline: it sits
   directly above the rows it holds, and the numbering walks the tree rather
   than the flat list of dates. */
t("a group takes the number directly above its members", () => {
  loadRows([
    { id: 1, name: "Late task", start: "2026-05-01", end: "2026-05-02", team: "Dev" },
    { id: 2, name: "Phase",  group: [3, 4] },
    { id: 3, name: "Step B", start: "2026-02-01", end: "2026-02-02", team: "Dev" },
    { id: 4, name: "Step A", start: "2026-01-01", end: "2026-01-02", team: "Dev" },
  ]);
  eqArr(A.state.tasks.map(x => x.id + " " + x.name),
        ["1 Phase", "2 Step A", "3 Step B", "4 Late task"]);
  eqArr(row("Phase").group, [idOfName("Step A"), idOfName("Step B")],
        "and the member list is rewritten, in order");
});

t("rows that start and end together keep the order they had", () => {
  loadRows([
    { id: 1, name: "One",   start: "2026-03-01", end: "2026-03-02", team: "Dev" },
    { id: 2, name: "Two",   start: "2026-03-01", end: "2026-03-02", team: "Dev" },
    { id: 3, name: "Three", start: "2026-03-01", end: "2026-03-02", team: "Dev" },
  ]);
  eqArr(A.state.tasks.map(x => x.name), ["One", "Two", "Three"],
        "a tie is not an excuse to shuffle the plan");
});

t("a row with no start goes last, whatever number it came in with", () => {
  loadRows([
    { id: 1, name: "Undated", team: "Dev" },
    { id: 2, name: "Dated", start: "2026-03-01", end: "2026-03-02", team: "Dev" },
  ]);
  eqArr(A.state.tasks.map(x => x.name), ["Dated", "Undated"]);
});

/* Renumbering behind the user's back would move the selection, the folds and
   the row being edited onto whatever rows happened to inherit those numbers. */
t("what is selected, folded and being edited is renumbered too", () => {
  outOfOrder();
  const last = idOfName("Last");
  A.state.selected = new Set([last]);
  A.state.selAnchor = last;
  A.state.collapsed = new Set([last]);
  A.state.inlineEdit = { id: last };
  row("Last").start = "2025-12-01"; row("Last").end = "2025-12-02";
  assert.strictEqual(A.renumberByTime(), true, "it moved to the front");
  const now = idOfName("Last");
  assert.strictEqual(now, 1, "and it is the first row now");
  eqArr(A.state.selected, [now], "the selection came with it");
  assert.strictEqual(A.state.selAnchor, now, "so did the shift-click anchor");
  eqArr(A.state.collapsed, [now], "and the fold");
  assert.strictEqual(A.state.inlineEdit.id, now, "and the row being edited");
  A.state.selected.clear(); A.state.selAnchor = null;
  A.state.collapsed.clear(); A.state.inlineEdit = null;
});

t("editing a date through the modal moves the row's number", () => {
  outOfOrder();
  A.openModal({ type: "edit", id: idOfName("Last") });
  Object.assign(els.fName, { value: "Last" });
  els.fStart.value = "2025-12-01"; els.fEnd.value = "2025-12-02";
  els.fHourStart.value = ""; els.fHourEnd.value = "";
  els.fPreds.value = ""; els.fTeam.value = "Dev"; els.fSystem.value = "";
  els.fMilestone.checked = false;
  A.saveModal();
  assert.strictEqual(els.modalErrors.textContent, "", "saved without errors");
  eqArr(A.state.tasks.map(x => x.name), ["Last", "First", "Middle"],
        "the row the user moved is now the first one");
});

/* A copy shares its source's dates, so it belongs beside it — the whole reason
   the numbering is worth doing. */
t("a duplicate lands beside its source, not at the end of the plan", () => {
  outOfOrder();
  A.duplicateTask(idOfName("First"));
  eqArr(A.state.tasks.map(x => x.name),
        ["First", "First - duplicate", "Middle", "Last"]);
});

t("a file the app wrote is already in order — reloading it moves nothing", () => {
  loadSample();
  const before = A.state.tasks.map(x => x.id + ":" + x.name).join("|");
  loadTasks(A.serializeCSV(A.state.tasks));
  assert.strictEqual(A.state.tasks.map(x => x.id + ":" + x.name).join("|"), before,
                     "the round trip is the identity");
  assert.strictEqual(A.renumberByTime(), false, "nothing left to move");
});

/* Groups nest one further level: group -> subgroup -> task. The subgroup is a
   real group (its dates come from its members) but it is never a main task. */
console.log("\n-- subgroups --");

/* Written with gaps in the numbering on purpose — a hand-edited file may have
   them — so loading it also proves the plan is renumbered on the way in. It
   settles as 1 Programme > (2 Phase A > 3 A1, 4 A2), 5 Standalone, then
   6 Loose and 7 Gate outside the group. */
const nested = () => loadRows([
  { id: 1, name: "Programme", group: [2, 7] },
  { id: 2, name: "Phase A",   group: [3, 4] },
  { id: 3, name: "A1", start: "2026-03-02", end: "2026-03-05", team: "Dev" },
  { id: 4, name: "A2", start: "2026-03-06", end: "2026-03-10", team: "Dev" },
  { id: 7, name: "Standalone", start: "2026-03-12", end: "2026-03-16", team: "Ops" },
  { id: 8, name: "Loose", start: "2026-03-03", end: "2026-03-08", team: "Ops" },
  { id: 9, name: "Gate", start: "2026-03-20", end: "2026-03-20", ms: true },
]);

/* The same plan with the third level in place: 1 > 2 > 3 > 4, plus the rows that
   sit beside it at every depth, so a filter can be caught keeping or dropping
   the wrong one. Settled depths: 1@0 2@1 3@2 4@3 5@2 6@1 7@0 8@0. */
const nested3 = () => loadRows([
  { id: 1, name: "Programme",  group: [2, 7] },
  { id: 2, name: "Phase A",    group: [3, 5] },
  { id: 3, name: "Workstream", group: [4] },
  { id: 4, name: "A1a", start: "2026-03-02", end: "2026-03-05", team: "Dev" },
  { id: 5, name: "A2",  start: "2026-03-06", end: "2026-03-10", team: "Dev" },
  { id: 7, name: "Standalone", start: "2026-03-12", end: "2026-03-16", team: "Ops" },
  { id: 8, name: "Loose", start: "2026-03-03", end: "2026-03-08", team: "Ops" },
  { id: 9, name: "Gate", start: "2026-03-20", end: "2026-03-20", ms: true },
]);

t("a subgroup sits one level down and keeps its members", () => {
  nested();
  assert.strictEqual(A.depthOf(2, A.state.tasks), 1, "the subgroup");
  assert.strictEqual(A.depthOf(3, A.state.tasks), 2, "its task");
  eqArr(A.descendantsOf(1, A.state.tasks).sort((a, b) => a - b), [2, 3, 4, 5]);
});

t("dates roll up through both levels, deepest first", () => {
  nested();
  assert.strictEqual(span(2), "2026-03-02..2026-03-10", "subgroup spans its own tasks");
  assert.strictEqual(span(1), "2026-03-02..2026-03-16", "group spans everything below it");
});

t("a task inside a subgroup still rolls up to the top-level main task", () => {
  nested();
  assert.strictEqual(A.mainTaskOf(3).id, 1, "not the subgroup — dependency arrows anchor on the main task");
});

t("the list view nests three levels and marks the subgroup", () => {
  nested();
  eqArr(A.displayTasks().map(r => r.t.id + "@" + r.level),
        ["1@0", "2@1", "3@2", "4@2", "5@1", "6@0", "7@0"]);
});

console.log("\n-- folding groups open and closed in the list view --");

t("folding a subgroup hides only what is inside it", () => {
  nested();
  A.state.view = "table";                 // folding is the list's idea, and only its
  A.state.collapsed.add(2);
  eqArr(A.displayTasks().map(r => r.t.id), [1, 2, 5, 6, 7]);
  A.state.collapsed.clear();
});

t("folding the top group hides the whole branch", () => {
  nested();
  A.state.view = "table";
  A.state.collapsed.add(1);
  eqArr(A.displayTasks().map(r => r.t.id), [1, 6, 7]);
  A.state.collapsed.clear();
});

/* The charts cut every branch at one depth, and say so in one place: the Lvl
   filter. A fold made in the list an hour ago used to cut them too, invisibly. */
t("a fold made in the list does not reach the charts", () => {
  nested();
  A.state.collapsed.add(1);
  for (const v of ["gantt", "zoom", "res"]) {
    A.state.view = v;
    eqArr(A.displayTasks().map(r => r.t.id), [1, 2, 3, 4, 5, 6, 7], v + " draws the whole plan");
  }
  A.state.view = "table";
  eqArr(A.displayTasks().map(r => r.t.id), [1, 6, 7], "and the list still folds");
  A.state.collapsed.clear();
});

t("open all and close all fold every group at once", () => {
  nested();
  A.state.view = "table";
  A.state.collapsed.clear();

  assert.strictEqual(A.foldAll(true), true, "something changed");
  eqArr([...A.state.collapsed].sort((a, b) => a - b), [1, 2], "every group, and only the groups");
  eqArr(A.displayTasks().map(r => r.t.id), [1, 6, 7]);

  assert.strictEqual(A.foldAll(true), false, "closing what is already closed writes nothing");

  assert.strictEqual(A.foldAll(false), true);
  assert.strictEqual(A.state.collapsed.size, 0, "open all leaves nothing folded");
  eqArr(A.displayTasks().map(r => r.t.id), [1, 2, 3, 4, 5, 6, 7]);
  assert.strictEqual(A.foldAll(false), false, "and opening what is open writes nothing either");
});

t("every group row gets a caret and a count of what is inside", () => {
  nested();
  A.state.view = "table"; useRef({});
  A.renderTable();
  const html2 = els.tableView.innerHTML;
  assert.strictEqual(count(html2, 'data-fold="'), 2, "one caret per group, none on plain tasks");
  assert(html2.includes('class="subGroupRow"'), "the subgroup is styled apart from its parent");
  assert(/data-fold="1"[^>]*>▾</.test(html2), "expanded groups show an open caret");
  A.state.collapsed.add(1);
  A.renderTable();
  assert(/data-fold="1"[^>]*>▸</.test(els.tableView.innerHTML), "folded groups show a closed caret");
  A.state.collapsed.clear();
  useRef(null);
});

t("a tree nested to the limit draws in the list, one indent step per level", () => {
  loadTasks(chainCsv(A.MAX_GROUP_DEPTH + 1));       // L0 … L4, each inside the one above
  A.state.view = "table"; useRef({});
  A.clearSelection();                               // a highlight from an earlier test would join the class
  A.renderTable();
  const html2 = els.tableView.innerHTML;
  for (let lvl = 0; lvl <= A.MAX_GROUP_DEPTH; lvl++)
    assert(html2.includes(`padding-left:${8 + lvl * 18}px`), `no row indented for level ${lvl}`);
  assert.strictEqual(count(html2, 'class="groupRow"'), 1, "one row at the top");
  assert.strictEqual(count(html2, 'class="subGroupRow"'), A.MAX_GROUP_DEPTH - 1,
    "every level between the top and the leaf is a subgroup");
  assert.strictEqual(count(html2, 'class="memberRow'), 1, "and the leaf is a plain member");
  useRef(null);
});

t("drop targets follow the depth limit, and groups are draggable", () => {
  nested3();
  A.state.view = "table"; useRef({});
  A.renderTable();
  const html2 = els.tableView.innerHTML;
  assert(/data-id="2"[^>]*draggable="true"/.test(html2), "a subgroup can be dragged");
  assert(/data-id="2"[^>]*data-drop="1"/.test(html2), "and can still receive");
  assert(/data-id="3"[^>]*data-drop="1"/.test(html2), "so can a second-level subgroup");
  /* The rule stated once, against the constant: a row takes members unless it
     already sits at the deepest level (or is a milestone, which is nobody's
     group). Written this way it keeps testing the limit when the limit moves. */
  for (const t0 of A.state.tasks) {
    const canDrop = new RegExp(`data-id="${t0.id}"[^>]*data-drop`).test(html2);
    const deepest = A.depthOf(t0.id, A.state.tasks) >= A.MAX_GROUP_DEPTH;
    assert.strictEqual(canDrop, !deepest && !t0.milestone,
      `${t0.name} at depth ${A.depthOf(t0.id, A.state.tasks)}: drop target = ${canDrop}`);
  }
  useRef(null);
});

console.log("\n-- the Lvl 1 … Lvl 4 filter cuts the plan at a depth --");

/* The filter is a depth cut, not a "groups only" switch: a childless task at the
   root is a branch of its own and is drawn at Lvl 1 beside the main groups,
   while the top setting means the whole plan — the tasks under the deepest
   subgroup included. */

const ganttIds = () => {
  A.renderGantt();
  return [...els.ganttLeftBody.innerHTML.matchAll(/data-id="(\d+)"/g)].map(m => +m[1]).sort((a, b) => a - b);
};
const ganttBars = () => {
  A.renderGantt();
  const bars = [...els.ganttBody.innerHTML.matchAll(/<div class="bar[^"]*" data-id="(\d+)"[\s\S]*?<\/div>/g)];
  return new Map(bars.map(m => [+m[1], m[0]]));
};

t("the Gantt draws the plan down to the chosen level", () => {
  nested3();
  A.state.view = "gantt"; useRef({});
  /* By name rather than by ID: the IDs follow the dates, so they move whenever
     the fixture does — the structure being cut is what this is about. */
  const shownAt = lvl => { A.state.groupLevel = lvl; return ganttIds().map(nameOfId).sort(); };
  eqArr(shownAt(1), ["Gate", "Loose", "Programme"], "Lvl 1: main groups, top-level tasks and the milestone");
  eqArr(shownAt(2), ["Gate", "Loose", "Phase A", "Programme", "Standalone"], "Lvl 2: one level in as well");
  eqArr(shownAt(3), ["A2", "Gate", "Loose", "Phase A", "Programme", "Standalone", "Workstream"],
        "Lvl 3: two levels in — the deepest task is still cut");
  eqArr(shownAt(4), ["A1a", "A2", "Gate", "Loose", "Phase A", "Programme", "Standalone", "Workstream"],
        "Lvl 4: everything, leaves included");
  assert.strictEqual(A.GROUP_LEVELS, 4, "four levels of grouping, four settings");
  A.state.groupLevel = A.GROUP_LEVELS;   // the top setting: everything
  useRef(null);
});

t("a childless top-level task counts as a group at Lvl 1", () => {
  nested3();
  A.state.view = "gantt"; useRef({});
  A.state.groupLevel = 1;
  const ids = ganttIds();
  assert(ids.includes(idOfName("Loose")), "Loose holds nothing but sits at the root, so it is drawn");
  assert(!ids.includes(idOfName("A2")), "a childless task one level in is not");
  A.state.groupLevel = A.GROUP_LEVELS; useRef(null);
});

t("no row is ever orphaned from its group by the cut", () => {
  nested3();
  A.state.view = "gantt"; useRef({});
  for (const lvl of [1, 2, 3]) {
    A.state.groupLevel = lvl;
    const shown = new Set(ganttIds());
    for (const id of shown) {
      const p = A.groupOf(id);
      assert(!p || shown.has(p.id), `Lvl ${lvl}: row ${id} drawn without its group ${p && p.id}`);
    }
  }
  A.state.groupLevel = A.GROUP_LEVELS; useRef(null);
});

t("row classes carry both the weight and the depth", () => {
  nested3();
  assert.strictEqual(A.rowClass(A.taskById(1), 0), "groupRow");
  assert.strictEqual(A.rowClass(A.taskById(2), 1), "subGroupRow d1");
  assert.strictEqual(A.rowClass(A.taskById(3), 2), "subGroupRow d2");
  assert.strictEqual(A.rowClass(A.taskById(4), 3), "memberRow d3");
  assert.strictEqual(A.rowClass(row("Loose"), 0), "", "a top-level plain task needs no class");
  // and every level a row can reach has an indent of its own in the stylesheet
  for (let d = 1; d <= A.MAX_GROUP_DEPTH; d++)
    assert(new RegExp(`\\.gRow\\.d${d}\\s+\\.cname\\s*\\{[^}]*padding-left`).test(html),
      `no indent for depth ${d}`);
});

t("the deepest level gets its own class rather than sharing the one above", () => {
  nested3();
  const deep = A.MAX_GROUP_DEPTH;
  assert.strictEqual(A.rowClass(A.taskById(4), deep), "memberRow d" + deep);
  assert.strictEqual(A.rowClass(A.taskById(3), deep), "subGroupRow d" + deep,
    "a group at the deepest level is still drawn as a group");
});

/* The user's rule for the list: a subgroup is a group, so it reads like one. */
t("subgroup names are as bold as the groups above them", () => {
  const weightOf = sel => {
    const m = html.match(new RegExp(sel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*\\{[^}]*font-weight:\\s*(\\d+)"));
    assert(m, "no font-weight rule for " + sel);
    return m[1];
  };
  assert.strictEqual(weightOf("tr.subGroupRow td.name"), "700", "subgroup rows are not bold in the list");
  assert.strictEqual(weightOf("tr.subGroupRow td.name"), weightOf("tr.groupRow td.name"),
    "a subgroup is lighter than its parent");
  // the chart panels are deliberately not part of this: the ask was the list view
  assert.strictEqual(weightOf(".gRow.subGroupRow"), "600", "the Gantt panel changed weight too");
});

t("the Lvl picker has one setting per level of grouping", () => {
  A.buildGroupLvlOptions();
  const opts = [...els.groupLvl.innerHTML.matchAll(/<option value="(\d+)">([^<]*)<\/option>/g)];
  assert.strictEqual(opts.length, A.GROUP_LEVELS, "one option per level: " + els.groupLvl.innerHTML);
  eqArr(opts.map(o => +o[1]), Array.from({ length: A.GROUP_LEVELS }, (_, i) => i + 1));
  assert(/main groups/.test(opts[0][2]), "the first setting names the main groups");
  assert(/everything/.test(opts[opts.length - 1][2]), "the last one is the whole plan");
  assert.strictEqual(A.state.groupLevel, A.GROUP_LEVELS, "and the app starts showing everything");
});

t("the left-hand list badges every group with what is inside it", () => {
  nested3();
  A.state.view = "gantt"; useRef({}); A.state.groupLevel = 2;
  A.renderGantt();
  const left = els.ganttLeftBody.innerHTML;
  assert(/class="subCount"[^>]*>5</.test(left), "the programme counts all 5 rows under it");
  assert(/class="subCount"[^>]*>3</.test(left), "the subgroup counts its 3");
  A.state.groupLevel = A.GROUP_LEVELS; useRef(null);
});

/* The count belongs on the bar too, not only in the left-hand list: it is the
   one thing saying this bar stands for rows the level cut away. */
t("a Gantt group bar carries the count of what the level hides", () => {
  nested3();
  A.state.view = "gantt"; useRef({}); A.state.pxPerDay = 24;
  A.state.groupLevel = 1;
  let byId = ganttBars();
  assert(/class="barCount"[^>]*>5</.test(byId.get(1)), "at Lvl 1 the programme stands for all 5 rows under it");
  /* Since the bracket (gantt-hierarchia-brief v1.1) a group bar is an 8px rule
     with its name under it — so the pill sits beside that name, inside the
     label span the CSS puts under the bracket, still inside the bar element. */
  assert(/<span class="lbl">[^<]*<span class="barCount"/.test(byId.get(1)), "the pill sits beside the name under the bracket");
  assert(/group of 5 task\(s\)/.test(byId.get(1)), "and the tooltip says so as well");

  A.state.groupLevel = 2;
  byId = ganttBars();
  assert(!/barCount/.test(byId.get(1)), "at Lvl 2 its own members are on screen, so it needs no pill");
  assert(/class="barCount"[^>]*>3</.test(byId.get(2)), "the subgroup below the cut still carries one");
  A.state.groupLevel = A.GROUP_LEVELS; useRef(null);
});

t("at Lvl 3 no bar carries a count", () => {
  nested3();
  A.state.view = "gantt"; useRef({}); A.state.groupLevel = A.GROUP_LEVELS;   // the top setting: everything
  A.renderGantt();
  assert(!/barCount/.test(els.ganttBody.innerHTML), "the pill only marks rows the view is hiding");
  useRef(null);
});

/* This used to assert the opposite: that folding a group in the list put its
   count on the chart's bar, because the chart hid those rows too. The chart no
   longer reads the folds at all — the Lvl filter is the one thing that decides
   what a chart shows — so at the top level a folded group's members are on
   screen, and a pill saying "rows you cannot see" would be a lie. */
t("a fold made in the list leaves the Gantt alone", () => {
  nested3();
  A.state.view = "gantt"; useRef({}); A.state.groupLevel = A.GROUP_LEVELS;   // the top setting: everything
  A.state.collapsed.add(2);
  const bars = ganttBars();
  assert(!/barCount/.test(bars.get(2)), "nothing is hidden, so nothing is counted: " + bars.get(2));
  assert(bars.has(3) && bars.has(5), "and the folded group's members are still drawn");
  A.state.collapsed.clear(); useRef(null);
});

/* The count stays anchored to the bar at every zoom — it used to sit at the
   bar's start so it could never trail past the end; under the bracket it sits
   beside the name, which the CSS pins 12px in from the bracket's left tab, so
   a narrow group still carries its count at its own start. */
t("even a narrow bar keeps its count on the bar", () => {
  nested3();
  A.state.view = "gantt"; useRef({});
  A.state.groupLevel = 2; A.state.pxPerDay = 2;      // 9-day subgroup => 18px wide
  const bar2 = ganttBars().get(2);
  assert(/class="barCount"[^>]*>3</.test(bar2), "the count is still shown");
  assert(/<span class="lbl">[^<]*<span class="barCount"/.test(bar2), "beside the name, under the bracket, inside the bar element");
  assert(!/lblIn/.test(bar2), "a group never takes its name inside — there is no inside to an 8px rule");
  A.state.groupLevel = A.GROUP_LEVELS; A.state.pxPerDay = 24; useRef(null);
});

/* Reported from the design view: the count pill floated over the sticky date
   header as the plan scrolled under it. The pill carries z-index:3 so it clears
   the summary bar's end caps — but the bar it is in was not a stacking context,
   so that 3 was competing with the header's own 3 out in the page, and the pill
   (later in the markup) won. Nothing here can see a rendered layer, so the rule
   is read out of the stylesheet: the bar isolates, and nothing at bar level is
   lifted as high as the header. */
t("nothing on a bar climbs over the sticky date header", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const zOf = re => {
    const m = css.match(re);
    assert(m, "missing rule: " + re);
    return Number(m[1]);
  };
  for (const sel of [/\.bar\{([^}]*)\}/, /\.hrBar\{([^}]*)\}/]) {
    const rule = css.match(sel);
    assert(rule && /isolation:isolate/.test(rule[1]),
      "a bar must be its own stacking context, or its pill escapes: " + (rule && rule[1]));
  }
  const head = zOf(/#ganttHead\{[^}]*z-index:(\d+)/);
  assert(zOf(/\.bar \.barCount\{[^}]*z-index:(\d+)/) >= 2,
    "the pill still has to clear the group bar's end caps");
  assert(zOf(/\.bar\.hit,[^{]*\{[^}]*z-index:(\d+)/) < head, "a search hit outranks the header");
  assert(zOf(/\.bar\.dragging\{[^}]*z-index:(\d+)/) < head, "a dragged bar outranks the header");
  assert(zOf(/\.msMark\.dragging\{[^}]*z-index:(\d+)/) < head, "a dragged milestone outranks the header");
  // the one mark that is allowed over it: the tag that says what the dates would become
  assert(zOf(/\.dragTag\{[^}]*z-index:(\d+)/) > head, "the drag tag has to stay readable");
  for (const h of [/#hvHead\{[^}]*z-index:(\d+)/, /#resHead\{[^}]*z-index:(\d+)/])
    assert.strictEqual(zOf(h), head, "every view's header sits at the same level");
});

/* Group and task bars share the row geometry — the same inline top, no inline
   height — and the stylesheet makes the group an 8px BRACKET in that space
   (gantt-hierarchia-brief v1.1): a thin rule across the top of where the bar
   was, tabs hanging down to where its bottom was, name and count under it.
   They used to be one 20px height; the shape is now what tells them apart. */
t("a group bar is a bracket in the same row space as a task bar", () => {
  nested();
  A.state.view = "gantt"; useRef({});
  A.state.groupLevel = A.GROUP_LEVELS; A.state.pxPerDay = 24;
  A.renderGantt();
  const bars = [...els.ganttBody.innerHTML.matchAll(/<div class="bar([^"]*)"[^>]*style="([^"]*)"/g)]
    .map(m => ({ group: /group/.test(m[1]), style: m[2] }));
  assert(bars.some(b => b.group) && bars.some(b => !b.group), "there are both kinds to compare");
  for (const b of bars) assert(!/height:/.test(b.style), "no inline height on any bar — the sheet decides: " + b.style);
  const tops = new Set(bars.map(b => (b.style.match(/top:(\d+)px/) || [])[1] % A.ROW_H));
  assert.strictEqual(tops.size, 1, "one offset into the row for every bar: " + [...tops]);
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const grp = css.match(/\.bar\.group\{([^}]*)\}/);
  assert(grp && /height:8px/.test(grp[1]), "the group is an 8px rule: " + (grp && grp[1]));
  const tab = css.match(/\.bar\.group::before,\.bar\.group::after\{([^}]*)\}/);
  assert(tab && /width:4px; height:14px/.test(tab[1]) && /var\(--ink/.test(tab[1]) && /var\(--edge/.test(tab[1]),
    "4×14 tabs hanging from it, in the group's own ink and edge: " + (tab && tab[1]));
  const lbl = css.match(/\.bar\.group \.lbl\{([^}]*)\}/);
  assert(lbl && /left:12px/.test(lbl[1]) && /top:9px/.test(lbl[1]) && /color:var\(--text\)/.test(lbl[1]) && /font-weight:700/.test(lbl[1]),
    "the name under the bracket, 12px clear of the left tab, in the text colour: " + (lbl && lbl[1]));
  /* and the health block still paints it: the group's classes are the same pair every bar wears */
  assert(/\.bar\.hDone,\.bar\.hProgress/.test(css) && css.indexOf(".bar.hDone,") > css.indexOf(".bar.group{"),
    "the health block comes after .bar.group, so a status colours the bracket too");
  useRef(null);
});

/* The rail (brief §2): from the bracket's left tab down to the last member's
   bar — one per group whose family is on screen, a subgroup hanging its own. */
t("a rail hangs from each group's left tab to the bottom of its last family member", () => {
  nested3();
  A.state.view = "gantt"; useRef({});
  A.state.groupLevel = A.GROUP_LEVELS; A.state.pxPerDay = 24;
  A.renderGantt();
  const body = els.ganttBody.innerHTML;
  const rails = [...body.matchAll(/<div class="grpRail([^"]*)" data-for="(\d+)" style="left:(-?[\d.]+)px;top:(\d+)px;height:(\d+)px"><\/div>/g)]
    .map(m => ({ cls: m[1], id: +m[2], left: +m[3], top: +m[4], h: +m[5] }));
  eqArr(rails.map(r => r.id), [1, 2, 3], "one rail per group that has family on screen, in tree order");
  const barOf = id => body.match(new RegExp('<div class="bar[^"]*" data-id="' + id + '" style="left:(-?[\\d.]+)px;top:(\\d+)px'));
  const top = id => +barOf(id)[2], left = id => +barOf(id)[1];
  // Programme(1) holds 2..6 (Phase A, Workstream, A1a, A2, Standalone); its rail ends at Standalone's bar
  const r1 = rails.find(r => r.id === 1);
  assert.strictEqual(r1.top, top(1) + 13, "starts at the bottom of the tab (bar top + 13)");
  assert.strictEqual(r1.top + r1.h, top(6) + A.BAR_H, "ends at the bottom of the last member's bar");
  assert.strictEqual(r1.left, left(1) - 1, "in the line of the left tab");
  // Phase A(2) holds 3, 4, 5 — its own, shorter rail
  const r2 = rails.find(r => r.id === 2);
  assert.strictEqual(r2.top + r2.h, top(5) + A.BAR_H);
  assert(/ h[A-Z]/.test(r1.cls), "wears the group's health class, which is what supplies --ink: " + r1.cls);
  // layering by order: the rail is written before its own bar and before every member bar
  assert(body.indexOf('data-for="1"') < body.indexOf('data-id="1" style'), "the rail precedes its group's bar");
  assert(body.indexOf('data-for="1"') < body.indexOf('data-id="4" style'), "and the members' bars, which therefore paint over it");
  useRef(null);
});

t("no family on screen, no rail — and a plain task never hangs one", () => {
  nested3();
  A.state.view = "gantt"; useRef({}); A.state.pxPerDay = 24;
  A.state.groupLevel = 1;                                   // members cut away: the groups stand alone
  A.renderGantt();
  assert(!/grpRail/.test(els.ganttBody.innerHTML), "a group reduced to one row hangs nothing");
  A.state.groupLevel = 2;
  A.renderGantt();
  const ids = [...els.ganttBody.innerHTML.matchAll(/data-for="(\d+)"/g)].map(m => +m[1]);
  eqArr(ids, [1], "at Lvl 2 only the programme has family on screen; Phase A is a closed row");
  A.state.groupLevel = A.GROUP_LEVELS;
  A.renderGantt();
  assert(!/data-for="7"/.test(els.ganttBody.innerHTML), "Loose is a task: no rail");
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const rule = css.match(/\.grpRail\{([^}]*)\}/);
  assert(rule && /width:3px/.test(rule[1]) && /opacity:\.4/.test(rule[1]) && /var\(--ink/.test(rule[1]) && /pointer-events:none/.test(rule[1]),
    "3px, 40%, the group's ink, untouchable — the approved values: " + (rule && rule[1]));
  useRef(null);
});

t("the zoom day view draws groups and tasks at one height too", () => {
  nested();
  A.state.view = "zoom"; A.state.zoomLevel = "day"; A.state.zoomBy = "task";
  useRef({});
  A.renderDay();
  const hs = [...els.hvBody.innerHTML.matchAll(/<div class="bar[^"]*"[^>]*height:(\d+)px/g)].map(m => m[1]);
  assert(hs.length > 1, "there are bars to compare");
  assert.strictEqual(new Set(hs).size, 1, "one height everywhere: " + JSON.stringify(hs));
  useRef(null);
});

t("the zoom day view puts a group's count on its bar", () => {
  nested();
  A.state.view = "zoom"; A.state.zoomLevel = "day"; A.state.zoomBy = "task";
  useRef({});
  A.renderDay();
  const bar = els.hvBody.innerHTML.match(/<div class="bar[^"]*" data-id="2"[\s\S]*?<\/div>/);
  assert(bar, "the subgroup has a bar");
  assert(/class="barCount"[^>]*>2</.test(bar[0]), "carrying its count: " + bar[0]);
  useRef(null);
});

t("the zoom day view draws a subgroup as a subtask at its own depth", () => {
  nested3();
  A.state.view = "zoom"; A.state.zoomBy = "task"; A.state.zoomLevel = "day";
  A.state.groupLevel = A.GROUP_LEVELS;   // the top setting: everything
  eqArr(A.dayModel().rows.map(r => r.t.id + "@" + r.level),
        ["1@0", "2@1", "3@2", "4@3", "5@2", "6@1", "7@0"],
        "tree order, each row at its real depth - a subgroup is never promoted to a main task");
});

t("the zoom day view cuts its subtask rows at the chosen level", () => {
  nested3();
  A.state.view = "zoom"; A.state.zoomBy = "task"; A.state.zoomLevel = "day";
  A.state.groupLevel = 1;
  eqArr(A.dayModel().rows.map(r => r.t.id), [1, 7], "Lvl 1: main tasks only");
  A.state.groupLevel = 2;
  eqArr(A.dayModel().rows.map(r => r.t.id), [1, 2, 6, 7], "Lvl 2: one level of subtasks");
  A.state.groupLevel = A.GROUP_LEVELS;   // the top setting: everything
  eqArr(A.dayModel().rows.map(r => r.t.id), [1, 2, 3, 4, 5, 6, 7], "the top level: all of them");
});

t("a day-view group row counts everything under it, whatever the level", () => {
  nested3();
  A.state.view = "zoom"; A.state.zoomBy = "task"; A.state.zoomLevel = "day";
  A.state.groupLevel = 1;
  const dm = A.dayModel();
  assert.strictEqual(dm.rows.find(r => r.t.id === 1).kids, 5, "the count is of the whole subtree");
  A.renderDay();
  assert(/class="subCount"[^>]*>5</.test(els.hvLeftBody.innerHTML), "and it is drawn");
  A.state.groupLevel = A.GROUP_LEVELS;   // the top setting: everything
});

/* At this scale a main task and its subtasks are read as one block, so the
   subtask names carry weight too — the indent is what says which is which. */
t("the zoom day view names its subtasks in bold", () => {
  nested();
  A.state.view = "zoom"; A.state.zoomBy = "task"; A.state.zoomLevel = "day";
  useRef({});
  A.renderDay();
  assert(/class="gRow[^"]*memberRow/.test(els.hvLeftBody.innerHTML),
    "subtask rows are tagged for the rule to hit");
  assert(/#hvLeftBody\s+\.gRow\.memberRow\s*\{[^}]*font-weight:\s*600/.test(html),
    "and the zoom panel weights them");
  useRef(null);
});

t("the hour view honours the level, on the bar and beside the name", () => {
  nested3();
  A.state.view = "zoom"; A.state.zoomLevel = "hour"; A.state.zoomBy = "task";
  A.state.zoomDay = "2026-03-05"; A.state.pxPerHour = 6;
  const ids = () => A.hourModel().rows.map(r => r.t.id).sort((a, b) => a - b);

  A.state.groupLevel = A.GROUP_LEVELS;   // the top setting: everything
  eqArr(ids(), [1, 2, 3, 4, 5, 7], "the top level: every row overlapping the window");
  A.state.groupLevel = 1;
  eqArr(ids(), [1, 7], "Lvl 1: the top level only");
  A.state.groupLevel = 2;
  eqArr(ids(), [1, 2, 7], "Lvl 2: one level in");

  const hm = A.hourModel();
  assert.strictEqual(hm.rows.find(r => r.t.id === 1).kids, 5);
  assert.strictEqual(hm.rows.find(r => r.t.id === 2).kids, 3);

  A.renderHour();
  const body = els.hvBody.innerHTML, left = els.hvLeftBody.innerHTML;
  assert(/class="barCount"[^>]*>5</.test(body), "the count rides on the group bar");
  assert(/class="barCount"[^>]*>3</.test(body), "and on the subgroup bar");
  assert(/class="subCount"[^>]*>5</.test(left), "the task name carries it too");
  assert(/group of 5 task\(s\)/.test(body), "and the tooltip says it");
  assert(/subGroupRow/.test(left), "a subgroup is marked apart from its parent");
  A.state.groupLevel = A.GROUP_LEVELS;   // the top setting: everything
});

t("the level picker only appears where rows are tasks", () => {
  nested3();
  useRef({});
  const shown = () => { A.render(); return els.groupLvlBox.style.display !== "none"; };
  A.state.view = "gantt"; assert(shown(), "Gantt");
  A.state.view = "zoom"; A.state.zoomBy = "task"; A.state.zoomLevel = "day";
  assert(shown(), "Zoom day view, Y = tasks");
  A.state.zoomLevel = "hour"; assert(shown(), "hour view — always task rows");
  A.state.zoomBy = "team"; assert(shown(), "hour view stays task rows whatever the Y dimension");
  A.state.zoomBy = "task";
  A.state.zoomLevel = "month"; assert(!shown(), "not the helicopter level");
  A.state.zoomLevel = "day"; A.state.zoomBy = "team";
  assert(!shown(), "not when rows are teams");
  A.state.view = "table"; assert(!shown(), "not the list view");
  A.state.zoomBy = "task"; useRef(null);
});

t("the picker reads back the level, and choosing one redraws at once", () => {
  nested3();
  A.state.view = "gantt"; useRef({});
  A.state.groupLevel = 2;
  A.render();
  assert.strictEqual(els.groupLvl.value, "2", "the select shows the level in force");
  els.groupLvl.value = "1";
  fire(els.groupLvl, "change");
  assert.strictEqual(A.state.groupLevel, 1, "picking a level sets it");
  eqArr([...els.ganttLeftBody.innerHTML.matchAll(/data-id="(\d+)"/g)].map(m => +m[1]).sort((a, b) => a - b),
        [1, 7, 8], "and the chart is already redrawn");
  A.state.groupLevel = A.GROUP_LEVELS; useRef(null);
});

t("a level out of range is clamped, never trusted", () => {
  assert.strictEqual(A.clampGroupLevel("9"), A.GROUP_LEVELS);
  assert.strictEqual(A.clampGroupLevel("0"), 1);
  assert.strictEqual(A.clampGroupLevel("nonsense"), 1);
  assert.strictEqual(A.clampGroupLevel("2"), 2);
});

/* Mass selection: click a row, shift-click another to take the range, then drag
   the lot into a group — or onto the ID column to take them all out. */
console.log("\n-- selecting several rows --");

const twoGroups = () => loadRows([
  { id: 1, name: "Group A", group: [2, 3, 4] },
  { id: 2, name: "A1", start: "2026-03-02", end: "2026-03-03", team: "Dev" },
  { id: 3, name: "A2", start: "2026-03-04", end: "2026-03-05", team: "Dev" },
  { id: 4, name: "A3", start: "2026-03-06", end: "2026-03-07", team: "Dev" },
  { id: 5, name: "Group B", group: [6] },
  { id: 6, name: "B1", start: "2026-03-08", end: "2026-03-09", team: "Ops" },
  { id: 7, name: "Loose", start: "2026-03-10", end: "2026-03-11", team: "Ops" },
]);
const selected = () => [...A.state.selected].sort((a, b) => a - b);

t("a row's section is the group holding it", () => {
  twoGroups();
  assert.strictEqual(A.sectionOf(2), 1);
  assert.strictEqual(A.sectionOf(6), 5);
  assert.strictEqual(A.sectionOf(7), null, "top-level rows share the null section");
  eqArr(A.sectionRows(1), [2, 3, 4]);
});

t("shift-click takes the range between the two rows", () => {
  twoGroups();
  A.selectRow(2, false);
  A.selectRow(4, true);
  eqArr(selected(), [2, 3, 4]);
});

t("the range works in either direction", () => {
  twoGroups();
  A.selectRow(4, false);
  A.selectRow(2, true);
  eqArr(selected(), [2, 3, 4]);
});

/* A selection spanning two groups has no single meaning for a move, and a group
   plus its own members would move the members twice — the section rule prevents
   both at once. */
t("a selection never leaves its section", () => {
  twoGroups();
  A.selectRow(2, false);
  els.toast.textContent = "";
  A.selectRow(6, true);                       // a row in the other group
  eqArr(selected(), [2], "the selection is left alone");
  assert(/stays inside one group/.test(els.toast.textContent), els.toast.textContent);
  A.selectRow(7, true);                       // a top-level row
  eqArr(selected(), [2], "and refused for the top level too");
});

t("a plain click starts a new selection anywhere", () => {
  twoGroups();
  A.selectRow(2, false); A.selectRow(4, true);
  A.selectRow(6, false);
  eqArr(selected(), [6], "clicking without shift is always allowed");
});

console.log("\n-- moving the selection into a group --");

/* A completed move renumbers the plan, so what these check is named, not
   numbered — the rows land in different places than they started. */
t("every selected row moves, and leaves its old group", () => {
  twoGroups();
  A.selectRow(idOfName("A1"), false); A.selectRow(idOfName("A2"), true);
  A.addSelectionToGroup(selected(), idOfName("Group B"));
  eqArr(row("Group B").group, idsOf("A1", "A2", "B1"), "they joined Group B");
  eqArr(row("Group A").group, idsOf("A3"), "and left Group A");
});

t("the move recalculates both groups and clears the selection", () => {
  twoGroups();
  A.selectRow(idOfName("A1"), false); A.selectRow(idOfName("A2"), true);
  A.addSelectionToGroup(selected(), idOfName("Group B"));
  assert.strictEqual(row("Group B").start, "2026-03-02", "Group B now starts at its earliest member");
  assert.strictEqual(A.state.selected.size, 0, "a completed move drops the highlight");
});

t("the whole selection can be taken out of its group at once", () => {
  twoGroups();
  A.selectRow(idOfName("A1"), false); A.selectRow(idOfName("A3"), true);
  A.removeSelectionFromGroup(selected());
  assert.strictEqual(A.groupOf(idOfName("A1")), undefined);
  assert.strictEqual(A.groupOf(idOfName("A3")), undefined);
  eqArr(row("Group A").group, [], "Group A is empty now");
});

/* "if a main group is moved all subtask is moved with it" — the members ride
   inside the group rather than being moved separately. */
t("moving a group carries everything inside it", () => {
  loadRows([
    { id: 1, name: "Programme", start: "2026-03-01", end: "2026-03-01" },
    { id: 2, name: "Phase A", group: [3, 4] },
    { id: 3, name: "A1", start: "2026-03-02", end: "2026-03-03", team: "Dev" },
    { id: 4, name: "A2", start: "2026-03-04", end: "2026-03-05", team: "Dev" },
  ]);
  A.addToGroup(2, 1);
  assert.strictEqual(A.groupOf(2).id, 1, "the group became a subgroup");
  assert.strictEqual(A.groupOf(3).id, 2, "its tasks are still its own");
  assert.strictEqual(A.groupOf(4).id, 2);
  assert.strictEqual(A.depthOf(3, A.state.tasks), 2, "and they came one level down with it");
  assert.strictEqual(span(1), "2026-03-02..2026-03-05", "the new parent spans them");
});

t("a mass move applies what it can and explains the rest", () => {
  /* A chain ending one level short of the limit: a plain task still fits inside
     "Sub", a group does not — it would carry its own member one level too far.
     Built from the constant so it stays a near-miss whatever the limit is. */
  const deep = A.MAX_GROUP_DEPTH;
  loadRows([
    ...Array.from({ length: deep }, (_, i) => ({
      id: i + 1,
      name: i === deep - 1 ? "Sub" : "N" + i,
      ...(i < deep - 1 ? { group: [i + 2] } : { start: "2026-03-01", end: "2026-03-02", team: "Dev" }),
    })),
    { id: 90, name: "G1", group: [91] },
    { id: 91, name: "x", start: "2026-03-03", end: "2026-03-04", team: "Dev" },
    { id: 92, name: "Plain", start: "2026-03-05", end: "2026-03-06", team: "Dev" },
  ]);
  els.toast.textContent = "";
  // Sub is already at the deepest level: G1 would reach level 4
  A.addSelectionToGroup(idsOf("G1", "Plain"), idOfName("Sub"));
  assert.strictEqual(A.groupOf(idOfName("Plain")).id, idOfName("Sub"), "the plain task fits and moves");
  assert.strictEqual(A.groupOf(idOfName("G1")), undefined, "the group would nest too deep and is refused");
  assert(/1 refused/.test(els.toast.textContent), els.toast.textContent);
});

t("selected rows are marked in the list", () => {
  twoGroups();
  A.state.view = "table"; useRef({});
  A.selectRow(2, false); A.selectRow(3, true);
  A.renderTable();
  const html2 = els.tableView.innerHTML;
  assert.strictEqual(count(html2, "selectedRow"), 2, "one class per selected row");
  assert(!/data-id="1"[^>]*selectedRow/.test(html2), "the group itself is not selected");
  A.clearSelection();
  A.renderTable();
  assert.strictEqual(count(els.tableView.innerHTML, "selectedRow"), 0, "clearing removes the highlight");
  useRef(null);
});

t("ctrl-click picks and drops a single row", () => {
  twoGroups();
  A.selectRow(2, false, false);
  A.selectRow(4, false, true);
  eqArr(selected(), [2, 4], "a row that is not adjacent joins the selection");
  A.selectRow(2, false, true);
  eqArr(selected(), [4], "ctrl-clicking a selected row takes it out again");
});

t("ctrl-click obeys the section rule too", () => {
  twoGroups();
  A.selectRow(2, false, false);
  els.toast.textContent = "";
  A.selectRow(6, false, true);
  eqArr(selected(), [2]);
  assert(/stays inside one group/.test(els.toast.textContent), els.toast.textContent);
});

t("emptying the selection by ctrl-click clears the anchor", () => {
  twoGroups();
  A.selectRow(2, false, false);
  A.selectRow(2, false, true);
  assert.strictEqual(A.state.selected.size, 0);
  assert.strictEqual(A.state.selAnchor, null, "so the next shift-click starts fresh");
});

/* Notes: 500 characters of free text, carried in a 13th CSV column and shown in
   the list view only. */
/* One definition drives the header, the body and the inline editor, so the three
   cannot drift apart — and a column can be folded away without any of them
   needing to know about it. */
console.log("\n-- the columns of the list, and folding them away --");

const cellCount = frag => (frag.match(/<t[hd][\s>]/g) || []).length;
const headOf = () => els.tableView.innerHTML.match(/<thead>([\s\S]*?)<\/thead>/)[1];
const firstBodyRow = () => els.tableView.innerHTML.match(/<tr class="[^"]*" data-id="\d+"[\s\S]*?<\/tr>/)[0];
const drawList = () => { A.state.view = "table"; useRef({}); A.renderTable(); };

/* Membership is not a column any more: a list of task IDs is a technical answer
   to a question the list already shows by indenting a row under its group. */
t("there is no Group column, and nothing in the list types an ID list", () => {
  const keys = A.COLUMNS.map(c => c.key);
  assert.strictEqual(keys[keys.length - 1], "actions", "actions stay at the end");
  assert(!keys.includes("group"), "the Group column is gone");
  assert(!/id="iGroup"/.test(html), "and so is its inline editor input");
  assert(!/id="fGroup"/.test(html), "and the dialog's box with it");
});

/* The four a row *is*, plus the actions that work on it. Everything else is a
   detail some plans keep and some do not. */
t("the ID, the name and the two dates refuse to be hidden", () => {
  eqArr(A.COLUMNS.filter(c => c.fixed).map(c => c.key), ["id", "name", "start", "end", "actions"]);
});

t("the columns a plan usually leaves blank come up folded", () => {
  const d = A.defaultHiddenCols();
  for (const k of ["duration", "hourStart", "hourEnd"])
    assert(d.has(k), k + " should come up folded away");
});

t("the header, a row and the editor all have the same number of cells", () => {
  chained();
  A.state.hiddenCols.clear();
  drawList();
  const n = A.columnLayout().length;
  assert.strictEqual(cellCount(headOf()), n, "header");
  assert.strictEqual(cellCount(firstBodyRow()), n, "body row");
  assert.strictEqual(cellCount(A.inlineRowHtml(A.taskById(1))), n, "inline editor");
  useRef(null);
});

t("hiding a column takes it out of every row and leaves a marker", () => {
  chained();
  A.state.hiddenCols.clear();
  A.runColAction("hideCol", "team");
  drawList();
  const head = headOf();
  assert(!/data-col="team"/.test(head), "the header is gone");
  assert(/<th class="colGap" data-showcols="team"/.test(head), "a double rule stands in its place");
  assert(!/>Dev</.test(firstBodyRow()), "and the value went with it");
  assert.strictEqual(cellCount(head), cellCount(firstBodyRow()), "the columns still line up");
  A.state.hiddenCols.clear(); useRef(null);
});

t("a run of neighbours collapses into one marker, in column order", () => {
  chained();
  A.state.hiddenCols.clear();
  // four *neighbours*: Labels sits between Team and System, so a run that skipped
  // it would be two runs — which is the thing this test is about
  A.runColAction("hideCol", "preds");
  A.runColAction("hideCol", "team");
  A.runColAction("hideCol", "labels");
  A.runColAction("hideCol", "system");
  drawList();
  const gaps = [...headOf().matchAll(/data-showcols="([^"]*)"/g)].map(m => m[1]);
  eqArr(gaps, ["preds,team,labels,system"], "one marker for the four of them");
  A.state.hiddenCols.clear(); useRef(null);
});

t("two separate runs leave two markers", () => {
  chained();
  A.state.hiddenCols.clear();
  A.runColAction("hideCol", "duration");
  A.runColAction("hideCol", "team");
  drawList();
  const gaps = [...headOf().matchAll(/data-showcols="([^"]*)"/g)].map(m => m[1]);
  eqArr(gaps, ["duration", "team"]);
  A.state.hiddenCols.clear(); useRef(null);
});

/* The ID and the name lead the header and neither can be folded, so the first
   marker a plan can have sits between the name and the first date. */
t("hiding the first hideable column puts the marker straight after the name", () => {
  chained();
  A.state.hiddenCols.clear();
  A.runColAction("hideCol", "duration");
  drawList();
  assert(/<th data-col="name"[^>]*>[^<]*<\/th><th class="colGap" data-showcols="duration"[^>]*><\/th><th data-col="start"/
    .test(headOf()), "the marker sits between the name and Start: " + headOf());
  A.state.hiddenCols.clear(); useRef(null);
});

t("hiding the last hideable column puts the marker before Actions", () => {
  chained();
  A.state.hiddenCols.clear();
  A.runColAction("hideCol", "notes");
  drawList();
  assert(/<th class="colGap" data-showcols="notes"[^>]*><\/th><th data-col="actions"/.test(headOf()),
    "the marker sits between Notes and Actions: " + headOf());
  A.state.hiddenCols.clear(); useRef(null);
});

t("showing a marker brings its whole run back", () => {
  chained();
  A.state.hiddenCols.clear();
  A.runColAction("hideCol", "start");
  A.runColAction("hideCol", "hourStart");
  A.runColAction("showCols", "start,hourStart");
  drawList();
  assert.strictEqual(A.state.hiddenCols.size, 0, "nothing left hidden");
  assert(/data-col="start"/.test(headOf()) && /data-col="hourStart"/.test(headOf()));
  assert(!/colGap/.test(headOf()), "and no marker left over");
  useRef(null);
});

t("show-all clears every run at once", () => {
  chained();
  A.state.hiddenCols.clear();
  ["duration", "team", "notes"].forEach(k => A.runColAction("hideCol", k));
  A.runColAction("showAllCols", "-");
  assert.strictEqual(A.state.hiddenCols.size, 0);
});

t("the four a row is, and the actions, cannot be hidden however they are asked", () => {
  chained();
  A.state.hiddenCols.clear();
  for (const k of ["id", "name", "start", "end", "actions"]) A.runColAction("hideCol", k);
  assert.strictEqual(A.state.hiddenCols.size, 0, "all of them refused");
  // and the chooser shows them, greyed, rather than leaving them out
  for (const k of ["id", "name", "start", "end"])
    assert(A.colMenuActions().find(i => i.cols === k).fixed, k + " should be offered but locked");
});

/* The header menu is a chooser now: every column, ticked where it is on screen.
   One item per column, whichever header it was opened from. */
t("the header menu lists every column with its state", () => {
  chained();
  A.state.hiddenCols.clear();
  const all = A.colMenuActions();
  eqArr(all.map(i => i.cols), A.activeColumns().map(c => c.key),
    "one item per column, in column order");
  assert(all.every(i => i.act === "toggleCol"), "and nothing else on offer while nothing is hidden");
  assert(all.every(i => i.shown), "everything is ticked");

  A.runColAction("hideCol", "team");
  const after = A.colMenuActions();
  assert.strictEqual(after.find(i => i.cols === "team").shown, false, "team unticked");
  assert.strictEqual(after[after.length - 1].act, "showAllCols", "with a way back from all of it");
  A.state.hiddenCols.clear();
});

t("ticking an item folds the column, and ticking it again brings it back", () => {
  chained();
  A.state.hiddenCols.clear();
  A.runColAction("toggleCol", "team");
  assert(A.state.hiddenCols.has("team"), "folded away");
  A.runColAction("toggleCol", "team");
  assert(!A.state.hiddenCols.has("team"), "and back");
  A.runColAction("toggleCol", "name");
  assert.strictEqual(A.state.hiddenCols.size, 0, "a fixed column cannot be ticked off");
});

t("the menu names the column in words, not markup", () => {
  chained();
  A.state.hiddenCols.clear();
  A.openColMenu({ col: A.columnByKey("duration") }, 10, 10);
  const m = els.rowMenu.innerHTML;
  assert(/☑ Duration/.test(m), "not the two-line header markup: " + m);
  assert(!/<br>/.test(m), "no column label reaches the menu as markup: " + m);
  A.closeRowMenu();
});

t("the columns that cannot be folded are in the list, and disabled", () => {
  chained();
  A.state.hiddenCols.clear();
  A.closeRowMenu();
  // opened from the Actions header, which used to offer nothing at all
  A.openColMenu({ col: A.columnByKey("actions") }, 10, 10);
  assert(A.rowMenuOpen(), "the chooser opens from any header");
  const m = els.rowMenu.innerHTML;
  assert(/data-cols="name" disabled/.test(m), "the name is offered and locked: " + m);
  assert(/data-cols="team"(?! disabled)/.test(m), "and an ordinary column is not");
  A.closeRowMenu();
});

/* readInline() reads every field by ID, so a row edited with a column folded
   away must still carry that column's input — or saving would blank the value. */
t("the editor keeps the inputs of the hidden columns, out of sight", () => {
  chained();
  A.state.hiddenCols.clear();
  A.runColAction("hideCol", "team");
  A.runColAction("hideCol", "system");
  const row = A.inlineRowHtml(A.taskById(1));
  assert(/id="iTeam"[^>]*value="Dev"/.test(row), "the team input is still there, with its value: " + row);
  assert(/id="iSystem"/.test(row), "and the system one");
  assert(/<td class="colGap"><span style="display:none">/.test(row), "parked inside the marker cell");
  A.state.hiddenCols.clear();
});

t("hiding a column is not an undo step, but it does need saving", () => {
  chained();
  A.state.hiddenCols.clear();
  A.resetHistory();
  A.state.dirty = false;
  A.runColAction("hideCol", "team");
  assert.strictEqual(A.history.undo.length, 0, "Ctrl+Z is for the plan, not for the view");
  assert.strictEqual(A.state.dirty, true, "but the file carries the setting, so it must be written");
  A.state.hiddenCols.clear();
});

console.log("\n-- the columns are arranged by dragging their headers --");

const keysNow = () => A.orderedColumns().map(c => c.key);
const defaultKeys = A.COLUMNS.map(c => c.key);

t("a column lands where it was dropped, either way along the row", () => {
  chained();
  A.resetColumnOrder();
  eqArr(keysNow(), defaultKeys, "nothing arranged yet");

  // dragged to the right: it goes *after* the header it was dropped on
  assert.strictEqual(A.moveColumn("team", "notes"), true);
  const right = keysNow();
  assert.strictEqual(right.indexOf("team"), right.indexOf("notes") + 1, "after Notes: " + right.join(","));

  // …and to the left it goes *before* it, which is the same sentence backwards
  assert.strictEqual(A.moveColumn("team", "id"), true);
  const left = keysNow();
  assert.strictEqual(left.indexOf("team"), 0, "team took the ID's place: " + left.join(","));
  assert.strictEqual(left.indexOf("id"), 1, "and the ID moved along one");
  assert.strictEqual(left.length, defaultKeys.length, "no column was lost or gained");
  A.resetColumnOrder();
});

t("a move onto itself, or onto a column that is not one, changes nothing", () => {
  chained();
  A.resetColumnOrder();
  assert.strictEqual(A.moveColumn("team", "team"), false);
  assert.strictEqual(A.moveColumn("team", "nonesuch"), false);
  assert.strictEqual(A.moveColumn("", "team"), false);
  assert.strictEqual(A.state.colOrder, null, "and nothing was written down");
});

t("the arrangement reaches the header, the body and the editor alike", () => {
  chained();
  A.resetColumnOrder();
  A.state.hiddenCols.clear();
  A.moveColumn("notes", "id");                 // Notes to the front
  drawList();
  const head = headOf();
  assert(/^<tr><th data-col="notes"/.test(head), "the first header is Notes: " + head.slice(0, 120));
  const row = firstBodyRow();
  assert.strictEqual(cellCount(head), cellCount(row), "the columns still line up");
  assert.strictEqual(cellCount(A.inlineRowHtml(A.taskById(1))), cellCount(head), "and so does the editor");
  A.resetColumnOrder(); useRef(null);
});

t("a hidden column keeps its place while it is away", () => {
  chained();
  A.resetColumnOrder();
  A.state.hiddenCols.clear();
  A.runColAction("hideCol", "team");
  A.moveColumn("notes", "id");
  A.runColAction("showCols", "team");
  const back = keysNow();
  assert.strictEqual(back.indexOf("team"), back.indexOf("labels") - 1,
    "team came back beside Labels, where it was: " + back.join(","));
  A.resetColumnOrder(); A.state.hiddenCols.clear();
});

t("the arrangement travels in the file, and only when there is one", () => {
  chained();
  A.resetColumnOrder();
  A.state.hiddenCols = A.defaultHiddenCols();
  assert(!/columnOrder/.test(A.serializeCSV(A.state.tasks)), "a plan in the declared order writes no line");

  A.moveColumn("notes", "id");
  const csv = A.serializeCSV(A.state.tasks);
  const line = csv.trim().split(/\r?\n/).find(l => l.startsWith("#VIEW|columnOrder"));
  assert(line, "the order is written: " + csv.slice(-300));
  eqArr(line.split("|")[2].split(";"), keysNow(), "the whole order, as keys");

  // and it comes back the same way
  eqArr(A.parseColOrder(csv), keysNow());
  A.resetColumnOrder();
  assert.strictEqual(A.parseColOrder("id|name\n1|T"), null, "a file that says nothing arranges nothing");
});

/* The file is the older half of this pair: it was written by whatever version
   the plan was last saved in, so it can name a column this one has dropped and
   can be silent about one this one has added. */
t("a key this version does not know is dropped; one it is not told about follows", () => {
  chained();
  const csv = "#VIEW|columnOrder|notes;fromTheFuture;id\n";
  const order = A.parseColOrder(csv);
  eqArr(order, ["notes", "id"], "the unknown key is not a column, so it is not in the order");
  A.state.colOrder = order;
  const keys = keysNow();
  assert.strictEqual(keys[0], "notes");
  assert.strictEqual(keys[1], "id");
  eqArr(keys.slice(2), defaultKeys.filter(k => k !== "notes" && k !== "id"),
    "and everything the line did not mention keeps the declared order, after it");
  A.resetColumnOrder();
});

t("the chooser offers the way back only once there is something to go back from", () => {
  chained();
  A.resetColumnOrder();
  A.state.hiddenCols.clear();
  assert(!A.colMenuActions().some(i => i.act === "resetColOrder"), "nothing arranged, nothing offered");
  A.moveColumn("notes", "id");
  assert(A.colMenuActions().some(i => i.act === "resetColOrder"), "arranged: the way back is there");
  A.runColAction("resetColOrder", "-");
  assert.strictEqual(A.state.colOrder, null, "and it puts the columns back");
  eqArr(keysNow(), defaultKeys);
});

t("arranging is not an undo step, but it does need saving", () => {
  chained();
  A.resetColumnOrder();
  A.resetHistory();
  A.state.dirty = false;
  A.state.hiddenCols.clear();
  useRef({});
  A.renderTable();                       // the drop runs through the same path
  A.moveColumn("notes", "id");
  A.runColAction("resetColOrder", "-");  // …which is what marks the view dirty
  assert.strictEqual(A.history.undo.length, 0, "Ctrl+Z is for the plan, not for the view");
  assert.strictEqual(A.state.dirty, true, "but the file carries the order, so it must be written");
  useRef(null);
});

/* The gesture itself, as far as a stub DOM can see it: the headers say they are
   draggable, and say it nowhere a drag could not be kept. */
t("every header is a handle, except where the order could not be saved", () => {
  chained();
  useRef({});
  A.renderTable();
  const head = headOf();
  const draggable = (head.match(/<th data-col="[^"]*" draggable="true"/g) || []).length;
  assert.strictEqual(draggable, (head.match(/<th data-col=/g) || []).length,
    "every column header carries the handle");
  useRef(null);
});

console.log("\n-- the hidden columns travel in the file --");

t("the setting is written as a last line that is not a task", () => {
  chained();
  /* The lines say what this plan does *differently* from the way the list comes
     up, so a plan left at the defaults still writes nothing at all — History
     starting folded away is not news. */
  A.state.hiddenCols = A.defaultHiddenCols();
  assert(!/#VIEW/.test(A.serializeCSV(A.state.tasks)), "nothing changed, nothing written");

  /* Two columns that come up *shown*, folded away by hand — hiding one that was
     folded already would be the default again and would write nothing. */
  A.state.hiddenCols = new Set(["team", "system"]);
  const lines = A.serializeCSV(A.state.tasks).trim().split(/\r?\n/);
  /* Every column that comes up folded is now shown, so all of them are named —
     read off defaultHiddenCols() rather than listed here, or adding a column
     that starts folded becomes an edit to this test. */
  const shown = A.columnLayout().filter(x => x.col).map(x => x.col.key)
    .filter(k => A.defaultHiddenCols().has(k));
  eqArr(lines.slice(A.state.tasks.length + 1),
    // the #META lines trail everything else — every save stamps who made it (A2)
    // and where the format is written up (site-v3-ai-terv: the file carries its own manual)
    ["#VIEW|hiddenColumns|team;system", "#VIEW|shownColumns|" + shown.join(";"),
     // format v2 (2026-08-23): the version stamp leads the #META block
     "#META|format|2", "#META|writer|" + A.WRITER_ID, "#META|spec|" + A.SPEC_URL],
    "in column order — and the columns that start folded say they were brought back");
  A.state.hiddenCols = A.defaultHiddenCols();
});

t("the settings line never shows up as a task", () => {
  chained();
  A.state.hiddenCols = new Set(["team"]);
  const out = A.serializeCSV(A.state.tasks);
  A.state.hiddenCols.clear();
  const back = A.parseCSV(out);
  assert.strictEqual(back.length, A.state.tasks.length, "the tasks, and only the tasks");
  assert(!back.some(t2 => /VIEW/.test(t2.name)), "and none of them is the settings line");
  assert(A.looksLikeProjectFile(out), "the file is still recognisably ours");
});

t("the setting survives a round trip", () => {
  chained();
  A.state.hiddenCols = new Set(["preds", "notes", "system"]);
  const out = A.serializeCSV(A.state.tasks);
  A.state.hiddenCols.clear();
  /* Exactly these three: setting hiddenCols to them also says the ones that
     come up folded are shown, and both halves of that survive the trip. */
  eqArr([...A.parseViewLine(out)].sort(), ["notes", "preds", "system"]);
});

t("a file with no settings line hides what starts hidden, and nothing else", () => {
  chained();
  /* Against the app's own answer, not a list kept in step by hand: History was
     the first column to come up folded and is no longer the only one. */
  const defs = [...A.defaultHiddenCols()].sort();
  assert(defs.includes("history"), "History still comes up folded");
  eqArr([...A.parseViewLine("")].sort(), defs, "the defaults, from an empty file");
});

/* The other half of the pair: a column that starts folded must be able to stay
   unfolded. "Hidden" alone cannot say that — an omission would read as the
   default all over again — so the choice is written out as its own line. */
t("bringing a default-hidden column out survives the round trip", () => {
  chained();
  A.state.hiddenCols = new Set(["team"]);          // history deliberately not in it
  const out = A.serializeCSV(A.state.tasks);
  const shownLine = out.split(/\r?\n/).find(l => l.startsWith("#VIEW|shownColumns|")) || "";
  assert(shownLine.split("|")[2].split(";").includes("history"),
    "the choice was not written: " + shownLine);
  eqArr([...A.parseViewLine(out)], ["team"], "and it came back unfolded");
  A.state.hiddenCols = A.defaultHiddenCols();
});

t("a key the app does not know is dropped, not carried", () => {
  eqArr([...A.parseViewLine("#VIEW|hiddenColumns|team;colour;name;actions")].sort(),
    [...A.defaultHiddenCols(), "team"].sort(),
    "an unknown column, and the two that may never be hidden, are all refused");
});

console.log("\n-- notes --");

t("notes round-trip through the file", () => {
  loadRows([{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", team: "Dev", notes: "Ring the vendor first" }]);
  assert.strictEqual(A.taskById(1).notes, "Ring the vendor first");
  const out = A.serializeCSV(A.state.tasks);
  assert(out.split("\r\n")[0].includes("|Notes|"), "the header gains the column");
  assert.strictEqual(A.parseCSV(out)[0].notes, "Ring the vendor first");
});

t("a file written before Notes existed still loads", () => {
  const old = "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone\n" +
              "9|Legacy||2026-03-02||2026-03-03|||Dev|||no";
  const tasks = A.parseCSV(old);
  assert.strictEqual(tasks.length, 1, "the missing column must not break the row");
  assert.strictEqual(tasks[0].notes, "", "it simply reads as no note");
});

/* Rewritten 2026-08-18 with the format change approved: this test used to
   assert the *loss* — "a/b second line" — which was the defect (D4), on every
   save, for good. The row is still one line with no bare pipe in it; what
   changed is that the characters come back. */
t("neither the delimiter nor a newline can break the row apart — and both come back", () => {
  loadRows([{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", team: "Dev" }]);
  A.taskById(1).notes = "a|b\nsecond line\r\nthird ~ line";
  const out = A.serializeCSV(A.state.tasks);
  const lines = out.trim().split("\r\n");
  assert.strictEqual(lines.length, 5, "header, one row, and the three #META lines (format, writer, spec) every save now trails with");
  const cells = lines[1].split("|");
  assert.strictEqual(cells.length, lines[0].split("|").length, "the row is as wide as its header — no bare pipe inside a cell");
  assert.strictEqual(A.parseCSV(out)[0].notes, "a|b\nsecond line\nthird ~ line", "what was typed is what comes back");
});

t("the cell escape: ~ first, so the pass cannot encode its own output", () => {
  assert.strictEqual(A.cellEnc("a|b"), "a~pb");
  assert.strictEqual(A.cellEnc("x\ny"), "x~ny");
  assert.strictEqual(A.cellEnc("x\r\ny"), "x~ny", "CRLF is one line break, not two");
  assert.strictEqual(A.cellEnc("~p"), "~tp", "a literal ~p in the text is not a pipe");
  assert.strictEqual(A.cellDec("~tp"), "~p");
  assert.strictEqual(A.cellDec("~t~p~n"), "~|\n");
  assert.strictEqual(A.cellDec("~x ~"), "~x ~", "an unknown code, or a lone ~, is left alone");
  for (const s of ["", "plain", "~", "~~", "a|b|c", "\n", "~t~p", "tilde~pipe|nl\nend", "~n~n~t~t"])
    assert.strictEqual(A.cellDec(A.cellEnc(s)), s, "round trip: " + JSON.stringify(s));
  assert(!/[|\r\n]/.test(A.cellEnc("a|b\nc\r\nd")), "nothing the format needs survives encoding");
});

t("a name with a pipe, a note on three lines: through the file and the merge unchanged", () => {
  loadRows([{ id: 1, name: "Cut-over", start: "2026-03-02", end: "2026-03-03", team: "Dev" }]);
  // set after loading: the test's own csvRow() helper writes raw cells, and is not the app
  A.taskById(1).name = "Cut-over | go-live";
  A.taskById(1).notes = "one\ntwo\nthree";
  const out = A.serializeCSV(A.state.tasks);
  const back = A.parseCSV(out);
  assert.strictEqual(back[0].name, "Cut-over | go-live");
  assert.strictEqual(back[0].notes, "one\ntwo\nthree");
  // a second save writes the same bytes: the escape is stable, not cumulative
  A.state.tasks = back.map(t => ({ ...t }));
  assert.strictEqual(A.serializeCSV(A.state.tasks).split("\r\n")[1], out.split("\r\n")[1]);
  // and the half-file check still counts it as one whole row
  assert(A.inspectPlanText(out).ok, "a note on three lines is one row to the read guard");
  // a file written by an older build carries no escapes and reads as it always did
  const old = CSV_HDR + "\n" + csvRow({ id: 1, name: "Plain / name", notes: "flat note" });
  assert.strictEqual(A.parseCSV(old)[0].name, "Plain / name");
});

t("an inline edit of another field does not flatten a note written on three lines", () => {
  /* A browser strips line breaks out of an <input>'s value, so the inline row's
     one-line notes box hands back "onetwothree" for a note it never showed the
     breaks of. That exact shape means "not typed in", and the note is kept. The
     stub DOM strips nothing, so both shapes are fed by hand. */
  loadRows([{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", team: "Dev" }]);
  A.taskById(1).notes = "one\ntwo\nthree";
  A.state.inlineEdit = { id: 1 };
  assert.strictEqual(A.inlineNotes("onetwothree"), "one\ntwo\nthree", "what Chrome hands back from an untouched box");
  assert.strictEqual(A.inlineNotes("one\ntwo\nthree"), "one\ntwo\nthree", "what a box that keeps breaks hands back");
  assert.strictEqual(A.inlineNotes("onetwothree four"), "onetwothree four", "typed into: the typing wins");
  assert.strictEqual(A.inlineNotes(""), "", "cleared on purpose stays cleared");
  A.taskById(1).notes = "flat";
  assert.strictEqual(A.inlineNotes("flat"), "flat", "a note with no breaks is simply what the box says");
  A.state.inlineEdit = null;
});

t("the note is capped at 500 characters both ways", () => {
  loadRows([{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", team: "Dev" }]);
  A.taskById(1).notes = "x".repeat(700);
  assert.strictEqual(A.parseCSV(A.serializeCSV(A.state.tasks))[0].notes.length, 500, "on write");
  assert.strictEqual(A.parseCSV(CSV_HDR + "\n" + csvRow({ id: 1, name: "T", notes: "y".repeat(700) }))[0].notes.length,
    500, "and on read");
});

t("the list view shows notes, escaped, and the charts do not", () => {
  loadRows([{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", team: "Dev", notes: "Ring <b>first</b>" }]);
  useRef({});
  A.state.view = "table"; A.renderTable();
  const html2 = els.tableView.innerHTML;
  assert(/<th data-col="notes"[^>]*>Notes<\/th>/.test(html2), "the column is there");
  assert(/Ring/.test(html2), "and the text with it");
  assert(!/<b>first<\/b>/.test(html2) && /&lt;b&gt;/.test(html2), "free text is escaped, never raw HTML");

  A.state.view = "gantt"; A.renderGantt();
  assert(!/Ring/.test(els.ganttLeftBody.innerHTML), "the Gantt stays clean");
  A.state.view = "zoom"; A.state.zoomLevel = "day"; A.state.zoomBy = "task"; A.renderDay();
  assert(!/Ring/.test(els.hvLeftBody.innerHTML), "so does the zoom view");
  useRef(null);
});

t("the header and the rows stay the same width", () => {
  loadRows([{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", team: "Dev", notes: "n" }]);
  useRef({}); A.state.view = "table"; A.renderTable();
  const html2 = els.tableView.innerHTML;
  const ths = [...html2.matchAll(/<th[^>]*>.*?<\/th>/gs)].length;
  const row1 = html2.match(/<tr class="[^"]*" data-id="1"[\s\S]*?<\/tr>/)[0];
  // read off the column definition, so adding a column needs no edit here
  const cols = A.columnLayout().length;
  assert.strictEqual(ths, cols, "one header per column on screen");
  assert.strictEqual((row1.match(/<td/g) || []).length, ths, "every row matches the header");
  assert(new RegExp(`colspan="${cols}"`).test(html2), "and the full-width rows span them all");
  useRef(null);
});

/* A drag holds the pointer, so neither the wheel nor the scrollbar is reachable —
   without this a long list cannot be dropped onto anything off screen. */
console.log("\n-- the list scrolls itself while dragging --");

t("hovering near an edge scrolls, the middle does not", () => {
  const pane = els.tableView;
  let top = 200; const MAX = 1000;
  Object.defineProperty(pane, "scrollTop",
    { get: () => top, set: v => { top = Math.max(0, Math.min(MAX, v)); }, configurable: true });
  pane.getBoundingClientRect = () => ({ top: 100, bottom: 500, left: 0, right: 800 });
  const over = y => fire(pane, "dragover", { clientY: y });
  const stop = () => fire(pane, "dragleave", {});

  top = 200; over(300); stop();
  assert.strictEqual(pane.scrollTop, 200, "the middle of the list is not an edge");
  top = 200; over(110); stop();
  assert(pane.scrollTop < 200, "near the top it scrolls up");
  top = 200; over(495); stop();
  assert(pane.scrollTop > 200, "near the bottom it scrolls down");
});

t("it speeds up closer to the edge and stops at the ends", () => {
  const pane = els.tableView;
  let top = 200; const MAX = 1000;
  Object.defineProperty(pane, "scrollTop",
    { get: () => top, set: v => { top = Math.max(0, Math.min(MAX, v)); }, configurable: true });
  const over = y => fire(pane, "dragover", { clientY: y });
  const stop = () => fire(pane, "dragleave", {});

  top = 200; over(140); const gentle = 200 - pane.scrollTop; stop();
  top = 200; over(102); const steep  = 200 - pane.scrollTop; stop();
  assert(steep > gentle, `closer to the edge must be faster (${steep} vs ${gentle})`);
  top = 0;   over(105); stop(); assert.strictEqual(pane.scrollTop, 0, "already at the top");
  top = MAX; over(498); stop(); assert.strictEqual(pane.scrollTop, MAX, "already at the bottom");
});

/* The zoom view could not be scrolled at all, at any level, and drag-panning it
   did nothing either — one cause behind both. #hvView is a column flex container
   and the chart row under the crumb takes its height from "flex:1; min-height:0".
   render() was showing it with display:block, which makes those inert: the row
   grew to its own content height instead of being bounded by the pane, so
   scrollHeight never exceeded clientHeight, nothing scrolled, and #hvView's
   overflow:hidden quietly cut off every row past the fold.

   A stub DOM has no layout engine, so this guards the property that was actually
   wrong — how the container is displayed — not the pixels that follow from it. */
console.log("\n-- the zoom view is laid out as a flex column --");

t("the zoom view is shown as a flex container, not a block", () => {
  loadSample();
  useRef({});
  A.state.view = "zoom"; A.render();
  assert.strictEqual(els.hvView.style.display, "flex",
    "display:block leaves #hv unbounded and the pane unscrollable");
  A.state.view = "table"; A.render();
  assert.strictEqual(els.hvView.style.display, "none", "and hidden when another view is up");
  useRef(null);
});

t("the other views keep the display they were written for", () => {
  loadSample();
  useRef({});
  A.state.view = "gantt"; A.render();
  assert.strictEqual(els.ganttView.style.display, "block",
    "#gantt sizes itself with height:100%, so a block parent is fine there");
  A.state.view = "table"; A.render();
  assert.strictEqual(els.tableView.style.display, "block");
  useRef(null);
});

/* The app bar used to hold every control there is — file actions, both
   management pages and the active view's own knobs — so it wrapped onto a
   second row whose contents moved with the view and the file name. What is
   app-level stays up top; what belongs to one view moved down a bar. */
console.log("\n-- app bar and context bar --");

t("the file actions live in the DB menu, not loose in the app bar", () => {
  const menu = html.match(/<div id="dbMenu"[\s\S]*?<\/div>\s*<\/div>/);
  assert(menu, "the menu is in the markup");
  for (const id of ["btnPickFolder", "btnNewFile", "btnImport", "btnReload"])
    assert(menu[0].includes(`id="${id}"`), id + " should be inside the menu");
});

t("the menu opens on its button and closes again on the next click", () => {
  fire(els.btnDbMenu, "click", { stopPropagation(){} });
  assert(els.dbMenu.classList.contains("open"), "the button opens it");
  docOn.click.forEach(fn => fn({ target: els.body, preventDefault(){}, stopPropagation(){} }));
  assert(!els.dbMenu.classList.contains("open"), "a click anywhere closes it");
});

t("Teams and Systems are outside the view switcher", () => {
  const toggle = html.match(/<div class="viewToggle" id="viewToggle">[\s\S]*?<\/div>/)[0];
  for (const id of ["btnViewTable", "btnViewGantt", "btnViewZoom"])
    assert(toggle.includes(id), id + " is one of the three plan views");
  for (const id of ["btnViewTeams", "btnViewSystems"])
    assert(!toggle.includes(id), id + " is a management page, not a view");
});

t("the breadcrumb moved into the context bar", () => {
  const ctx = html.match(/<div id="ctxBar"[\s\S]*?\n<\/div>/)[0];
  assert(ctx.includes('id="hvCrumb"'), "the crumb is part of the context bar now");
  for (const id of ["listCtrls", "searchBox", "groupLvlBox", "zoomBySelect", "zoomBox"])
    assert(ctx.includes(`id="${id}"`), id + " belongs to a view, so it lives here");
  const hv = html.match(/<div id="hvView">[\s\S]*?<div id="hv">/)[0];
  assert(!hv.includes("hvCrumb"), "and no longer inside the zoom view itself");
});

t("the context bar follows the view, and goes away where nothing applies", () => {
  loadSample();
  useRef({});
  const shown = v => { A.state.view = v; A.render();
                       return [els.ctxBar.style.display, els.hvCrumb.style.display]; };
  assert.deepStrictEqual(shown("table"), ["flex", "none"], "list view: no crumb");
  assert.deepStrictEqual(shown("gantt"), ["flex", "none"], "Gantt: no crumb");
  assert.deepStrictEqual(shown("zoom"),  ["flex", "flex"], "Zoom: the crumb is the crumb");
  assert.strictEqual(shown("teams")[0], "none", "management pages have no view controls");
  useRef(null);
  A.state.view = "table"; A.render();
  assert.strictEqual(els.ctxBar.style.display, "none", "and nothing to control with no file open");
});

t("the wheel hints hang off a chip instead of a line of text", () => {
  loadSample();
  useRef({}); A.state.view = "zoom"; A.state.zoomLevel = "month";
  A.renderZoom();
  const chip = els.hvCrumb.innerHTML.match(/<span class="hintChip" title="([^"]+)">/);
  assert(chip, "the crumb ends in a hint chip");
  assert(chip[1].includes("Shift + wheel"), "carrying the gestures: " + chip[1]);
  useRef(null);
});

/* Undo hangs off markDirty(), the one place every change already goes through.
   The tests drive it the same way the app does: mutate, then markDirty. */
console.log("\n-- undo and redo --");
const keyPress = (k, mod) => (docOn.keydown || []).forEach(fn =>
  fn(Object.assign({ key: k, ctrlKey: false, metaKey: false, shiftKey: false,
                     preventDefault(){ this._pd = true; } }, mod)));
const editNamed = (name, newName) => {
  A.state.tasks.find(x => x.name === name).name = newName;
  A.markDirty();
};

t("a change records the plan as it was, and undo puts it back", () => {
  loadSample();
  A.resetHistory();
  assert.strictEqual(A.history.undo.length, 0, "a freshly loaded file has no history");
  editNamed("Development", "Development phase");
  assert.strictEqual(A.history.undo.length, 1, "one step recorded");
  A.undo();
  assert(A.state.tasks.some(x => x.name === "Development"), "the old name is back");
  assert.strictEqual(A.history.undo.length, 0, "and the step is spent");
});

t("redo re-applies it, and a new change forks the redo branch off", () => {
  loadSample(); A.resetHistory();
  editNamed("Development", "Development phase");
  A.undo();
  assert.strictEqual(A.history.redo.length, 1);
  A.redo();
  assert(A.state.tasks.some(x => x.name === "Development phase"), "redone");
  A.undo();
  editNamed("Testing", "Testing phase");     // a different change while a redo was pending
  assert.strictEqual(A.history.redo.length, 0, "the abandoned future is dropped");
});

t("undoing past the beginning is refused, not a crash", () => {
  loadSample(); A.resetHistory();
  assert.strictEqual(A.undo(), false, "nothing to undo");
  assert.strictEqual(A.redo(), false, "nothing to redo");
});

t("marking dirty without changing anything records no step", () => {
  loadSample(); A.resetHistory();
  A.markDirty(); A.markDirty();
  assert.strictEqual(A.history.undo.length, 0, "no phantom steps in the stack");
});

t("the stack is capped, dropping the oldest step", () => {
  loadSample(); A.resetHistory();
  const first = A.state.tasks[0].name;
  for (let i = 0; i < A.HISTORY_MAX + 8; i++) { A.state.tasks[0].name = "n" + i; A.markDirty(); }
  assert.strictEqual(A.history.undo.length, A.HISTORY_MAX, "capped at " + A.HISTORY_MAX);
  for (let i = 0; i < A.HISTORY_MAX; i++) A.undo();
  assert.notStrictEqual(A.state.tasks[0].name, first, "the oldest steps are genuinely gone");
});

t("undo restores whole structural changes, not just fields", () => {
  loadSample(); A.resetHistory();
  const before = A.state.tasks.length;
  A.duplicateTask(A.state.tasks.find(x => x.name === "Infrastructure build-out").id);
  A.markDirty();
  assert.strictEqual(A.state.tasks.length, before + 1);
  A.undo();
  assert.strictEqual(A.state.tasks.length, before, "the copy is gone again");
});

t("an undo leaves the file dirty — it is a change like any other", () => {
  loadSample(); A.resetHistory();
  editNamed("Development", "Development phase");
  A.state.dirty = false;
  A.undo();
  assert.strictEqual(A.state.dirty, true, "so the next save writes the restored version");
});

t("the buttons follow what is actually available", () => {
  loadSample(); A.resetHistory();
  assert(els.btnUndo.disabled && els.btnRedo.disabled, "nothing to do on a fresh file");
  editNamed("Development", "Development phase");
  assert(!els.btnUndo.disabled, "a step is available");
  A.undo();
  assert(els.btnUndo.disabled && !els.btnRedo.disabled, "spent, and a redo is waiting");
});

t("opening a file starts a history of its own", () => {
  loadSample(); A.resetHistory();
  editNamed("Development", "Development phase");
  A.resetHistory();                       // what openFile() does once a file is loaded
  assert.strictEqual(A.history.undo.length, 0, "you cannot undo across files");
  assert(els.btnUndo.disabled);
});

t("Ctrl+Z and Ctrl+Y are wired, Ctrl+Shift+Z redoes too", () => {
  loadSample(); A.resetHistory();
  editNamed("Development", "Development phase");
  keyPress("z", { ctrlKey: true });
  assert(A.state.tasks.some(x => x.name === "Development"), "Ctrl+Z undid it");
  keyPress("y", { ctrlKey: true });
  assert(A.state.tasks.some(x => x.name === "Development phase"), "Ctrl+Y redid it");
  keyPress("z", { ctrlKey: true });
  keyPress("z", { ctrlKey: true, shiftKey: true });
  assert(A.state.tasks.some(x => x.name === "Development phase"), "Ctrl+Shift+Z redid it");
});

/* In a text field Ctrl+Z is the field's own undo; stealing it there would throw
   away the whole row's edit instead of the last character. */
t("Ctrl+Z is left alone while a row or a dialog is being edited", () => {
  loadSample(); A.resetHistory();
  editNamed("Development", "Development phase");
  A.state.inlineEdit = { id: 1 };
  keyPress("z", { ctrlKey: true });
  assert(A.state.tasks.some(x => x.name === "Development phase"), "the inline row keeps the key");
  A.state.inlineEdit = null;
  els.overlay.classList.add("open");
  keyPress("z", { ctrlKey: true });
  assert(A.state.tasks.some(x => x.name === "Development phase"), "so does the task editor");
  els.overlay.classList.remove("open");
});

t("closing the tab warns only when something is unsaved", () => {
  const ask = () => { const ev = { preventDefault(){ this._pd = true; }, returnValue: null };
                      (winOn.beforeunload || []).forEach(fn => fn(ev)); return !!ev._pd; };
  A.state.dirty = false;
  assert.strictEqual(ask(), false, "a saved file leaves quietly");
  A.state.dirty = true;
  assert.strictEqual(ask(), true, "unsaved work gets the browser's prompt");
  A.state.dirty = false;
});

/* Search: the list narrows down, the charts mark their matches. A chart with
   holes in it is not a timeline any more, which is why they differ. */
console.log("\n-- search --");
const search = q => { A.state.search = q; A.searchHits.i = -1; A.render(); };

t("accents and case are ignored on both sides", () => {
  assert.strictEqual(A.normSearch("Megelőzők"), "megelozok");
  assert.strictEqual(A.normSearch("K 26.12.15."), "k 26.12.15.");
  assert.strictEqual(A.normSearch(null), "");
});

t("a task is found by name, id, team, system or note", () => {
  loadSample();
  const dev = A.state.tasks.find(x => x.name === "Development");
  dev.notes = "Ütemezés egyeztetve";
  A.state.search = "development";     assert(A.taskHit(dev), "by name");
  A.state.search = String(dev.id);    assert(A.taskHit(dev), "by id");
  A.state.search = dev.team.slice(2); assert(A.taskHit(dev), "by team");
  A.state.search = "utemezes";        assert(A.taskHit(dev), "by note, unaccented");
  A.state.search = "nothing here";    assert(!A.taskHit(dev), "and misses when it should");
  A.state.search = "";                assert(A.taskHit(dev), "an empty box matches everything");
  dev.notes = "";
});

t("the list narrows down to the matches, keeping their groups for context", () => {
  loadSample();
  useRef({}); A.state.view = "table";
  search("Data migration tooling");
  const body = els.tableView.innerHTML;
  assert(body.includes("Data migration tooling"), "the match is there");
  assert(body.includes("Development"), "and the group holding it, so it is placed");
  assert(!body.includes("Infrastructure build-out"), "unrelated work is gone");
  assert(body.includes("hitRow"), "the match itself is marked, its parent is not");
  assert.strictEqual(count(body, "hitRow"), 1);
  search("");
  assert(els.tableView.innerHTML.includes("Infrastructure build-out"), "clearing brings it back");
  useRef(null);
});

t("milestones do not slip past the search the way they slip past the filters", () => {
  loadSample();
  const rows = q => { A.state.search = q; return A.displayTasks(true).map(r => r.t.name); };
  assert(!rows("Data migration tooling").includes("Code complete"),
    "a milestone is project-wide, but it is not what you searched for");
  assert(rows("Code complete").includes("Code complete"), "and is found when it is");
  A.state.search = "";
});

t("the Gantt marks its matches instead of hiding the rest", () => {
  loadSample();
  useRef({}); A.state.view = "gantt";
  search("Data migration tooling");
  const body = els.ganttBody.innerHTML, left = els.ganttLeftBody.innerHTML;
  assert(body.includes("Infrastructure build-out"), "every bar is still drawn");
  assert.strictEqual(count(body, "hit\""), 1, "exactly one bar is marked");
  assert(left.includes("hit\""), "and its row in the left panel");
  useRef(null);
});

t("Enter walks the matches, and wraps around", () => {
  loadSample();
  useRef({}); A.state.view = "gantt";
  search("team");                                  // matches many tasks by team name
  const n = A.searchHits.list.length;
  assert(n > 2, "the sample has several, got " + n);
  assert.strictEqual(els.searchCount.textContent, n + " match(es) · ⏎");
  A.jumpToHit(1);
  assert.strictEqual(A.searchHits.i, 0);
  assert.strictEqual(els.searchCount.textContent, "1 / " + n);
  for (let i = 0; i < n; i++) A.jumpToHit(1);
  assert.strictEqual(A.searchHits.i, 0, "all the way round and back to the first");
  A.jumpToHit(-1);
  assert.strictEqual(A.searchHits.i, n - 1, "Shift+Enter goes the other way");
  useRef(null);
});

t("jumping scrolls the match to the middle of the pane", () => {
  loadSample();
  useRef({}); A.state.view = "gantt";
  search("Go-live weekend");
  const hit = A.searchHits.list[0];
  els.ganttRight.scrollLeft = 0; els.ganttRight.scrollTop = 0;
  A.jumpToHit(1);
  assert.strictEqual(els.ganttRight.scrollLeft,
    Math.max(0, hit.x - els.ganttRight.clientWidth / 2), "centred horizontally");
  assert.strictEqual(els.ganttRight.scrollTop,
    Math.max(0, hit.y - els.ganttRight.clientHeight / 2), "and vertically");
  useRef(null);
});

t("every zoom level marks its matches, one stop per task", () => {
  loadSample();
  useRef({}); A.state.view = "zoom"; A.state.zoomBy = "task";
  for (const [lvl, host] of [["month", "hvBody"], ["day", "hvBody"], ["hour", "hvBody"]]) {
    A.state.zoomLevel = lvl;
    A.state.zoomDay = "2026-10-20";
    search("Development");
    assert(A.searchHits.list.length >= 1, lvl + ": found nothing");
    assert(els[host].innerHTML.includes(" hit"), lvl + ": nothing marked in the chart");
    // a bar split into monthly segments must still be a single stop for Enter
    assert.strictEqual(A.searchHits.list.length,
      new Set(A.searchHits.list.map(h => h.x + ":" + h.y)).size, lvl + ": duplicate stops");
  }
  A.state.zoomLevel = "month"; useRef(null);
});

t("a team row is a match when one of its tasks is", () => {
  loadSample();
  useRef({}); A.state.view = "zoom"; A.state.zoomBy = "team"; A.state.zoomLevel = "month";
  search("Data migration tooling");
  const owner = A.state.tasks.find(x => x.name === "Data migration tooling").team;
  const left = els.hvLeftBody.innerHTML;
  assert(left.includes(owner), "the owning team is listed");
  assert(left.includes("hit"), "and marked, so you can see who carries it");
  assert.strictEqual(count(left, 'class="hvRow hit"'), 1, "only that team");
  search(""); A.state.zoomBy = "task"; useRef(null);
});

t("the counter says what happened, and clearing empties it", () => {
  loadSample();
  useRef({}); A.state.view = "table";
  search("zzzz-no-such-task");
  assert.strictEqual(els.searchCount.textContent, "no match");
  search("Development");
  assert(/match\(es\)$/.test(els.searchCount.textContent), els.searchCount.textContent);
  A.clearSearch();
  assert.strictEqual(A.state.search, "");
  assert.strictEqual(els.searchInput.value, "");
  assert.strictEqual(els.searchCount.textContent, "");
  useRef(null);
});

t("Ctrl+F puts the cursor in the box, but only where there is one", () => {
  // the toggle is disabled exactly where there is no bar to open — see render()
  els.btnCtxBar.disabled = true;
  els.searchInput._focused = false;
  keyPress("f", { ctrlKey: true });
  assert(!els.searchInput._focused, "no file open: leave the browser's own find alone");
  els.btnCtxBar.disabled = false;
  keyPress("f", { ctrlKey: true });
  assert(els.searchInput._focused, "focused rather than opening find-in-page");
});

/* The bar folds away, and asking for the search box asks for the bar back: a
   shortcut that put the cursor in a box nobody can see would be a dead key. */
t("Ctrl+F opens the filter bar it needs", () => {
  els.btnCtxBar.disabled = false;
  A.state.ctxBar = false;
  els.searchInput._focused = false;
  keyPress("f", { ctrlKey: true });
  assert.strictEqual(A.state.ctxBar, true, "the bar came back");
  assert(els.searchInput._focused, "and the cursor is in the box");
  /* …and the box is a chip that folds shut since 2026-08-23. Focusing an input
     nobody can see would answer the shortcut without answering the question. */
  assert(A.searchChipOpen(), "🔴 the search chip stayed folded under the cursor");
});

t("search survives escaping — a hostile name cannot break the markup", () => {
  loadSample();
  A.state.tasks.push({ id: 996, name: '<img src=x onerror=alert(1)>', start: "2026-10-01", hourStart: "",
                       end: "2026-10-20", hourEnd: "", preds: [], team: "", system: "", group: [],
                       milestone: false, notes: "" });
  useRef({}); A.state.view = "gantt";
  search("<img");
  assert(!els.ganttBody.innerHTML.includes("<img "), "chart unescaped");
  assert(!els.ganttLeftBody.innerHTML.includes("<img "), "left panel unescaped");
  search(""); A.state.tasks.pop(); useRef(null);
});

console.log("\n-- export: Excel (SpreadsheetML) --");

/* Pull one row of <Cell> elements apart into plain values, with the empty cells
   kept as "" — the level columns are only meaningful if the blanks line up. */
const xlRowCells = rowXml =>
  (rowXml.match(/<Cell(?:\s[^>]*)?\/>|<Cell(?:\s[^>]*)?>[\s\S]*?<\/Cell>/g) || []).map(c => {
    const m = c.match(/<Data[^>]*>([\s\S]*?)<\/Data>/);
    return m ? m[1] : "";
  });
const xlSheetRows = xml => (xml.match(/<Row[^>]*>[\s\S]*?<\/Row>/g) || []).map(xlRowCells);
const xlHeadOf = xml => xlSheetRows(xml)[0];
const xlColIdx = (xml, head) => xlHeadOf(xml).indexOf(head);

t("the workbook is the format Excel opens, and it is well-formed enough to parse", () => {
  loadSample();
  const xml = A.spreadsheetML(A.xlRows(), "sample-project.csv");
  assert(xml.startsWith('<?xml version="1.0" encoding="UTF-8"?>'), "no XML declaration");
  assert(xml.includes('<?mso-application progid="Excel.Sheet"?>'), "no Excel processing instruction");
  assert(xml.includes('xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"'), "no ss namespace");
  // .csv is dropped from the sheet name, and the tab is named after the file
  assert(xml.includes('<Worksheet ss:Name="sample-project">'), "sheet not named after the file");
  assert.strictEqual((xml.match(/<Worksheet[ >]/g) || []).length, 1, "one sheet only");
  // every tag opened is closed: the counts of the repeated elements have to match
  for (const tag of ["Workbook", "Worksheet", "Table", "Row"])
    assert.strictEqual((xml.match(new RegExp("<" + tag + "[ >]", "g")) || []).length,
                       (xml.match(new RegExp("</" + tag + ">", "g")) || []).length, tag + " unbalanced");
});

t("one header row, then one row per task — nothing else travels", () => {
  loadSample();
  const rows = A.xlRows();
  const sheet = xlSheetRows(A.spreadsheetML(rows, "x"));
  assert.strictEqual(sheet.length, A.state.tasks.length + 1, "row count is header + every task");
  assert.strictEqual(rows.length, A.state.tasks.length, "a task went missing from the walk");
  // no chart, no view line, no grouping — the plan itself stays behind
  const xml = A.spreadsheetML(rows, "x");
  assert(!xml.includes("#VIEW"), "view settings leaked into the export");
});

t("one name column per level a row can sit at, and one column for everything else", () => {
  const head = xlHeadOf(A.spreadsheetML([], "x"));
  // four levels of grouping means five levels of row: the deepest holds tasks
  assert.strictEqual(A.XL_LEVELS, A.MAX_GROUP_DEPTH + 1);
  const levels = Array.from({ length: A.XL_LEVELS }, (_, i) => "Level " + (i + 1));
  eqArr(head.filter(h => /^Level /.test(h)), levels);
  /* Read off XL_COLS rather than written out again: the sheet grows a column
     every time the plan does, and a list copied here by hand is an edit due on
     every one of them. What this asserts is the *shape* — ID, then the level
     run, then one column each and no duplicates — which is the thing that can
     actually break. */
  eqArr(head, A.XL_COLS.map(c => c.head));
  assert.strictEqual(head[0], "ID");
  eqArr(head.slice(1, 1 + A.XL_LEVELS), levels);
  assert.strictEqual(new Set(head).size, head.length, "a header is repeated: " + head.join(", "));
  // every field the file carries has somewhere to land, under whatever name
  for (const want of ["Start", "End", "Predecessors", "Team", "System",
                      "Accountable", "Responsible", "Status", "History",
                      "Notes", "Group", "Milestone", "Event", "Emoji", "Not Finalized"])
    assert(head.includes(want), "no column for " + want);
});

t("a task's name sits in the column of its level, and the others stay empty", () => {
  loadTasks(chainCsv(A.XL_LEVELS));        // one row per level, each inside the one above
  const sheet = xlSheetRows(A.spreadsheetML(A.xlRows(), "x"));
  const names = sheet.slice(1).map(r => r.slice(1, 1 + A.XL_LEVELS));
  // the names run down the diagonal: row N fills column N and nothing else
  const want = Array.from({ length: A.XL_LEVELS }, (_, r) =>
    Array.from({ length: A.XL_LEVELS }, (_, c) => c === r ? "L" + r : ""));
  assert.deepStrictEqual(names, want, JSON.stringify(names));
});

t("members follow their group, deepest last — the list order survives", () => {
  loadSample();
  const rows = A.xlRows();
  for (const r of rows) assert.strictEqual(r.level, A.depthOf(r.t.id, A.state.tasks), "row " + r.t.id);
  // a member never appears before the group that holds it
  const at = new Map(rows.map((r, i) => [r.t.id, i]));
  for (const g of A.state.tasks)
    for (const m of (g.group || []))
      assert(at.get(g.id) < at.get(m), `member ${m} listed before its group ${g.id}`);
});

t("a Team filter or a folded group never shortens the export", () => {
  loadSample();
  const all = A.xlRows().length;
  const g = A.state.tasks.find(A.isGroup);
  A.state.collapsed.add(g.id);
  A.state.filterTeam = A.state.tasks.map(t => t.team).find(Boolean) || "";
  assert(A.displayTasks(false).length < all, "the fixture cannot show this — screen would be unfiltered");
  assert.strictEqual(A.xlRows().length, all, "screen state changed what the file contains");
  A.state.collapsed.delete(g.id); A.state.filterTeam = "";
});

t("dates go out typed, so Excel sorts them as dates", () => {
  loadSample();
  const xml = A.spreadsheetML(A.xlRows(), "x");
  assert(xml.includes('<Style ss:ID="date"><NumberFormat ss:Format="yyyy\\-mm\\-dd"/></Style>'), "no date format");
  assert(/<Cell ss:StyleID="date"><Data ss:Type="DateTime">\d{4}-\d\d-\d\dT00:00:00\.000<\/Data>/.test(xml),
         "dates are not DateTime cells");
  assert(/<Data ss:Type="Number">\d+<\/Data>/.test(xml), "IDs are not numbers");
});

t("an empty value is an empty cell, not an empty string", () => {
  // a milestone has no team, no system and no predecessors
  assert.strictEqual(A.xlCell("", "String"), "<Cell/>");
  assert.strictEqual(A.xlDate(""), '<Cell ss:StyleID="date"/>');
  assert(!A.xlCell("", "String").includes("<Data"), "an empty cell must carry no Data element");
});

t("a hostile name cannot break the workbook", () => {
  loadSample();
  const t0 = A.state.tasks[0];
  const old = t0.name;
  t0.name = `Fix <b>"A&B"</b> & 'C' `;
  const xml = A.spreadsheetML(A.xlRows(), "x");
  assert(xml.includes("Fix &lt;b&gt;&quot;A&amp;B&quot;&lt;/b&gt; &amp; &apos;C&apos;"), "not escaped");
  assert(!/<Data ss:Type="String">[^<]*<b>/.test(xml), "raw markup landed in a cell");
  t0.name = old;
});

t("control characters are dropped — XML 1.0 cannot carry them", () => {
  assert.strictEqual(A.xmlEsc("a\x01b\x1Fc"), "abc");
  assert.strictEqual(A.xmlEsc("keep\ttabs\nand\r\nlines"), "keep\ttabs\nand\r\nlines");
});

t("the sheet name is one Excel accepts", () => {
  assert.strictEqual(A.xlSheetName("plan.csv"), "plan");
  assert.strictEqual(A.xlSheetName("a/b:c*d?e[f]g"), "a-b-c-d-e-f-g");
  assert.strictEqual(A.xlSheetName(""), "Tasks");
  assert.strictEqual(A.xlSheetName("x".repeat(40)).length, 31);
});

t("milestones and durations read the same as in the file", () => {
  loadSample();
  const xml = A.spreadsheetML(A.xlRows(), "x");
  const sheet = xlSheetRows(xml);
  const iMs = xlColIdx(xml, "Milestone"), iDur = xlColIdx(xml, "Duration (days)");
  const byName = new Map(sheet.slice(1).map(r => [r.slice(1, 5).find(Boolean), r]));
  for (const t0 of A.state.tasks) {
    const r = byName.get(t0.name);
    if (!r) continue;                                   // duplicate names: skip, not our subject
    assert.strictEqual(r[iMs], t0.milestone ? "yes" : "no", t0.name + " milestone");
    assert.strictEqual(r[iDur], String(t0.milestone ? 0 : A.durationDays(t0)), t0.name + " duration");
  }
});

/* =====================================================================
   Accountable / Responsible, and the resource view built on them
   ===================================================================== */

console.log("\n-- who is on a task: Accountable and Responsible --");

const peopleOf = t => A.taskPeople(t).map(p => p.name + ":" + A.personHours(p) + ":" + p.role);
/* The app runs in its own vm context, so its objects fail deepStrictEqual on the
   prototype alone — compare the two fields that matter instead. */
const personIs = (got, name, hours, msg) =>
  assert.strictEqual(got.name + "|" + got.hours, name + "|" + hours, msg);

t("a bare name means a full working day", () => {
  const p = A.parsePerson("Anna Kovacs");
  assert.strictEqual(p.name, "Anna Kovacs");
  assert.strictEqual(p.hours, null, "no number was given");
  assert.strictEqual(A.personHours(p), A.DEFAULT_WORKLOAD_H, "so it costs whatever an unquantified name costs");
  assert.strictEqual(A.DEFAULT_WORKLOAD_H, 0,
    "naming somebody says who is on the task, not how long it takes them");
});

t("a trailing number after a colon is the workload", () => {
  personIs(A.parsePerson("Anna:6"), "Anna", 6);
  personIs(A.parsePerson("  Anna Kovacs : 2.5 "), "Anna Kovacs", 2.5);
  personIs(A.parsePerson("Anna:2,5"), "Anna", 2.5, "a comma decimal too");
});

t("a colon that is not a workload leaves the name alone", () => {
  personIs(A.parsePerson("Anna: stand-in"), "Anna: stand-in", null);
  personIs(A.parsePerson("Team A: Anna"), "Team A: Anna", null);
});

t("names are separated by ';' and tidied on the way in", () => {
  eqArr(A.parsePeople("Bela:6 ;  Csaba:4").map(p => p.name), ["Bela", "Csaba"]);
  assert.strictEqual(A.normPeople("  Bela:6 ;;  Csaba "), "Bela:6;Csaba", "blanks dropped, spacing normalised");
  assert.strictEqual(A.normPeople(""), "");
});

t("the same person named twice on one task is one person", () => {
  // and the entry carrying the hours wins, whichever way round they were typed
  assert.strictEqual(A.normPeople("Anna;Anna:6"), "Anna:6");
  assert.strictEqual(A.normPeople("Anna:6;anna"), "Anna:6");
  loadRowsNow([{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-02", acc: "Anna:3", resp: "Anna:9;Bela" }]);
  // accountable is where Anna was seen first, so that is the entry she keeps
  eqArr(peopleOf(A.taskById(1)), ["Anna:3:accountable", "Bela:" + A.DEFAULT_WORKLOAD_H + ":responsible"]);
});

t("accountable is one name: the editor refuses the second, sanitation drops it", () => {
  const errs = A.validateTask(
    { name: "T", start: "2026-03-02", end: "2026-03-02", preds: [], group: [],
      accountable: "Anna;Bela", responsible: "" }, 1, []);
  assert(errs.some(e => /Accountable: one person only/.test(e)), errs.join(" | "));

  const warn = [];
  loadTasks(CSV_HDR_NOW + "\n" + csvRowNow({ id: 1, name: "T", start: "2026-03-02", end: "2026-03-02", acc: "Anna;Bela" }) + "\n");
  assert.strictEqual(A.taskById(1).accountable, "Anna", "a file that names two keeps the first");
  warn.push(...A.sanitizeTasks(A.state.tasks));
});

t("a workload has to fit in a day", () => {
  const errsOf = resp => A.validateTask(
    { name: "T", start: "2026-03-02", end: "2026-03-02", preds: [], group: [], accountable: "", responsible: resp },
    1, []).filter(e => /Workload/.test(e));
  assert.strictEqual(errsOf("Anna:8").length, 0, "eight hours is fine");
  assert.strictEqual(errsOf("Anna:24").length, 0, "so is the whole day");
  assert.strictEqual(errsOf("Anna:0").length, 1, "nobody spends no time on a task they are on");
  assert.strictEqual(errsOf("Anna:25").length, 1, "a day has " + A.MAX_WORKLOAD_H + " hours");
});

t("sanitation keeps the name and drops an impossible number", () => {
  // straight off the file, before loadTasks() has had a chance to repair it
  const tasks = A.parseCSV(CSV_HDR_NOW + "\n" +
    csvRowNow({ id: 1, name: "T", start: "2026-03-02", end: "2026-03-02", resp: "Anna:30;Bela:4" }) + "\n");
  const warn = A.sanitizeTasks(tasks);
  assert.strictEqual(tasks[0].responsible, "Anna;Bela:4", "Anna stays, her 30 hours do not");
  assert(warn.some(w => /not between 0 and/.test(w)), "and it is reported: " + warn.join(" | "));
});

t("a milestone is a moment, not somebody's work", () => {
  loadRowsNow([{ id: 1, name: "Go live", start: "2026-03-10", end: "2026-03-10", ms: true, acc: "Anna", resp: "Bela" }]);
  const t1 = A.taskById(1);
  assert.strictEqual(t1.accountable + t1.responsible, "", "the file's entries are cleared, as team and system are");
  const d = { name: "M", start: "2026-03-02", end: "2026-03-02", preds: [], group: [], milestone: true,
              accountable: "Anna", responsible: "" };
  assert(A.validateTask(d, 1, []).some(e => /A milestone is a moment/.test(e)), "and the editor says so");
  A.normalizeMilestone(d);
  assert.strictEqual(d.accountable, "", "ticking the box clears them, exactly like team and system");
});

console.log("\n-- every row's own name, which the plan never shows --");

/* The ID is a position and moves with the plan; the uid is the row itself and
   never moves. Nothing reads it yet — it is here so that a merge, one day, can
   ask "is this the same row?" of two files that have both been renumbered since
   they parted. */
t("a task is given one when it is created, and keeps it through everything", () => {
  loadRowsNow([{ id: 1, name: "Late", start: "2026-03-20", end: "2026-03-24" },
               { id: 2, name: "Early", start: "2026-03-02", end: "2026-03-04" }]);
  const early = row("Early"), late = row("Late");
  assert(A.UID_RE.test(early.uid), "a real uid: " + early.uid);
  assert.notStrictEqual(early.uid, late.uid, "one each");

  // the numbers have already swapped once, on load: the uid did not
  const keep = early.uid;
  assert.strictEqual(early.id, 1, "Early sorted to the front");
  A.setTaskStatus(early, "Done");
  early.start = "2026-04-01"; early.end = "2026-04-30";   // now the later of the two
  A.renumberByTime();
  assert.strictEqual(row("Early").uid, keep, "editing and renumbering leave it alone");
  assert.strictEqual(row("Early").id, 2, "even when the ID it used to have is now somebody else's");
});

t("it round-trips through the file, and a copy is a new row", () => {
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04" }]);
  const uid = row("Build").uid;
  const out = A.serializeCSV(A.state.tasks);
  assert(out.split(/\r?\n/)[0].includes("|UID|"), "written as a column of its own: " + out.split(/\r?\n/)[0]);
  assert(out.includes("|" + uid), "and the value with it");
  loadTasks(out);
  assert.strictEqual(row("Build").uid, uid, "the same row came back");

  A.duplicateTask(idOfName("Build"));
  const copy = row("Build - duplicate");
  assert(A.UID_RE.test(copy.uid), "the copy has one of its own");
  assert.notStrictEqual(copy.uid, uid, "a duplicate is a new row, not the same row twice");
});

t("a plan written before the column existed is given uids on the way in", () => {
  loadRows([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03" },
            { id: 2, name: "B", start: "2026-03-04", end: "2026-03-05" }]);
  const uids = A.state.tasks.map(t => t.uid);
  assert(uids.every(u => A.UID_RE.test(u)), "every row has one: " + uids.join(","));
  assert.strictEqual(new Set(uids).size, uids.length, "and no two share");
  /* Minting them is not an edit and does not ask to be saved: they reach the
     file with the next ordinary save. (The read-only tests further down are
     where "opening a file leaves it exactly as it was" is actually pinned.) */
  A.resetHistory();
  A.sanitizeTasks(A.state.tasks);
  assert.strictEqual(A.history.undo.length, 0, "and it is not an undo step either");
});

t("two rows wearing one uid: the second is re-minted, the first keeps it", () => {
  const tasks = [{ id: 1, name: "A", uid: "same-one" }, { id: 2, name: "B", uid: "same-one" },
                 { id: 3, name: "C", uid: "!!" }];
  assert.strictEqual(A.seedUids(tasks), 2, "the clash and the rubbish");
  assert.strictEqual(tasks[0].uid, "same-one", "the first row keeps what it had");
  assert.strictEqual(new Set(tasks.map(t => t.uid)).size, 3);
  assert(tasks.every(t => A.UID_RE.test(t.uid)));
  assert.strictEqual(A.seedUids(tasks), 0, "asked again, it mints nothing");
});

t("it is in the file and nowhere else", () => {
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04", team: "Dev" }]);
  const uid = row("Build").uid;
  assert(!A.COLUMNS.some(c => c.key === "uid"), "not a column of the list");
  drawList();
  assert(!els.tableView.innerHTML.includes(uid), "not drawn in the list");
  assert(!A.XL_COLS.some(c => c.key === "uid"), "not exported to Excel");
  A.state.search = uid.slice(0, 8);
  assert.strictEqual(A.taskHit(row("Build")), false, "and not searchable — it names nothing a reader knows");
  A.state.search = ""; useRef(null);
});

console.log("\n-- who is at the keyboard, and when they touched a row --");

/* Identity and name are two things: the id is minted once per browser, never
   shown, and never changes; the name is a label that can be corrected at any
   time without disturbing a row that has already been written. */
t("the browser has an id of its own, and it is not the name", () => {
  const id = A.myId();
  assert(/^[a-z0-9]{3,8}$/.test(id), "a short, dull id: " + id);
  assert.strictEqual(A.myId(), id, "asked twice, it is the same id");
  A.setMyName("Anna Kovacs");
  assert.strictEqual(A.myName(), "Anna Kovacs");
  assert.strictEqual(A.myId(), id, "naming yourself does not change who this browser is");
  // a name is a label: anything but the two delimiters, and not endless
  assert.strictEqual(A.cleanMyName("  Anna | Kovacs; "), "Anna Kovacs");
  assert.strictEqual(A.cleanMyName("x".repeat(60)).length, 40);
  assert.strictEqual(A.cleanMyName(null), "");
});

t("the id is in every uid, which is what makes two people unable to collide", () => {
  const id = A.myId();
  const uid = A.newUid();
  assert(uid.startsWith(id + "-"), uid + " should start with " + id);
  assert(A.UID_RE.test(uid));
  // two uids from one browser differ by the counter and the random tail
  assert.notStrictEqual(A.newUid(), A.newUid());
});

t("About says who you are, and is where it is changed", () => {
  A.setMyName("Anna Kovacs");
  A.syncAboutMe();
  assert(/<b>Anna Kovacs<\/b>/.test(els.aboutMeWho.innerHTML), els.aboutMeWho.innerHTML);
  A.setMyName("");
  A.syncAboutMe();
  assert(/Nobody has said who you are/.test(els.aboutMeWho.innerHTML));
  assert(/Set your name/.test(els.btnAboutName.textContent));
  assert(html.includes('id="aboutMe"') && html.includes('id="btnAboutName"'), "the panel is in the page");
  A.setMyName("Anna Kovacs");
});

t("a row says when it was made and who made it", () => {
  A.setMyName("Anna Kovacs");
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04" }]);
  A.state.tasks = []; A.resetHistory();
  const t1 = A.seedTask({ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04",
                          preds: [], group: [], team: "", system: "", accountable: "", responsible: "",
                          notes: "", status: "Planned" });
  const p = A.parseStamp(t1.created);
  assert(p, "a stamp that can be read back: " + t1.created);
  assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(p.when), "UTC to the second: " + p.when);
  assert.strictEqual(p.id, A.myId());
  assert.strictEqual(p.name, "Anna Kovacs");
  assert.strictEqual(t1.modified, t1.created, "a row that has never been touched was last touched when it was made");
  assert(/· Anna Kovacs$/.test(A.stampText(t1.created)), A.stampText(t1.created));
  assert.strictEqual(A.stampText("rubbish"), "", "and nonsense in the cell shows nothing at all");
});

t("a stamp is stored in UTC and shown on the reader's own clock", () => {
  /* The row's history is dated in local time (today()), the stamp beside it is
     a UTC instant — for the two hours a night when the calendars disagree, the
     Modified column used to name the day before the History column did, for
     one and the same click. What is shown is the instant moved to the local
     clock; what is stored does not move. */
  const utc = "2026-08-11T22:40:03Z";
  const local = new Date(utc);
  const want = A.fmtDate(local) + " " + String(local.getHours()).padStart(2, "0") + ":" + String(local.getMinutes()).padStart(2, "0");
  assert.strictEqual(A.stampText(utc + " ak7 Anna Kovacs"), want + " · Anna Kovacs");
  assert.strictEqual(A.stampText(utc), want, "no name, no dot");
  assert.strictEqual(A.parseStamp(utc + " ak7 Anna").when, utc, "reading it back gives the stored UTC, untouched");
  // the shown day is the day the same instant's history entry would carry
  assert.strictEqual(want.slice(0, 10), A.fmtDate(local), "one calendar for the stamp and the history");
  // a hand-edited cell with a bare date has no clock to move
  assert.strictEqual(A.stampText("2026-08-11 ak7 Anna"), "2026-08-11 · Anna");
  // an unparseable time part is shown as it stands rather than as Invalid Date
  assert.strictEqual(A.stampText("2026-08-11Tnoon ak7 Anna"), "2026-08-11 noon · Anna");
});

t("an edit stamps the row that changed, and only that row", () => {
  A.setMyName("Anna Kovacs");
  loadRowsNow([{ id: 1, name: "One", start: "2026-03-02", end: "2026-03-04" },
               { id: 2, name: "Two", start: "2026-03-05", end: "2026-03-06" }]);
  A.state.tasks.forEach(t => { t.created = "2026-01-01T00:00:00Z ak7 Old Hand"; t.modified = t.created; });
  A.resetHistory();

  A.setMyName("Bela Nagy");
  row("One").name = "One, edited";
  A.markDirty();

  assert.strictEqual(A.parseStamp(row("One, edited").modified).name, "Bela Nagy", "the editor is named");
  assert.strictEqual(row("Two").modified, "2026-01-01T00:00:00Z ak7 Old Hand",
    "a row nobody touched keeps the stamp it had");
  assert.strictEqual(A.parseStamp(row("One, edited").created).name, "Old Hand",
    "and created is never rewritten");
});

/* The ids follow the dates, so half the plan is renumbered by an insert. If that
   counted as an edit, one new task would put your name on two hundred rows. */
t("a renumber is not an edit", () => {
  A.setMyName("Anna Kovacs");
  loadRowsNow([{ id: 1, name: "Later", start: "2026-03-20", end: "2026-03-24" },
               { id: 2, name: "Middle", start: "2026-03-10", end: "2026-03-12", preds: [1] }]);
  A.state.tasks.forEach(t => { t.modified = "2026-01-01T00:00:00Z ak7 Old Hand"; });
  A.resetHistory();
  const wasId = row("Later").id;

  A.state.tasks.push(A.seedTask({ id: 99, name: "Earliest", start: "2026-01-05", end: "2026-01-06",
    preds: [], group: [], team: "", system: "", accountable: "", responsible: "", notes: "", status: "Planned" }));
  A.renumberByTime();
  A.markDirty();

  assert.notStrictEqual(row("Later").id, wasId, "the numbers really did move");
  assert.strictEqual(row("Later").modified, "2026-01-01T00:00:00Z ak7 Old Hand",
    "…and the row itself was not touched");
  assert.strictEqual(A.parseStamp(row("Earliest").modified).name, "Anna Kovacs", "the new row is the only one stamped");
});

t("who made each status change is kept beside the history, not inside it", () => {
  A.setMyName("Anna Kovacs");
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04" }]);
  A.setMyName("Bela Nagy");
  A.changeStatus([idOfName("Build")], "In Progress");
  const t1 = row("Build");
  assert.strictEqual(t1.history[t1.history.length - 1].by, "Bela Nagy");

  const out = A.serializeCSV(A.state.tasks);
  const cells = out.split(/\r?\n/)[1].split("|");
  assert(/^2026-\d\d-\d\d Planned;/.test(cells[A.HEADER.indexOf("History")]),
    "History is untouched, so an older build still reads every entry");
  assert(/Bela Nagy$/.test(cells[A.HEADER.indexOf("History By")]), "the names are in a column of their own");

  loadTasks(out);
  assert.strictEqual(row("Build").history[1].by, "Bela Nagy", "and they come back");

  // out of step with the history — a hand-edited file — drops the names, not the history
  const bent = out.replace(/\|2026-\d\d-\d\d Anna Kovacs;/, "|1999-01-01 Somebody;");
  const back = A.parseCSV(bent);
  assert.strictEqual(back[0].history.length, 2, "what happened is kept");
  assert(!back[0].history.some(e => e.by), "who did it is dropped rather than guessed");
});

t("a plan written before the stamps existed says what it honestly can", () => {
  // an older file: history, but no Created / Modified / History By columns
  loadRows([{ id: 1, name: "Old", start: "2026-03-02", end: "2026-03-04" }]);
  const t1 = row("Old");
  /* **Compared as the same calendar, which it was not.** `created` is a UTC
     stamp and the seeded history entry is dated in *local* time, so a string
     prefix match on the two was only true while the two calendars agreed — and
     failed for the couple of hours a day when they do not. Nothing to do with
     what this test is about; it is the same "a fixture that reads the clock is a
     time bomb" as the drift fixtures. The app stores the stamp in UTC and the
     history as a local calendar day on purpose — the one place they met the eye
     side by side, stampText(), now puts the stamp on the local clock too. */
  assert.strictEqual(A.fmtDate(new Date(t1.created)), t1.history[0].date,
    "created is the day the plan first knew about it: " + t1.created);
  assert.strictEqual(A.parseStamp(t1.created).name, "", "and it names nobody, because nobody knows");
  assert.strictEqual(t1.modified, "", "nothing is invented about when it was last touched");
});

console.log("\n-- a plan file is called .gantora now --");

t("new files take the new extension, old ones still open", () => {
  assert.strictEqual(A.PLAN_EXT, ".gantora");
  eqArr(A.PLAN_EXTS, [".gantora", ".csv"]);
  assert(A.isPlanFile("plan.gantora") && A.isPlanFile("PLAN.CSV"), "both are plan files");
  assert(!A.isPlanFile("plan.xml") && !A.isPlanFile("plan"), "and nothing else is");
  assert.strictEqual(A.dropPlanExt("switchover.gantora"), "switchover");
  assert.strictEqual(A.dropPlanExt("switchover.csv"), "switchover");
  assert.strictEqual(A.dropPlanExt("switchover"), "switchover");
});


console.log("\n-- the people columns in the file --");

t("they sit after System, and the older layout still loads", () => {
  /* Format v2 (2026-08-23, Daniel's itemised decision): the first column is
     called what it is — "Seq", a time-ordered ordinal, displayed as "#" (a
     stored "#" would collide with the #-line family) — and membership is the
     CHILD's "Parent" cell (the parent's UID), not a Group list. The positions
     are unchanged; the v1 names still READ through csvIndex's aliases, which
     is the promise old files live on now. */
  eqArr(A.HEADER.slice(0, 15),
                  ["Seq","Activity Name","Duration","Start","Hour (Start)","End","Hour (End)",
                   "Predecessors","Team","System","Accountable","Responsible","Parent","Milestone","Notes"]);
  /* Everything since has been **appended** rather than inserted, so an older
     reader loses cells off the end instead of misreading the row. Asserted as a
     run in order rather than as the whole list, so the next appended column is
     one line here and not a rewrite. */
  eqArr(A.HEADER.slice(15), ["Status","History","Event","Emoji","Not Finalized",
                             "Baseline End","Date History","UID","Created","Modified","History By",
                             "Reported","Labels"]);

  // the pre-people header: Group, Milestone and Notes sat two places to the left
  const oldRows = [{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04", team: "Dev",
                     system: "CRM", group: [2, 3], notes: "a note" },
                   { id: 2, name: "A", start: "2026-03-02", end: "2026-03-02", team: "Dev", system: "CRM" },
                   { id: 3, name: "B", start: "2026-03-03", end: "2026-03-04", team: "Dev", system: "CRM" }];
  const old = CSV_HDR + "\n" + oldRows.map(csvRow).join("\n") + "\n";
  assert.strictEqual(old.split("\n")[1].split("|").length, 13, "13 columns, as that layout had");
  const tasks = A.parseCSV(old);
  const build = tasks.find(x => x.name === "Build");
  eqArr(build.group, [2, 3], "Group is read from where that file's header says it is");
  assert.strictEqual(build.notes, "a note", "and so is Notes");
  assert.strictEqual(build.accountable + build.responsible, "", "the columns it has no room for read as empty");
});

t("a file with no header row at all is read as the older layout", () => {
  const bare = [{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04", group: [2, 3], notes: "a note" },
                { id: 2, name: "A", start: "2026-03-02", end: "2026-03-02" },
                { id: 3, name: "B", start: "2026-03-03", end: "2026-03-04" }].map(csvRow).join("\n") + "\n";
  const build = A.parseCSV(bare).find(x => x.name === "Build");
  eqArr(build.group, [2, 3]);
  assert.strictEqual(build.notes, "a note");
});

t("a header column the app does not know is skipped, not counted", () => {
  const idx = A.csvIndex(["ID", "Activity Name", "Colour", "Accountable"]);
  assert.strictEqual(idx.name, 1);
  assert.strictEqual(idx.accountable, 3, "counted by position, not by how many we recognised");
  assert.strictEqual(idx.notes, undefined, "a field the file has no column for stays absent");
});

/* A0 measurement for the roadmap-dogfood format extension (2026-08-19): before
   adding a read-only flag or writer stamp as a new column, confirm what today's
   *shipped* parser actually does with a column it has never heard of — not just
   at header-indexing level (above), but end to end, read then write. It opens
   without error and the known fields are untouched, but the unknown cell's data
   never enters the task object, so a save with today's app SILENTLY DROPS it.
   This is the compatibility invariant the brief asks for: the old app opens the
   extended file cleanly, it just doesn't carry the new column forward — which
   also means a v2-flagged file loses its flag the moment an old build resaves
   it, a fact the flag's own "accident-prevention, not protection" framing must
   hold up against. */
/* CONTRACT CHANGED with format v2 (2026-08-23, extensibility rule 2 — decided
   by Daniel): what this test used to document — an unknown column silently
   dropped on save — was the measured v1 behaviour AND its known cost: an older
   build truncating a newer file. Rule 2 inverts it on purpose: the unknown
   column is carried through open AND save, raw, under its own header name, so
   no Gantora can ever eat a newer Gantora's data. */
t("A0 inverted by v2 rule 2: an unknown column survives open AND save", () => {
  const header = A.HEADER.slice(); header.push("Owner Email");
  const line = csvRowNow({ id: 1, name: "Solo task", start: "2026-08-20", end: "2026-08-24" }) + "|daniel@example.com";
  const text = [header.join("|"), line].join("\r\n") + "\r\n";

  const tasks = A.parseCSV(text);
  assert.strictEqual(tasks.length, 1, "an unknown trailing column does not break parsing");
  assert.strictEqual(tasks[0].name, "Solo task", "known fields still read correctly");
  assert.strictEqual(tasks[0].extra["Owner Email"], "daniel@example.com", "the cargo rides under its header name, not in any task field");

  const out = A.serializeCSV(tasks);
  const outLines = out.trim().split("\r\n");
  assert.ok(outLines[0].endsWith("|Owner Email"), "the unknown header comes back, appended after ours");
  assert.ok(outLines[1].endsWith("|daniel@example.com"), "…with its data on the row, exactly as it arrived");
  // and the round trip is stable: our own output re-reads to the same cargo
  assert.strictEqual(A.parseCSV(out)[0].extra["Owner Email"], "daniel@example.com");
});

/* A0, second question: a row inserted BEFORE the header (rather than as a new
   column) — the brief predicted this breaks. It does, but quietly: header
   detection only ever looks at line 0, so the real header line is never
   recognised, parsing falls back to LEGACY_INDEX, and the header line itself
   is skipped as a malformed row (its first cell is not numeric). Confirms the
   encoding must be a header column — a leading meta row is not a viable path
   for the read-only flag or writer stamp. */
t("A0: a row before the header is not a viable path for a new field", () => {
  // id/name/duration/start/hourStart/end/hourEnd/preds/team/system sit at the
  // same position in both layouts — group is where the two column sets first
  // diverge (accountable/responsible were inserted right before it), so this
  // is the smallest fixture that actually exposes the misalignment.
  const line = csvRowNow({ id: 1, name: "Solo task", start: "2026-08-20", end: "2026-08-24", parent: "uid-of-parent" });
  const text = ["META|v2|readonly:no", CSV_HDR_NOW, line].join("\r\n") + "\r\n";
  const tasks = A.parseCSV(text);
  assert.strictEqual(tasks.length, 1, "a task row still surfaces (via LEGACY_INDEX fallback)");
  assert.strictEqual(tasks[0].name, "Solo task", "the columns before the divergence still read fine — which is what makes this dangerous, not obviously broken");
  eqArr(tasks[0].group, [], "the membership is lost — LEGACY_INDEX has no 'accountable' column to shift the read through");
  assert.strictEqual(tasks[0].notes, "uid-of-parent", "…and the Parent cell resurfaces in Notes instead: LEGACY_INDEX's notes slot lands on the modern row's Parent column");
});

/* ---- Format v2 (2026-08-23, Daniel's itemised decision) --------------------
   The four facts of the round: the ordinal is called Seq (shown as #), the
   CHILD carries its membership as the parent's UID, every save stamps
   #META|format|2, and reading v1 is forever — the upgrade is one open and
   one save, reported, never silent. */
console.log("\n-- format v2: Seq, the Parent direction, and what a v1 file becomes --");

t("a v1 file reads whole and one save writes it as v2 — the promised upgrade", () => {
  const v1Header = A.HEADER.map(h => h === "Seq" ? "ID" : h === "Parent" ? "Group" : h);
  const mk = (id, name, uid, group) => {
    const c = {}; A.CSV_COLS.forEach(col => c[col.key] = "");
    c.id = id; c.name = name; c.start = "2026-03-0" + id; c.end = "2026-03-0" + (+id + 1);
    c.milestone = "no"; c.status = "Planned"; c.history = "2026-03-01 Planned";
    c.event = "no"; c.notFinal = "no"; c.reported = "no"; c.uid = uid;
    c.parent = group;                                // lands in the v1 Group slot below
    return A.CSV_COLS.map(col => c[col.key]).join("|");
  };
  const v1 = v1Header.join("|") + "\r\n" + mk("1", "Phase", "uid-phase", "2;3") + "\r\n" +
    mk("2", "Dig", "uid-dig", "") + "\r\n" + mk("3", "Pour", "uid-pour", "") + "\r\n" +
    "#META|writer|somebody-old\r\n";
  assert.strictEqual(A.planFormatOf(v1), "", "no stamp is what v1 means");
  const tasks = A.parseCSV(v1);
  eqArr(tasks.find(t => t.name === "Phase").group, [2, 3], "the v1 Group list read as it always was");

  const out = A.serializeCSV(tasks);
  assert(out.startsWith("Seq|"), "the save speaks v2");
  assert(out.includes("#META|format|2\r\n"), "…and stamps it");
  const cells = out.split("\r\n").map(l => l.split("|"));
  assert.strictEqual(cells[1][12], "", "the parent row's old list slot is empty now");
  assert.strictEqual(cells[2][12], "uid-phase", "each child names its parent by UID");
  assert.strictEqual(cells[3][12], "uid-phase");
  eqArr(A.parseCSV(out).find(t => t.name === "Phase").group, [2, 3],
    "and the round trip is stable: v2 reads back to the same membership");
});

t("both spellings in one file read to one plan — a v1 row pasted into a v2 plan unions", () => {
  const rows = [
    { id: 1, name: "Phase", start: "2026-03-02", end: "2026-03-05", group: [2] },
    { id: 2, name: "Dig", start: "2026-03-02", end: "2026-03-03" },
  ];
  loadRowsNow(rows);
  eqArr(A.taskById(1).group, [2], "the v2 Parent cell made the membership");
});

t("an unknown #META key survives the round trip — rule 2 for the head", () => {
  const text = CSV_HDR_NOW + "\r\n" + csvRowNow({ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03" }) + "\r\n" +
    "#META|color|blue\r\n#META|writer|somebody\r\n";
  eqArr(A.parseMetaExtra(text), ["#META|color|blue"], "the known keys are ours; color is cargo");
  const was = A.state.metaExtra;
  try {
    A.state.metaExtra = A.parseMetaExtra(text);
    const out = A.serializeCSV(A.parseCSV(text));
    assert(out.includes("#META|color|blue\r\n"), "the cargo comes home with the save");
    assert(out.includes("#META|format|2\r\n") && out.includes("#META|writer|" + A.WRITER_ID), "beside our own lines, not instead of them");
  } finally { A.state.metaExtra = was; }
});

console.log("\n-- #META: the read-only flag and the writer stamp (roadmap-dogfood A4/2) --");

t("a plain file carries no #META lines beyond the writer stamp", () => {
  loadTasks(CSV_HDR_NOW + "\r\n" + csvRowNow({ id: 1, name: "T", start: "2026-03-02", end: "2026-03-02" }) + "\r\n");
  const out = A.serializeCSV(A.state.tasks);
  assert.ok(!out.includes("#META|readOnly"), "nothing written for the default, same as #VIEW");
  assert.ok(out.includes("#META|writer|" + A.WRITER_ID), "the writer line is written unconditionally, every save");
  assert.ok(out.includes("#META|spec|" + A.SPEC_URL), "and the spec line beside it — the file carries its own manual");
});

t("a read-only-flagged plan writes the flag back, and only while it's set", () => {
  loadTasks(CSV_HDR_NOW + "\r\n" + csvRowNow({ id: 1, name: "T", start: "2026-03-02", end: "2026-03-02" }) + "\r\n");
  A.state.planReadOnly = true;
  assert.ok(A.serializeCSV(A.state.tasks).includes("#META|readOnly|yes"));
  A.state.planReadOnly = false;
  assert.ok(!A.serializeCSV(A.state.tasks).includes("#META|readOnly"));
});

t("planReadOnlyOf() reads the flag back, in the same y/i/1/t shapes every other flag accepts", () => {
  assert.strictEqual(A.planReadOnlyOf("#META|readOnly|yes"), true);
  assert.strictEqual(A.planReadOnlyOf("#META|readOnly|igen"), true, "the Hungarian yes, same as milestone/event/notFinal");
  assert.strictEqual(A.planReadOnlyOf("#META|readOnly|no"), false);
  assert.strictEqual(A.planReadOnlyOf(""), false, "a file that predates the flag reads as not read-only");
  assert.strictEqual(A.planReadOnlyOf("#META|writer|gantora-cli"), false, "a writer line with no readOnly line at all");
});

t("planWriterOf() reads back who wrote the file last", () => {
  assert.strictEqual(A.planWriterOf("#META|writer|gantora-cli"), "gantora-cli");
  assert.strictEqual(A.planWriterOf(""), "", "a file older than the stamp says nothing, not a false claim");
});

t("open -> save round-trips the flag through this app, unbroken", () => {
  const flagged = CSV_HDR_NOW + "\r\n" +
    csvRowNow({ id: 1, name: "Roadmap", start: "2026-03-02", end: "2026-03-04" }) + "\r\n" +
    "#META|readOnly|yes\r\n#META|writer|gantora-cli\r\n";
  loadTasks(flagged);
  assert.strictEqual(A.state.planReadOnly, true, "adoptPlan()'s own step (mirrored by loadTasks) reads the flag on open");
  const resaved = A.serializeCSV(A.state.tasks);
  assert.ok(resaved.includes("#META|readOnly|yes"), "still flagged after this app resaves it");
  assert.ok(resaved.includes("#META|writer|" + A.WRITER_ID), "and now stamped as this app's own write, not the CLI's");
});

t("a #META line is silently skipped by parseCSV(), same trick as #VIEW and #DEL", () => {
  const text = CSV_HDR_NOW + "\r\n" +
    csvRowNow({ id: 1, name: "T", start: "2026-03-02", end: "2026-03-02" }) + "\r\n" +
    "#META|readOnly|yes\r\n#META|writer|gantora-cli\r\n";
  const tasks = A.parseCSV(text);
  assert.strictEqual(tasks.length, 1, "the #META lines are not mistaken for task rows");
});

t("both fields round-trip through the file", () => {
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04",
                 acc: "Anna Kovacs:2", resp: "Bela Nagy:6;Csaba Toth" }]);
  const csv = A.serializeCSV(A.state.tasks);
  eqArr(csv.split("\r\n")[0].split("|"), A.HEADER);
  const back = A.parseCSV(csv)[0];
  assert.strictEqual(back.accountable, "Anna Kovacs:2");
  assert.strictEqual(back.responsible, "Bela Nagy:6;Csaba Toth");
});

t("the delimiter cannot survive inside a name — but the name does", () => {
  loadRowsNow([{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-02" }]);
  A.taskById(1).responsible = "An|na:4";
  const line = A.serializeCSV(A.state.tasks).split("\r\n")[1];
  assert.strictEqual(line.split("|").length, A.HEADER.length, "still one cell per column");
  // was "An/na:4" until 2026-08-18 (D4): the pipe went out escaped and comes back
  assert.strictEqual(A.parseCSV(A.serializeCSV(A.state.tasks))[0].responsible, "An|na:4");
});

t("the columns sit after System in the list and in the export", () => {
  const keys = A.COLUMNS.map(c => c.key);
  assert.strictEqual(keys[keys.indexOf("system") + 1], "accountable");
  assert.strictEqual(keys[keys.indexOf("system") + 2], "responsible");
  const heads = A.XL_COLS.map(c => c.head);
  assert.strictEqual(heads[heads.indexOf("System") + 1], "Accountable");
  assert.strictEqual(heads[heads.indexOf("System") + 2], "Responsible");
});

t("the list shows the names, escaped, with the workload as a pill", () => {
  loadRowsNow([{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-04", resp: "A<b>x:4;Plain" }]);
  useRef({}); A.state.view = "table"; A.renderTable();
  const html2 = els.tableView.innerHTML;
  assert(html2.includes("A&lt;b&gt;x"), "the name is escaped");
  assert(!/A<b>x/.test(html2), "and never rendered as markup");
  assert(/<span class="wh">4h<\/span>/.test(html2), "the explicit workload is shown");
  assert(html2.includes(">Plain<"), "a name without one is just the name");
  useRef(null);
});

t("both editors offer the fields, even with the columns folded away", () => {
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04", acc: "Anna:2", resp: "Bela:6" }]);
  const t1 = A.taskById(1);
  const open = A.inlineRowHtml(t1);
  assert(/id="iAcc"[^>]*value="Anna:2"/.test(open), "the inline row carries Accountable");
  assert(/id="iResp"[^>]*value="Bela:6"/.test(open), "and Responsible");

  /* Folded columns keep their inputs parked out of sight — readInline() reads
     every field by id, so a row saved with Responsible hidden must not lose it. */
  A.state.hiddenCols = new Set(["accountable", "responsible"]);
  const folded = A.inlineRowHtml(t1);
  assert(/id="iAcc"/.test(folded) && /id="iResp"/.test(folded), "still there, inside the gap cell");
  assert(/colGap/.test(folded));
  A.state.hiddenCols = new Set();

  // and the dialog has them too, next to Team and System
  assert(/id="fAcc"/.test(html) && /id="fResp"/.test(html), "the task dialog carries both");
  assert(html.indexOf('id="fAcc"') > html.indexOf('id="fSystem"'), "after System, as in the file");
});

t("the search reaches the people", () => {
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04", resp: "Zsofia Kiss:4" },
               { id: 2, name: "Test", start: "2026-03-05", end: "2026-03-06", team: "QA" }]);
  A.state.search = "zsofia";      // accents and case ignored, as everywhere else
  assert(A.taskHit(row("Build")), "found by the person on it");
  assert(!A.taskHit(row("Test")), "and only that task");
  A.state.search = "";
});

t("duplicating a task copies who is on it", () => {
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04", acc: "Anna:2", resp: "Bela:6" }]);
  A.duplicateTask(idOfName("Build"));
  const copy = row("Build - duplicate");
  assert.strictEqual(copy.accountable, "Anna:2");
  assert.strictEqual(copy.responsible, "Bela:6");
});

console.log("\n-- the Person filter --");

t("every name anywhere in the plan is offered, once", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", acc: "Anna", resp: "Bela:4" },
               { id: 2, name: "B", start: "2026-03-04", end: "2026-03-05", resp: "anna:2;Csaba" }]);
  eqArr(A.knownPeople(), ["Anna", "Bela", "Csaba"], "sorted, and one entry per person");
  eqArr(A.knownValues("person"), A.knownPeople(), "the filter reads the same list");
});

t("picking a person keeps every task they are on, either way round", () => {
  loadRowsNow([{ id: 1, name: "Acc", start: "2026-03-02", end: "2026-03-03", acc: "Anna" },
               { id: 2, name: "Resp", start: "2026-03-04", end: "2026-03-05", resp: "Bela:4;Anna:2" },
               { id: 3, name: "Neither", start: "2026-03-06", end: "2026-03-07", resp: "Csaba" }]);
  A.state.filterPerson = "Anna";
  eqArr(A.displayTasks().map(r => r.t.name).sort(), ["Acc", "Resp"]);
  A.state.filterPerson = "anna";
  assert.strictEqual(A.displayTasks().length, 2, "the name is matched case-insensitively");
  A.state.filterPerson = "";
});

t("a group stays on screen when the filter keeps anything inside it", () => {
  loadRowsNow([{ id: 1, name: "Phase", start: "2026-03-02", end: "2026-03-05", group: [2, 3] },
               { id: 2, name: "Inner", start: "2026-03-02", end: "2026-03-03", resp: "Anna" },
               { id: 3, name: "Other", start: "2026-03-04", end: "2026-03-05", resp: "Bela" }]);
  A.state.filterPerson = "Anna";
  eqArr(A.displayTasks().map(r => r.t.name), ["Phase", "Inner"]);
  A.state.filterPerson = "";
});

t("milestones are project-wide, so no person filter hides one", () => {
  loadRowsNow([{ id: 1, name: "Work", start: "2026-03-02", end: "2026-03-03", resp: "Bela" },
               { id: 2, name: "Go live", start: "2026-03-10", end: "2026-03-10", ms: true }]);
  A.state.filterPerson = "Anna";
  eqArr(A.displayTasks().map(r => r.t.name), ["Go live"], "nobody is Anna, the milestone stays");
  A.state.filterPerson = "";
});

t("a filter left pointing at a name the plan no longer has falls away", () => {
  loadRowsNow([{ id: 1, name: "Work", start: "2026-03-02", end: "2026-03-03", resp: "Bela" }]);
  A.state.filterPerson = "Anna";
  A.refreshFilterSelects();
  assert.strictEqual(A.state.filterPerson, "", "and the whole plan comes back");
  assert(els.filterPerson.innerHTML.includes("Person: all"));
  assert(els.filterPerson.innerHTML.includes("Person: Bela"));
});

console.log("\n-- resource view: hours a day, per person --");

const resRow = (m, name) => m.rows.find(r => A.samePerson(r.name, name));
const hoursOn = (m, name, date) => {
  const r = resRow(m, name);
  const i = m.keys.indexOf(date);
  return i < 0 || !r ? null : r.hours[i];
};

t("the columns are the working days, weekends left out", () => {
  // 2026-03-02 is a Monday; 07/08 are the Saturday and Sunday after it
  loadRowsNow([{ id: 1, name: "Work", start: "2026-03-02", end: "2026-03-11", resp: "Anna" }]);
  const m = A.resourceModel();
  assert(m.keys.includes("2026-03-06"), "Friday is there");
  assert(!m.keys.includes("2026-03-07"), "Saturday is not");
  assert(!m.keys.includes("2026-03-08"), "nor Sunday");
  assert(m.keys.includes("2026-03-09"), "and Monday follows Friday");
});

/* 🔴 Where Cancelled belongs, stated once and asserted: DONE work happened,
   CANCELLED work never will. So a calendar of hours keeps the finished work and
   drops the cancelled; anything asking what is still ahead drops both. */
t("cancelled work loads nobody, while finished work still fills the days it took", () => {
  // 2026-03-02 is a Monday
  loadRowsNow([
    { id: 1, name: "Wiring",  start: "2026-03-02", end: "2026-03-03", resp: "Anna:4" },
    { id: 2, name: "Dropped", start: "2026-03-02", end: "2026-03-03", resp: "Anna:4" },
  ]);
  A.setTaskStatus(row("Wiring"), "Done");
  const before = hoursOn(A.resourceModel(), "Anna", "2026-03-02");
  assert.strictEqual(before, 8, "both tasks are on Anna's board");

  A.setTaskStatus(row("Dropped"), "Cancelled");
  const after = hoursOn(A.resourceModel(), "Anna", "2026-03-02");
  /* 🔴 Cancelled hours leave the board — nobody is ever going to spend them.
     The finished ones stay: this board is a calendar of hours, and the hours
     somebody spent were spent. */
  assert.strictEqual(after, 4, "cancelled hours go, finished hours stay");
});

t("except a weekend a task starts or ends on", () => {
  /* Both people carry explicit hours: a bare name costs nothing now, so with
     one this could not tell "not charged on that day" from "charged nothing". */
  loadRowsNow([{ id: 1, name: "Long", start: "2026-03-02", end: "2026-03-11", resp: "Anna:4" },
               { id: 2, name: "Weekend fix", start: "2026-03-07", end: "2026-03-07", resp: "Bela:6" }]);
  const m = A.resourceModel();
  assert(m.keys.includes("2026-03-07"), "Saturday earned its column");
  assert(!m.keys.includes("2026-03-08"), "Sunday still did not");
  assert.strictEqual(hoursOn(m, "Bela", "2026-03-07"), 6, "the task that starts there is on it");
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-07"), 0,
    "the task that merely spans the weekend costs nothing on it");
});

t("a task's own end day counts even when it is a Sunday", () => {
  loadRowsNow([{ id: 1, name: "Cutover", start: "2026-03-06", end: "2026-03-08", resp: "Anna:5" }]);
  const m = A.resourceModel();
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-06"), 5, "Friday, a working day");
  assert(!m.keys.includes("2026-03-07"), "the Saturday in the middle earns no column at all");
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-08"), 5, "Sunday is where it ends, so it is on screen");
});

t("hours cumulate across every task a person is on", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-04", resp: "Anna:3" },
               { id: 2, name: "B", start: "2026-03-03", end: "2026-03-05", acc: "Anna:2" },
               { id: 3, name: "C", start: "2026-03-03", end: "2026-03-03", resp: "Anna" }]);
  const m = A.resourceModel();
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-02"), 3, "one task");
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-03"), 3 + 2 + A.DEFAULT_WORKLOAD_H,
    "all three — and the bare name adds whatever a name with no hours costs");
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-05"), 2, "only the one still running");
  assert.strictEqual(resRow(m, "Anna").tasks.length, 3);
});

t("the accountable person is charged too, and never twice", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-02", acc: "Anna:4", resp: "Anna:4;Bela:2" }]);
  const m = A.resourceModel();
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-02"), 4, "named in both fields, charged once");
  assert.strictEqual(hoursOn(m, "Bela", "2026-03-02"), 2);
});

t("groups and milestones add nothing — a group's hours are its members'", () => {
  loadRowsNow([{ id: 1, name: "Phase", group: [2], acc: "Anna:8" },
               { id: 2, name: "Inner", start: "2026-03-02", end: "2026-03-03", resp: "Anna:3" },
               { id: 3, name: "Go live", start: "2026-03-04", end: "2026-03-04", ms: true }]);
  const m = A.resourceModel();
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-02"), 3, "the container does not double the work");
  assert.strictEqual(resRow(m, "Anna").tasks.length, 1);
});

t("the five bands are the ones that were asked for", () => {
  const band = A.workBand;
  eqArr([1, 4].map(band), ["w1", "w1"], "1–4 light green, 4 included");
  eqArr([4.5, 6].map(band), ["w2", "w2"], "above 4 up to 6 green");
  eqArr([6.5, 8].map(band), ["w3", "w3"], "above 6 up to 8 dark green");
  eqArr([8.5, 9].map(band), ["w4", "w4"], "above 8 up to 9 red");
  eqArr([9.5, 24].map(band), ["w5", "w5"], "above 9 dark red");
  eqArr(A.RES_BANDS.map(b => b.cls), ["w1", "w2", "w3", "w4", "w5"]);
});

t("the drawn cell carries its band, its number and what is in it", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-02", resp: "Anna:5" },
               { id: 2, name: "B", start: "2026-03-02", end: "2026-03-02", resp: "Anna:5" }]);
  useRef({}); A.state.view = "res"; A.state.pxPerResDay = 40;
  A.renderResources();
  const body = els.resBody.innerHTML;
  assert(/class="rCell w5"/.test(body), "ten hours is the dark red band");
  assert(/>10</.test(body), "and the number is on the cell");
  assert(/#1 A — 5 h/.test(body) && /#2 B — 5 h/.test(body), "the tooltip names both tasks");
  assert(els.resLeftBody.innerHTML.includes("Anna"), "the row is the person");
  /* No colour legend here any more. The cell carries the hours as a number and
     the colour only grades them, so the legend was explaining what the figure
     already said — while eating a column that has to hold a name on a phone.
     What a band means is on the cell's own tooltip, which is asserted above. */
  assert(!/id="resLegend"/.test(html), "the legend is gone from the resource view");
  assert(!/resLegendHtml/.test(html), "and so is the code that built it");
  useRef(null);
});

t("a day with no work draws no cell at all", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-02", resp: "Anna:5" },
               { id: 2, name: "B", start: "2026-03-05", end: "2026-03-05", resp: "Anna:5" }]);
  const m = A.resourceModel();
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-03"), 0);
  useRef({}); A.state.view = "res"; A.renderResources();
  assert.strictEqual((els.resBody.innerHTML.match(/class="rCell /g) || []).length, 2, "two cells, not five");
  useRef(null);
});

t("weekend columns are marked as such in the header and the body", () => {
  loadRowsNow([{ id: 1, name: "Weekend fix", start: "2026-03-07", end: "2026-03-09", resp: "Anna" }]);
  useRef({}); A.state.view = "res"; A.renderResources();
  assert(/class="rDay we"/.test(els.resHeadLower.innerHTML), "the column says it is a weekend");
  assert(/class="resWe"/.test(els.resBody.innerHTML), "and the body shades it");
  useRef(null);
});

t("Team and System narrow the work counted; Person narrows the rows", () => {
  loadRowsNow([{ id: 1, name: "Dev work", start: "2026-03-02", end: "2026-03-02", team: "Dev", resp: "Anna:4;Bela:4" },
               { id: 2, name: "QA work",  start: "2026-03-02", end: "2026-03-02", team: "QA",  resp: "Anna:3" }]);
  let m = A.resourceModel();
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-02"), 7, "unfiltered, both count");

  A.state.filterTeam = "Dev";
  m = A.resourceModel();
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-02"), 4, "only the Dev task is counted");
  assert(resRow(m, "Bela"), "everyone on the remaining work is still a row");

  /* Picking a person turns the board into *their work*: the rows stop being
     people and become the tasks that person is on, still narrowed by Team. */
  A.state.filterPerson = "Anna";
  m = A.resourceModel();
  eqArr(m.rows.map(r => r.name), ["#1 Dev work"], "one person: the rows are their tasks");
  assert.strictEqual(m.person, "Anna", "and the model says whose board it is");
  assert.strictEqual(m.rows[0].hours.reduce((s, h) => s + h, 0), 4,
    "the cells hold what that one task costs them, not their whole day");

  A.state.filterTeam = "";
  m = A.resourceModel();
  eqArr(m.rows.map(r => r.name), ["#1 Dev work", "#2 QA work"],
    "with the Team filter off, both of Anna's tasks are rows");
  A.state.filterPerson = "";
  m = A.resourceModel();
  eqArr(m.rows.map(r => r.name), ["Anna", "Bela"], "and letting the person go puts the people back");
});

t("a plan that names nobody says so instead of drawing an empty grid", () => {
  loadRowsNow([{ id: 1, name: "Work", start: "2026-03-02", end: "2026-03-03", team: "Dev" }]);
  const m = A.resourceModel();
  assert.strictEqual(m.rows.length, 0);
  useRef({}); A.state.view = "res"; A.renderResources();
  assert(/hvEmpty/.test(els.resBody.innerHTML), "the empty state, not a crash");
  assert(/Accountable/.test(els.resBody.innerHTML), "and it says what to do about it");
  useRef(null);
});

t("a plan with no dates at all is still safe to open", () => {
  A.state.tasks = []; useRef({}); A.state.view = "res";
  eqArr(A.resourceDays(), []);
  A.renderResources();
  assert(/hvEmpty/.test(els.resBody.innerHTML));
  useRef(null);
});

/* The sample plan carries people now, so the view has a realistic board to draw
   rather than a three-row fixture: twelve names across nine months, and every
   band on the scale somewhere in it. */
t("the sample plan draws a board of twelve people over the whole project", () => {
  loadSample();
  const m = A.resourceModel();
  assert.strictEqual(m.rows.length, 12, "one row per person named in the plan");
  eqArr(A.knownPeople(), m.rows.map(r => r.name), "and the Person filter offers the same list");
  assert(m.days.length > 150, "the columns span the project, got " + m.days.length);

  const bands = new Set();
  for (const r of m.rows) for (const h of r.hours) if (h) bands.add(A.workBand(h));
  eqArr([...bands].sort(), ["w1", "w2", "w3", "w4", "w5"], "every band is exercised");

  const busiest = m.rows.reduce((a, b) => (a.busiest > b.busiest ? a : b));
  assert(busiest.busiest > 9, busiest.name + " is over-allocated, which is what the view is for");

  assert(m.rows.every(r => r.tasks.every(x => !A.isGroup(x) && !x.milestone)),
    "no container and no moment carries hours");

  /* The weekend columns on screen are exactly the weekend days a task starts or
     ends on — stated as the rule rather than as a list, so moving a date in the
     sample changes the answer without breaking the check. */
  const ends = new Set();
  for (const x of A.state.tasks) {
    const a = A.parseDate(x.start);
    if (!a) continue;
    const b = A.parseDate(x.end) || a;
    if (A.isWeekend(a)) ends.add(A.fmtDate(a));
    if (A.isWeekend(b)) ends.add(A.fmtDate(b));
  }
  eqArr(m.keys.filter(k => A.isWeekend(A.parseDate(k))).sort(), [...ends].sort());
});

t("the view is wired into the switcher, the slider and the context bar", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-02", resp: "Anna:5" }]);
  useRef({});
  A.setView("res");
  assert.strictEqual(els.resView.style.display, "block", "the pane is on screen");
  assert.strictEqual(els.ganttView.style.display, "none", "and the others are not");
  assert.strictEqual(els.tableView.style.display, "none");
  assert(els.btnViewRes.classList.contains("active"), "the button is lit");
  assert.strictEqual(els.zoomBox.style.display, "flex", "the zoom slider applies here");
  assert.strictEqual(els.listCtrls.style.display, "", "so do the filters — an empty display, which is the stylesheet's cue to lay them out");
  assert.strictEqual(els.groupLvlBox.style.display, "none", "the Lvl cut does not — rows are people");

  const u = A.zoomUnit();
  assert.strictEqual(u.get(), A.state.pxPerResDay);
  A.setZoom(A.RES_PX_MAX + 50);
  assert.strictEqual(A.state.pxPerResDay, A.RES_PX_MAX, "the slider is clamped to the view's own range");
  A.state.pxPerResDay = 40;
  A.state.view = "table"; useRef(null);
});

/* =====================================================================
   Design view — the timeline you plan in
   ===================================================================== */

console.log("\n-- design view: moving a task through time --");

/* A three-level plan with a spare top-level task to drop things onto.
   Phase (group)
     Build (group)
       Code, Review
   Later
   Solo  */
const designPlan = () => loadRowsNow([
  { id: 1, name: "Phase",  group: [2] },
  { id: 2, name: "Build",  group: [3, 4] },
  { id: 3, name: "Code",   start: "2026-03-02", end: "2026-03-06" },
  { id: 4, name: "Review", start: "2026-03-09", end: "2026-03-11" },
  { id: 5, name: "Later",  start: "2026-04-06", end: "2026-04-10" },
  { id: 6, name: "Solo",   start: "2026-05-04", end: "2026-05-08" },
]);
const at = n => spanOf(n);

t("a task and its whole subtree slide by the same number of days", () => {
  designPlan();
  eqArr(A.subtreeIds(idOfName("Build")).map(nameOfId).sort(), ["Build", "Code", "Review"]);
  A.shiftSubtree(idOfName("Build"), 7);
  assert.strictEqual(at("Code"), "2026-03-09..2026-03-13");
  assert.strictEqual(at("Review"), "2026-03-16..2026-03-18");
});

t("dragging a task keeps its length and reports the move", () => {
  designPlan();
  assert.strictEqual(A.designMoveInTime(idOfName("Solo"), 5), null, "accepted");
  assert.strictEqual(at("Solo"), "2026-05-09..2026-05-13", "five days later, same four-day length");
  assert(/moved 5 days later/.test(els.toast.textContent), els.toast.textContent);
});

t("dragging a group takes everything inside it", () => {
  designPlan();
  A.designMoveInTime(idOfName("Build"), -3);
  assert.strictEqual(at("Code"), "2026-02-27..2026-03-03");
  assert.strictEqual(at("Review"), "2026-03-06..2026-03-08");
  assert.strictEqual(at("Build"), "2026-02-27..2026-03-08", "the group follows its members");
  assert.strictEqual(at("Phase"), "2026-02-27..2026-03-08", "and so does the group above it");
  assert(/2 task\(s\) inside moved with it/.test(els.toast.textContent), els.toast.textContent);
});

t("dragging a milestone takes the work hanging off it, as editing its date does", () => {
  loadRowsNow([{ id: 1, name: "Gate", start: "2026-03-10", end: "2026-03-10", ms: true },
               { id: 2, name: "After", start: "2026-03-11", end: "2026-03-13", preds: [1] }]);
  A.designMoveInTime(idOfName("Gate"), 4);
  assert.strictEqual(at("Gate"), "2026-03-14..2026-03-14");
  assert.strictEqual(at("After"), "2026-03-15..2026-03-17", "the dependent moved with it");
});

t("a drag of nothing changes nothing", () => {
  designPlan();
  const before = at("Solo");
  assert.strictEqual(A.designMoveInTime(idOfName("Solo"), 0), null);
  assert.strictEqual(at("Solo"), before);
});

t("a move that lands in front of a predecessor is allowed, and warned about", () => {
  loadRowsNow([{ id: 1, name: "First", start: "2026-03-02", end: "2026-03-06" },
               { id: 2, name: "Second", start: "2026-03-09", end: "2026-03-13", preds: [1] }]);
  assert.strictEqual(A.designMoveInTime(idOfName("Second"), -5), null, "direct manipulation is not refused");
  assert.strictEqual(at("Second"), "2026-03-04..2026-03-08");
  assert(/starts before predecessor/.test(els.toast.textContent), els.toast.textContent);
});

console.log("\n-- design view: extending and shortening --");

t("the end grip moves the end only", () => {
  designPlan();
  assert.strictEqual(A.designResizeBy(idOfName("Solo"), "end", 4), null);
  assert.strictEqual(at("Solo"), "2026-05-04..2026-05-12");
  // durations are inclusive here, as everywhere else in the app
  assert(/9 days/.test(els.toast.textContent), els.toast.textContent);
});

t("the start grip moves the start only", () => {
  designPlan();
  A.designResizeBy(idOfName("Solo"), "start", -2);
  assert.strictEqual(at("Solo"), "2026-05-02..2026-05-08");
  A.designResizeBy(idOfName("Solo"), "start", 3);
  assert.strictEqual(at("Solo"), "2026-05-05..2026-05-08", "shortening from the front too");
});

t("resizing a member re-rolls the group's dates", () => {
  designPlan();
  A.designResizeBy(idOfName("Review"), "end", 6);
  assert.strictEqual(at("Review"), "2026-03-09..2026-03-17");
  assert.strictEqual(at("Build"), "2026-03-02..2026-03-17");
  assert.strictEqual(at("Phase"), "2026-03-02..2026-03-17");
});

t("a group has no end to pull — its dates are its members'", () => {
  designPlan();
  const before = at("Build");
  const err = A.designResizeBy(idOfName("Build"), "end", 5);
  assert(/is a group/.test(err), err);
  assert.strictEqual(at("Build"), before, "and nothing moved");
});

t("a milestone has no length to change", () => {
  loadRowsNow([{ id: 1, name: "Gate", start: "2026-03-10", end: "2026-03-10", ms: true }]);
  const err = A.designResizeBy(idOfName("Gate"), "end", 5);
  assert(/no length/.test(err), err);
  assert.strictEqual(at("Gate"), "2026-03-10..2026-03-10");
});

t("neither end can be dragged past the other", () => {
  designPlan();
  const before = at("Solo");                                  // 2026-05-04..2026-05-08
  assert(/cannot be dragged past the end/.test(A.designResizeBy(idOfName("Solo"), "start", 9)));
  assert(/cannot be dragged before the start/.test(A.designResizeBy(idOfName("Solo"), "end", -9)));
  assert.strictEqual(at("Solo"), before, "a refused resize leaves the dates alone");
  // ending on the day it starts is a one-day task, not a refusal
  assert.strictEqual(A.designResizeBy(idOfName("Solo"), "end", -4), null);
  assert.strictEqual(at("Solo"), "2026-05-04..2026-05-04");
});

console.log("\n-- design view: dropped on a task = put after it --");

/* What the *target* is decides what the drop means. On a plain task it is a
   reschedule and nothing else; on a group it is a membership change and nothing
   else. These two blocks are that pair. */

t("a task dropped on a task starts where the target ends, keeping its length", () => {
  designPlan();
  assert.strictEqual(A.designDropOnto(idOfName("Solo"), idOfName("Later")), null);
  assert.strictEqual(at("Solo"), "2026-04-10..2026-04-14", "starts on the target's end, keeps its 5 days");
  assert(/put after "Later"/.test(els.toast.textContent), els.toast.textContent);
});

t("and the target is left alone — it does not become a group", () => {
  designPlan();
  assert.strictEqual(at("Later"), "2026-04-06..2026-04-10");
  A.designDropOnto(idOfName("Solo"), idOfName("Later"));
  assert(!A.isGroup(row("Later")), "a plain task stays a plain task");
  assert.strictEqual(at("Later"), "2026-04-06..2026-04-10", "and keeps its own dates");
  assert(!A.groupOf(idOfName("Solo")), "the moved row joined nothing");
});

t("a group dropped on a task is the whole group dragged after it", () => {
  designPlan();
  A.designDropOnto(idOfName("Build"), idOfName("Later"));
  // Build ran 03-02..03-11 and now starts on Later's end, 04-10 — 39 days later
  assert.strictEqual(at("Code"), "2026-04-10..2026-04-14");
  assert.strictEqual(at("Review"), "2026-04-17..2026-04-19", "the gap between them survives");
  assert.strictEqual(at("Build"), "2026-04-10..2026-04-19");
  assert.strictEqual(A.groupOf(idOfName("Build")).name, "Phase", "it did not leave the group it was in");
  assert(/2 task\(s\) inside came along/.test(els.toast.textContent), els.toast.textContent);
});

t("membership is not touched either way", () => {
  designPlan();
  A.designDropOnto(idOfName("Code"), idOfName("Later"));
  assert.strictEqual(A.groupOf(idOfName("Code")).name, "Build", "it is still where it was");
  assert.strictEqual(at("Code"), "2026-04-10..2026-04-14", "only its dates moved");
  assert.strictEqual(at("Build"), "2026-03-09..2026-04-14", "and its group stretched to follow");
});

t("a milestone can be put after a task, and takes its dependents along", () => {
  loadRowsNow([{ id: 1, name: "Work", start: "2026-03-02", end: "2026-03-06" },
               { id: 2, name: "Gate", start: "2026-03-10", end: "2026-03-10", ms: true },
               { id: 3, name: "After", start: "2026-03-11", end: "2026-03-13", preds: [2] }]);
  assert.strictEqual(A.designDropOnto(idOfName("Gate"), idOfName("Work")), null);
  assert.strictEqual(at("Gate"), "2026-03-06..2026-03-06", "on the day Work ends");
  assert.strictEqual(at("After"), "2026-03-07..2026-03-09", "the work hanging off it moved too");
});

t("a task can be put after a milestone", () => {
  loadRowsNow([{ id: 1, name: "Gate", start: "2026-03-10", end: "2026-03-10", ms: true },
               { id: 2, name: "Work", start: "2026-04-01", end: "2026-04-03" }]);
  assert.strictEqual(A.designDropOnto(idOfName("Work"), idOfName("Gate")), null);
  assert.strictEqual(at("Work"), "2026-03-10..2026-03-12", "starts on the milestone's day");
});

t("a row with no dates cannot be put after anything, or followed", () => {
  loadRowsNow([{ id: 1, name: "Dated", start: "2026-03-02", end: "2026-03-06" },
               { id: 2, name: "Undated" }]);
  assert(/no start date/.test(A.designDropOnto(idOfName("Undated"), idOfName("Dated"))));
  assert(/no date to follow/.test(A.designDropOnto(idOfName("Dated"), idOfName("Undated"))));
});

t("dropping it where it already starts is a no-op", () => {
  loadRowsNow([{ id: 1, name: "First", start: "2026-03-02", end: "2026-03-06" },
               { id: 2, name: "Second", start: "2026-03-06", end: "2026-03-10" }]);
  assert.strictEqual(A.designDropOnto(idOfName("Second"), idOfName("First")), null);
  assert.strictEqual(at("Second"), "2026-03-06..2026-03-10");
});

console.log("\n-- design view: dropped into a group = join it, dates unchanged --");

t("a task keeps its dates and the group is what moves", () => {
  designPlan();
  const before = at("Solo");                                  // 2026-05-04..2026-05-08
  assert.strictEqual(at("Build"), "2026-03-02..2026-03-11");
  assert.strictEqual(A.designDropOnto(idOfName("Solo"), idOfName("Build")), null);
  assert.strictEqual(at("Solo"), before, "not one day of the moved row changed");
  assert.strictEqual(A.groupOf(idOfName("Solo")).name, "Build");
  assert.strictEqual(at("Build"), "2026-03-02..2026-05-08", "the group stretched to hold it");
  assert.strictEqual(at("Phase"), "2026-03-02..2026-05-08", "and so did the group above it");
  assert(/still 2026-05-04/.test(els.toast.textContent), els.toast.textContent);
});

t("a group dropped into a group keeps every date under it", () => {
  designPlan();
  const code = at("Code"), review = at("Review"), build = at("Build");
  // give Later some members so it is a group to drop into
  loadRowsNow([
    { id: 1, name: "Phase",  group: [2] },
    { id: 2, name: "Build",  group: [3, 4] },
    { id: 3, name: "Code",   start: "2026-03-02", end: "2026-03-06" },
    { id: 4, name: "Review", start: "2026-03-09", end: "2026-03-11" },
    { id: 5, name: "Host",   group: [6] },
    { id: 6, name: "Inner",  start: "2026-04-06", end: "2026-04-10" },
  ]);
  assert.strictEqual(A.designDropOnto(idOfName("Build"), idOfName("Host")), null);
  assert.strictEqual(A.groupOf(idOfName("Build")).name, "Host");
  eqArr([at("Code"), at("Review"), at("Build")], [code, review, build], "nothing was rescheduled");
  assert.strictEqual(at("Host"), "2026-03-02..2026-04-10", "the target group spans both now");
});

t("a member dropped into another group leaves the first, which re-rolls", () => {
  loadRowsNow([
    { id: 1, name: "A group", group: [2, 3] },
    { id: 2, name: "Stay",  start: "2026-03-02", end: "2026-03-06" },
    { id: 3, name: "Move",  start: "2026-03-09", end: "2026-03-13" },
    { id: 4, name: "B group", group: [5] },
    { id: 5, name: "Other", start: "2026-05-04", end: "2026-05-08" },
  ]);
  A.designDropOnto(idOfName("Move"), idOfName("B group"));
  assert.strictEqual(A.groupOf(idOfName("Move")).name, "B group");
  assert.strictEqual(at("Move"), "2026-03-09..2026-03-13", "its dates are its own");
  assert.strictEqual(at("A group"), "2026-03-02..2026-03-06", "the group it left shrank");
  assert.strictEqual(at("B group"), "2026-03-09..2026-05-08", "the group it joined grew");
});

t("dropping on the group it is already in is a no-op, not an error", () => {
  designPlan();
  const before = at("Code");
  assert.strictEqual(A.designDropOnto(idOfName("Code"), idOfName("Build")), null);
  assert.strictEqual(at("Code"), before);
  assert.strictEqual(A.groupOf(idOfName("Code")).name, "Build");
});

t("a milestone still never sits inside a group", () => {
  loadRowsNow([{ id: 1, name: "Host", group: [2] },
               { id: 2, name: "Work", start: "2026-03-02", end: "2026-03-06" },
               { id: 3, name: "Gate", start: "2026-03-10", end: "2026-03-10", ms: true }]);
  assert(/never sit inside a group/.test(A.designDropOnto(idOfName("Gate"), idOfName("Host"))));
  assert(!A.groupOf(idOfName("Gate")), "and it joined nothing");
});

t("a task cannot be dropped inside itself", () => {
  designPlan();
  assert.strictEqual(A.designDropOnto(idOfName("Build"), idOfName("Build")), null, "on itself: nothing happens");
  const err = A.designDropOnto(idOfName("Phase"), idOfName("Code"));
  assert(/is inside/.test(err), err);
  assert.strictEqual(at("Code"), "2026-03-02..2026-03-06", "and nothing moved");
});

/* A stack already as deep as the plan allows: L1 holds L2 holds … down to the
   last one. Built from the constant, so raising the limit needs no edit here.
   L1 … L{n-1} are groups; the deepest row is a plain task. */
const designStack = extra => {
  const n = A.MAX_GROUP_DEPTH + 1;                 // rows, not levels of grouping
  const rows = [];
  for (let i = 1; i <= n; i++)
    rows.push(i < n ? { id: i, name: "L" + i, group: [i + 1] }
                    : { id: i, name: "L" + i, start: "2026-03-02", end: "2026-03-06" });
  loadRowsNow(rows.concat((extra || []).map((r, k) => ({ ...r, id: n + k + 1 }))));
  return { deepestGroup: "L" + (n - 1), oneUp: "L" + (n - 2) };
};

t("a single task fits under any group that exists", () => {
  const { deepestGroup } = designStack([{ name: "Loose", start: "2026-05-04", end: "2026-05-08" }]);
  assert.strictEqual(A.depthOf(idOfName(deepestGroup), A.state.tasks), A.MAX_GROUP_DEPTH - 1);
  assert.strictEqual(A.designDropOnto(idOfName("Loose"), idOfName(deepestGroup)), null,
    "one more level is exactly the last one");
  assert.strictEqual(A.depthOf(idOfName("Loose"), A.state.tasks), A.MAX_GROUP_DEPTH);
  assert.strictEqual(at("Loose"), "2026-05-04..2026-05-08", "and its dates were kept");
});

t("a group two levels tall needs two levels of room — refused, nothing moved", () => {
  const { deepestGroup, oneUp } = designStack([
    { name: "Outer", group: [] },
    { name: "Inner", start: "2026-05-04", end: "2026-05-08" },
  ]);
  A.taskById(idOfName("Outer")).group = [idOfName("Inner")];
  A.recalcPlan();

  const err = A.designDropOnto(idOfName("Outer"), idOfName(deepestGroup));
  assert(err, "the drop is refused");
  assert(new RegExp("nest at most " + A.MAX_GROUP_DEPTH + " levels deep").test(err), err);
  assert.strictEqual(at("Inner"), "2026-05-04..2026-05-08", "nothing was rescheduled");
  assert(!A.groupOf(idOfName("Outer")), "and it joined nothing");

  // one level higher it fits, and then it really does move — still without a date change
  assert.strictEqual(A.designDropOnto(idOfName("Outer"), idOfName(oneUp)), null);
  assert.strictEqual(A.groupOf(idOfName("Outer")).name, oneUp);
  assert.strictEqual(at("Inner"), "2026-05-04..2026-05-08");
});

console.log("\n-- design view: dropping a task on the date bar --");

/* A stand-in for a rendered bar: enough of an element for the handlers to aim
   with — closest(), a dataset and a style box they move. */
const fakeBar = (id, sel) => {
  const el = { dataset: { id: String(id) }, style: { left: "100px", width: "80px" },
               classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } } };
  el.closest = q => (q.split(",").some(s => s.trim() === sel) ? el : null);
  return el;
};

/* The header strips are hit-tested against their live rectangles, because the
   chart's header is sticky — the stub DOM has no layout, so give it one. */
const headRect = (id, r) => { document.getElementById(id).getBoundingClientRect = () => r; };

t("it comes out of its group and every date stays put", () => {
  designPlan();
  const before = at("Code");
  assert.strictEqual(A.groupOf(idOfName("Code")).name, "Build");
  assert.strictEqual(A.designUngroup(idOfName("Code")), null);
  assert(!A.groupOf(idOfName("Code")), "it is at the top level now");
  assert.strictEqual(at("Code"), before, "and not one day moved");
  assert(/taken out of "Build"/.test(els.toast.textContent), els.toast.textContent);
});

t("a subgroup comes out whole, with everything under it", () => {
  designPlan();
  const code = at("Code"), review = at("Review"), build = at("Build");
  A.designUngroup(idOfName("Build"));
  assert(!A.groupOf(idOfName("Build")), "the subgroup is top-level now");
  assert.strictEqual(A.groupOf(idOfName("Code")).name, "Build", "its members are still its own");
  eqArr([at("Build"), at("Code"), at("Review")], [build, code, review], "and nothing was rescheduled");
  assert(/2 task\(s\) inside came with it/.test(els.toast.textContent), els.toast.textContent);
});

t("the group it left re-rolls its dates", () => {
  designPlan();
  assert.strictEqual(at("Build"), "2026-03-02..2026-03-11");
  A.designUngroup(idOfName("Code"));                       // Code ran 03-02..03-06
  assert.strictEqual(at("Build"), "2026-03-09..2026-03-11", "what is left is what it spans");
  assert.strictEqual(at("Code"), "2026-03-02..2026-03-06", "the row that left is untouched");
});

t("a row already at the top level is a no-op, not an error", () => {
  designPlan();
  const before = at("Solo");
  assert.strictEqual(A.designUngroup(idOfName("Solo")), null);
  assert.strictEqual(at("Solo"), before);
});

t("the date bar is only a target for a row that is in a group", () => {
  designPlan();
  useRef({}); A.state.view = "gantt"; A.state.groupLevel = A.GROUP_LEVELS;
  A.renderGantt();
  // a real header rectangle for the hit test to aim at
  headRect("ganttHead",     { top: 100, bottom: 156, left: 300, right: 1200 });
  headRect("ganttLeftHead", { top: 100, bottom: 156, left: 0,   right: 300  });
  const overHead = { clientX: 700, clientY: 120 };

  const grabbed = id => {
    fireEl(els.ganttBody, "mousedown", mouse(700, 400, { target: fakeBar(id, ".bar") }));
    fireWin("mousemove", mouse(700, 380));
    const on = A.designOverHead(overHead);
    fireWin("mouseup", mouse(700, 380));
    return on;
  };
  assert.strictEqual(grabbed(idOfName("Code")), true, "a member can be taken out up there");
  assert.strictEqual(grabbed(idOfName("Solo")), false, "a top-level row has nothing to come out of");
  assert.strictEqual(A.designOverHead(overHead), false, "and with no drag in flight it is just a header");
  useRef(null);
});

t("releasing on the date bar takes the task out, and only that", () => {
  designPlan();
  useRef({}); A.state.view = "gantt"; A.state.groupLevel = A.GROUP_LEVELS;
  A.renderGantt();
  headRect("ganttHead",     { top: 100, bottom: 156, left: 300, right: 1200 });
  headRect("ganttLeftHead", { top: 100, bottom: 156, left: 0,   right: 300  });

  const before = at("Code");
  fireEl(els.ganttBody, "mousedown", mouse(700, 400, { target: fakeBar(idOfName("Code"), ".bar") }));
  fireWin("mousemove", mouse(760, 130));        // up onto the date bar, and 60px sideways
  fireWin("mouseup", mouse(760, 130));
  assert(!A.groupOf(idOfName("Code")), "it left its group");
  assert.strictEqual(at("Code"), before, "the sideways travel on the way up changed no date");
  useRef(null);
});

t("the combined view refuses it like every other edit", () => {
  designPlan();
  A.state.combined = ["a.csv"];
  assert(/read-only/.test(String(A.designUngroup(idOfName("Code")))));
  assert.strictEqual(A.groupOf(idOfName("Code")).name, "Build", "and it stayed where it was");
  A.state.combined = null;
});

console.log("\n-- design view: the pointer half --");

t("a drag that starts on a bar is an edit, not a pan", () => {
  const p = els.ganttRight;
  p.scrollLeft = 300;
  fireEl(p, "mousedown", mouse(500, 300, { target: fakeBar(1, ".bar") }));
  fireWin("mousemove", mouse(400, 300));
  assert.strictEqual(p.scrollLeft, 300, "the chart did not pan under the drag");
  fireWin("mouseup", mouse(400, 300));
  assert(!p.classList.contains("panning"));
});

t("mousedown, move, release on a bar reschedules the task", () => {
  designPlan();
  useRef({}); A.state.view = "gantt";
  A.renderGantt();
  const id = idOfName("Solo");
  A.state.pxPerDay = 20;
  fireEl(els.ganttBody, "mousedown", mouse(400, 500, { target: fakeBar(id, ".bar") }));
  fireWin("mousemove", mouse(460, 500));          // 60px = 3 days at 20 px/day
  fireWin("mouseup", mouse(460, 500));
  assert.strictEqual(at("Solo"), "2026-05-07..2026-05-11", "three days later");
  A.state.pxPerDay = 28; useRef(null);
});

t("a shaky click on a bar is not a drag", () => {
  designPlan();
  useRef({}); A.state.view = "gantt"; A.renderGantt();
  const before = at("Solo");
  fireEl(els.ganttBody, "mousedown", mouse(400, 500, { target: fakeBar(idOfName("Solo"), ".bar") }));
  fireWin("mousemove", mouse(402, 501));
  fireWin("mouseup", mouse(402, 501));
  assert.strictEqual(at("Solo"), before, "the double-click that opens the editor still gets through");
  useRef(null);
});

t("the rows are hit-tested by height, in the order they were drawn", () => {
  designPlan();
  useRef({}); A.state.view = "gantt"; A.state.groupLevel = A.GROUP_LEVELS;
  A.renderGantt();
  const first = A.designRowAt(1), second = A.designRowAt(A.ROW_H + 1);
  assert(first && second && first.t.id !== second.t.id, "consecutive bands are consecutive rows");
  assert.strictEqual(A.designRowIndex(first.t.id), 0);
  assert.strictEqual(A.designRowAt(-5), null, "above the chart is no row");
  assert.strictEqual(A.designRowAt(99999), null, "and so is below it");
  useRef(null);
});

t("the bar carries grips, and a group does not", () => {
  designPlan();
  useRef({}); A.state.view = "gantt"; A.state.groupLevel = A.GROUP_LEVELS;
  A.renderGantt();
  const bars = els.ganttBody.innerHTML.match(/<div class="bar[^>]*data-id="\d+"[\s\S]*?<\/div>/g) || [];
  const solo = bars.find(b => /Solo/.test(b)), build = bars.find(b => /class="bar group/.test(b));
  assert(/data-grip="start"/.test(solo) && /data-grip="end"/.test(solo), "a task has both grips");
  assert(build && !/data-grip=/.test(build), "a group has none — its dates are its members'");
  assert(/id="designBand"/.test(els.ganttBody.innerHTML) && /id="designTag"/.test(els.ganttBody.innerHTML),
    "the drag's feedback elements are drawn with the chart");
  useRef(null);
});

console.log("\n-- touch-editing mode: an enlarged handle standing on the bar --");
const fireDoc = (type, ev) => (docOn[type] || []).forEach(fn => fn(ev));

t("barEditable() excludes a milestone, read-only mode, and a task that is gone", () => {
  designPlan();
  const id = idOfName("Solo");
  assert.strictEqual(A.barEditable(id), true);
  assert.strictEqual(A.barEditable(999999), false, "no such task");
  A.state.readOnly = true;
  assert.strictEqual(A.barEditable(id), false);
  A.state.readOnly = false;
  A.state.tasks.push({ id: 777, name: "Launch", start: "2026-06-01", end: "2026-06-01",
    milestone: true, event: false, group: [], preds: [], team: "", system: "", accountable: "",
    responsible: "", notes: "", status: "Planned", history: [] });
  assert.strictEqual(A.barEditable(777), false, "a milestone has no start/end to drag apart");
  A.state.tasks.pop();
});

t("enterBarEditMode() only opens on a phone, and syncs the overlay onto the bar's own position and colours", () => {
  const wasW = ctx.window.innerWidth;
  designPlan();
  const id = idOfName("Solo");
  const bar = { className: "bar healthOk", style: { left: "140px", top: "64px", width: "56px" } };
  els.ganttBody.querySelector = sel => (sel === `.bar[data-id="${id}"]` ? bar : null);
  try {
    ctx.window.innerWidth = 390;                  // a phone
    A.enterBarEditMode(id);
    assert.strictEqual(els.designEditBar.style.display, "flex");
    assert(els.gantt.classList.contains("editingBar"), "the panel is marked so it stops scrolling");
    assert.strictEqual(els.designEditBar.style.left, "140px", "the same left the real bar has");
    assert.strictEqual(els.designEditBar.style.width, "56px", "the same width the real bar has");
    assert.strictEqual(els.designEditBar.style.top, (64 + A.BAR_H / 2 - A.DESIGN_EDIT_H / 2) + "px",
      "centred on the real bar's own vertical middle");
    assert.strictEqual(els.designEditBar.className, "editBar healthOk", "the real bar's own colouring, carried over");
    assert.strictEqual(els.designEditBar.dataset.id, String(id));
    assert.strictEqual(els.designEditLbl.textContent, "Solo", "the name is readable on the enlarged handle too");

    A.exitBarEditMode();
    assert.strictEqual(els.designEditBar.style.display, "none");
    assert(!els.gantt.classList.contains("editingBar"));

    ctx.window.innerWidth = 1400;                 // a desk: the mode never opens there at all
    A.enterBarEditMode(id);
    assert.strictEqual(els.designEditBar.style.display, "none", "no overlay on a desk");
    assert(!els.gantt.classList.contains("editingBar"));
  } finally {
    delete els.ganttBody.querySelector;
    ctx.window.innerWidth = wasW;
  }
});

t("the mode follows the bar if it moves, and closes itself if the bar is gone", () => {
  const wasW = ctx.window.innerWidth;
  ctx.window.innerWidth = 390;
  designPlan();
  const id = idOfName("Solo");
  let bar = { className: "bar", style: { left: "10px", top: "0px", width: "40px" } };
  els.ganttBody.querySelector = () => bar;
  try {
    A.enterBarEditMode(id);
    bar = { className: "bar", style: { left: "80px", top: "0px", width: "40px" } };   // as if the chart redrew after a move
    els.ganttBody.querySelector = () => bar;
    A.syncEditBar();
    assert.strictEqual(els.designEditBar.style.left, "80px", "resynced onto the bar's new position");

    els.ganttBody.querySelector = () => null;      // folded away, or the row filtered out
    A.syncEditBar();
    assert.strictEqual(els.designEditBar.style.display, "none", "nothing left to stand on, so the mode ends");
  } finally {
    delete els.ganttBody.querySelector;
    ctx.window.innerWidth = wasW;
  }
});

t("leaving the Gantt view closes the mode — the enlarged handle means nothing on a chart not on screen", () => {
  const wasW = ctx.window.innerWidth;
  ctx.window.innerWidth = 390;
  designPlan();
  A.state.view = "gantt";
  const id = idOfName("Solo");
  els.ganttBody.querySelector = () => ({ className: "bar", style: { left: "0px", top: "0px", width: "40px" } });
  try {
    A.enterBarEditMode(id);
    assert.strictEqual(els.designEditBar.style.display, "flex");
    A.setView("zoom");
    assert.strictEqual(els.designEditBar.style.display, "none");
  } finally {
    delete els.ganttBody.querySelector;
    A.setView("gantt");
    ctx.window.innerWidth = wasW;
  }
});

t("a tap outside the overlay closes the mode; a tap on it does not", () => {
  const wasW = ctx.window.innerWidth;
  ctx.window.innerWidth = 390;
  designPlan();
  els.ganttBody.querySelector = () => ({ className: "bar", style: { left: "0px", top: "0px", width: "40px" } });
  try {
    A.enterBarEditMode(idOfName("Solo"));
    fireDoc("click", mouse(0, 0, { target: fakeBar(idOfName("Solo"), "#designEditBar") }));
    assert.strictEqual(els.designEditBar.style.display, "flex", "the tap that landed on the overlay itself does not close it");

    fireDoc("click", mouse(0, 0, { target: { closest: () => null } }));
    assert.strictEqual(els.designEditBar.style.display, "none", "anywhere else does");
  } finally {
    delete els.ganttBody.querySelector;
    A.exitBarEditMode();
    ctx.window.innerWidth = wasW;
  }
});

t("pointerdown, move, release on the enlarged handle reschedules the task, same as a mouse on the real bar", () => {
  designPlan();
  useRef({}); A.state.view = "gantt"; A.renderGantt();
  const id = idOfName("Solo");
  A.state.pxPerDay = 20;
  fireEl(els.ganttBody, "pointerdown", mouse(400, 500, { target: fakeBar(id, ".editBar"), pointerType: "touch" }));
  fireWin("pointermove", mouse(460, 500));          // 60px = 3 days at 20 px/day
  fireWin("pointerup", mouse(460, 500));
  assert.strictEqual(at("Solo"), "2026-05-07..2026-05-11", "three days later");
  A.state.pxPerDay = 28; useRef(null);
});

t("a mouse pointerdown on the enlarged handle is left to the mousedown listener, not run twice", () => {
  designPlan();
  useRef({}); A.state.view = "gantt"; A.renderGantt();
  const before = at("Solo");
  fireEl(els.ganttBody, "pointerdown", mouse(400, 500, { target: fakeBar(idOfName("Solo"), ".editBar"), pointerType: "mouse" }));
  fireWin("pointermove", mouse(460, 500));
  fireWin("pointerup", mouse(460, 500));
  assert.strictEqual(at("Solo"), before, "nothing moved — a real mouse click already fires mousedown for the same click");
  useRef(null);
});

/* The icon moved out of the button's text and into a data-ic attribute the
   CSS draws, so the label is a text node of its own — which is what makes it
   one translation key rather than an emoji glued to a word, and what lets the
   mobile bar stack the icon over the label. */
t("the view is called Design on screen", () => {
  assert(/id="btnViewGantt"[^>]*data-ic="📐"[^>]*>Design</.test(html), "the switcher says Design");
  assert(!/>Gantt</.test(html), "and nothing still says Gantt");
});

/* The label is now the whole translation key, so a label somebody edits and
   does not translate would reach a Hungarian reader in English. The shell is
   translated from its markup rather than through trans(), so the completeness
   test further down cannot see these — this is where they are checked. */
t("every view label is a key all three dictionaries carry", () => {
  const toggle = html.match(/<div class="viewToggle" id="viewToggle">[\s\S]*?<\/div>/)[0];
  const labels = [...toggle.matchAll(/<button id="btnView\w+"[^>]*>([^<]+)</g)].map(m => m[1].trim());
  assert.strictEqual(labels.length, 9, "nine labels");
  for (const lang of ["hu", "de", "fr"])
    for (const label of labels)
      assert(label in A.I18N[lang], `${lang} has no "${label}"`);
});

/* Nine views will not sit on one bar. Five stay on it and the rest go behind
   the grip at its right end — a fixed split, decided here rather than by
   measuring the window, which is the version that can be checked without a
   browser. The presentation view joined the four behind it in v1.2. */
t("the switcher keeps five views and the other four sit behind the grip", () => {
  const toggle = html.slice(html.indexOf('<div class="viewToggle" id="viewToggle">'));
  const cut = toggle.indexOf('<div class="menuWrap viewMoreWrap">');
  assert(cut > 0, "the overflow wrapper is inside the switcher");

  const onBar = [...toggle.slice(0, cut).matchAll(/<button id="(btnView\w+)"/g)].map(m => m[1]);
  eqArr(onBar, ["btnViewDash", "btnViewTable", "btnViewGantt", "btnViewZoom", "btnViewRes"],
    "the five you work in stay on the bar");

  const menu = toggle.slice(toggle.indexOf('<div id="moreViewsMenu"'));
  const inMenu = [...menu.slice(0, menu.indexOf("</div>")).matchAll(/<button id="(btnView\w+)"/g)].map(m => m[1]);
  /* In the order they are reached for, not the order they were built (Daniel,
     2026-08-22): a menu behind a grip is read top-down every time, so the
     Cockpit — the one somebody opens on the night that matters — leads. */
  eqArr(inMenu, ["btnViewCockpit", "btnViewPres", "btnViewTeam", "btnViewReport"],
    "and the four you go to read are behind the grip");

  /* The grip is not a view and must not be counted as one — it has no label
     and no dictionary entry, so it is named differently on purpose. */
  assert(!/id="btnView\w*More/.test(html), "the grip is not named as a view button");
});

/* Stacked in a menu the icons are a column, and a character that draws as a
   narrow black-and-white glyph pulls its label out of line with the ones above
   it — which is what the picture frame did until it was asked for the colour
   emoji. Most emoji are drawn in colour by default; the handful that are not
   need the variation selector, written as an entity so the source stays
   readable. Either is fine, having neither is the bug. */
t("every icon in the overflow menu draws as a colour emoji, not a text glyph", () => {
  const menu = html.slice(html.indexOf('<div id="moreViewsMenu"'));
  const icons = [...menu.slice(0, menu.indexOf("</div>")).matchAll(/data-ic="([^"]+)"/g)].map(m => m[1]);
  assert.strictEqual(icons.length, 4, "one per view behind the grip");
  for (const raw of icons) {
    // the parser decodes the entity, so the test compares what the DOM will hold
    const cps = [...raw.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n))];
    assert(/\p{Emoji_Presentation}/u.test(cps[0]) || cps[1] === "\uFE0F",
      JSON.stringify(raw) + " draws as a text glyph — it needs &#65039; after it");
  }
});

t("the grip lights up when the view you are in is one of the three behind it", () => {
  const was = A.state.view;
  A.setView("table");
  assert.strictEqual(els.btnMoreViews.classList.contains("active"), false);
  for (const v of ["team", "report", "cockpit"]) {
    A.setView(v);
    assert(els.btnMoreViews.classList.contains("active"), v + " should light the grip");
  }
  A.setView("table");
  assert.strictEqual(els.btnMoreViews.classList.contains("active"), false,
    "and it goes out again on the way back");
  A.cockStop();
  A.state.view = was;
});

t("choosing a view closes the menu it was chosen from", () => {
  els.moreViewsMenu.classList.add("open");
  A.setView("report");
  assert.strictEqual(els.moreViewsMenu.classList.contains("open"), false);
  A.setView("table");
});

t("every view button carries its icon as data, and its label as text", () => {
  const toggle = html.match(/<div class="viewToggle" id="viewToggle">[\s\S]*?<\/div>/)[0];
  const btns = [...toggle.matchAll(/<button id="(btnView\w+)"([^>]*)>([^<]*)</g)];
  assert.strictEqual(btns.length, 9, "nine views");
  for (const [, id, attrs, label] of btns) {
    assert(/data-ic="[^"]+"/.test(attrs), id + " has no icon");
    assert(label.trim() && !/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(label),
      id + " keeps an emoji in its label: " + JSON.stringify(label));
  }
});

console.log("\n-- following a task: status and history --");

/* The pair of fields is one mechanism, so most of these check both at once: a
   status that moved without leaving a dated entry behind it, or an entry that
   does not match where the task now stands, is the failure worth catching. */

const stTask = (name, o) => {
  loadRowsNow([{ id: 1, name, start: "2026-03-02", end: "2026-03-04", ...(o || {}) }]);
  return row(name);
};
const stepsOf = t2 => (t2.history || []).map(h => h.date + " " + h.status);

t("the six statuses, and nothing else", () => {
  /* 🔴 Six since 2026-08-21. Cancelled is last on purpose and is not a stage of
     work: it is work that will not happen, kept as a row because deleting it
     would lose the fact that somebody planned it and then decided against it. */
  eqArr(A.STATUSES, ["Planned", "In Progress", "On Hold", "Blocked", "Done", "Cancelled"]);
  assert.strictEqual(A.DEFAULT_STATUS, "Planned", "a task is Planned the moment it exists");
  assert(A.STATUSES.every(s => A.STATUS_DOT[s]), "each one has a mark of its own");
  // both spellings, and the words other trackers and hand-typed plans use
  for (const w of ["cancelled", "canceled", "Dropped", "törölve", "lemondva"])
    assert.strictEqual(A.normStatus(w), "Cancelled", w + " should read as Cancelled");
});

t("a status is recognised however it is written", () => {
  assert.strictEqual(A.normStatus("in progress"), "In Progress");
  assert.strictEqual(A.normStatus("In-Progress"), "In Progress");
  assert.strictEqual(A.normStatus("WIP"), "In Progress");
  assert.strictEqual(A.normStatus("DONE"), "Done");
  assert.strictEqual(A.normStatus("completed"), "Done");
  // the Hungarian words a plan out of another tool arrives with, accents and all
  assert.strictEqual(A.normStatus("Folyamatban"), "In Progress");
  assert.strictEqual(A.normStatus("Kész"), "Done");
  assert.strictEqual(A.normStatus("Felfüggesztve"), "On Hold");
  assert.strictEqual(A.normStatus("Blokkolt"), "Blocked");
  assert.strictEqual(A.normStatus("Tervezett"), "Planned");
  // and a word this app does not know stays unknown rather than becoming Planned
  // (Cancelled used to be the example here — it is a real status since 08-21)
  assert.strictEqual(A.normStatus("Marinating"), "");
  assert.strictEqual(A.normStatus(""), "");
});

t("a new task starts Planned, with the day it was created", () => {
  const t2 = A.seedTask({ id: 1, name: "New" });
  assert.strictEqual(t2.status, "Planned");
  eqArr(stepsOf(t2), [A.today() + " Planned"], "one entry: the creation");
});

t("a status change is recorded once, with today's date", () => {
  const t2 = A.seedTask({ id: 1, name: "T" });
  assert.strictEqual(A.setTaskStatus(t2, "In Progress"), true, "it moved");
  assert.strictEqual(A.setTaskStatus(t2, "In Progress"), false, "…and picking the same one again is not an event");
  eqArr(stepsOf(t2), [A.today() + " Planned", A.today() + " In Progress"]);
  A.setTaskStatus(t2, "Done");
  assert.strictEqual(t2.status, "Done");
  assert.strictEqual(t2.history.length, 3, "every real change is kept: " + JSON.stringify(t2.history));
});

t("both fields round-trip through the file", () => {
  const t2 = stTask("Build");
  A.setTaskStatus(t2, "In Progress", "2026-03-05");
  A.setTaskStatus(t2, "Blocked", "2026-03-09");
  const csv = A.serializeCSV(A.state.tasks);
  assert(csv.split("\r\n")[1].includes("Blocked"), "the status cell: " + csv.split("\r\n")[1]);
  const back = A.parseCSV(csv)[0];
  assert.strictEqual(back.status, "Blocked");
  eqArr(stepsOf(back), stepsOf(t2), "the trail came back exactly as it went out");
});

t("a plan written before any of this loads at Planned, dated today", () => {
  // the pre-status header: thirteen columns, nothing about where a task stands
  const tasks = A.parseCSV(CSV_HDR + "\n" + csvRow({ id: 1, name: "Legacy", start: "2026-03-02", end: "2026-03-03" }));
  assert.strictEqual(tasks[0].status, "", "reading leaves the row exactly as the file had it");
  eqArr(tasks[0].history, []);
  // the repair pass gives it both — and says nothing, because a missing column
  // is a default rather than a repair
  eqArr(A.sanitizeTasks(tasks), [], "an old plan must not open behind warnings");
  assert.strictEqual(tasks[0].status, "Planned");
  eqArr(stepsOf(tasks[0]), [A.today() + " Planned"], "today is when this app first saw it");
});

t("a hand-edited status the app has never heard of is repaired and reported", () => {
  const tasks = A.parseCSV(CSV_HDR_NOW + "\n" +
    csvRowNow({ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", status: "Marinating" }));
  const w = A.sanitizeTasks(tasks);
  assert.strictEqual(tasks[0].status, "Planned");
  assert(w.some(x => /Marinating/.test(x)), "must say what it did: " + JSON.stringify(w));
});

/* 🔴 The other half of that, and the reason the word above had to change: a
   plan written by a NEWER build arrives with Cancelled in it, and this build
   has to keep it. Anything else and a round trip through an older copy would
   quietly bring cancelled work back to life. */
t("a cancelled row survives being read from a file", () => {
  const tasks = A.parseCSV(CSV_HDR_NOW + "\n" +
    csvRowNow({ id: 1, name: "Dropped", start: "2026-03-02", end: "2026-03-03", status: "Cancelled" }));
  const w = A.sanitizeTasks(tasks);
  assert.strictEqual(tasks[0].status, "Cancelled", "kept as it was written");
  assert(!w.length, "and nothing to repair: " + JSON.stringify(w));
});

t("a history that contradicts the status is brought into line, and reported", () => {
  const tasks = A.parseCSV(CSV_HDR_NOW + "\n" +
    csvRowNow({ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03",
                status: "Done", history: "2026-03-02 Planned" }));
  const w = A.sanitizeTasks(tasks);
  eqArr(stepsOf(tasks[0]), ["2026-03-02 Planned", A.today() + " Done"],
    "the missing step is dated today rather than invented");
  assert(w.some(x => /history/.test(x)), "must say so: " + JSON.stringify(w));
});

t("rubbish in the history cell is left out, not repaired", () => {
  eqArr(A.parseHistory("2026-03-02 Planned;nonsense;2026-13-40 Done;2026-03-09 Blocked")
          .map(h => h.date + " " + h.status),
        ["2026-03-02 Planned", "2026-03-09 Blocked"]);
  eqArr(A.parseHistory(""), []);
});

console.log("\n-- a group's status is its members' --");

/* One group over three tasks, so each member can be moved on its own and the
   group asked what it now says. Built through the file so the group really is
   a group before anything is set. */
const grpFixture = () => {
  loadRowsNow([
    { id: 1, name: "Wave", start: "2026-03-02", end: "2026-03-12", group: [2, 3, 4] },
    { id: 2, name: "One",  start: "2026-03-02", end: "2026-03-04" },
    { id: 3, name: "Two",  start: "2026-03-05", end: "2026-03-08" },
    { id: 4, name: "Three", start: "2026-03-09", end: "2026-03-12" },
  ]);
};
const setAll = (...pairs) => {
  for (const [n, s] of pairs) A.setTaskStatus(row(n), s);
  A.recalcGroups();
  return row("Wave").status;
};

t("the loudest member wins, in the order given", () => {
  eqArr(A.STATUS_STRENGTH, ["Blocked", "On Hold", "In Progress", "Planned", "Done", "Cancelled"]);
  grpFixture();
  assert.strictEqual(setAll(["One", "Done"], ["Two", "Done"], ["Three", "Done"]), "Done",
    "only when every member is Done");
  assert.strictEqual(setAll(["Three", "In Progress"]), "In Progress");
  assert.strictEqual(setAll(["Two", "Planned"]), "In Progress", "In Progress outranks Planned");
  assert.strictEqual(setAll(["One", "On Hold"]), "On Hold");
  assert.strictEqual(setAll(["Three", "Blocked"]), "Blocked", "and Blocked outranks everything");
});

/* 🔴 Cancelled is quieter than all of them, and it has to be: a cancelled
   member is work that is not happening, so it can neither speak for the group
   nor hold it back. */
t("a cancelled member neither speaks for its group nor holds it back", () => {
  grpFixture();
  assert.strictEqual(setAll(["One", "Cancelled"], ["Two", "Done"], ["Three", "Done"]), "Done",
    "the rest of it is finished, so the group is");
  assert.strictEqual(setAll(["Two", "Blocked"]), "Blocked", "and a live member still decides");
  assert.strictEqual(setAll(["One", "Cancelled"], ["Two", "Cancelled"], ["Three", "Cancelled"]),
    "Cancelled", "only a group where every member is cancelled is itself cancelled");
});

/* The rule the order alone does not state: a group with work behind it and work
   still ahead of it is under way, whether the work in flight has landed or not.
   Reported from a real plan — every task running and on track, and the group
   above them reading Planned, which the dashboard then counted as not started. */
t("started work outranks work that has not started", () => {
  grpFixture();
  assert.strictEqual(setAll(["One", "In Progress"], ["Two", "Planned"], ["Three", "Planned"]),
    "In Progress", "one member running is enough");
  assert.strictEqual(setAll(["One", "Done"], ["Two", "Planned"], ["Three", "Planned"]),
    "In Progress", "and one member Done says the same thing a day later");
  assert.strictEqual(setAll(["One", "Planned"]), "Planned",
    "only a group where nothing has begun is Planned");
});

t("a subgroup settles before the group holding it", () => {
  loadRowsNow([
    { id: 1, name: "Top",  start: "2026-03-02", end: "2026-03-12", group: [2] },
    { id: 2, name: "Mid",  start: "2026-03-02", end: "2026-03-12", group: [3, 4] },
    { id: 3, name: "Leaf1", start: "2026-03-02", end: "2026-03-06" },
    { id: 4, name: "Leaf2", start: "2026-03-07", end: "2026-03-12" },
  ]);
  A.setTaskStatus(row("Leaf1"), "Done");
  A.setTaskStatus(row("Leaf2"), "Blocked");
  A.recalcGroups();
  assert.strictEqual(row("Mid").status, "Blocked", "the subgroup reads its own members");
  assert.strictEqual(row("Top").status, "Blocked", "and the group above reads the subgroup's answer");
});

t("the roll-up is dated in the group's own history", () => {
  grpFixture();
  const before = row("Wave").history.length;
  A.setTaskStatus(row("One"), "Blocked");
  A.recalcGroups();
  assert.strictEqual(row("Wave").status, "Blocked");
  assert.strictEqual(row("Wave").history.length, before + 1, "the group's change is an event too");
  const last = row("Wave").history[row("Wave").history.length - 1];
  assert.strictEqual(last.status, "Blocked");
  assert.strictEqual(last.date, A.today());
  // and a roll-up that changes nothing records nothing
  A.recalcGroups();
  assert.strictEqual(row("Wave").history.length, before + 1);
});

t("a childless row keeps the status it was given", () => {
  loadRowsNow([{ id: 1, name: "Solo", start: "2026-03-02", end: "2026-03-04" }]);
  A.setTaskStatus(row("Solo"), "On Hold");
  A.recalcGroups();
  assert.strictEqual(row("Solo").status, "On Hold", "a row with no members is not a group");
  assert.strictEqual(A.groupStatus(row("Solo"), A.state.tasks), null);
});

t("a group's status cannot be set by hand, on any of the four routes", () => {
  grpFixture();
  const wave = idOfName("Wave");
  A.setTaskStatus(row("One"), "Done"); A.setTaskStatus(row("Two"), "Done");
  A.setTaskStatus(row("Three"), "Done"); A.recalcGroups();
  assert.strictEqual(row("Wave").status, "Done");

  // 1. the quick edit refuses and says why
  A.changeStatus([wave], "Blocked");
  assert.strictEqual(row("Wave").status, "Done", "the quick edit wrote to a group");
  assert(/rolled up/.test(els.toast.textContent), els.toast.textContent);

  // 2. the cell is not a click target, and its menu does not open
  useRef({}); A.state.view = "table"; A.renderTable();
  const cell = els.tableView.innerHTML.match(/<td class="statusCell calcCell"[^>]*>/);
  assert(cell, "the group row has no calculated status cell");
  assert(!/data-status/.test(cell[0]), "and it must not be clickable: " + cell[0]);
  A.openStatusMenu(wave, 10, 10);
  assert(!A.rowMenuOpen(), "the status menu opened on a group");

  // 3. the row menu offers a caption instead of the five statuses
  A.openRowMenu(wave, 10, 10);
  assert(!new RegExp('data-act="' + A.STATUS_ACT).test(els.rowMenu.innerHTML), "the menu offered them anyway");
  assert(/rolled up from 3 member/.test(els.rowMenu.innerHTML), els.rowMenu.innerHTML);
  A.closeRowMenu();

  // 4. both editors show it disabled
  assert(/id="iStatus"[^>]*disabled/.test(A.inlineRowHtml(row("Wave"))), "the inline row lets you type into it");
  A.openModal({ type: "edit", id: idOfName("Wave") });
  assert.strictEqual(els.fStatus.disabled, true, "the dialog lets you type into it");
  /* The sentence moved into the mark's title on 2026-08-21 — the dialog's
     explanations are a tap away rather than stacked under every field. */
  assert(/Rolled up/.test(els.fStatusHint.title), els.fStatusHint.title);
  A.closeModal();
  useRef(null);
});

t("a mass change moves the members and lets their groups follow", () => {
  grpFixture();
  const ids = ["One", "Two", "Three"].map(idOfName).concat(idOfName("Wave"));
  A.openMassChange(ids);
  els.mStatus.value = "On Hold";
  A.applyMassChange();
  for (const n of ["One", "Two", "Three"]) assert.strictEqual(row(n).status, "On Hold", n);
  assert.strictEqual(row("Wave").status, "On Hold", "the group followed its members rather than being written to");
});

t("a selection of tasks and groups moves the tasks and says what it left", () => {
  grpFixture();
  A.changeStatus([idOfName("Wave"), idOfName("One")], "Blocked");
  assert.strictEqual(row("One").status, "Blocked");
  assert.strictEqual(row("Wave").status, "Blocked", "…and the group followed, rather than being set");
  assert(/1 group\(s\) left alone/.test(els.toast.textContent), els.toast.textContent);
});

console.log("\n-- the quick edit --");

t("one click on the Status cell moves the task on, and dates it", () => {
  const t2 = stTask("Build");
  A.changeStatus([t2.id], "In Progress");
  assert.strictEqual(row("Build").status, "In Progress");
  eqArr(stepsOf(row("Build")), [A.today() + " Planned", A.today() + " In Progress"]);
  assert(/In Progress/.test(els.toast.textContent), "and it says what happened: " + els.toast.textContent);
});

t("the same status again changes nothing and says so", () => {
  const t2 = stTask("Build");
  const before = JSON.stringify(A.state.tasks);
  A.changeStatus([t2.id], "Planned");
  assert.strictEqual(JSON.stringify(A.state.tasks), before, "the plan was touched");
  assert(/already Planned/.test(els.toast.textContent), els.toast.textContent);
});

t("the quick edit runs through the same row action the menu offers", () => {
  const t2 = stTask("Build");
  A.runRowAction(A.STATUS_ACT + "Done", t2.id);
  assert.strictEqual(row("Build").status, "Done");
});

t("a whole selection moves on at once, and each row keeps its own trail", () => {
  loadRowsNow([{ id: 1, name: "One", start: "2026-03-02", end: "2026-03-03" },
               { id: 2, name: "Two", start: "2026-03-04", end: "2026-03-05" },
               { id: 3, name: "Three", start: "2026-03-06", end: "2026-03-07" }]);
  A.setTaskStatus(row("Two"), "Done");                 // already where the pick is going
  A.selectRow(idOfName("One"), false);
  A.selectRow(idOfName("Three"), true);
  A.runRowAction(A.STATUS_SEL_ACT + "Done", idOfName("One"));
  assert.strictEqual(row("One").status, "Done");
  assert.strictEqual(row("Three").status, "Done");
  assert.strictEqual(row("Two").history.length, 2, "the row that was already Done gained no entry");
  A.clearSelection();
});

t("the menu ticks the status the rows are at, and only when they agree", () => {
  loadRowsNow([{ id: 1, name: "One", start: "2026-03-02", end: "2026-03-03" },
               { id: 2, name: "Two", start: "2026-03-04", end: "2026-03-05" }]);
  const one = idOfName("One"), two = idOfName("Two");
  assert(A.statusItems([one], A.STATUS_ACT).find(i => i.act === A.STATUS_ACT + "Planned").label.includes("✓"),
    "the status it is at is ticked");
  A.setTaskStatus(row("Two"), "Done");
  assert(!A.statusItems([one, two], A.STATUS_ACT).some(i => i.label.includes("✓")),
    "two rows at different statuses tick nothing");
});

t("read-only refuses the quick edit and says why", () => {
  const t2 = stTask("Build");
  A.state.readOnly = true;
  A.changeStatus([t2.id], "Done");
  A.state.readOnly = false;
  assert.strictEqual(row("Build").status, "Planned", "the plan was changed in read-only mode");
  assert(/read-only/.test(els.toast.textContent), els.toast.textContent);
});

t("the list draws the pill and hands the cell the quick edit", () => {
  stTask("Build");
  useRef({}); A.state.view = "table"; A.renderTable();
  const out = els.tableView.innerHTML;
  assert(/class="stPill stPlanned"/.test(out), "the pill carries the status in its class");
  assert(/data-status="/.test(out), "and the cell is the click target");
  useRef(null);
});

t("clicking the cell opens the five statuses at the pointer", () => {
  const t2 = stTask("Build");
  A.openStatusMenu(t2.id, 40, 40);
  assert(A.rowMenuOpen(), "no menu opened");
  for (const s of A.STATUSES)
    assert(els.rowMenu.innerHTML.includes(`data-act="${A.STATUS_ACT}${s}"`), "missing: " + s);
  assert(!/data-act="edit"/.test(els.rowMenu.innerHTML), "the quick edit is the statuses and nothing else");
  A.closeRowMenu();
});

t("the right-click menu opens with the same block, above the rest", () => {
  const t2 = stTask("Build");
  A.openRowMenu(t2.id, 40, 40);
  const html2 = els.rowMenu.innerHTML;
  assert(html2.indexOf(`data-act="${A.STATUS_ACT}Done"`) < html2.indexOf('data-act="edit"'),
    "following a plan is mostly this one action, so it comes first");
  A.closeRowMenu();
});

t("a mass change writes a dated entry into every row it moves", () => {
  loadRowsNow([{ id: 1, name: "One", start: "2026-03-02", end: "2026-03-03" },
               { id: 2, name: "Two", start: "2026-03-04", end: "2026-03-05" }]);
  A.openMassChange([idOfName("One"), idOfName("Two")]);
  els.mStatus.value = "In Progress";
  els.mTeam.value = "Dev";
  A.applyMassChange();
  for (const n of ["One", "Two"]) {
    assert.strictEqual(row(n).status, "In Progress", n);
    assert.strictEqual(row(n).team, "Dev", n + ": the ordinary fields still work");
    eqArr(stepsOf(row(n)), [A.today() + " Planned", A.today() + " In Progress"], n);
  }
});

t("a mass change left blank leaves the status alone, like every other field", () => {
  const t2 = stTask("Build");
  A.setTaskStatus(t2, "Blocked");
  A.openMassChange([t2.id]);
  els.mStatus.value = ""; els.mTeam.value = "Ops";
  A.applyMassChange();
  assert.strictEqual(row("Build").status, "Blocked", "an untouched picker must not reset anything");
  assert.strictEqual(row("Build").history.length, 2, "and must not add an entry");
});

t("a duplicate carries the status but starts its own history", () => {
  const t2 = stTask("Build");
  A.setTaskStatus(t2, "In Progress", "2026-03-05");
  A.duplicateTask(t2.id);
  const copy = row("Build - duplicate");
  assert.strictEqual(copy.status, "In Progress", "it is a copy of the row as it stands");
  eqArr(stepsOf(copy), [A.today() + " In Progress"], "but it did not live through the original's steps");
});

t("the History column starts folded away, and is never editable", () => {
  assert(A.defaultHiddenCols().has("history"), "History is a record, read when a task is questioned");
  assert(!A.defaultHiddenCols().has("status"), "Status is not — it is the field the plan is followed by");
  assert.strictEqual(A.columnByKey("history").edit({}), "", "nothing here can be typed into");
});

t("the editors offer the status, and never rewrite the history behind it", () => {
  const t2 = stTask("Build");
  A.setTaskStatus(t2, "On Hold", "2026-03-06");
  const open = A.inlineRowHtml(row("Build"));
  assert(/id="iStatus"/.test(open), "the inline row carries the picker");
  assert(/<option value="On Hold" selected>/.test(open), "at the status the row is at: " + open.match(/<select[^>]*id="iStatus".*?<\/select>/)[0]);
  assert(/id="fStatus"/.test(html) && /id="fHistory"/.test(html), "and the dialog carries both");

  /* The picker reads back empty in this stub DOM, which is exactly the case
     that must not clear anything: an untouched picker means "leave it alone". */
  A.startInline(row("Build").id);
  // getElementById, not els[…]: the stub only creates an element once the app
  // has asked for that id, and the inline row's inputs are never wired up here
  const set = (id, v) => { document.getElementById(id).value = v; };
  set("iStatus", ""); set("iName", "Build"); set("iStart", "2026-03-02"); set("iEnd", "2026-03-04");
  A.saveInline();
  assert.strictEqual(row("Build").status, "On Hold", "saving the row moved it back to Planned");
  assert.strictEqual(row("Build").history.length, 2, "and rewrote its history");
});

t("a task made in the dialog arrives Planned, with the day it was made", () => {
  loadRowsNow([{ id: 1, name: "One", start: "2026-03-02", end: "2026-03-03" }]);
  A.openModal({ type: "new" });
  Object.assign(els.fName, { value: "Fresh" });
  els.fStart.value = "2026-03-10"; els.fEnd.value = "2026-03-11";
  els.fHourStart.value = ""; els.fHourEnd.value = "";
  // no fGroup: membership is carried off the task now, not typed into a box
  els.fPreds.value = ""; els.fTeam.value = ""; els.fSystem.value = "";
  els.fAcc.value = ""; els.fResp.value = ""; els.fMilestone.checked = false;
  A.saveModal();
  assert.strictEqual(row("Fresh").status, "Planned");
  eqArr(stepsOf(row("Fresh")), [A.today() + " Planned"]);
  // and the dialog is where a single task's trail is read back
  A.openModal({ type: "edit", id: idOfName("Fresh") });
  assert(els.fHistory.innerHTML.includes(A.today()), "the dialog shows the trail: " + els.fHistory.innerHTML);
  A.closeModal();
});

console.log("\n-- schedule health: the seven buckets --");

/* The rule is about *today*, so the fixtures are dated relative to it rather
   than written down — a plan with hard-coded 2026 dates would start passing for
   the wrong reason the moment the calendar moved past it. */
const dayOff = n => A.fmtDate(A.addDays(new Date(), n));
const bucketOf = (o) => {
  loadRowsNow([{ id: 1, name: "T", start: o.start, end: o.end }]);
  const t2 = row("T");
  if (o.status) A.setTaskStatus(t2, o.status);
  return A.healthOf(t2);
};

t("every task lands in exactly one bucket, and they add up", () => {
  /* The order is the order every stacked bar and legend is drawn in, healthiest
     first — and Blocked is last, at the far end where the eye lands, because it
     is the one that stops a plan dead. */
  /* Cancelled is second, beside Done: the two terminal states, and everything
     after them is work still in the air. */
  eqArr(A.HEALTH_KEYS, ["done", "cancelled", "progress", "upcoming", "lateStart", "hold", "overdue", "blocked"]);
  loadSample();
  const counts = A.healthCounts();
  const total = A.dashTasks().length;
  assert.strictEqual(A.HEALTH_KEYS.reduce((n, k) => n + counts[k], 0), total,
    "the buckets must partition the tasks, or every percentage on the board is wrong");
});

t("Done is green whatever the dates say", () => {
  assert.strictEqual(bucketOf({ start: dayOff(-9), end: dayOff(-5), status: "Done" }), "done",
    "finished last week is finished");
  assert.strictEqual(bucketOf({ start: dayOff(3), end: dayOff(5), status: "Done" }), "done");
});

t("work that is merely running goes overdue when its end date passes", () => {
  for (const st of ["Planned", "In Progress"])
    assert.strictEqual(bucketOf({ start: dayOff(-9), end: dayOff(-2), status: st }), "overdue", st);
});

/* **Stopped work says why it stopped; running work says whether it is late.**
   This reverses the v3.0.0 order, on a report from a real plan: the one Blocked
   task in it had a July end date, so it was counted overdue and the Blocked
   card read 0 with a blocked task on screen. Blocked and On Hold are answers
   somebody gave by hand and are the *reason* the date passed, so they are asked
   before the dates. In Progress is not — see the test above. */
t("Blocked and On Hold outrank the dates in both directions", () => {
  // in its window: as before
  assert.strictEqual(bucketOf({ start: dayOff(-2), end: dayOff(2), status: "Blocked" }), "blocked");
  assert.strictEqual(bucketOf({ start: dayOff(-2), end: dayOff(2), status: "On Hold" }), "hold");
  /* A task stopped now is worth seeing now, even if it was not due to start
     until next month — which is where these outrank Upcoming. */
  assert.strictEqual(bucketOf({ start: dayOff(20), end: dayOff(30), status: "Blocked" }), "blocked");
  assert.strictEqual(bucketOf({ start: dayOff(20), end: dayOff(30), status: "On Hold" }), "hold");
  // and past its end date: this is the part that changed
  assert.strictEqual(bucketOf({ start: dayOff(-9), end: dayOff(-2), status: "Blocked" }), "blocked",
    "a blocked task must be counted as blocked, whatever its dates say");
  assert.strictEqual(bucketOf({ start: dayOff(-9), end: dayOff(-2), status: "On Hold" }), "hold");
});

/* Its bucket no longer says the date has gone by, so the tooltip has to. */
t("a stopped task past its end date says both", () => {
  loadRowsNow([{ id: 1, name: "Stuck", start: dayOff(-9), end: dayOff(-2), team: "Dev" }]);
  const t1 = A.taskById(1);
  A.setTaskStatus(t1, "Blocked");
  assert.strictEqual(A.healthOf(t1), "blocked");
  assert(/Blocked — and past its end date/.test(A.healthTip(t1)), A.healthTip(t1));
  // one still inside its window says only what it is
  loadRowsNow([{ id: 1, name: "Stuck", start: dayOff(-1), end: dayOff(6), team: "Dev" }]);
  const t2 = A.taskById(1);
  A.setTaskStatus(t2, "Blocked");
  assert.strictEqual(A.healthTip(t2), "&#10;Blocked");
});

t("running now is In Progress; still ahead is Upcoming", () => {
  assert.strictEqual(bucketOf({ start: dayOff(-2), end: dayOff(2), status: "In Progress" }), "progress");
  assert.strictEqual(bucketOf({ start: dayOff(0), end: dayOff(0), status: "In Progress" }), "progress",
    "a one-day task on its own day counts as running");
  assert.strictEqual(bucketOf({ start: dayOff(5), end: dayOff(9) }), "upcoming");
});

t("its start is behind us and nobody has moved it on: Late Start", () => {
  assert.strictEqual(bucketOf({ start: dayOff(-2), end: dayOff(2), status: "Planned" }), "lateStart");
  assert.strictEqual(bucketOf({ start: dayOff(0), end: dayOff(4), status: "Planned" }), "lateStart");
});

t("healthAt answers for a past day, out of the task's own history", () => {
  /* Built through the file, in order, the way a plan that has been followed for
     a month actually reads — setTaskStatus always dates its entry today, so a
     backdated trail can only come from a file. */
  loadRowsNow([{ id: 1, name: "T", start: dayOff(-20), end: dayOff(20), status: "Done",
                 history: `${dayOff(-20)} Planned;${dayOff(-5)} In Progress;${dayOff(-1)} Done` }]);
  const t2 = row("T");
  assert.strictEqual(A.healthOf(t2), "done");
  assert.strictEqual(A.healthAt(t2, A.addDays(new Date(), -3)), "progress", "three days ago it was running");
  assert.strictEqual(A.healthAt(t2, A.addDays(new Date(), -10)), "lateStart", "ten days ago nobody had started it");
  assert.strictEqual(A.statusAt(t2, A.addDays(new Date(), -30)), "Planned", "before its first entry");
  assert.strictEqual(A.bornAt(t2, A.addDays(new Date(), -30)), false, "and the plan did not know it yet");
});

t("the tone reaches every chart that draws one task, and no other", () => {
  loadSample();
  useRef({});
  A.setTaskStatus(row("Requirements workshop"), "Done");
  A.setTaskStatus(row("Infrastructure build-out"), "Blocked");

  A.state.view = "gantt"; A.renderGantt();
  assert(/class="bar[^"]*hDone/.test(els.ganttBody.innerHTML), "the Design view is not coloured");
  assert(/class="bar[^"]*hBlocked/.test(els.ganttBody.innerHTML), "…including the new bucket");

  A.state.view = "zoom"; A.state.zoomLevel = "day"; A.state.zoomBy = "task"; A.renderZoom();
  assert(/class="bar[^"]*h[A-Z]/.test(els.hvBody.innerHTML), "the day view is not coloured");
  A.state.zoomLevel = "hour"; A.renderZoom();
  assert(/class="hrBar [a-z]+ h[A-Z]/.test(els.hvBody.innerHTML), "the hour view is not coloured");

  /* A Team lane holds many tasks at once and a resource cell holds none — a
     colour on either would stand for nothing in particular. */
  A.state.zoomLevel = "month"; A.state.zoomBy = "team"; A.renderZoom();
  assert(!/hDone|hOverdue|hBlocked/.test(els.hvBody.innerHTML), "a Team lane took a health colour");
  A.state.zoomBy = "task";
  A.state.view = "res"; A.renderResources();
  assert(!/hDone|hOverdue|hBlocked/.test(els.resBody.innerHTML), "a resource cell took one");

  A.state.view = "table"; useRef(null);
});

/* The helicopter is the one view whose colour already meant something — the
   density of a month's subtasks — so an Upcoming task keeps its shade there. */
t("the helicopter keeps its density shading for Upcoming tasks only", () => {
  loadSample();
  useRef({}); A.state.view = "zoom"; A.state.zoomLevel = "month"; A.state.zoomBy = "task";
  A.renderZoom();
  const segs = els.hvBody.innerHTML.match(/class="hvSeg d\d[^"]*"/g) || [];
  assert(segs.length, "no segments drawn");
  assert(segs.some(c => !/h[A-Z]/.test(c)), "no segment kept its plain density shade");
  A.state.view = "table"; useRef(null);
});

t("milestones take the same bucket, in every view that draws one", () => {
  loadRowsNow([{ id: 1, name: "Missed", start: dayOff(-4), end: dayOff(-4), ms: true },
               { id: 2, name: "Hit", start: dayOff(-3), end: dayOff(-3), ms: true },
               { id: 3, name: "Ahead", start: dayOff(20), end: dayOff(20), ms: true },
               { id: 4, name: "Work", start: dayOff(-6), end: dayOff(9) }]);
  A.setTaskStatus(row("Hit"), "Done");
  assert.strictEqual(A.healthOf(row("Missed")), "overdue", "a milestone whose day has passed");
  assert.strictEqual(A.healthOf(row("Hit")), "done");
  assert.strictEqual(A.healthOf(row("Ahead")), "upcoming");

  useRef({});
  A.state.view = "gantt"; A.renderGantt();
  const g = els.ganttBody.innerHTML;
  assert(/class="msMark hOverdue"/.test(g), "the ★ is not coloured: " + (g.match(/class="msMark[^"]*"/g) || []).join(" "));
  assert(/class="msMark hDone"/.test(g));

  A.state.view = "zoom"; A.state.zoomLevel = "day"; A.state.zoomBy = "task"; A.renderZoom();
  const z = els.hvBody.innerHTML;
  assert(/class="hvMsLine hOverdue"/.test(z), "the rule is not coloured");
  assert(/class="hvMsFoot hOverdue"/.test(z), "nor the name under it");
  A.state.view = "table"; useRef(null);
});

/* The bucket classes tie with the colours they replace on specificity —
   .bar.group, .hrBar.main, .hvSeg.d3 all carry two classes too — so **source
   order** is the whole mechanism. This is the one thing a stub DOM cannot see:
   it reads the class in the markup, never the colour on screen. So read the
   stylesheet. */
t("the health rules come after every colour they have to beat", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const at = re => { const i = css.search(re); assert(i > 0, "missing rule: " + re); return i; };
  const tones = at(/\.bar\.hDone,/);
  for (const beaten of [/\.bar\{/, /\.bar\.group\{/, /\.bar\.mainBar\{/, /\.bar\.extraBar\{/,
                        /\.hrBar\.main\{/, /\.hrBar\.sub\{/, /\.hvSeg\.d4,/, /\.lgSw\{/])
    assert(at(beaten) < tones, "the health block must come after " + beaten);
});

t("the Design view carries the colour key, built from the buckets themselves", () => {
  loadSample();
  useRef({}); A.state.view = "gantt"; A.renderGantt();
  const key = els.ganttLegend.innerHTML;
  for (const b of A.HEALTH)
    assert(key.includes('class="lgSw ' + b.cls + '"'), "no swatch for " + b.key + ": " + key);
  assert.strictEqual((key.match(/lgSw/g) || []).length, A.HEALTH.length, "one swatch per bucket and no more");
  A.state.view = "table"; useRef(null);
});

t("the tooltip says why the bar is that colour", () => {
  loadRowsNow([{ id: 1, name: "T", start: dayOff(-9), end: dayOff(-2) }]);
  assert(/Overdue — ended/.test(A.healthTip(row("T"))), A.healthTip(row("T")));
  loadRowsNow([{ id: 1, name: "L", start: dayOff(-2), end: dayOff(4) }]);
  assert(/Late start/.test(A.healthTip(row("L"))), A.healthTip(row("L")));
});

console.log("\n-- the dashboard --");

/* A plan with one of everything, dated around today so the buckets are real:
   two milestones cutting it into windows, tasks ending either side of them,
   and two teams with people on them. */
const dashFixture = () => {
  loadRowsNow([
    { id: 1,  name: "Spec",      start: dayOff(-30), end: dayOff(-20), team: "Design", resp: "Anna:4" },
    { id: 2,  name: "Wireframe", start: dayOff(-25), end: dayOff(-18), team: "Design", resp: "Bela:8" },
    { id: 3,  name: "Alpha",     start: dayOff(-15), end: dayOff(-15), ms: true },
    { id: 4,  name: "Build",     start: dayOff(-14), end: dayOff(-2),  team: "Dev", resp: "Csaba:8" },
    { id: 5,  name: "Integrate", start: dayOff(-10), end: dayOff(3),   team: "Dev", resp: "Dora:8;Elek:8" },
    { id: 6,  name: "Beta",      start: dayOff(5),   end: dayOff(5),   ms: true },
    { id: 7,  name: "Handover",  start: dayOff(8),   end: dayOff(14),  team: "Dev", resp: "Csaba:4" },
    { id: 8,  name: "Training",  start: dayOff(10),  end: dayOff(16),  team: "Ops" },
  ]);
  A.setTaskStatus(row("Spec"), "Done");
  A.setTaskStatus(row("Wireframe"), "Done");
  A.setTaskStatus(row("Alpha"), "Done");
  A.setTaskStatus(row("Integrate"), "In Progress");
  // "Build" is left Planned with its end behind us -> overdue
};

t("the board counts leaf tasks — not groups, not milestones", () => {
  loadSample();
  const leaves = A.dashTasks();
  assert(!leaves.some(t2 => t2.milestone), "a milestone is a moment, and has its own section");
  assert(!leaves.some(t2 => A.isGroup(t2)), "a group's dates are its members', so it would count twice");
  assert(leaves.length < A.state.tasks.length, "the sample has groups and milestones to leave out");
});

t("the cards, the stacked bar and the total are the same numbers", () => {
  dashFixture();
  const counts = A.healthCounts();
  const total = A.dashTasks().length;
  assert.strictEqual(total, 6, "six leaf tasks; the two milestones are counted elsewhere");
  assert.strictEqual(counts.done, 2, "Spec and Wireframe");
  assert.strictEqual(counts.overdue, 1, "Build ended before today and is still Planned");
  assert.strictEqual(counts.progress, 1, "Integrate");
  assert.strictEqual(counts.upcoming, 2, "Handover and Training");

  const bar = A.dashHealthBarHtml(counts, total);
  const widths = [...bar.matchAll(/class="hSeg h\w+" style="width:([\d.]+)%/g)].map(m => +m[1]);
  assert(Math.abs(widths.reduce((a2, b2) => a2 + b2, 0) - 100) < 0.5,
    "the stacked bar must add up to 100%: " + widths.join(" + "));
});

t("every KPI card is a bucket you can click through to", () => {
  dashFixture();
  const html2 = A.dashKpiHtml(A.healthCounts(), A.dashTasks().length);
  for (const c of A.DASH_CARDS)
    assert(html2.includes('data-health="' + c.key + '"'), "no card for " + (c.key || "total"));
  /* Every card in the top row behaves the same way — click it, see those rows.
     A milestone count among them would be the one card counting something else,
     so it lives in the Milestone Health section instead. */
  assert(!/data-dash=/.test(html2), "the top row is the task buckets and the total, nothing else");
  assert(A.dashMilestoneHtml().includes('data-dash="milestones"'), "and the milestone table is still reachable");
});

/* ---- The three cards that are not just reporting a number ----
   Late Start speaks up at the first task, Overdue at the second, Blocked at the
   first. The two thresholds are deliberately different: that is the rule as it
   was asked for, and the test is where it is written down. */
const kpiCard = (h, key) => (h.match(new RegExp(`<button class="([^"]*)"[^>]*data-health="${key}"`)) || [])[1] || "";

t("a card with nothing wrong about it stays plain", () => {
  loadRowsNow([
    { id: 1, name: "Later", start: dayOff(5), end: dayOff(9), team: "Dev" },
    { id: 2, name: "Now", start: dayOff(-1), end: dayOff(4), team: "Dev" },
  ]);
  A.setTaskStatus(row("Now"), "In Progress");
  const h = A.dashKpiHtml(A.healthCounts(), A.dashTasks().length);
  for (const key of ["lateStart", "overdue", "blocked"])
    assert(!/kpiWarn|kpiAlert|kpiStop/.test(kpiCard(h, key)), key + " should be plain: " + kpiCard(h, key));
});

t("one task that should have started turns Late Start yellow", () => {
  loadRowsNow([
    { id: 1, name: "Should have begun", start: dayOff(-3), end: dayOff(4), team: "Dev" },
    { id: 2, name: "Later", start: dayOff(5), end: dayOff(9), team: "Dev" },
  ]);
  const counts = A.healthCounts();
  assert.strictEqual(counts.lateStart, 1, "the fixture really is one late start");
  const h = A.dashKpiHtml(counts, A.dashTasks().length);
  assert(/kpiWarn/.test(kpiCard(h, "lateStart")), "from the very first one: " + kpiCard(h, "lateStart"));
  /* The exact class the stylesheet inks — it wore a "Card" suffix once, which
     no CSS rule defined, so the edge sat grey while this test read the class
     name and was satisfied. */
  assert(kpiCard(h, "lateStart").split(" ").includes("hLateStart"),
    "and it keeps its health colour on the edge: " + kpiCard(h, "lateStart"));
});

/* Asked for as "more than one", so one overdue task is *not* the threshold —
   worth knowing, because Late Start beside it lights up at one. */
t("Overdue turns orange at the second task, not the first", () => {
  loadRowsNow([
    { id: 1, name: "Missed", start: dayOff(-9), end: dayOff(-2), team: "Dev" },
    { id: 2, name: "Fine", start: dayOff(1), end: dayOff(4), team: "Dev" },
  ]);
  assert.strictEqual(A.healthCounts().overdue, 1);
  let h = A.dashKpiHtml(A.healthCounts(), A.dashTasks().length);
  assert(!/kpiAlert/.test(kpiCard(h, "overdue")), "one is not yet 'more than one'");

  loadRowsNow([
    { id: 1, name: "Missed", start: dayOff(-9), end: dayOff(-2), team: "Dev" },
    { id: 2, name: "Missed too", start: dayOff(-8), end: dayOff(-3), team: "Dev" },
    { id: 3, name: "Fine", start: dayOff(1), end: dayOff(4), team: "Dev" },
  ]);
  assert.strictEqual(A.healthCounts().overdue, 2);
  h = A.dashKpiHtml(A.healthCounts(), A.dashTasks().length);
  assert(/kpiAlert/.test(kpiCard(h, "overdue")), "two is: " + kpiCard(h, "overdue"));
});

t("one blocked task turns Blocked red", () => {
  loadRowsNow([
    { id: 1, name: "Stuck", start: dayOff(-1), end: dayOff(6), team: "Dev" },
    { id: 2, name: "Fine", start: dayOff(1), end: dayOff(4), team: "Dev" },
  ]);
  A.setTaskStatus(row("Stuck"), "Blocked");
  assert.strictEqual(A.healthCounts().blocked, 1);
  const h = A.dashKpiHtml(A.healthCounts(), A.dashTasks().length);
  assert(/kpiStop/.test(kpiCard(h, "blocked")), "nothing about being stopped is a matter of degree");
  /* And it says why. Since 2026-08-22 the card's own sentence leads the
     tooltip — on a tablet it is the ONLY place that sentence appears (the
     view-controls brief's §2 takes it off the card) — and the alert's reason
     comes under it, above the "show these in the list" line every card has. */
  const tip = h.slice(h.indexOf('data-health="blocked"'));
  assert(/title="[^"]*Stopped by something[^"]*Work is stopped[^"]*Show these/.test(tip),
    "the note, then why it is red, then what a press does: " + tip.slice(0, 320));
});

t("the thresholds live in one place, and only three cards have one", () => {
  eqArr(A.DASH_ALERTS.map(a => a.key + "@" + a.at), ["lateStart@1", "overdue@2", "blocked@1"]);
  for (const c of A.DASH_CARDS)
    if (!A.DASH_ALERTS.some(a => a.key === c.key))
      assert.strictEqual(A.dashAlert(c.key, 999), null, (c.key || "total") + " must never tint");
});

t("the tints are written after the card they repaint", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const at = re => { const i = css.search(re); assert(i > 0, "missing rule: " + re); return i; };
  assert(at(/\.kpi\.kpiWarn\{/) > at(/\n  \.kpi\{/), "the plain card is declared first");
  for (const [cls, hex] of [["kpiWarn", "#fef9c3"], ["kpiAlert", "#ffedd5"], ["kpiStop", "#fecaca"]])
    assert(new RegExp(`\\.kpi\\.${cls}\\{background:${hex}`).test(css), cls + " lost its colour");
});

/* The rule the user gave: a milestone owns the tasks that end after the
   previous milestone and on or before it. */
t("a milestone owns the work that ends in its own window", () => {
  dashFixture();
  const ms = A.milestoneHealth();
  eqArr(ms.map(m => m.t.name), ["Alpha", "Beta"], "in date order");

  const alpha = ms[0], beta = ms[1];
  // Alpha owns everything ending on or before its own day
  eqArr(alpha.owned.map(o => o.name).sort(), ["Spec", "Wireframe"]);
  assert.strictEqual(alpha.done, 2, "both are Done");

  /* Build ends after Alpha and before Beta, so it belongs to **Beta** — that is
     the whole point of "after the previous milestone", and it is the difference
     between a milestone owning its own stretch and owning everything before it. */
  eqArr(beta.owned.map(o => o.name).sort(), ["Build", "Integrate"]);
  assert(!alpha.owned.some(o => o.name === "Build"), "a task cannot belong to two milestones");
  // and work after the last milestone belongs to no milestone at all
  assert(!ms.some(m => m.owned.some(o => o.name === "Handover")), "Handover ends after every milestone");
});

t("a milestone is At Risk when its own window is in trouble", () => {
  dashFixture();
  const betaOf = () => A.milestoneHealth().find(m => m.t.name === "Beta");
  /* Beta's own date is still ahead, but Build — which ends inside its window —
     is already overdue. That is the earliest honest warning there is. */
  assert.strictEqual(betaOf().key, "risk", "the date has not slipped, but the work behind it has");
  A.setTaskStatus(row("Build"), "Done");
  assert.strictEqual(betaOf().key, "track", "with the window clean it is back on track");
  A.setTaskStatus(row("Integrate"), "Blocked");
  assert.strictEqual(betaOf().key, "risk", "blocked counts as trouble too, not just overdue");
});

t("a milestone's own status and date decide Completed and Overdue", () => {
  dashFixture();
  const ms = A.milestoneHealth();
  assert.strictEqual(ms.find(m => m.t.name === "Alpha").key, "completed", "the milestone itself is Done");
  A.setTaskStatus(row("Alpha"), "In Progress");
  assert.strictEqual(A.milestoneHealth().find(m => m.t.name === "Alpha").key, "overdue",
    "its day has passed and nobody marked it");
});

t("milestone progress is the share of its window that is Done", () => {
  dashFixture();
  const pctOf = n => A.milestoneHealth().find(m => m.t.name === n).pct;
  assert.strictEqual(pctOf("Alpha"), 100, "both of its two tasks are Done");
  assert.strictEqual(pctOf("Beta"), 0, "neither of its two is");
  A.setTaskStatus(row("Build"), "Done");
  assert.strictEqual(pctOf("Beta"), 50, "one of the two");
});

t("the team table is the busiest teams first, and the board shows six", () => {
  dashFixture();
  const rows = A.teamWorkload();
  eqArr(rows.map(r => r.team), ["Dev", "Design", "Ops"], "most tasks first");
  assert.strictEqual(rows[0].n, 3);
  assert.strictEqual(rows[0].overdue, 1, "Build is Dev's");
  assert.strictEqual(A.DASH_TOP_TEAMS, 6, "the board's cut-off");
});

t("a team with nobody named on it has no workload to show, and says so", () => {
  dashFixture();
  const ops = A.teamWorkload().find(r => r.team === "Ops");
  assert.strictEqual(ops.load, null, "0% and 100% would both be lies");
  assert(A.dashTeamHtml().includes("—"), "the cell says so rather than showing a number");
});

t("workload is the busiest day against the people on the team", () => {
  dashFixture();
  const dev = A.teamWorkload().find(r => r.team === "Dev");
  assert.strictEqual(dev.people, 3, "Csaba, Dora, Elek — Csaba once, though he is on two tasks");
  assert(dev.load > 0, "Integrate is running now with two people on it: " + dev.load);
  assert(dev.load <= 100, "nobody is over-booked in this fixture: " + dev.load);
});

t("the full pages hold back nothing the board summarises", () => {
  dashFixture();
  A.state.dashAll = "teams";
  const all = A.dashFullHtml();
  for (const r of A.teamWorkload()) assert(all.includes(">" + r.team + "<"), "missing team " + r.team);
  A.state.dashAll = "milestones";
  const msAll = A.dashFullHtml();
  for (const m of A.milestoneHealth()) assert(msAll.includes(m.t.name), "missing milestone " + m.t.name);
  assert(msAll.includes('data-dash=""'), "and there is a way back");
  A.state.dashAll = "";
});

t("planned is what the dates promise, actual what the history says", () => {
  dashFixture();
  A.state.dashWeight = "count";
  const m = A.progressModel();
  // 4 of the 6 leaf tasks end on or before today (Spec, Wireframe, Build, plus none)
  const now = new Date();
  const p = A.progressAt(A.dashTasks(), now, A.dashTasks().length);
  assert.strictEqual(p.actual, A.dashPct(2, 6), "two are Done");
  assert(p.planned > p.actual, "three should have ended by now and only two have");
  assert(m.variance < 0, "so the plan is behind: " + m.variance);
  A.state.dashWeight = "duration";
});

t("the weighting picker changes the question, not the plan", () => {
  dashFixture();
  A.state.dashWeight = "count";
  const byCount = A.progressModel().today.actual;
  A.state.dashWeight = "duration";
  const byDuration = A.progressModel().today.actual;
  assert.notStrictEqual(byCount, byDuration, "a six-week task is not the same promise as a one-day one");
  assert.strictEqual(A.state.tasks.length, 8, "and nothing about the plan moved");
});

t("the trend looks back eight weeks and reconstructs each one", () => {
  dashFixture();
  const m = A.trendModel();
  assert.strictEqual(m.weeks.length, A.DASH_WEEKS);
  assert.strictEqual(m.weeks[m.weeks.length - 1].label, "Today");
  // the overdue line is exact from day one: an end date in the past is a fact
  assert.strictEqual(m.weeks[m.weeks.length - 1].counts.overdue, 1);
  assert(m.thin, "a plan whose history starts today has to say so");
  assert(A.renderDash === undefined || true);
});

t("the overdue donut splits by how late, and adds up to the overdue count", () => {
  dashFixture();
  const o = A.overdueBands();
  assert.strictEqual(o.total, A.healthCounts().overdue);
  assert.strictEqual(o.rows.reduce((n, b) => n + b.n, 0), o.total, "every overdue task is in exactly one band");
  const svg = A.donutHtml(o.rows, o.total, String(o.total), "tasks");
  assert.strictEqual((svg.match(/<circle/g) || []).length, o.rows.filter(b => b.n).length || 1);
});

t("an empty plan draws a board rather than a crash", () => {
  loadTasks(CSV_HDR_NOW + "\n");
  useRef({}); A.state.view = "dash";
  A.renderDash();
  const out = els.dashView.innerHTML;
  assert(out.includes("kpiRow"), "the cards are still there");
  assert(/no milestones yet/i.test(out), "and the empty sections say what they are: " + out.slice(0, 200));
  A.state.view = "table"; useRef(null);
});

t("the whole board is drawn from the plan, every time", () => {
  dashFixture();
  useRef({}); A.state.view = "dash";
  A.renderDash();
  const out = els.dashView.innerHTML;
  for (const bit of ["Schedule Health", "Planned vs Actual", "Tasks Status Trend",
                     "Milestone Health", "Overdue Tasks Trend", "Overdue by Duration", "Workload by Team"])
    assert(out.includes(bit), "missing card: " + bit);
  assert(out.includes("dashSvg"), "the charts are drawn");
  assert.strictEqual((out.match(/lgSw h\w+/g) || []).length > A.HEALTH.length, true, "the footer carries the key");
  A.state.view = "table"; useRef(null);
});

/* The layout bugs from the first build, pinned. A stub DOM cannot see a card,
   but it can see the three things that broke one: a table that negotiates its
   own column widths, a name with no way to be cut, and a card order. */
t("a long milestone name is cut rather than allowed to take the row", () => {
  const long = "Regulatory sign-off for the Q3 switchover window and everything after it";
  loadRowsNow([{ id: 1, name: long, start: dayOff(6), end: dayOff(6), ms: true },
               { id: 2, name: "Work", start: dayOff(-3), end: dayOff(3) }]);
  const out = A.dashMilestoneHtml();
  assert(out.includes('title="' + long + '"'), "the full name must still be reachable: " + out.slice(0, 300));
  assert(/<colgroup>/.test(out), "fixed widths, or one long name takes the whole card");
  assert.strictEqual((out.match(/<col /g) || []).length, 4, "four columns, not five");
  assert(!/Planned date/.test(out), "the header that wrapped onto two lines is gone");

  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/\.dashTbl\{[^}]*table-layout:fixed/.test(css), "the <col> widths are only binding with table-layout:fixed");
  assert(/\.dashTbl td\{[^}]*text-overflow:ellipsis/.test(css), "and every cell has to be able to cut");
});

t("the cards are ordered so no short one sits beside a tall one", () => {
  dashFixture();
  useRef({}); A.state.view = "dash";
  A.renderDash();
  const titles = [...els.dashView.innerHTML.matchAll(/<h3>([^<]*)/g)].map(m => m[1].trim());
  eqArr(titles, ["Schedule Health", "Milestone Health", "Workload by Team",
                 "Tasks Status Trend", "Planned vs Actual Progress",
                 "Overdue Tasks Trend", "Overdue by Duration"],
    "the two tables come first, then the charts together");
  // the stacked bar is one long bar, so it runs the full width
  assert(/<section class="card wide">/.test(els.dashView.innerHTML), "Schedule Health does not span the row");
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/\.dashGrid\{[^}]*align-items:stretch/.test(css), "cards in a row must end at the same line");
  A.state.view = "table"; useRef(null);
});

t("a week before the plan's history starts is a gap, not a zero", () => {
  dashFixture();
  const m = A.trendModel();
  assert(m.weeks.some(w => !w.known), "this fixture's history starts today");
  // the status lines lift the pen over the unknown weeks…
  const svg = A.dashKpiHtml && els.dashView ? null : null;
  const path = A.svgPath([null, null, 3, 4], 10);
  assert(path.startsWith("M"), "the pen comes down at the first real point: " + path);
  assert.strictEqual((path.match(/M/g) || []).length, 1);
  assert.strictEqual(A.svgPath([1, null, 3], 10).match(/M/g).length, 2, "and lifts across a gap");
  // …while the overdue line reaches back regardless: an end date in the past is a fact
  assert.strictEqual(m.weeks[0].all.overdue >= 0, true);
  assert(m.weeks[m.weeks.length - 1].all.overdue >= m.weeks[0].all.overdue,
    "overdue can only accumulate in a plan nobody has touched");
  void svg;
});

console.log("\n-- the list, filtered by a bucket --");

t("the Status filter narrows the list to one bucket", () => {
  dashFixture();
  A.state.filterHealth = "overdue";
  const rows = A.displayTasks();
  eqArr(rows.map(r => r.t.name), ["Build"], "only the overdue one");
  A.state.filterHealth = "done";
  eqArr(A.displayTasks().map(r => r.t.name).sort(), ["Alpha", "Spec", "Wireframe"],
    "and a Done milestone counts as Done");
  A.state.filterHealth = "";
});

/* Team, System and Person are project-wide filters and milestones ignore them.
   The health filter is the one that does not: it is how the dashboard hands
   over "show me these rows", and a milestone that is not one of them has no
   business in the answer. */
t("milestones are not exempt from the health filter, unlike the others", () => {
  dashFixture();
  A.state.filterTeam = "Dev";
  assert(A.displayTasks().some(r => r.t.milestone), "a milestone ignores the Team filter, as always");
  A.state.filterTeam = "";
  A.state.filterHealth = "upcoming";
  eqArr(A.displayTasks().map(r => r.t.name).sort(), ["Beta", "Handover", "Training"],
    "Beta is upcoming too, and belongs in the answer");
  A.state.filterHealth = "";
});

t("the picker offers every bucket, empty or not", () => {
  dashFixture();
  A.refreshFilterSelects();
  for (const b of A.HEALTH)
    assert(els.filterHealth.innerHTML.includes('value="' + b.key + '"'), "no entry for " + b.key);
  assert(els.filterHealth.innerHTML.includes('value=""'), "and a way back to all of them");
});

t("a bucket this build does not know is dropped rather than kept", () => {
  A.state.filterHealth = "somethingElse";
  A.refreshFilterSelects();
  assert.strictEqual(A.state.filterHealth, "");
});

t("the dashboard is the whole plan: it has no filter row", () => {
  dashFixture();
  useRef({});
  A.setView("dash");
  assert.strictEqual(els.listCtrls.style.display, "none",
    "a board narrowed to one team answers a different question from the one its title asks");
  assert.strictEqual(els.dashView.style.display, "block");
  A.setView("table");
  assert.strictEqual(els.listCtrls.style.display, "");
  useRef(null);
});

t("leaving the board forgets which full page was open", () => {
  A.state.dashAll = "teams";
  useRef({});
  A.setView("table");
  assert.strictEqual(A.state.dashAll, "");
  useRef(null);
});

t("both fields go out to Excel and come back in", () => {
  const t2 = stTask("Build");
  A.setTaskStatus(t2, "In Progress", "2026-03-05");
  A.setTaskStatus(t2, "Done", "2026-03-11");
  const back = A.parseImport(A.spreadsheetML(A.xlRows(), "x"), "x.xml").rows[0];
  assert.strictEqual(back.status, "Done");
  eqArr(back.history.map(h => h.date + " " + h.status),
        ["2026-03-02 Planned", "2026-03-05 In Progress", "2026-03-11 Done"].map((s, i) =>
          i === 0 ? A.today() + " Planned" : s));
});

t("a foreign sheet's status word is read, and an unknown one is reported", () => {
  const sheet = asHtml([["ID", "Task", "Start", "End", "Állapot"],
                        ["1", "Magyar", "2026-03-02", "2026-03-03", "Folyamatban"],
                        ["2", "Ismeretlen", "2026-03-04", "2026-03-05", "Páfrány"],
                        ["3", "Elvetve", "2026-03-06", "2026-03-07", "Törölve"]]);
  const got = A.parseImport(sheet, "x.html");
  assert.strictEqual(got.rows[0].status, "In Progress", "the Hungarian column and word both read");
  assert.strictEqual(got.rows[1].status, "Planned", "an unknown word falls back");
  assert(got.warn.some(w => /Páfrány/.test(w)), "…and is reported: " + JSON.stringify(got.warn));
  // "Törölve" became a word this app knows on 2026-08-21 — see the six statuses
  assert.strictEqual(got.rows[2].status, "Cancelled", "and a cancelled row imports as one");
});

console.log("\n-- combined view: several files at once, read-only --");

/* Loading is asynchronous, so these run from a queue after the synchronous
   suite rather than inline — the count stays honest and the output in order. */
const asyncTests = [];
const ta = (name, fn) => asyncTests.push([name, fn]);

ta("the name is asked for once a session, and never where nothing is written", async () => {
  A.setMyName("");
  A.state.me.asked = false;

  A.state.readOnly = true;
  A.askWhoIAm();
  await new Promise(r => setTimeout(r, 0));
  assert.notStrictEqual(els.askOverlay.style.display, "flex", "a read-only page writes nothing, so it has no author");

  A.state.readOnly = false;
  const p1 = A.askWhoIAm();
  await new Promise(r => setTimeout(r, 0));
  assert.strictEqual(els.askOverlay.style.display, "flex");
  els.askInput.value = "Nobody";
  A.askSubmit();
  await p1;
  assert.strictEqual(A.myName(), "Nobody");

  A.setMyName(""); A.state.me.asked = false;
  const p2 = A.askWhoIAm();
  await new Promise(r => setTimeout(r, 0));
  A.askCancel();                                            // backed out
  await p2;
  const p3 = A.askWhoIAm();
  await new Promise(r => setTimeout(r, 0));
  assert.strictEqual(els.askOverlay.style.display, "none", "asked once and not again, even after a refusal");
  await p3;
  assert.strictEqual(A.myName(), "", "and an unnamed author is allowed");

  A.setMyName("Anna Kovacs");
  A.state.me.asked = true;
});

/* A /DB that lives in memory: enough of the File System Access API for
   openCombined(), and nothing else. */
const fakeDb = files => ({
  getFileHandle: async name => {
    if (!(name in files)) throw new Error("no such file: " + name);
    return { getFile: async () => ({ text: async () => files[name] }) };
  },
});

// csvRow() from the list-view section: thirteen columns are too many to line up by eye
const csvFile = rows => CSV_HDR + "\n" + rows.map(csvRow).join("\n") + "\n";

// two files that both number from 1, both with a group and a dependency
const FILE_A = csvFile([
  { id: 1, name: "A group",  start: "2026-01-01", end: "2026-01-20", group: [2, 3] },
  { id: 2, name: "A first",  start: "2026-01-01", end: "2026-01-10", team: "Alpha" },
  { id: 3, name: "A second", start: "2026-01-11", end: "2026-01-20", team: "Alpha", preds: [2] },
]);
const FILE_B = csvFile([
  { id: 1, name: "B group",      start: "2026-02-01", end: "2026-02-20", group: [2] },
  { id: 2, name: "B only child", start: "2026-02-01", end: "2026-02-20", team: "Beta" },
]);

/* Two plans that both work to the same programme dates. Every file has to stand
   alone, so each carries its own copy of the shared milestones. */
const FILE_MS_A = csvFile([
  { id: 1, name: "A work",       start: "2026-01-01", end: "2026-01-10", team: "Alpha", preds: [2] },
  { id: 2, name: "Go-live",      start: "2026-03-01", end: "2026-03-01", ms: true },
  { id: 3, name: "Design frozen", start: "2026-02-01", end: "2026-02-01", ms: true },
]);
const FILE_MS_B = csvFile([
  { id: 1, name: "B work",       start: "2026-01-05", end: "2026-01-15", team: "Beta", preds: [2] },
  { id: 2, name: " go-LIVE ",    start: "2026-03-01", end: "2026-03-01", ms: true },
  { id: 3, name: "Design frozen", start: "2026-04-09", end: "2026-04-09", ms: true },
]);

const openCombo = async (files, view) => {
  A.state.view = view || "table";
  A.state.dbHandle = fakeDb(files);
  await A.openCombined(Object.keys(files));
};
const endCombo = async () => { A.state.dbHandle = fakeDb({}); await A.leaveCombined(); };
const rowByName = n => A.state.tasks.find(t => t.name === n);

ta("the picker offers the project files, and ticks the ones you are looking at", async () => {
  A.state.dbHandle = {
    entries: async function* () {
      yield ["alpha.csv", { kind: "file" }];
      yield ["_import-old.csv", { kind: "file" }];   // "_" is where import sources live
      yield ["notes.txt", { kind: "file" }];
      yield ["sub", { kind: "directory" }];
      yield ["beta.csv", { kind: "file" }];
    },
  };
  A.state.combined = ["beta.csv"];
  await A.openCombinePicker();
  const html = els.combineList.innerHTML;
  assert(html.includes('value="alpha.csv"'), "alpha.csv not listed");
  assert(html.includes('value="beta.csv"'), "beta.csv not listed");
  assert(!html.includes("_import-old"), "an import source was offered as a project file");
  assert(!html.includes("notes.txt"), "a non-csv was offered");
  assert(!html.includes(">sub<"), "a directory was offered");
  assert(/value="beta\.csv" checked/.test(html), "the open set is not pre-ticked");
  assert(A.combineOpen(), "the dialog did not open");
  A.closeCombine();
  A.state.combined = null;
  assert(!A.combineOpen());
});

ta("every file's tasks arrive, and each row says where it came from", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  assert.strictEqual(A.state.tasks.length, 5, "not all rows loaded");
  eqArr(A.state.combined, ["a.csv", "b.csv"]);
  assert.strictEqual(rowByName("A first").src, "a.csv");
  assert.strictEqual(rowByName("B only child").src, "b.csv");
  await endCombo();
});

ta("a milestone every plan shares is drawn once", async () => {
  await openCombo({ "a.csv": FILE_MS_A, "b.csv": FILE_MS_B });
  const goLive = A.state.tasks.filter(t => /go-live/i.test(t.name.trim()));
  assert.strictEqual(goLive.length, 1, "the same date was drawn twice: " + goLive.map(t => t.name));
  assert.strictEqual(goLive[0].name, "Go-live", "the first spelling is the one kept");
  /* Same name, different date, so they are two different moments and both stay:
     a name alone is not what makes a milestone the same milestone. */
  assert.strictEqual(A.state.tasks.filter(t => t.name === "Design frozen").length, 2,
    "the two Design frozen dates are a fortnight apart and are not one milestone");
  await endCombo();
});

ta("the shared row says every plan it was found in", async () => {
  await openCombo({ "a.csv": FILE_MS_A, "b.csv": FILE_MS_B });
  const goLive = A.state.tasks.find(t => t.name === "Go-live");
  assert.strictEqual(goLive.src, "a.csv, b.csv", "one row, both files: " + goLive.src);
  assert.strictEqual(rowByName("A work").src, "a.csv", "and an ordinary row still says its own");
  await endCombo();
});

/* Dropping a row that other rows point at is how a combined view loses a
   dependency without saying so. */
ta("a dependency on a dropped copy lands on the row that survived", async () => {
  await openCombo({ "a.csv": FILE_MS_A, "b.csv": FILE_MS_B });
  const goLive = A.state.tasks.find(t => t.name === "Go-live");
  eqArr(rowByName("A work").preds, [goLive.id], "A's own dependency still points at it");
  eqArr(rowByName("B work").preds, [goLive.id], "and B's now points at the same row");
  for (const t of A.state.tasks)
    for (const p of t.preds)
      assert(A.state.tasks.some(x => x.id === p), `#${t.id} points at ${p}, which is not there`);
  await endCombo();
});

ta("two files numbering from 1 do not collide", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  const ids = A.state.tasks.map(t => t.id);
  assert.strictEqual(new Set(ids).size, ids.length, "IDs collided: " + ids.join(","));
  await endCombo();
});

ta("each file still shows its own IDs — the internal ones never reach the screen", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  // both files call their first row 1, and both still do on screen
  eqArr(A.state.tasks.filter(t => t.src === "a.csv").map(A.dispId), [1, 2, 3]);
  eqArr(A.state.tasks.filter(t => t.src === "b.csv").map(A.dispId), [1, 2]);
  assert(rowByName("B group").id !== rowByName("B group").srcId, "file B was not shifted at all");
  await endCombo();
});

ta("shifting the IDs carries the dependencies and the groups with it", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  // "A second" follows "A first", and both sit in "A group" — within file A only
  const first = rowByName("A first"), second = rowByName("A second"), grp = rowByName("A group");
  eqArr(second.preds, [first.id], "predecessor lost in the shift");
  eqArr(grp.group, [first.id, second.id], "group membership lost in the shift");
  // and file B's group holds file B's row, not one of file A's
  const bGrp = rowByName("B group"), bKid = rowByName("B only child");
  eqArr(bGrp.group, [bKid.id]);
  assert.strictEqual(A.taskById(bGrp.group[0]).src, "b.csv", "a group reached into the other file");
  await endCombo();
});

ta("a group's dates still roll up from its members", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  const grp = rowByName("A group");
  assert.strictEqual(grp.start, "2026-01-01");
  assert.strictEqual(grp.end, "2026-01-20");
  await endCombo();
});

ta("the DB File column appears after Notes, and only here", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  const keys = A.activeColumns().map(c => c.key);
  assert(keys.includes("src"), "no source column in the combined view");
  assert.strictEqual(keys[keys.indexOf("notes") + 1], "src", "not directly after Notes");
  assert(A.headRowHtml().includes("DB File"), "not in the header");
  assert(els.tableView.innerHTML.includes("a.csv"), "the file name is not in the rows");
  await endCombo();
  assert(!A.activeColumns().map(c => c.key).includes("src"), "the source column outlived the combined view");
});

ta("no actions column, and no way in to editing", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  const html = els.tableView.innerHTML;
  assert(!A.activeColumns().map(c => c.key).includes("actions"), "actions column still built");
  assert(!html.includes("data-act="), "action buttons rendered");
  assert(!html.includes("Add new task"), "the append row is still offered");
  assert(!html.includes('draggable="true"'), "rows can still be dragged into groups");
  assert(!html.includes("data-drop="), "rows are still drop targets");
  await endCombo();
});

ta("the editing paths refuse, and none of them dirties anything", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  const before = JSON.stringify(A.state.tasks);
  const id = rowByName("A first").id;
  A.startInline(id);
  assert.strictEqual(A.state.inlineEdit, null, "inline editing opened");
  A.openModal({ type: "edit", id });
  assert.strictEqual(els.overlay.classList.contains("open"), false, "the dialog opened");
  A.runRowAction("del", id);
  A.runRowAction("dup", id);
  // the design view's three gestures go through the same one question
  const other = A.state.tasks.find(x => x.id !== id && !x.milestone && !A.isGroup(x));
  assert(/read-only/.test(String(A.designMoveInTime(id, 5))), "a bar could be dragged through time");
  assert(/read-only/.test(String(A.designResizeBy(id, "end", 5))), "a bar could be stretched");
  assert(/read-only/.test(String(A.designDropOnto(id, other.id))), "a bar could be dropped into a group");
  assert.strictEqual(JSON.stringify(A.state.tasks), before, "a row action changed the plan");
  assert.strictEqual(A.undo(), false, "undo ran");
  assert.strictEqual(A.redo(), false, "redo ran");
  assert.strictEqual(A.state.dirty, false, "the combined view went dirty");
  await endCombo();
});

ta("hiding a column works on screen and is written nowhere", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  A.runColAction("hideCol", "team");
  assert(A.state.hiddenCols.has("team"), "the column did not fold away");
  assert(!A.headRowHtml().includes(">Team<"), "still in the header");
  assert.strictEqual(A.state.dirty, false, "folding a column asked for a save");
  assert.strictEqual(A.planRef(), null, "there is a file to save to");
  await endCombo();
  // and it does not follow you back to a single file
  assert.strictEqual(A.state.hiddenCols.size, 0);
});

ta("saving is not merely disabled, it has nothing to write to", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  assert.strictEqual(A.readOnly(), true);
  assert.strictEqual(A.planRef(), null, "a file handle survived into the combined view");
  await A.saveFile(true);                       // must not throw, must not write
  assert.strictEqual(els.btnSave.disabled, true, "Save is still offered");
  assert.strictEqual(els.btnNewTask.disabled, true, "New task is still offered");
  assert.strictEqual(els.fileSelect.disabled, true, "the file dropdown is still live");
  assert(els.combinedChip.textContent.includes("2"), "the chip does not say how many files");
  await endCombo();
});

ta("a file that is not one of ours is left out, not fatal", async () => {
  await openCombo({ "a.csv": FILE_A, "junk.csv": "hello;world\r\nnot;a;plan\r\n" });
  eqArr(A.state.combined, ["a.csv"], "the junk file was adopted");
  assert.strictEqual(A.state.tasks.length, 3);
  await endCombo();
});

ta("leaving puts the app back where it was", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  await endCombo();
  assert.strictEqual(A.state.combined, null);
  assert.strictEqual(A.readOnly(), false);
  assert.strictEqual(els.combinedChip.style.display, "none", "the chip stayed on screen");
  assert(A.activeColumns().map(c => c.key).includes("actions"), "the actions column did not come back");
});

ta("every view draws the combined plan", async () => {
  for (const view of ["table", "gantt", "zoom"]) {
    await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B }, view);
    const host = view === "table" ? els.tableView : view === "gantt" ? els.ganttLeftBody : els.hvLeftBody;
    assert(host.innerHTML.length > 0, view + " drew nothing");
    assert(host.innerHTML.includes("group"), view + " is missing the rows");
    await endCombo();
  }
});

ta("the search reaches the file name", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  A.state.search = "b.csv";
  eqArr(A.state.tasks.filter(A.taskHit).map(t => t.name), ["B group", "B only child"]);
  A.state.search = "";
  await endCombo();
});

ta("the export carries the DB File column, in the same place", async () => {
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  const heads = A.xlCols().map(c => c.head);
  assert.strictEqual(heads[heads.indexOf("Notes") + 1], "DB File", heads.join(", "));
  const xml = A.spreadsheetML(A.xlRows(), "combined");
  assert(xml.includes("<Data ss:Type=\"String\">a.csv</Data>"), "the file name is not in the sheet");
  assert.strictEqual(xlSheetRows(xml).length, A.state.tasks.length + 1);
  await endCombo();
  assert(!A.xlCols().map(c => c.head).includes("DB File"), "the column outlived the combined view");
});

console.log("\n-- read-only mode --");

t("the DB menu offers the mode, and the app bar has a chip for it", () => {
  assert(/id="btnReadOnly"/.test(html), "no way into read-only mode from the menu");
  assert(/id="roChip"/.test(html), "nothing on the app bar says you are in it");
  assert(/id="btnReadOnly"[\s\S]{0,400}?<\/div>\s*<\/div>/.test(html), "the toggle is not inside the DB menu");
});

t("the address says the mode", () => {
  assert.strictEqual(A.parseLaunchParams("?mode=readonly", "").readOnly, true);
  assert.strictEqual(A.parseLaunchParams("mode=readonly", "").readOnly, true, "the leading ? is optional");
  assert.strictEqual(A.parseLaunchParams("?mode=read-only", "").readOnly, true);
  assert.strictEqual(A.parseLaunchParams("?MODE=ReadOnly", "").readOnly, true, "the parameter is case-insensitive");
  assert.strictEqual(A.parseLaunchParams("?mode=edit", "").readOnly, false);
  assert.strictEqual(A.parseLaunchParams("", "").readOnly, false);
  // a shortcut to a file:// page cannot always carry a "?", so the hash counts too
  assert.strictEqual(A.parseLaunchParams("", "#mode=readonly").readOnly, true);
  // and the query wins when both are there
  assert.strictEqual(A.parseLaunchParams("?mode=edit", "#mode=readonly").readOnly, false);
});

t("the address says which file", () => {
  const p = A.parseLaunchParams("?mode=readonly&file=switchover.csv", "");
  assert.strictEqual(p.readOnly, true);
  assert.strictEqual(p.file, "switchover.csv");
  /* Typed by hand: the folder in front of it is tidied away, and the extension
     is left exactly as typed — including missing, which openWantedFile()
     resolves against the folder, since a plan may be .gantora or .csv. */
  assert.strictEqual(A.parseLaunchParams("?file=switchover", "").file, "switchover");
  assert.strictEqual(A.parseLaunchParams("?file=DB/switchover.csv", "").file, "switchover.csv");
  assert.strictEqual(A.parseLaunchParams("?file=DB%5Cswitchover.csv", "").file, "switchover.csv");
  assert.strictEqual(A.parseLaunchParams("?file=go%20live.csv", "").file, "go live.csv");
  assert.strictEqual(A.parseLaunchParams("?file=", "").file, "", "an empty name is not a file");
  // a stray % is a typo, not a reason for the app not to start
  assert.strictEqual(A.parseLaunchParams("?file=100%&mode=readonly", "").readOnly, true);
  assert.strictEqual(A.parseLaunchParams("?file=100%&mode=readonly", "").file, "");
  // a file on its own is a normal, editable launch
  assert.strictEqual(A.parseLaunchParams("?file=switchover.csv", "").readOnly, false);
});

/* The website's gallery embeds a shared copy and puts it straight into the view
   whose tab was pressed. Two of these were the bug: the shared-copy branch never
   read the launch parameters at all, so every tab opened on the list — and the
   embedded app took its language from the reader's own browser, so a German page
   showed an English demo. */
t("the address says which view, in whatever word the caller uses for it", () => {
  const view = s => A.parseLaunchParams(s, "").view;
  assert.strictEqual(view("?view=gantt"), "gantt");
  assert.strictEqual(view("?view=design"), "gantt", "the same screen, and the site calls it Design");
  assert.strictEqual(view("?view=plan"), "gantt");
  assert.strictEqual(view("?view=list"), "table", "what a reader calls List the code calls table");
  assert.strictEqual(view("?view=dashboard"), "dash");
  assert.strictEqual(view("?VIEW=Presentation"), "pres", "case does not matter, here as elsewhere");
  assert.strictEqual(view("?view=nonsense"), "", "an unknown view is dropped in silence");
  assert.strictEqual(view("?view="), "", "and so is an empty one");
  // it travels with the other parameters rather than replacing them
  const p = A.parseLaunchParams("?mode=readonly&file=switchover.csv&view=zoom", "");
  assert.strictEqual(p.view, "zoom");
  assert.strictEqual(p.file, "switchover.csv");
  assert.strictEqual(p.readOnly, true);
  // every value in the table is a view the app actually has
  for (const v of Object.values(A.VIEW_PARAM))
    assert(A.PRO_VIEWS.includes(v) || ["dash", "table", "gantt", "zoom"].includes(v), v + " is not a view");
});

t("the address says which language, and only one it has", () => {
  const lang = s => A.parseLaunchParams(s, "").lang;
  assert.strictEqual(lang("?lang=de"), "de");
  assert.strictEqual(lang("?lang=HU"), "hu");
  assert.strictEqual(lang("?lang=es"), "", "a language this build does not carry is not a language");
  assert.strictEqual(lang(""), "");
});

ta("a view asked for in the address waits for the plan, then is taken once", async () => {
  const keep = { want: A.state.wantView, view: A.state.view, tasks: A.state.tasks, fh: A.planRef() };
  try {
    /* applyLaunchParams runs before anything is loaded, so setting the view
       there would draw an empty screen and be overwritten when the file lands. */
    A.state.view = "table";
    A.applyLaunchParams({ readOnly: false, file: "", view: "gantt" });
    assert.strictEqual(A.state.wantView, "gantt", "remembered");
    assert.strictEqual(A.state.view, "table", "and not applied yet — there is no plan to draw");

    A.state.tasks = A.parseCSV(wholePlan(3));
    useRef({});
    A.takeWantedView();
    assert.strictEqual(A.state.view, "gantt", "taken once the plan is on screen");
    assert.strictEqual(A.state.wantView, "", "and cleared, so a second file opens where the reader was");

    A.state.view = "table";
    A.takeWantedView();
    assert.strictEqual(A.state.view, "table", "nothing asked for, nothing changed");
  } finally {
    A.state.wantView = keep.want; A.state.view = keep.view;
    A.state.tasks = keep.tasks; useRef(keep.fh);
  }
});

t("a shared copy reads the address too — the bug was that it did not", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const at = script.indexOf("if (window.PPP_SNAPSHOT)");
  const branch = script.slice(at, script.indexOf("} else {", at));
  /* Since the plan-in-a-link (2026-08-19) the address is read once, before the
     three-way branch, and the two shared-copy parameters are applied by one
     helper on both the embedded and the linked copy. */
  const before = script.slice(at - 1200, at);
  assert(/launch = parseLaunchParams\(location\.search, location\.hash\)/.test(before),
    "the address is read before the branch — a shared copy used to skip it, so ?view= did nothing");
  assert(/const applyViewAndLang = p => \{[^}]*setLang\(p\.lang\)[^}]*setView\(p\.view\)/.test(before),
    "one helper applies the view and the language at once — the plan is already on screen");
  assert(/applyViewAndLang\(launch\)/.test(branch), "and the shared-copy branch calls it");

  /* The frame channel: only the two things a reader could do by clicking. */
  assert(/d\.gantora !== "view"/.test(branch), "one message shape, and everything else ignored");
  for (const forbidden of ["saveFile", "openFile", "pickFolder", "createWritable", "adoptPlan"])
    assert(!branch.includes(forbidden), "the message channel must not reach " + forbidden);
  assert(!/window\.addEventListener\("message"/.test(script.slice(0, at)),
    "and it is wired only on a shared copy, which is the only page anybody embeds");
});

t("the folder is asked for read permission, and only in this mode", () => {
  A.state.readOnly = true;
  assert.strictEqual(A.fsMode(), "read");
  assert.strictEqual(A.readOnly(), true, "the editing gate is not closed");
  A.state.readOnly = false;
  assert.strictEqual(A.fsMode(), "readwrite");
  assert.strictEqual(A.readOnly(), false);
});

t("applying the launch parameters puts the app in the mode before anything opens", () => {
  A.applyLaunchParams(A.parseLaunchParams("?mode=readonly&file=switchover.csv", ""));
  assert.strictEqual(A.state.readOnly, true);
  assert.strictEqual(A.state.wantFile, "switchover.csv");
  assert.strictEqual(A.fsMode(), "read", "the first permission check would still ask to write");
  assert.strictEqual(els.roChip.style.display, "", "the chip is not on screen");
  assert(/Leave/.test(els.btnReadOnly.textContent), "the menu still offers to enter the mode: " + els.btnReadOnly.textContent);
  A.applyLaunchParams(A.parseLaunchParams("", ""));
  assert.strictEqual(A.state.readOnly, false);
  assert.strictEqual(A.state.wantFile, "");
  assert.strictEqual(els.roChip.style.display, "none");
});

t("the read-only reasons do not borrow each other's words", () => {
  A.state.combined = ["a.csv"];
  assert(/combined view/.test(A.roReason()), A.roReason());
  A.state.combined = null;
  A.state.readOnly = true;
  assert(/read-only mode/.test(A.roReason()), A.roReason());
  assert(/DB menu/.test(A.roReason()), "the way out is not named");
  A.state.readOnly = false;
  A.state.planReadOnly = true;
  assert(/This plan is read-only/.test(A.roReason()), A.roReason());
  assert(!/DB menu/.test(A.roReason()), "there is nothing to switch off here, unlike the app mode");
  assert(/Save As/.test(A.roReason()), "the way out is named, and it is a copy, not a toggle");
  A.state.planReadOnly = false;
});

console.log("\n-- a plan that declares itself read-only (roadmap-dogfood A4/3) --");

t("readOnly() covers the plan's own flag, the same way it covers every other reason", () => {
  assert.strictEqual(A.readOnly(), false);
  A.state.planReadOnly = true;
  assert.strictEqual(A.readOnly(), true);
  A.state.planReadOnly = false;
});

t("the badge appears and disappears with the flag, and only the flag", () => {
  A.syncCombinedUI();
  assert.strictEqual(els.planRoChip.style.display, "none");
  A.state.planReadOnly = true;
  A.syncCombinedUI();
  assert.strictEqual(els.planRoChip.style.display, "");
  A.state.planReadOnly = false;
  A.syncCombinedUI();
  assert.strictEqual(els.planRoChip.style.display, "none");
});

t("Save disables, but Save As does not — the escape hatch stays open", () => {
  loadTasks(A.HEADER.join("|") + "\r\n" + "1|T|1|2026-03-02||2026-03-02|||||||||no||Planned||no||no|||x-1-2-3||||yes|" + "\r\n");
  useRef({});                               // a folder is "open" for these two checks
  A.state.planReadOnly = true;
  A.syncCombinedUI();
  assert.strictEqual(els.btnSave.disabled, true, "Save is off — the app must never write the flagged file");
  assert.strictEqual(A.blockedByReadOnly("saving a copy"), false,
    "but Save As is not gated on the plan's own flag — blockedByReadOnly() asks state.readOnly alone");
  A.state.planReadOnly = false; useRef(null);
});

t("opening a flagged plan sets the badge on, through the same path adoptPlan uses", () => {
  const flagged = A.HEADER.join("|") + "\r\n" +
    "1|Roadmap|1|2026-03-02||2026-03-02|||||||||no||Planned||no||no|||x-9-9-9||||yes|" + "\r\n" +
    "#META|readOnly|yes\r\n";
  loadTasks(flagged);
  assert.strictEqual(A.state.planReadOnly, true);
  A.syncCombinedUI();
  assert.strictEqual(els.planRoChip.style.display, "", "the badge follows straight from opening the file, no extra step");
  A.state.planReadOnly = false;
  A.syncCombinedUI();
});

/* An in-memory IndexedDB, exactly as far as idb() uses it: one store, and the
   request/transaction callbacks fired in the order the real one fires them —
   r.onsuccess is assigned after the request is made, so nothing may run before
   the caller's synchronous block has finished. */
const idbStore = new Map();
ctx.indexedDB = {
  open() {
    const rq = {};
    queueMicrotask(() => {
      const queued = [];
      const tx = {
        objectStore: () => ({
          get: k => { const r = {}; queued.push(() => { r.result = idbStore.get(k); if (r.onsuccess) r.onsuccess(); }); return r; },
          put: (v, k) => { const r = {}; queued.push(() => { idbStore.set(k, v); if (r.onsuccess) r.onsuccess(); }); return r; },
          delete: k => { const r = {}; queued.push(() => { idbStore.delete(k); if (r.onsuccess) r.onsuccess(); }); return r; },
        }),
      };
      rq.result = { close(){}, transaction: () => tx };
      if (rq.onsuccess) rq.onsuccess();
      queueMicrotask(() => { queued.forEach(f => f()); if (tx.oncomplete) tx.oncomplete(); });
    });
    return rq;
  },
};

/* A /DB folder that remembers what was written to it and every permission it
   was asked for — which is the point: read-only mode must never ask to write. */
const fakeRoot = (files, opts = {}) => ({
  name: "DB",
  asked: opts.asked || [],
  async queryPermission(o){ this.asked.push("query:" + o.mode); return opts.query || "granted"; },
  async requestPermission(o){ this.asked.push("request:" + o.mode); return opts.request || "granted"; },
  async getFileHandle(name, o) {
    if (!(name in files)) {
      if (!o || !o.create) throw new Error("no such file: " + name);
      files[name] = "";
    }
    return {
      getFile: async () => ({ text: async () => files[name] }),
      createWritable: async () => ({ write: async t => { files[name] = t; }, close: async () => {} }),
    };
  },
  async getDirectoryHandle(){ throw new Error("this fake root is the DB folder itself"); },
  entries: async function* () {
    for (const n of Object.keys(files))
      yield [n, { kind: "file",
                  // the wastebasket reads the deletion day off this
                  getFile: async () => ({ lastModified: (opts.mtimes || {})[n] || 0,
                                          text: async () => files[n] }) }];
  },
  async removeEntry(n){ delete files[n]; },
});

/* The folder the user actually picks: "PPPlus", with /DB inside it. Asking for
   a subfolder with `create: true` is a write, so this refuses it unless write
   permission was granted — exactly as the real API does. */
const fakeParent = (db, opts = {}) => ({
  name: opts.name || "PPPlus",
  asked: opts.asked || [],
  granted: opts.granted || "read",
  async queryPermission(o){ this.asked.push("query:" + o.mode); return opts.query || "granted"; },
  async requestPermission(o){ this.asked.push("request:" + o.mode); return opts.request || "granted"; },
  async getDirectoryHandle(name, o) {
    this.asked.push("dir:" + name + (o && o.create ? ":create" : ""));
    if (o && o.create && this.granted !== "readwrite")
      throw new Error("NotAllowedError: creating needs write permission");
    if (name !== "DB" || !db) {
      if (!o || !o.create) throw new Error("NotFoundError: no such directory");
      throw new Error("NotAllowedError: creating needs write permission");
    }
    return db;
  },
});

// back to a clean, editable, nothing-open app between tests
const resetApp = () => {
  A.state.readOnly = false; A.state.wantFile = ""; A.state.combined = null;
  A.state.rootHandle = null; A.state.dbHandle = null; useRef(null);
  useName(""); A.state.tasks = []; A.state.dirty = false; A.state.inlineEdit = null;
  /* Nothing open means no version we have agreed with. It used to be a number
     that reset itself to 0 and so short-circuited the guard by accident; an
     opaque stamp is an object, and a left-over one is truthy — a clean app has to
     say so out loud. */
  A.state.stamp = null; A.state.stampUnknown = false;
  A.state.teams = []; A.state.systems = [];
  A.state.savedViews = []; A.state.activeView = "";
  A.state.tombstones = {};
  idbStore.clear();
  ctx.localStorage.clear();     // "the file I had open last" must not leak between tests
  A.syncCombinedUI();
};

ta("a folder with plans in it opens one, rather than leaving the app empty", async () => {
  resetApp();
  const root = fakeRoot({ "switchover.csv": FILE_A, "other.csv": FILE_B });
  idbStore.set("root", root);
  await A.restoreSavedFolder(true);
  assert(A.planName(), "nothing opened, so the folder is a dead end");
  assert.strictEqual(A.planName(), "other.csv", "the first plan in the list, as the picker orders them");
  assert(A.state.tasks.length, "and its tasks are on screen");
  resetApp();
});

ta("the plan that was last open still wins over the first one", async () => {
  resetApp();
  const root = fakeRoot({ "switchover.csv": FILE_A, "other.csv": FILE_B });
  idbStore.set("root", root);
  ctx.localStorage.setItem("ppp.lastFile", "switchover.csv");
  await A.restoreSavedFolder(true);
  assert.strictEqual(A.planName(), "switchover.csv",
    "opening the first one over the top would drop somebody back weeks");
  ctx.localStorage.removeItem("ppp.lastFile");
  resetApp();
});

ta("save as writes a new file and leaves you editing it", async () => {
  resetApp();
  const files = { "switchover.csv": FILE_A };
  const root = fakeRoot(files);
  idbStore.set("root", root);
  await A.restoreSavedFolder(true);
  const from = A.planName();

  const p = A.saveAsFile();
  await new Promise(r => setTimeout(r, 0));
  els.askInput.value = "copy-of-plan";
  A.askSubmit();
  const made = await p;

  assert.strictEqual(made, "copy-of-plan.gantora", "it took the typed name and the new extension");
  assert(files["copy-of-plan.gantora"], "the file is in /DB");
  assert.strictEqual(A.planName(), "copy-of-plan.gantora", "and it is the one being edited now");
  assert.strictEqual(A.state.dirty, false, "saved, so there is nothing outstanding");
  assert.strictEqual(files[from], FILE_A, "the file it came from was not touched");
  assert(files["copy-of-plan.gantora"].includes("|"), "and the copy is a plan, not an empty file");
  resetApp();
});

/* 🔴 The padlock chip tells people, in four languages, to "use Save As to make
   an editable copy". Until 2026-08-23 the copy came out carrying the lock, so
   the advice led to a file with the same door shut (Daniel: "save as viszi
   magával a lakatot"). The flag belongs to the plan it was set on — the same
   rule the clipboard road already followed. */
ta("save as makes an editable copy — the lock does not travel into it", async () => {
  resetApp();
  const locked = "#META|readOnly|yes\r\n" + FILE_A;
  const files = { "roadmap.gantora": locked };
  const root = fakeRoot(files);
  idbStore.set("root", root);
  await A.restoreSavedFolder(true);
  await A.openFile("roadmap.gantora");
  assert.strictEqual(A.state.planReadOnly, true, "the plan we start from is flagged");
  assert(A.readOnly(), "…and every editing path is shut while it is");

  const p = A.saveAsFile();
  await new Promise(r => setTimeout(r, 0));
  els.askInput.value = "roadmap-mine";
  A.askSubmit();
  const made = await p;

  assert.strictEqual(made, "roadmap-mine.gantora");
  assert(!/#META\|readOnly/.test(files["roadmap-mine.gantora"]), "the copy carries no lock");
  assert(/#META\|readOnly\|yes/.test(files["roadmap.gantora"]), "…and the original keeps its own, untouched");
  assert.strictEqual(A.state.planReadOnly, false, "the copy on screen is editable at once");
  assert.strictEqual(A.readOnly(), false, "…which is what the advice promised");
  resetApp();
});

/* ================= Carrying a plan between stores ==========================
   gantora-terv-atvitel-brief.md, approved 2026-08-21. Read with one adapter,
   write with the other, over the seam that already exists — which is why two
   fake stores are enough to test the whole of it. */
console.log("\n-- carrying a plan from one store to the other --");

/* A store in twelve lines: the seam's four operations over a plain object, so
   a test can look at both sides of a copy at once. That is the point of the
   seam, and this is the cheapest possible proof of it. */
const fakeStore = (files) => ({
  kind: "fake",
  _files: files,
  async list() { return Object.keys(files).filter(n => A.isPlanFile(n)).sort(); },
  async exists(n) { return Object.prototype.hasOwnProperty.call(files, n); },
  async read(n) {
    return Object.prototype.hasOwnProperty.call(files, n)
      ? { ok: true, text: files[n] } : { ok: false, why: "unreadable" };
  },
  async writeText(n, text) { files[n] = text; return n; },
});

ta("a plan copied to the other store arrives byte for byte, and the source stays", async () => {
  const from = { "switchover.gantora": FILE_A, "other.gantora": FILE_B };
  const to = {};
  const r = await A.copyPlanTo(fakeStore(from), fakeStore(to), "switchover.gantora");
  assert.strictEqual(r.ok, true, "it went across: " + JSON.stringify(r));
  assert.strictEqual(to["switchover.gantora"], FILE_A,
    "🔴 byte for byte — UIDs, History and #META all — or the two copies stop being mergeable");
  assert.strictEqual(from["switchover.gantora"], FILE_A, "🔴 copy, never move: the source is untouched");
  assert.strictEqual(Object.keys(from).length, 2, "…and nothing else moved either");
});

ta("a name already taken is not written over — it is reported, or given a free one", async () => {
  const from = { "plan.gantora": FILE_A };
  const to = { "plan.gantora": FILE_B };
  const src = fakeStore(from), dst = fakeStore(to);

  const stop = await A.copyPlanTo(src, dst, "plan.gantora");
  assert.strictEqual(stop.ok, false, "it refused");
  assert.strictEqual(stop.why, "exists", "…and said why: " + stop.why);
  assert.strictEqual(to["plan.gantora"], FILE_B, "🔴 somebody else's plan is still theirs");

  /* Asked to find a name instead, it takes the next free one — and both
     extensions count as taken, because "plan.csv" and "plan.gantora" are one
     plan with two names to anybody reading the folder. */
  const go = await A.copyPlanTo(src, dst, "plan.gantora", { rename: true });
  assert.strictEqual(go.ok, true);
  assert.strictEqual(go.name, "plan-2.gantora", "it took: " + go.name);
  assert.strictEqual(to["plan-2.gantora"], FILE_A);
  assert.strictEqual(to["plan.gantora"], FILE_B, "and still did not touch the one that was there");

  to["plan-3.csv"] = "x";
  assert.strictEqual(await A.freeNameOn(dst, "plan.gantora"), "plan-4.gantora",
    "a .csv of that name counts as taken too");
});

ta("a read-only plan crosses read-only, because the flag protects the contents", async () => {
  const ro = A.HEADER.join("|") + "\r\n#META|readOnly|yes\r\n";
  const to = {};
  const r = await A.copyPlanTo(fakeStore({ "Roadmap__ai.gantora": ro }), fakeStore(to), "Roadmap__ai.gantora");
  assert.strictEqual(r.ok, true);
  assert.strictEqual(to["Roadmap__ai.gantora"], ro, "the flag travelled with it, like every other byte");
  assert.strictEqual(A.planReadOnlyOf(to["Roadmap__ai.gantora"]), true, "…and still reads as read-only");
});

ta("in a batch, what got across stays across, and the failures are named", async () => {
  const src = fakeStore({ "a.gantora": FILE_A, "b.gantora": FILE_B });
  /* The third one is not in the source at all: a plan that vanished between
     the listing and the copy is exactly the failure a cloud folder produces. */
  const to = {};
  const dst = fakeStore(to);
  const { done, failed } = await A.copyPlansTo(src, dst, ["a.gantora", "gone.gantora", "b.gantora"]);
  eqArr(done.map(r => r.name), ["a.gantora", "b.gantora"], "the two that could, did");
  eqArr(failed.map(r => r.name), ["gone.gantora"], "and the one that could not is named, not swallowed");
  assert.strictEqual(to["b.gantora"], FILE_B,
    "🔴 a failure in the middle must not undo what already worked — the run carries on past it");
});

/* 🔴 The borrow has to GIVE BACK. Two stores attached at once would be a new
   invariant for the whole app — restoreSavedFolder(), pickFolder(),
   folderOpen() and the boot path all assume exactly one — so the attachment is
   restored in a `finally`, failure or not. */
ta("borrowing the local store puts it back exactly as it was, even when the copy throws", async () => {
  resetApp();
  const files = { "switchover.csv": FILE_A };
  const root = fakeRoot(files);
  idbStore.set("root", root);
  A.setStorage(A.FsaAdapter);
  await A.restoreSavedFolder(true);
  const attached = A.FsaAdapter.open();
  assert(attached, "the local store is open to begin with");

  let sawInside = null;
  const r = await A.FsaAdapter.borrow(async store => { sawInside = await store.list(); return "done"; });
  assert.strictEqual(r.ok, true, JSON.stringify(r));
  assert.strictEqual(r.value, "done");
  assert(sawInside.includes("switchover.csv"), "the borrower could read the folder: " + sawInside);
  assert.strictEqual(A.FsaAdapter.open(), attached, "…and the store is back as it was");

  const bad = await A.FsaAdapter.borrow(async () => { throw new Error("network went"); });
  assert.strictEqual(bad.ok, false, "a throw is an answer, not an exception");
  assert.strictEqual(bad.why, "failed");
  assert.strictEqual(A.FsaAdapter.open(), attached,
    "🔴 a failed borrow must still put the store back — otherwise one bad copy loses the folder");
  resetApp();
});

ta("with nothing remembered on this device, the borrow says so instead of half-attaching", async () => {
  resetApp();
  idbStore.delete("root");
  const r = await A.FsaAdapter.borrow(async () => "never");
  assert.strictEqual(r.ok, false);
  assert.strictEqual(r.why, "noLocal", "and it names what the person has to do: " + r.why);
  assert.strictEqual(A.FsaAdapter.open(), false, "nothing was attached on the way out");
  resetApp();
});

/* The direction is the one thing the wording must get right, and it comes from
   the store you are IN — not from the markup, which can only carry one of the
   two. */
t("the copy item names the direction it goes, and hides where there is no second store", () => {
  const was = A.myLang();
  try {
    A.setLang("en");
    resetApp();
    A.setStorage(A.FsaAdapter);
    A.state.dbHandle = fakeDb({});
    A.state.snapshot = null;
    assert(/Dropbox/.test(A.copyAwayLabel()), "from the disk it goes to Dropbox: " + A.copyAwayLabel());
    assert(/📤/.test(A.syncCopyAway()), "and the item is offered: " + A.syncCopyAway());

    /* A shared copy is one fixed file with nowhere to send it. Hidden, not
       greyed: greyed says "you are missing something" about a build that is
       not missing anything. */
    A.state.snapshot = { plans: [] };
    assert.strictEqual(A.canCopyAway(), false, "a shared copy has no second store");
    assert.strictEqual(A.syncCopyAway(), "", "…and is not shown the item at all");
    A.state.snapshot = null;

    /* Every reason it can refuse is a sentence about what to do next, in every
       language — a copy that fails silently is the worst kind. */
    for (const lang of ["hu", "de", "fr"]) {
      A.setLang(lang);
      for (const why of Object.keys(A.COPY_AWAY_WHY))
        assert.notStrictEqual(A.trans(A.COPY_AWAY_WHY[why]), A.COPY_AWAY_WHY[why],
          lang + ": “" + why + "” is still English");
    }
  } finally { A.setLang(was || "en"); resetApp(); }
});

/* ================= Deleting a plan: the wastebasket ========================
   gantora-terv-torles-brief.md. The app's first destructive file operation, and
   the reason it is safe is that it is a RENAME — the bytes never move. */
ta("deleting a plan renames it out of the list, and the toast offers it back", async () => {
  resetApp();
  const files = { "switchover.csv": FILE_A, "other.csv": FILE_B };
  const root = fakeRoot(files);
  idbStore.set("root", root);
  await A.restoreSavedFolder(true);
  A.state.dirty = false;

  const p = A.deletePlan("other.csv");
  await new Promise(r => setTimeout(r, 0));
  A.askSubmit();                                  // yes, delete it
  assert.strictEqual(await p, true);

  assert.strictEqual(files["other.csv"], undefined, "it is out of the plan list");
  /* 🔴 …and the bytes are still there, under a name isPlanFile() does not
     recognise. That is the whole protection: nothing was destroyed. */
  assert.strictEqual(files["other.csv.trash"], FILE_B, "the bytes moved to the wastebasket, unchanged");

  await A.restorePlan("other.csv.trash");
  assert.strictEqual(files["other.csv"], FILE_B, "and it comes back byte for byte");
  assert.strictEqual(files["other.csv.trash"], undefined, "with nothing left behind");
  resetApp();
});

ta("a plan restored to a name that is taken comes back under another one", async () => {
  resetApp();
  const files = { "switchover.csv": FILE_A, "gone.gantora.trash": FILE_A };
  const root = fakeRoot(files);
  idbStore.set("root", root);
  await A.restoreSavedFolder(true);

  // somebody made a new plan called the same thing while the old one sat in the bin
  files["gone.gantora"] = FILE_B;
  const p = A.restorePlan("gone.gantora.trash");
  await new Promise(r => setTimeout(r, 0));
  els.askInput.value = "gone-2";
  A.askSubmit();
  assert.strictEqual(await p, true);
  assert.strictEqual(files["gone.gantora"], FILE_B, "🔴 the plan that was already there is untouched");
  assert.strictEqual(files["gone-2.gantora"], FILE_A, "and the restored one is beside it");
  resetApp();
});

ta("the wastebasket lists what is in it, newest first, and hides itself when empty", async () => {
  resetApp();
  const files = { "switchover.csv": FILE_A, "old.gantora.trash": FILE_A, "new.gantora.trash": FILE_B };
  const root = fakeRoot(files, { mtimes: { "old.gantora.trash": 1000, "new.gantora.trash": 2000 } });
  idbStore.set("root", root);
  await A.restoreSavedFolder(true);

  const list = await A.syncTrashUI();
  eqArr(list.map(r => r.name), ["new.gantora.trash", "old.gantora.trash"], "newest first");
  assert.strictEqual(els.btnTrash.style.display, "", "…and the way in is offered");
  await A.openTrash();
  const shown = els.trashList.innerHTML;
  assert(/>new</.test(shown.replace(/\s+/g, "")) || /new/.test(shown), "the rows name the plans");
  assert(!/>new\.gantora\.trash</.test(shown), "…without the suffix: " + shown.slice(0, 160));

  /* 🔴 Destroying is the one call that really deletes, and it asks again — with
     the word on the button, not "OK". */
  const p = A.destroyPlan("old.gantora.trash");
  await new Promise(r => setTimeout(r, 0));
  A.askCancel();
  assert.strictEqual(await p, false, "backing out destroys nothing");
  assert.strictEqual(files["old.gantora.trash"], FILE_A, "…and it is still there");

  const p2 = A.destroyPlan("old.gantora.trash");
  await new Promise(r => setTimeout(r, 0));
  A.askSubmit();
  assert.strictEqual(await p2, true);
  assert.strictEqual(files["old.gantora.trash"], undefined, "now it is gone for good");

  delete files["new.gantora.trash"];
  await A.syncTrashUI();
  assert.strictEqual(els.btnTrash.style.display, "none", "an empty wastebasket is not a menu item");
  resetApp();
});

ta("save as will not write over a plan that is already there", async () => {
  resetApp();
  const files = { "switchover.csv": FILE_A, "other.csv": FILE_B };
  const root = fakeRoot(files);
  idbStore.set("root", root);
  await A.restoreSavedFolder(true);

  /* First the name of a file that exists, then whatever is suggested: the same
     way New file asks, because both create and neither may overwrite. */
  const p = A.saveAsFile();
  await new Promise(r => setTimeout(r, 0));
  els.askInput.value = "switchover";
  A.askSubmit();
  await new Promise(r => setTimeout(r, 0));
  A.askCancel();
  const made = await p;

  assert.strictEqual(made, null, "backing out of the second prompt writes nothing");
  assert.strictEqual(files["switchover.csv"], FILE_A, "the plan that was already there is untouched");
  resetApp();
});

ta("launched read-only: the remembered folder reconnects without a write prompt", async () => {
  resetApp();
  const files = { "switchover.csv": FILE_A, "other.csv": FILE_B };
  const root = fakeRoot(files);
  idbStore.set("root", root);
  A.applyLaunchParams(A.parseLaunchParams("?mode=readonly&file=switchover.csv", ""));

  assert.strictEqual(await A.restoreSavedFolder(true), true, "the saved folder did not reconnect");
  eqArr(root.asked, ["query:read"], "the wrong permission was asked for");
  assert.strictEqual(A.planName(), "switchover.csv", "the file in the address did not open");
  assert.strictEqual(A.state.tasks.length, 3);
  assert.strictEqual(A.state.wantFile, "", "the address is still asking for it on the next reconnect");
  assert.strictEqual(A.readOnly(), true);
  resetApp();
});

ta("the picker is asked for read too, when there is no remembered folder", async () => {
  resetApp();
  const files = { "switchover.csv": FILE_A };
  const root = fakeRoot(files);
  const modes = [];
  ctx.window.showDirectoryPicker = async o => { modes.push(o.mode); return root; };
  A.applyLaunchParams(A.parseLaunchParams("?mode=readonly&file=switchover.csv", ""));

  await A.pickFolder();
  eqArr(modes, ["read"], "the folder picker asked to change files");
  assert.strictEqual(A.planName(), "switchover.csv", "the file in the address did not open after the pick");

  // and the normal launch still asks for what editing needs
  resetApp();
  modes.length = 0;
  await A.pickFolder();
  eqArr(modes, ["readwrite"]);
  delete ctx.window.showDirectoryPicker;
  resetApp();
});

ta("read-only: picking the folder that HOLDS /DB works — listing it is a read", async () => {
  resetApp();
  const files = { "switchover.csv": FILE_A, "other.csv": FILE_B, "_lists.csv": "Team|Alpha\r\n" };
  const db = fakeRoot(files);
  const parent = fakeParent(db);                      // granted "read" only, as the mode asks for
  idbStore.set("root", parent);
  A.applyLaunchParams(A.parseLaunchParams("?mode=readonly&file=switchover.csv", ""));

  assert.strictEqual(await A.restoreSavedFolder(true), true, "the folder did not open at all");
  // the whole bug in one line: /DB must be reached without asking to create it
  assert(!parent.asked.includes("dir:DB:create"), "read-only asked to create /DB: " + parent.asked.join(", "));
  assert(parent.asked.every(a => !a.endsWith(":readwrite")), "a write permission was asked for: " + parent.asked.join(", "));

  // and everything a viewer needs is there: the list, the registry, the file
  assert(els.fileSelect.innerHTML.includes("switchover.csv"), "the file list is empty: " + els.fileSelect.innerHTML);
  assert(els.fileSelect.innerHTML.includes("other.csv"), "only one file was listed");
  assert(!els.fileSelect.innerHTML.includes("_lists.csv"), "the registry was offered as a project file");
  eqArr(A.state.teams, ["Alpha"], "the shared team list was not read");
  assert.strictEqual(A.planName(), "switchover.csv", "the file in the address did not open");
  assert.strictEqual(A.state.tasks.length, 3);
  assert.strictEqual(els.fileSelect.disabled, false, "there is no way to pick another file");
  resetApp();
});

ta("read-only: a folder with no /DB inside says which folder, and keeps the handle", async () => {
  resetApp();
  const parent = fakeParent(null, { name: "Documents" });
  idbStore.set("root", parent);
  A.applyLaunchParams(A.parseLaunchParams("?mode=readonly", ""));

  assert.strictEqual(await A.restoreSavedFolder(true), false);
  assert(/Documents/.test(els.toast.textContent), "the folder is not named: " + els.toast.textContent);
  assert(/DB/.test(els.toast.textContent), "what is missing is not named: " + els.toast.textContent);
  assert(idbStore.has("root"), "a perfectly good folder handle was forgotten");
  resetApp();
});

ta("editing mode still creates /DB when it is not there", async () => {
  resetApp();
  const db = fakeRoot({ "plan.csv": FILE_A });
  const parent = fakeParent(db, { granted: "readwrite" });
  const modes = [];
  ctx.window.showDirectoryPicker = async o => { modes.push(o.mode); return parent; };

  await A.pickFolder();
  eqArr(modes, ["readwrite"]);
  assert(parent.asked.includes("dir:DB:create"), "the DB folder is no longer created: " + parent.asked.join(", "));
  assert.strictEqual(A.state.dbHandle, db, "the DB subfolder is not the one in use");
  assert(els.fileSelect.innerHTML.includes("plan.csv"), "the file list is empty");
  delete ctx.window.showDirectoryPicker;
  resetApp();
});

ta("a folder that will not open is not remembered as the default", async () => {
  resetApp();
  const parent = fakeParent(null, { name: "Downloads" });   // read-only, and no /DB in it
  ctx.window.showDirectoryPicker = async () => parent;
  A.applyLaunchParams(A.parseLaunchParams("?mode=readonly", ""));

  await A.pickFolder();
  assert.strictEqual(A.state.dbHandle, null, "a folder we cannot read became the open one");
  assert(/Downloads/.test(els.toast.textContent), "nothing said why: " + els.toast.textContent);
  assert(!idbStore.has("root"), "it was remembered anyway, so the next launch repeats the failure");
  delete ctx.window.showDirectoryPicker;
  resetApp();
});

ta("a file named in the address that is not in /DB is said out loud", async () => {
  resetApp();
  const root = fakeRoot({ "switchover.csv": FILE_A });
  idbStore.set("root", root);
  A.applyLaunchParams(A.parseLaunchParams("?mode=readonly&file=missing.csv", ""));
  await A.restoreSavedFolder(true);
  assert.strictEqual(A.planName(), "", "something opened anyway");
  assert(/missing\.csv/.test(els.toast.textContent), "no word about it: " + els.toast.textContent);
  resetApp();
});

ta("?file= on its own opens the file and leaves editing alone", async () => {
  resetApp();
  const root = fakeRoot({ "switchover.csv": FILE_A });
  idbStore.set("root", root);
  A.applyLaunchParams(A.parseLaunchParams("?file=switchover", ""));
  assert.strictEqual(A.state.readOnly, false);
  assert.strictEqual(await A.restoreSavedFolder(true), true);
  eqArr(root.asked, ["query:readwrite"], "an editable launch must still ask to write");
  assert.strictEqual(A.planName(), "switchover.csv");
  assert.strictEqual(els.btnSave.disabled, false, "Save is off in an editable launch");
  resetApp();
});

ta("read-only: nothing that edits the plan gets through, and the file is untouched", async () => {
  resetApp();
  const files = { "plan.csv": FILE_A };
  const root = fakeRoot(files);
  A.state.rootHandle = root; A.state.dbHandle = root;
  await A.openFile("plan.csv");                       // opened while editing is still allowed
  const onDisk = files["plan.csv"];
  assert.strictEqual(await A.setReadOnly(true), true);

  const before = JSON.stringify(A.state.tasks);
  const id = A.state.tasks.find(t => !A.isGroup(t)).id;
  A.startInline(id);
  assert.strictEqual(A.state.inlineEdit, null, "inline editing opened");
  A.openModal({ type: "edit", id });
  assert.strictEqual(els.overlay.classList.contains("open"), false, "the dialog opened");
  A.runRowAction("del", id);
  A.runRowAction("dup", id);
  const other = A.state.tasks.find(x => x.id !== id && !x.milestone && !A.isGroup(x));
  assert(/read-only mode/.test(String(A.designMoveInTime(id, 5))), "a bar could be dragged through time");
  assert(/read-only mode/.test(String(A.designResizeBy(id, "end", 5))), "a bar could be stretched");
  assert(/read-only mode/.test(String(A.designDropOnto(id, other.id))), "a bar could be dropped into a group");
  assert(/read-only mode/.test(String(A.designUngroup(id))), "a bar could be dragged out of its group");
  assert.strictEqual(JSON.stringify(A.state.tasks), before, "the plan changed");
  assert.strictEqual(A.undo(), false, "undo ran");
  assert.strictEqual(A.redo(), false, "redo ran");
  assert.strictEqual(A.state.dirty, false, "read-only went dirty");

  await A.saveFile(false);                            // must not throw, must not write
  assert.strictEqual(files["plan.csv"], onDisk, "the file on disk was rewritten");
  resetApp();
});

ta("read-only: the app bar says so, and still lets you look around", async () => {
  resetApp();
  const root = fakeRoot({ "plan.csv": FILE_A, "b.csv": FILE_B });
  A.state.rootHandle = root; A.state.dbHandle = root;
  await A.openFile("plan.csv");
  await A.setReadOnly(true);

  assert.strictEqual(els.btnSave.disabled, true, "Save is still offered");
  assert.strictEqual(els.btnNewTask.disabled, true, "New task is still offered");
  assert.strictEqual(els.btnNewFile.disabled, true, "New file is still offered");
  assert.strictEqual(els.btnImport.disabled, true, "Import is still offered");
  assert.strictEqual(els.roChip.style.display, "", "the chip is not on screen");
  // the point of the mode is reading, so moving between files and views stays on
  assert.strictEqual(els.fileSelect.disabled, false, "the file dropdown went dead");
  assert.strictEqual(els.btnExport.disabled, false, "Export went dead");
  assert.strictEqual(els.btnReload.disabled, false, "Reload went dead");
  assert.strictEqual(els.combinedChip.style.display, "none", "the combined chip appeared by mistake");

  const table = els.tableView.innerHTML;
  assert(!A.activeColumns().map(c => c.key).includes("actions"), "actions column still built");
  assert(!table.includes("data-act="), "action buttons rendered");
  assert(!table.includes("Add new task"), "the append row is still offered");
  assert(!table.includes('draggable="true"'), "rows can still be dragged");

  // another file can be opened, and comes up read-only as well
  await A.openFile("b.csv");
  assert.strictEqual(A.planName(), "b.csv");
  assert.strictEqual(els.btnSave.disabled, true, "Save came back with the next file");
  resetApp();
});

ta("read-only: /DB itself is not written to either", async () => {
  resetApp();
  const files = { "plan.csv": FILE_A };
  const root = fakeRoot(files);
  A.state.rootHandle = root; A.state.dbHandle = root;
  await A.openFile("plan.csv");
  await A.setReadOnly(true);
  const named = Object.keys(files).join(",");

  assert.strictEqual(await A.createFile("brand-new"), null, "a file was created");
  await A.newFile();
  assert.strictEqual(Object.keys(files).join(","), named, "/DB gained a file: " + Object.keys(files));

  assert.strictEqual(await A.addListEntry("team"), null, "a team was added");
  eqArr(A.state.teams, [], "the registry changed");
  assert(!("_lists.csv" in files), "the registry file was written");

  A.state.teams = ["Delta"];
  await A.deleteListEntry("team", "Delta");
  eqArr(A.state.teams, ["Delta"], "a team was deleted");
  assert(!("_lists.csv" in files), "the registry file was written");
  A.state.teams = [];

  await A.startImport();
  assert.notStrictEqual(els.importOverlay.style.display, "flex", "the importer opened");
  resetApp();
});

ta("read-only: the Teams page still reads, and no longer edits", async () => {
  resetApp();
  A.state.teams = ["Alpha", "Beta"];
  A.state.readOnly = true;
  A.renderManage("team");
  const page = els.manageView.innerHTML;
  assert(page.includes("Alpha") && page.includes("Beta"), "the teams are gone from the page");
  assert(!page.includes("btnManageAdd"), "the Add button is still there");
  assert(!page.includes('data-act="del"'), "the Delete buttons are still there");
  assert(page.includes("Read-only"), "the page does not say why");
  resetApp();
});

console.log("\n-- three lists, one page: teams, systems and people --");

ta("the registry file carries all three kinds, and reads them back", async () => {
  resetApp();
  const files = {};
  const root = fakeRoot(files);
  A.state.rootHandle = root; A.state.dbHandle = root;
  A.state.teams = ["Alpha"]; A.state.systems = ["ERP"]; A.state.people = ["Anna Kovacs"];
  await A.saveLists();
  const text = files["_lists.csv"];
  assert(/^Team\|Alpha$/m.test(text), "the team line: " + text);
  assert(/^System\|ERP$/m.test(text), "the system line: " + text);
  assert(/^Person\|Anna Kovacs$/m.test(text), "and the people are in the same file: " + text);

  A.state.teams = []; A.state.systems = []; A.state.people = [];
  await A.loadLists();
  eqArr(A.state.teams, ["Alpha"]);
  eqArr(A.state.systems, ["ERP"]);
  eqArr(A.state.people, ["Anna Kovacs"], "read back as its own list");

  // a file written by a version that knows a fourth list is not damaged by this one
  files["_lists.csv"] = "Team|Alpha\r\nColour|Blue\r\nPerson|Bela Nagy\r\n";
  await A.loadLists();
  eqArr(A.state.people, ["Bela Nagy"], "the line it knows is read");
  eqArr(A.state.teams, ["Alpha"], "and the one it does not is simply not one of these three");
  resetApp();
});

t("the people list is the registry plus whoever the plan names", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", acc: "Anna Kovacs:3", resp: "Bela Nagy:6" }]);
  A.state.people = ["Csaba Toth"];
  eqArr(A.knownValues("person"), ["Anna Kovacs", "Bela Nagy", "Csaba Toth"],
    "somebody with no tasks yet is still on the list");
  eqArr(A.knownPeople(), ["Anna Kovacs", "Bela Nagy"], "and the plan's own names are still just those");
  A.state.people = [];
});

t("a person is counted once per task, however many ways they are on it", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", acc: "Anna:3", resp: "Anna:2;Bela:4" },
               { id: 2, name: "B", start: "2026-03-04", end: "2026-03-05", resp: "Bela:4" }]);
  assert.strictEqual(A.taskCountFor("person", "Anna"), 1, "accountable and responsible on the same task");
  assert.strictEqual(A.taskCountFor("person", "Bela"), 2);
  assert.strictEqual(A.taskCountFor("person", "Nobody"), 0);
});

/* The point of the whole page: a name in this app is the word itself, written
   into every task that carries it. Correcting the list without correcting the
   tasks would not rename anybody — it would invent a second name nobody is on. */
t("renaming a person rewrites every task that names them, and keeps the hours", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", acc: "Anna Kovacs:3", resp: "Bela Nagy:6" },
               { id: 2, name: "B", start: "2026-03-04", end: "2026-03-05", resp: "Anna Kovacs:2;Csaba Toth:4" },
               { id: 3, name: "C", start: "2026-03-06", end: "2026-03-07", resp: "Csaba Toth:4" }]);
  A.state.people = ["Anna Kovacs"];
  assert.strictEqual(A.applyRename("person", "Anna Kovacs", "Anna Kovács"), 2, "two tasks name her");
  assert.strictEqual(row("A").accountable, "Anna Kovács:3", "accountable, hours kept");
  assert.strictEqual(row("B").responsible, "Anna Kovács:2;Csaba Toth:4", "responsible, in place and in order");
  assert.strictEqual(row("C").responsible, "Csaba Toth:4", "and a task that never named her is untouched");
  eqArr(A.state.people, ["Anna Kovács"], "the list entry followed");
  A.state.people = [];
});

t("renaming onto a name the task already carries merges the two", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", resp: "Anna:2;Bela:4" }]);
  assert.strictEqual(A.applyRename("person", "Bela", "Anna"), 1);
  assert.strictEqual(row("A").responsible, "Anna:2", "one person, once — the first entry's hours stand");
});

t("renaming a team follows into the tasks, the registry and the filter", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", team: "Dev" },
               { id: 2, name: "B", start: "2026-03-04", end: "2026-03-05", team: "Ops" }]);
  A.state.teams = ["Dev", "Ops"];
  A.state.filterTeam = "Dev";
  assert.strictEqual(A.applyRename("team", "Dev", "Delivery"), 1);
  assert.strictEqual(row("A").team, "Delivery");
  assert.strictEqual(row("B").team, "Ops", "the other team is not touched");
  eqArr(A.state.teams, ["Delivery", "Ops"], "the registry entry moved, and the list stayed sorted");
  assert.strictEqual(A.state.filterTeam, "Delivery",
    "a filter pointing at the old name would have emptied the list the rename just fixed");
  A.state.filterTeam = ""; A.state.teams = [];
});

t("a rename to nothing, or to the same name, changes nothing", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", team: "Dev" }]);
  assert.strictEqual(A.applyRename("team", "Dev", "   "), -1, "a name has to be a name");
  assert.strictEqual(A.applyRename("team", "Dev", "Dev"), 0);
  assert.strictEqual(row("A").team, "Dev");
});

t("the People page lists everyone, what they are on, and what can be done", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", acc: "Anna Kovacs:3" }]);
  A.state.people = ["Csaba Toth"];       // on the list, on no task
  A.state.readOnly = false;
  A.renderManage("person");
  const page = els.manageView.innerHTML;
  assert(/<h2>People<\/h2>/.test(page), "the page says which list it is");
  assert(/<th>Person name<\/th>/.test(page), "and the column heading is the singular of it, not 'Peopl'");
  assert(page.includes("Anna Kovacs") && page.includes("Csaba Toth"), "both kinds of name are listed");
  assert(/data-act="rename" data-name="Anna Kovacs"/.test(page), "a name in use can be corrected");
  assert(/data-act="show" data-name="Anna Kovacs"/.test(page), "…and her tasks opened");
  assert(!/data-act="del" data-name="Anna Kovacs"/.test(page), "but not deleted while a task names her");
  assert(/data-act="del" data-name="Csaba Toth"/.test(page), "somebody on no task can be taken off the list");
  assert(/Add person/.test(page), "and a way to add one");
  A.state.people = [];
});

t("each list has its own page, and the menu that opens it", () => {
  eqArr(Object.keys(A.MANAGE_VIEWS), ["teams", "systems", "people"]);
  assert.strictEqual(A.manageKind("people"), "person");
  assert.strictEqual(A.manageKind("table"), "", "a plan view is not a management page");
  for (const id of ["btnListsMenu", "listsMenu", "btnViewTeams", "btnViewSystems", "btnViewPeople"])
    assert(html.includes(`id="${id}"`), "missing from the page: " + id);
  A.setView("people");
  assert.strictEqual(A.state.view, "people");
  assert(els.btnListsMenu.classList.contains("active"), "the menu button lights for any of the three");
  A.setView("table");
  assert(!els.btnListsMenu.classList.contains("active"));
});

ta("leaving the mode is the one moment write permission is asked for", async () => {
  resetApp();
  const root = fakeRoot({ "plan.csv": FILE_A });
  A.state.rootHandle = root; A.state.dbHandle = root;
  await A.openFile("plan.csv");
  await A.setReadOnly(true);
  root.asked.length = 0;

  assert.strictEqual(await A.setReadOnly(false), false, "the mode did not come off");
  eqArr(root.asked, ["request:readwrite"], "write permission was not asked for");
  assert.strictEqual(A.readOnly(), false);
  assert.strictEqual(A.fsMode(), "readwrite");
  assert.strictEqual(els.roChip.style.display, "none", "the chip stayed on screen");
  assert.strictEqual(els.btnSave.disabled, false, "Save did not come back");
  assert.strictEqual(els.btnNewFile.disabled, false, "New file did not come back");
  assert(A.activeColumns().map(c => c.key).includes("actions"), "the actions column did not come back");
  assert(/Read-only mode/.test(els.btnReadOnly.textContent), "the menu still offers to leave: " + els.btnReadOnly.textContent);

  // and now editing really does reach the disk again
  A.startInline(A.state.tasks[0].id);
  assert(A.state.inlineEdit, "inline editing is still refused");
  A.cancelInline();
  resetApp();
});

ta("refusing the permission leaves the mode on rather than a half-open app", async () => {
  resetApp();
  const root = fakeRoot({ "plan.csv": FILE_A }, { request: "denied" });
  A.state.rootHandle = root; A.state.dbHandle = root;
  await A.openFile("plan.csv");
  await A.setReadOnly(true);

  assert.strictEqual(await A.setReadOnly(false), true, "the mode came off without permission");
  assert.strictEqual(A.readOnly(), true);
  assert.strictEqual(els.btnSave.disabled, true, "Save is offered but cannot write");
  assert(/read-only/.test(els.toast.textContent), "nothing said why: " + els.toast.textContent);
  resetApp();
});

ta("the combined view and the mode do not undo each other", async () => {
  resetApp();
  A.state.readOnly = true;
  await openCombo({ "a.csv": FILE_A, "b.csv": FILE_B });
  assert.strictEqual(A.readOnly(), true);
  assert(/combined view/.test(A.roReason()), "the combined view lost its own words");
  assert.strictEqual(els.fileSelect.disabled, true, "the dropdown is live in the combined view");
  await A.leaveCombined();
  // leaving the combined view is not a way out of read-only mode
  assert.strictEqual(A.state.combined, null);
  assert.strictEqual(A.readOnly(), true, "read-only mode was dropped on the way out");
  assert.strictEqual(els.roChip.style.display, "", "the chip is not back");
  resetApp();
});

console.log("\n-- folded groups are remembered, in the file --");

/* Two groups and a plain task, so "folded" and "open" are both meaningful. */
const folding = () => loadRowsNow([
  { id: 1, name: "Wave one", start: "2026-03-02", end: "2026-03-04", group: [2, 3] },
  { id: 2, name: "Prep",     start: "2026-03-02", end: "2026-03-02" },
  { id: 3, name: "Build",    start: "2026-03-03", end: "2026-03-04" },
  { id: 4, name: "Wave two", start: "2026-03-05", end: "2026-03-06", group: [5] },
  { id: 5, name: "Cutover",  start: "2026-03-05", end: "2026-03-06" },
]);

t("only the closed groups are written, and only while they are groups", () => {
  folding();
  A.state.collapsed = new Set([idOfName("Wave one")]);
  const out = A.serializeCSV(A.state.tasks);
  assert(out.includes(`#VIEW|collapsed|${idOfName("Wave one")}`), "the fold is not in the file:\n" + out);
  assert(!out.includes(String(idOfName("Wave two")) + ";"), "an open group was written as closed");

  // a plain task cannot be folded, so its id never reaches the line
  A.state.collapsed = new Set([idOfName("Prep")]);
  assert(!/#VIEW\|collapsed/.test(A.serializeCSV(A.state.tasks)),
         "a row that is not a group was written as folded");

  // nothing folded, nothing written — an unfolded plan gains no line at all
  A.state.collapsed.clear();
  assert(!/#VIEW\|collapsed/.test(A.serializeCSV(A.state.tasks)));
});

t("a fold survives the round trip through the file", () => {
  folding();
  const one = idOfName("Wave one"), two = idOfName("Wave two");
  A.state.collapsed = new Set([one, two]);
  eqArr([...A.parseCollapsed(A.serializeCSV(A.state.tasks))].sort((a, b) => a - b), [one, two].sort((a, b) => a - b));
  A.state.collapsed.clear();
});

t("a file that says nothing about folds comes up open", () => {
  assert.strictEqual(A.parseCollapsed("").size, 0);
  assert.strictEqual(A.parseCollapsed("ID|Activity Name\n1|x\n").size, 0);
  // and rubbish in the line is left out rather than repaired
  eqArr([...A.parseCollapsed("#VIEW|collapsed|3;nonsense;-2;0;7")], [3, 7]);
});

t("the two #VIEW settings are separate lines and do not read each other", () => {
  folding();
  // the defaults are in there so this stays a test about two keys, not three
  A.state.hiddenCols = new Set([...A.defaultHiddenCols(), "team"]);
  A.state.collapsed = new Set([idOfName("Wave one")]);
  const out = A.serializeCSV(A.state.tasks);
  const lines = out.split(/\r?\n/).filter(l => l.startsWith("#VIEW"));
  assert.strictEqual(lines.length, 2, "expected one line each:\n" + lines.join("\n"));
  eqArr([...A.parseViewLine(out)].sort(), [...A.defaultHiddenCols(), "team"].sort(),
    "the columns line was misread");
  eqArr([...A.parseCollapsed(out)], [idOfName("Wave one")], "the folds line was misread");
  // and each is blind to the other's key
  assert.strictEqual(A.viewValue(out, "nothingLikeThis"), "");
  A.state.hiddenCols.clear(); A.state.collapsed.clear();
});

ta("opening a file restores its folds, renumbered with everything else", async () => {
  resetApp();
  folding();
  A.state.collapsed = new Set([idOfName("Wave two")]);
  const saved = A.serializeCSV(A.state.tasks);

  /* Written out of time order on purpose: loading renumbers by time, and the
     fold has to be carried onto the row's new id rather than left pointing at
     whatever inherits the old one. */
  const shuffled = saved.split(/\r?\n/).filter(Boolean);
  const body = shuffled.filter(l => /^\d/.test(l)).reverse();
  const rest = shuffled.filter(l => !/^\d/.test(l));
  const text = [rest[0], ...body, ...rest.slice(1)].join("\r\n") + "\r\n";

  const root = fakeRoot({ "plan.csv": text });
  A.state.dbHandle = root; A.state.rootHandle = root;
  A.state.collapsed = new Set([999]);              // whatever was on screen before must go
  await A.openFile("plan.csv");

  assert.strictEqual(A.state.collapsed.size, 1, "the folds did not come from the file: " + [...A.state.collapsed]);
  const folded = A.taskById([...A.state.collapsed][0]);
  assert(folded, "the fold points at a row that is not there");
  assert.strictEqual(folded.name, "Wave two", "the fold landed on the wrong row after renumbering");
  resetApp();
});

ta("folding a group writes it, like hiding a column", async () => {
  resetApp();
  const files = { "plan.csv": null };
  folding();
  files["plan.csv"] = A.serializeCSV(A.state.tasks);
  const root = fakeRoot(files);
  A.state.dbHandle = root; A.state.rootHandle = root;
  await A.openFile("plan.csv");

  const id = idOfName("Wave one");
  A.state.collapsed.add(id);
  A.markViewDirty();
  await A.saveFile(true);
  assert(files["plan.csv"].includes("#VIEW|collapsed|" + id), "the fold never reached the disk");

  // and it is a view change, not a plan change: nothing lands on the undo stack
  A.resetHistory();
  A.state.collapsed.delete(id);
  A.markViewDirty();
  assert.strictEqual(A.history.undo.length, 0, "folding a group became an undo step");
  resetApp();
});

console.log("\n-- the period is kept the right way round while you type --");

const per = (start, hourStart, end, hourEnd) => ({ start, hourStart, end, hourEnd });

t("a period already in order is left alone", () => {
  assert.strictEqual(A.orderPeriod(per("2026-05-04", "", "2026-05-08", ""), "start"), null);
  assert.strictEqual(A.orderPeriod(per("2026-05-04", "", "2026-05-04", ""), "end"), null, "one day is in order");
  assert.strictEqual(A.orderPeriod(per("2026-05-04", "09:00", "2026-05-04", "17:00"), "end"), null);
});

t("a half-typed or missing date is not a date yet", () => {
  assert.strictEqual(A.orderPeriod(per("2026-05-20", "", "", ""), "start"), null);
  assert.strictEqual(A.orderPeriod(per("", "", "2026-05-08", ""), "end"), null);
  assert.strictEqual(A.orderPeriod(per("2026-05-20", "", "2026-0", ""), "start"), null);
});

t("moving the Start past the End brings the End with it", () => {
  const f = A.orderPeriod(per("2026-05-20", "", "2026-05-08", ""), "start");
  assert.strictEqual(f.end, "2026-05-20");
  assert.strictEqual(f.start, "2026-05-20", "the day you picked is the one that stands");
});

t("moving the End back before the Start brings the Start with it", () => {
  const f = A.orderPeriod(per("2026-05-04", "", "2026-05-01", ""), "end");
  assert.strictEqual(f.start, "2026-05-01");
  assert.strictEqual(f.end, "2026-05-01");
});

t("the hours travel with the end that moves", () => {
  const f = A.orderPeriod(per("2026-05-20", "09:30", "2026-05-08", "17:00"), "start");
  assert.strictEqual(f.hourEnd, "09:30", "the end takes the start's hour, not the one it had");
  const g = A.orderPeriod(per("2026-05-04", "09:30", "2026-05-01", "17:00"), "end");
  assert.strictEqual(g.hourStart, "17:00");
});

t("an end hour pulled back before the start hour is backwards too", () => {
  const f = A.orderPeriod(per("2026-05-04", "14:00", "2026-05-04", "10:00"), "end");
  assert(f, "same day, but the period still runs backwards");
  assert.strictEqual(f.start, "2026-05-04", "the day does not move, only the hour");
  assert.strictEqual(f.hourStart, "10:00");
});

t("it repairs exactly what the save would have refused, and nothing else", () => {
  /* A blank end hour means "any time that day", which is why a start hour on
     the same day is not backwards. The editor must agree with validateTask on
     that, or it would move a period the save was about to accept. */
  const d = per("2026-05-04", "16:00", "2026-05-04", "");
  const errs = A.validateTask({ ...d, name: "x", preds: [], group: [] }, 1, []);
  assert.strictEqual(errs.some(e => /earlier than Start/.test(e)), false, "the validator accepts it");
  assert.strictEqual(A.orderPeriod(d, "start"), null, "so the editor must leave it be");
});

t("the dialog moves the field, tells you, and re-reads the duration", () => {
  els.fStart.value = "2026-05-20"; els.fHourStart.value = "";
  els.fEnd.value = "2026-05-08";   els.fHourEnd.value = "";
  els.fMilestone.checked = false;
  assert.strictEqual(A.fixModalPeriod("start"), true);
  assert.strictEqual(els.fEnd.value, "2026-05-20");
  assert.strictEqual(els.fDuration.textContent, "1 day(s)", "one day, recalculated from the fields as they now are");
  assert(/End moved to 2026-05-20/.test(els.toast.textContent), "it has to say what it did: " + els.toast.textContent);
});

t("the list row does the same thing, so which editor you opened does not matter", () => {
  // the inline fields only exist once a row is being edited, so ask for them
  const iStart = document.getElementById("iStart"), iEnd = document.getElementById("iEnd");
  iStart.value = "2026-05-04"; document.getElementById("iHs").value = "";
  iEnd.value = "2026-05-01";   document.getElementById("iHe").value = "";
  assert.strictEqual(A.fixInlinePeriod("end"), true);
  assert.strictEqual(iStart.value, "2026-05-01");
  assert(/Start moved to 2026-05-01/.test(els.toast.textContent), els.toast.textContent);
  // and it stays out of the way when there is nothing to repair
  iEnd.value = "2026-05-09";
  assert.strictEqual(A.fixInlinePeriod("end"), false);
  assert.strictEqual(iStart.value, "2026-05-01", "an ordered period must not be touched");
});

t("the dialog's Start field is wired to it", () => {
  chained();                                   // any plan; the dialog needs a task to edit
  useRef({});
  A.openModal({ type: "edit", id: 1 });        // editing, so nothing "follows" anything
  els.fStart.value = "2026-09-30"; els.fHourStart.value = "";
  els.fEnd.value = "2026-09-10";   els.fHourEnd.value = "";
  fire(els.fStart, "change");
  assert.strictEqual(els.fEnd.value, "2026-09-30", "the change handler did not run the repair");
  A.closeModal();
  useRef(null);
});

console.log("\n-- a new file cannot land on a name already taken --");

t("a typed name becomes a file name", () => {
  assert.strictEqual(A.cleanFileName("  My Plan  "), "My Plan");
  assert.strictEqual(A.cleanFileName("plan.csv"), "plan", "the extension is added, not typed");
  assert.strictEqual(A.cleanFileName("plan.gantora"), "plan", "either extension");
  assert.strictEqual(A.cleanFileName("a/b:c*d"), "abcd", "path characters go");
  assert.strictEqual(A.cleanFileName("__fixture"), "fixture", "a leading _ is how the fixtures hide");
  assert.strictEqual(A.cleanFileName("   "), "");
});

ta("creating over an existing name asks for another one instead of overwriting", async () => {
  resetApp();
  const files = { "plan.csv": A.serializeCSV([]) };
  const before = files["plan.csv"];
  const root = fakeRoot(files);
  A.state.dbHandle = root; A.state.rootHandle = root;

  const p = A.createFile("plan");
  await new Promise(r => setTimeout(r, 0));
  const asked = els.askLabel.textContent, offered = els.askInput.value;
  els.askInput.value = "plan-two";
  A.askSubmit();
  const made = await p;

  assert(/already exists/.test(asked || ""), "it has to say why it is asking: " + asked);
  assert.strictEqual(offered, "plan-2", "and offer the first free number");
  /* A name taken under *either* extension is taken: "plan.csv" is in the way
     of a new "plan.gantora", or you would edit the wrong one for a week. */
  assert.strictEqual(made, "plan-two" + A.PLAN_EXT, "new files are written as .gantora");
  assert.strictEqual(files["plan.csv"], before, "the existing plan must not be touched");
  assert(("plan-two" + A.PLAN_EXT) in files, "the new file was not created");
  resetApp();
});

ta("the suggestion counts past the numbers already taken, and backing out creates nothing", async () => {
  resetApp();
  const files = { "plan.csv": "", "plan-2.csv": "", "plan-3.csv": "" };
  const root = fakeRoot(files);
  A.state.dbHandle = root; A.state.rootHandle = root;

  const p = A.createFile("plan");
  await new Promise(r => setTimeout(r, 0));
  const offered = els.askInput.value;
  A.askCancel();
  const made = await p;

  assert.strictEqual(offered, "plan-4");
  assert.strictEqual(made, null);
  assert.strictEqual(Object.keys(files).length, 3, "backing out must leave /DB alone");
  resetApp();
});

ta("a free name is created without a question", async () => {
  resetApp();
  const files = {};
  const root = fakeRoot(files);
  A.state.dbHandle = root; A.state.rootHandle = root;
  const made = await A.createFile("brand new");
  assert.notStrictEqual(els.askOverlay.style.display, "flex", "must not ask for a name that is free");
  assert.strictEqual(made, "brand new" + A.PLAN_EXT);
  resetApp();
});

/* 🔴 Daniel, 2026-08-23, desktop list finding 1: "ha egyszer már kiválasztott
   egy munkakönyvtárat, akkor automatikusan azt a könyvtár első fájlját töltse
   be. Ez iOS-en jól megy, de desktopon nem." The two paths that lead there —
   picking a folder, and coming back to a remembered one — both end in
   openFirstProject(), and NOTHING covered either of them end to end. A test
   that drives the real path is the only way to tell whether the code does what
   it reads like it does. */
/* 🔴 Two folders, two memories (Daniel, same finding: "ez a könyvtár
   Dropbox/Helyi függő, azaz kettő lehet"). A plan name means nothing in the
   other store, so one remembered name for both meant that opening something in
   Dropbox forgot where you were locally — and coming back opened whatever
   happened to be first instead of where you left off. */
t("the plan you had open is remembered per store, not once for both", () => {
  try {
    ctx.localStorage.clear();
    A.setStorage(A.FsaAdapter);
    A.rememberLastFile("local-one.gantora");
    assert.strictEqual(A.lastFileName(), "local-one.gantora");

    A.setStorage(A.DropboxAdapter);
    assert.strictEqual(A.lastFileName(), "", "🔴 the local plan's name leaked into Dropbox");
    A.rememberLastFile("dropbox-one.gantora");
    assert.strictEqual(A.lastFileName(), "dropbox-one.gantora");

    A.setStorage(A.FsaAdapter);
    assert.strictEqual(A.lastFileName(), "local-one.gantora", "…and the local one is still where it was");

    /* Nobody loses the plan they had open across this change: the single old
       key is read as this machine's, and only as this machine's. */
    ctx.localStorage.clear();
    ctx.localStorage.setItem("ppp.lastFile", "from-before-the-split.gantora");
    assert.strictEqual(A.lastFileName(), "from-before-the-split.gantora", "the old key still answers locally");
    A.setStorage(A.DropboxAdapter);
    assert.strictEqual(A.lastFileName(), "", "…but not in the other store, which never wrote it");
  } finally { A.setStorage(A.FsaAdapter); ctx.localStorage.clear(); }
});

/* 🔴 Daniel, 2026-08-23: "amíg tölt be egy terv a kiválasztásától ez 1-2
   másodperc is lehet mérettől függően. legyen egy loading indicator". */
ta("the strip is on screen BEFORE the work starts, and gone after it", async () => {
  const seen = [];
  const out = await A.withBusy(async () => {
    seen.push(els.busyBar.style.display);          // what the work itself sees
    return "done";
  });
  assert.strictEqual(out, "done", "it hands back what the work returned");
  assert.strictEqual(seen[0], "block",
    "🔴 the strip was already up when the work began — showing it and calling straight in would paint it after the work, not during");
  assert.strictEqual(els.busyBar.style.display, "none", "and it is gone when the work is");
});

ta("…and a plan opened inside a folder does not switch it off early", async () => {
  let inner = "";
  await A.withBusy(async () => {
    await A.withBusy(async () => {});             // opening a plan, inside opening a folder
    inner = els.busyBar.style.display;            // the outer one is still working
  });
  assert.strictEqual(inner, "block", "🔴 the inner finish took the strip away while the outer was still going");
  assert.strictEqual(els.busyBar.style.display, "none");
});

ta("the strip animates a transform, so it keeps moving while the plan is parsed", () => {
  /* A plan is parsed, recalculated and drawn on the main thread, which is
     exactly when an indicator has to be alive. Only transform and opacity are
     animated off it; a width animation, or a spinner drawn by script, freezes
     at the moment it is needed and reads as "stuck". */
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const rule = (css.match(/#busyBar > span\{([^}]*)\}/) || [])[1];
  assert(rule, "the strip has a moving part");
  assert(/animation:busySlide/.test(rule), "…which is animated: " + rule);
  const frames = (css.match(/@keyframes busySlide\{([^}]*)\}/) || [])[1];
  assert(frames && /transform:translateX/.test(frames), "by a transform: " + frames);
  assert(!/width:|left:|margin/.test(frames), "🔴 and by nothing that costs a layout: " + frames);
  assert(/prefers-reduced-motion:reduce\)\{\s*#busyBar > span\{animation:none/.test(css.replace(/\n\s*/g, " ")),
    "asked for stillness, it stays lit and says the same thing without moving");
});

ta("a folder with plans in it opens one, without being asked twice", async () => {
  resetApp();
  const files = { "b-second.gantora": FILE_B, "a-first.gantora": FILE_A, "_lists.csv": "Team|Alpha\r\n" };
  const root = fakeRoot(files);
  A.setStorage(A.FsaAdapter);
  A.state.rootHandle = root; A.state.dbHandle = root;
  useRef(null); useName("");

  assert.strictEqual(await A.openFirstProject(), true, "🔴 a folder full of plans left nothing on screen");
  assert(A.planName(), "…and something is actually open: " + A.planName());
  /* The FIRST of the folder, in the order the listing gives — which is the
     order the plan switcher shows, so what opens is what the reader would have
     pressed at the top of the list. */
  const names = await A.listProjectFiles();
  assert.strictEqual(A.planName(), names[0], "the one that opens is the first: " + names.join(", "));
  /* And it does not open a second time over itself: something already on screen
     is the reader's, not ours to replace. */
  useName(names[1]);
  assert.strictEqual(await A.openFirstProject(), false, "a plan already open is left alone");
  assert.strictEqual(A.planName(), names[1]);
  resetApp();
});

ta("the folder lists both, and the address finds either", async () => {
  resetApp();
  const files = { "new.gantora": FILE_A, "old.csv": FILE_B, "_lists.csv": "Team|Alpha\r\n",
                  "notes.txt": "x" };
  const root = fakeRoot(files);
  A.state.rootHandle = root; A.state.dbHandle = root;
  eqArr(await A.listProjectFiles(), ["new.gantora", "old.csv"],
    "both extensions, and nothing else — the registry hides behind its underscore");

  // "?file=old" with no extension resolves against the folder
  A.state.wantFile = "old";
  assert.strictEqual(await A.openWantedFile(), true);
  assert.strictEqual(A.planName(), "old.csv");

  A.state.wantFile = "new";
  assert.strictEqual(await A.openWantedFile(), true);
  assert.strictEqual(A.planName(), "new.gantora", "…and prefers the new extension where both could match");
  resetApp();
});

console.log("\n-- saved views --");

/* Long enough to scroll in both directions at the default widths: five months
   of work, two groups, a person on two of the rows, and a milestone. */
const viewFixture = () => loadRowsNow([
  { id: 1, name: "Discovery", start: "2026-03-02", end: "2026-03-20", group: [2, 3], team: "Dev", system: "Core" },
  { id: 2, name: "Interviews", start: "2026-03-02", end: "2026-03-10", team: "Dev", system: "Core", acc: "Anna Kovacs:4" },
  { id: 3, name: "Report", start: "2026-03-11", end: "2026-03-20", team: "Dev", system: "Core", resp: "Bela Nagy" },
  { id: 4, name: "Build", start: "2026-04-01", end: "2026-05-29", group: [5], team: "Ops", system: "Ledger" },
  { id: 5, name: "Migration", start: "2026-04-01", end: "2026-05-29", team: "Ops", system: "Ledger", acc: "Anna Kovacs" },
  { id: 6, name: "Cutover", start: "2026-06-15", end: "2026-06-15", team: "Ops", system: "Ledger" },
  { id: 7, name: "Go live", start: "2026-06-30", end: "2026-06-30", ms: true },
]);

// the state a saved view touches, back to the app's own defaults
const resetViewState = () => {
  A.state.view = "table"; A.state.zoomLevel = "month"; A.state.zoomBy = "task";
  A.state.zoomFocus = null; A.state.zoomDay = null; A.state.zoomMonth = null;
  A.state.pxPerDay = 28; A.state.pxPerMonth = 130; A.state.pxPerDayZoom = 24;
  A.state.pxPerHour = 6; A.state.pxPerResDay = 40;
  A.state.groupLevel = A.GROUP_LEVELS;
  A.state.filterTeam = ""; A.state.filterSystem = ""; A.state.filterPerson = "";
  A.state.savedViews = []; A.state.activeView = "";
  els.ganttRight.scrollLeft = 0; els.ganttRight.scrollTop = 0;
  els.hvRight.scrollLeft = 0; els.hvRight.scrollTop = 0;
  els.resRight.scrollLeft = 0; els.resRight.scrollTop = 0;
};

t("the escape brings back exactly what the format needs to eat", () => {
  for (const s of ["Sales; EU", "a|b", "tilde ~ here", "~s literal", "", "plain", "a=b=c"])
    assert.strictEqual(A.svDec(A.svEnc(s)), s, "did not survive: " + s);
  // and neither separator is left loose in the encoded form
  assert(!A.svEnc("Sales; EU|x").includes(";"), "a semicolon would split the pairs");
  assert(!A.svEnc("Sales; EU|x").includes("|"), "a pipe would split the cells");
  // a newline cannot survive: a record is one line, so it becomes a space
  assert.strictEqual(A.svEnc("two\nlines"), "two lines");
});

t("a saved view round-trips through its settings cell", () => {
  const v = { name: "n", view: "zoom", level: "day", by: "team", focus: "Sales; EU",
              day: "", px: 24, lvl: 2, top: "7", team: "R&D=Core", system: "", person: "Anna Kovacs",
              at: "2026-04-15", hour: "09:30" };
  const back = A.parseSavedViewCell(A.savedViewCell(v));
  for (const k of ["view", "level", "by", "focus", "top", "team", "person", "at", "hour"])
    assert.strictEqual(back[k], String(v[k]), k + " did not survive the trip");
  assert.strictEqual(back.px, 24);
  assert.strictEqual(back.lvl, 2);
  // an empty field is not written at all, and reads back as absent
  assert(!A.savedViewCell(v).includes("day="), "an empty setting was written out");
  assert(!A.savedViewCell(v).includes("system="), "an empty filter was written out");
  // "=" inside a value is fine: the reader splits on the first one only
  assert.strictEqual(back.team, "R&D=Core");
  // a key this app does not know is dropped rather than carried
  assert.strictEqual(A.parseSavedViewCell("view=gantt;fromTheFuture=x").fromTheFuture, undefined);
});

t("the file's saved views are read as a list, and the rubbish left out", () => {
  const text =
    "#VIEW|hiddenColumns|team\r\n" +
    "#VIEW|savedView|plan - DesignView 1|view=gantt;px=40;at=2026-04-10;top=3\r\n" +
    "#VIEW|savedView|plan - ZoomView 1|view=zoom;level=day;px=18;at=2026-05-02\r\n" +
    "#VIEW|savedView|plan - DesignView 1|view=gantt;px=9;at=2026-01-01\r\n" +   // a name already taken
    "#VIEW|savedView|nonsense|view=teams;px=10\r\n" +                          // not a view with a chart
    "#VIEW|savedView||view=gantt;px=10\r\n";                                   // no name at all
  const list = A.parseSavedViews(text);
  eqArr(list.map(v => v.name), ["plan - DesignView 1", "plan - ZoomView 1"]);
  assert.strictEqual(list[0].px, 40, "the first line by that name is the one that counts");
  assert.strictEqual(list[1].level, "day");
  // the other #VIEW keys are untouched by the new one (some columns start folded)
  eqArr([...A.parseViewLine(text)].sort(), [...A.defaultHiddenCols(), "team"].sort());
  assert.strictEqual(A.parseSavedViews("").length, 0);
});

t("each saved view is a line of its own in the file", () => {
  viewFixture();
  resetViewState();
  A.state.savedViews = [
    { name: "plan - DesignView 1", view: "gantt", px: 28, lvl: 4, at: "2026-04-10", hour: "", top: "3" },
    { name: "plan - ResourceView 1", view: "res", px: 40, lvl: 4, at: "2026-04-11", hour: "", top: "Anna Kovacs" },
  ];
  const out = A.serializeCSV(A.state.tasks);
  const lines = out.split(/\r?\n/).filter(l => l.startsWith("#VIEW|savedView"));
  assert.strictEqual(lines.length, 2, "one line each:\n" + lines.join("\n"));
  eqArr(A.parseSavedViews(out).map(v => v.name), ["plan - DesignView 1", "plan - ResourceView 1"]);
  // and a plan with none gains no line
  A.state.savedViews = [];
  assert(!/#VIEW\|savedView/.test(A.serializeCSV(A.state.tasks)));
});

t("the offered name says the file, the kind of view, and the first free number", () => {
  viewFixture();
  resetViewState();
  useName("sample-project.csv");
  assert.strictEqual(A.autoViewName("gantt"), "sample-project - DesignView 1");
  A.state.savedViews = [{ name: "sample-project - DesignView 1", view: "gantt" }];
  assert.strictEqual(A.autoViewName("gantt"), "sample-project - DesignView 2");
  // the count is per kind, so the other views still start at one
  assert.strictEqual(A.autoViewName("zoom"), "sample-project - ZoomView 1");
  assert.strictEqual(A.autoViewName("res"), "sample-project - ResourceView 1");
  useName("");
  resetViewState();
});

t("the Design view is saved as a moment and a row, not as pixels", () => {
  viewFixture();
  resetViewState();
  A.state.view = "gantt";
  A.renderGantt();
  const pane = els.ganttRight;
  pane.scrollLeft = 40 * A.state.pxPerDay;           // 40 days in
  pane.scrollTop = 2 * A.ROW_H;                      // the third row at the top

  const v = A.captureView("here");
  const { min } = A.ganttRange();
  const wanted = A.fmtDate(A.addDays(min, 40 + Math.floor(pane.clientWidth / 2 / A.state.pxPerDay)));
  assert.strictEqual(v.at, wanted, "the middle of the pane was read as the wrong day");
  assert.strictEqual(v.view, "gantt");
  assert.strictEqual(v.px, A.state.pxPerDay);
  assert.strictEqual(v.lvl, A.state.groupLevel);
  // the top row is the task, not its position
  const third = A.designRowIndex(+v.top);
  assert.strictEqual(third, 2, "the top row was recorded as row " + third);
  resetViewState();
});

t("restoring puts the same day in the middle whatever the day width is now", () => {
  viewFixture();
  resetViewState();
  A.state.view = "gantt";
  A.renderGantt();
  const pane = els.ganttRight;
  pane.scrollLeft = 40 * A.state.pxPerDay;
  const v = A.captureView("here");

  // somebody else's screen, and a day three times as wide
  A.state.pxPerDay = 84;
  pane.clientWidth = 1100;
  pane.scrollLeft = 0;
  A.applyView(v);

  const { min } = A.ganttRange();
  const centre = (pane.scrollLeft + pane.clientWidth / 2) / A.state.pxPerDay;
  assert.strictEqual(A.state.pxPerDay, v.px, "the saved width is part of the view");
  assert.strictEqual(A.fmtDate(A.addDays(min, Math.floor(centre))), v.at,
                     "the saved day is not in the middle any more");
  pane.clientWidth = 800;
  resetViewState();
});

t("the row that was at the top comes back to the top", () => {
  viewFixture();
  resetViewState();
  A.state.view = "gantt";
  A.renderGantt();
  const pane = els.ganttRight;
  pane.scrollTop = 3 * A.ROW_H;
  const v = A.captureView("here");
  const wasTop = +v.top;

  pane.scrollTop = 0;
  A.applyView(v);
  assert.strictEqual(A.designRowIndex(wasTop) * A.ROW_H, pane.scrollTop, "a different row is at the top");

  /* And when the depth filter has moved that row somewhere else, it still lands
     on the row rather than on the number it used to have. */
  const at4 = pane.scrollTop;
  A.state.groupLevel = 1;
  A.renderGantt();
  A.scrollToRow(wasTop);
  assert.strictEqual(pane.scrollTop, A.designRowIndex(wasTop) * A.ROW_H);
  assert(pane.scrollTop !== at4 || A.designRowIndex(wasTop) === 3, "the fixture stopped testing anything");
  resetViewState();
});

t("a top row that is gone leaves the chart at its first row", () => {
  viewFixture();
  resetViewState();
  A.state.view = "gantt";
  A.renderGantt();
  els.ganttRight.scrollTop = 2 * A.ROW_H;
  A.scrollToRow("99999");
  assert.strictEqual(els.ganttRight.scrollTop, 0, "an id that is not on the chart must not guess");
  resetViewState();
});

t("the level and the filters travel with the view", () => {
  viewFixture();
  resetViewState();
  A.state.view = "gantt"; A.state.groupLevel = 1;
  A.state.filterTeam = "Ops"; A.state.filterSystem = "Ledger";
  A.render();
  const v = A.captureView("narrow");

  A.state.groupLevel = A.GROUP_LEVELS;
  A.state.filterTeam = ""; A.state.filterSystem = "";
  A.applyView(v);
  assert.strictEqual(A.state.groupLevel, 1, "the Lvl setting did not come back");
  assert.strictEqual(A.state.filterTeam, "Ops");
  assert.strictEqual(A.state.filterSystem, "Ledger");
  resetViewState();
});

t("the Zoom view brings back its level, its Y dimension and its window", () => {
  viewFixture();
  resetViewState();
  A.state.view = "zoom";
  A.drillToDay(A.monthIndex(A.parseDate("2026-04-01")));
  A.drillToHour(A.parseDate("2026-04-15"));
  const v = A.captureView("hours");
  assert.strictEqual(v.level, "hour");
  assert.strictEqual(v.day, "2026-04-15", "the day the window is built around is part of the view");
  assert.strictEqual(v.px, A.state.pxPerHour);

  A.backToHelicopter();
  A.state.zoomBy = "team";
  A.applyView(v);
  assert.strictEqual(A.state.zoomLevel, "hour");
  assert.strictEqual(A.state.zoomDay, "2026-04-15");
  assert.strictEqual(A.state.zoomBy, "task", "the Y dimension is the saved one, not the one left on screen");
  const hv = els.hvRight;
  const centre = (hv.scrollLeft + hv.clientWidth / 2) / (A.state.pxPerHour * 24);
  const win = A.addDays(A.parseDate("2026-04-15"), -A.HOUR_WINDOW);
  assert.strictEqual(A.fmtDate(A.addDays(win, Math.floor(centre))), v.at, "the middle of the window moved");
  resetViewState();
});

t("a lane view remembers which team the row was", () => {
  viewFixture();
  resetViewState();
  A.state.view = "zoom"; A.state.zoomBy = "team";
  A.renderZoom();
  els.hvRight.scrollTop = A.HV_ROW_H;                // the second lane
  const v = A.captureView("lanes");
  assert.strictEqual(v.by, "team");
  assert(v.top === "Dev" || v.top === "Ops", "a lane row is a team, not a number: " + v.top);

  els.hvRight.scrollTop = 0;
  A.state.zoomBy = "task";
  A.applyView(v);
  assert.strictEqual(A.state.zoomBy, "team");
  assert.strictEqual(els.hvRight.scrollTop, A.HV_ROW_H, "the lane did not come back to the top");
  resetViewState();
});

t("the Resource view counts working columns, not calendar days", () => {
  viewFixture();
  resetViewState();
  A.state.view = "res";
  A.renderResources();
  const pane = els.resRight;
  pane.scrollLeft = 10 * A.state.pxPerResDay;
  pane.scrollTop = A.RES_ROW_H;                      // the second person
  const v = A.captureView("people");

  const m = A.resourceModel();
  assert(m.keys.includes(v.at), "the middle landed on a day the view does not draw: " + v.at);
  assert(m.rows.some(r => r.name === v.top), "the top row is not one of the people: " + v.top);

  pane.scrollLeft = 0; pane.scrollTop = 0;
  A.applyView(v);
  const i = m.keys.indexOf(v.at);
  assert.strictEqual(pane.scrollLeft, Math.max(0, i * A.state.pxPerResDay - pane.clientWidth / 2),
                     "the saved day is not in the middle");
  assert.strictEqual(pane.scrollTop, m.rows.findIndex(r => r.name === v.top) * A.RES_ROW_H);

  /* A weekend has no column of its own, so a view saved on one is put back on
     the first column after it rather than nowhere. */
  const sat = m.days.find(d => A.isWeekend(d));
  if (sat) {
    A.centreOn(A.fmtDate(sat), "");
    assert(pane.scrollLeft >= 0, "a day with no column must not throw the scroll off");
  }
  resetViewState();
});

t("the picker only claims a view while you are in that kind of view", () => {
  viewFixture();
  resetViewState();
  A.state.savedViews = [{ name: "a - ZoomView 1", view: "zoom", level: "month", px: 130, lvl: 4, at: "2026-04-01", hour: "", top: "" }];
  A.state.activeView = "a - ZoomView 1";
  A.state.view = "zoom";
  A.refreshSavedViews();
  assert.strictEqual(A.state.activeView, "a - ZoomView 1");
  assert.strictEqual(els.savedViewSelect.value, "a - ZoomView 1");
  assert.strictEqual(els.btnDelView.disabled, false, "there is something to delete");

  A.state.view = "gantt";
  A.refreshSavedViews();
  assert.strictEqual(A.state.activeView, "", "the name outlived the view it names");
  assert.strictEqual(els.btnDelView.disabled, true, "nothing is picked, so nothing can be deleted");
  assert(els.savedViewSelect.innerHTML.includes("a - ZoomView 1"), "the view is still in the list");
  resetViewState();
});

t("picking a view applies it, and deleting one takes it off the list", () => {
  viewFixture();
  resetViewState();
  A.state.view = "gantt";
  A.render();
  const v = A.captureView("plan - DesignView 1");
  A.state.savedViews = [v];

  A.state.view = "res"; A.render();
  assert(A.applySavedView("plan - DesignView 1"), "the view was not applied");
  assert.strictEqual(A.state.view, "gantt");
  assert.strictEqual(A.state.activeView, "plan - DesignView 1");
  assert.strictEqual(A.applySavedView("no such view"), false);

  A.deleteSavedView("plan - DesignView 1");
  assert.strictEqual(A.state.savedViews.length, 0, "the view was not deleted");
  assert.strictEqual(A.state.activeView, "");
  resetViewState();
});

ta("saving a view writes it to the file, and opening the file brings it back", async () => {
  resetApp();
  viewFixture();
  resetViewState();
  const files = { "plan.csv": A.serializeCSV(A.state.tasks) };
  const root = fakeRoot(files);
  A.state.dbHandle = root; A.state.rootHandle = root;
  await A.openFile("plan.csv");

  A.state.view = "gantt";
  A.render();
  els.ganttRight.scrollLeft = 30 * A.state.pxPerDay;
  els.ganttRight.scrollTop = 2 * A.ROW_H;

  A.saveCurrentView();
  await new Promise(r => setTimeout(r, 0));
  A.askCancel();
  await new Promise(r => setTimeout(r, 0));
  assert.strictEqual(A.state.savedViews.length, 0, "a cancelled prompt saved a view anyway");

  A.saveCurrentView();
  await new Promise(r => setTimeout(r, 0));
  els.askInput.value = "";                     // blank: the offered name stands
  A.askSubmit();
  await new Promise(r => setTimeout(r, 0));    // saveCurrentView writes without waiting
  assert.strictEqual(A.state.savedViews.length, 1);
  assert.strictEqual(A.state.savedViews[0].name, "plan - DesignView 1");
  assert(files["plan.csv"].includes("#VIEW|savedView|plan - DesignView 1|"),
         "the view never reached the disk:\n" + files["plan.csv"].split(/\r?\n/).slice(-4).join("\n"));

  // it is a view change, not a plan change: nothing lands on the undo stack
  A.resetHistory();
  A.markViewDirty();
  assert.strictEqual(A.history.undo.length, 0, "saving a view became an undo step");

  const saved = A.state.savedViews[0];
  A.state.savedViews = [];
  await A.openFile("plan.csv");
  assert.strictEqual(A.state.savedViews.length, 1, "the file did not give the view back");
  assert.strictEqual(A.state.savedViews[0].at, saved.at);
  assert.strictEqual(A.state.savedViews[0].top, saved.top);
  assert.strictEqual(A.state.savedViews[0].px, saved.px);

  // and it can be applied straight from the file it came out of
  A.state.view = "table";
  assert(A.applySavedView("plan - DesignView 1"));
  assert.strictEqual(A.state.view, "gantt");
  resetViewState();
  resetApp();
});

ta("read-only: a saved view can be used, but not added to or deleted", async () => {
  resetApp();
  viewFixture();
  resetViewState();
  A.state.savedViews = [{ name: "plan - DesignView 1", view: "gantt", px: 28, lvl: 4, at: "2026-04-10", hour: "", top: "" }];
  const files = { "plan.csv": A.serializeCSV(A.state.tasks) };
  const before = files["plan.csv"];
  const root = fakeRoot(files);
  A.state.dbHandle = root; A.state.rootHandle = root;
  A.state.readOnly = true;
  await A.openFile("plan.csv");

  assert.strictEqual(A.state.savedViews.length, 1, "read-only still reads the views the file carries");
  assert(A.applySavedView("plan - DesignView 1"), "a saved view must still be usable read-only");

  A.state.view = "gantt";
  A.render();
  assert.strictEqual(els.btnSaveView.disabled, true, "the save button is offered in read-only mode");
  A.saveCurrentView();
  assert.strictEqual(A.state.savedViews.length, 1, "read-only mode saved a view");
  A.deleteSavedView("plan - DesignView 1");
  assert.strictEqual(A.state.savedViews.length, 1, "read-only mode deleted a view");
  assert.strictEqual(files["plan.csv"], before, "the file was written to in read-only mode");
  resetViewState();
  resetApp();
});

ta("a shared page carries the saved views of the plan in it", async () => {
  resetApp();
  viewFixture();
  resetViewState();
  useName("plan.csv");
  A.state.savedViews = [{ name: "plan - ZoomView 1", view: "zoom", level: "day", by: "task",
                          px: 18, lvl: 4, at: "2026-04-20", hour: "", top: "" }];
  const page = A.buildSnapshot(A.appSource(), { made: "2026-04-01", from: "plan.csv", files: await A.snapshotFiles() });
  const payload = JSON.parse(page.match(/window\.PPP_SNAPSHOT=(\{[\s\S]*?\});<\/script>/)[1]);

  resetApp();
  A.state.savedViews = [];
  assert.strictEqual(A.openSnapshot(payload), true);
  eqArr(A.state.savedViews.map(v => v.name), ["plan - ZoomView 1"]);
  assert.strictEqual(A.state.savedViews[0].level, "day");
  A.state.snapshot = false; A.state.readOnly = false;
  resetViewState();
  resetApp();
});

console.log("\n-- mass change: one edit across a selection --");

/* A group, its two members, a plain task and a milestone: every rule the mass
   change has is about one of those kinds of row. */
const massFixture = () => loadRowsNow([
  { id: 1, name: "Wave",    start: "2026-03-02", end: "2026-03-05", group: [2, 3], team: "Dev", system: "Core" },
  { id: 2, name: "Prep",    start: "2026-03-02", end: "2026-03-03", team: "Dev", system: "Core", resp: "Bela:6" },
  { id: 3, name: "Build",   start: "2026-03-04", end: "2026-03-05", team: "Dev", system: "Core" },
  { id: 4, name: "Solo",    start: "2026-03-06", end: "2026-03-06", team: "Ops", system: "Edge" },
  { id: 5, name: "Go live", start: "2026-03-09", end: "2026-03-09", ms: true },
]);

// the stub only creates an element once the app has asked for it, so reach
// through getElementById rather than els[…] — the dialog's fields are new here
const mEl = id => document.getElementById(id);
const massClear = () => A.MASS_FIELDS.forEach(f => { mEl(f.id).value = ""; });

// fill the dialog the way a user would, then press Apply
const massSet = fields => {
  massClear();
  for (const [id, v] of Object.entries(fields)) mEl(id).value = v;
  A.applyMassChange();
};

t("only the fields that were filled in are part of the change", () => {
  massClear();
  eqArr(Object.keys(A.readMass()), [], "a blank dialog would change something");
  mEl("mTeam").value = "  Ops  ";
  mEl("mStart").value = "2026-04-01";
  const set = A.readMass();
  assert.strictEqual(set.team, "Ops", "the value was not trimmed");
  assert.strictEqual(set.start, "2026-04-01");
  assert.strictEqual("system" in set, false, "an untouched field joined the change");
  massClear();
});

t("a filled field is written to every selected task, a blank one to none", () => {
  massFixture();
  const ids = [idOfName("Prep"), idOfName("Build"), idOfName("Solo")];
  A.openMassChange(ids);
  massSet({ mTeam: "Ops", mSystem: "Edge" });

  for (const n of ["Prep", "Build", "Solo"]) {
    assert.strictEqual(row(n).team, "Ops", n + " kept its team");
    assert.strictEqual(row(n).system, "Edge", n + " kept its system");
  }
  // the fields nobody filled in are exactly as they were
  assert.strictEqual(row("Prep").responsible, "Bela:6", "a blank field wiped a value");
  assert.strictEqual(row("Prep").start, "2026-03-02", "a blank field moved a date");
  assert.strictEqual(row("Solo").name, "Solo");
});

t("milestones are left out, whatever is filled in", () => {
  massFixture();
  const ms = row("Go live");
  const was = { start: ms.start, team: ms.team, responsible: ms.responsible };
  A.openMassChange([idOfName("Solo"), idOfName("Go live")]);
  massSet({ mStart: "2026-05-01", mEnd: "2026-05-01", mTeam: "Ops", mResp: "Anna:8" });

  const after = row("Go live");
  assert.strictEqual(after.start, was.start, "a milestone was moved");
  assert.strictEqual(after.team, was.team, "a milestone was given a team");
  assert.strictEqual(after.responsible, was.responsible, "a milestone was given people");
  assert.strictEqual(row("Solo").start, "2026-05-01", "the task next to it was not changed");
});

t("a selection of nothing but milestones has no mass change to offer", () => {
  massFixture();
  const acts = A.selectionActions([idOfName("Go live")]).map(i => i.act);
  assert(!acts.includes("massSel"), "offered on a milestone: " + acts);
  // and asking for it anyway does nothing
  A.closeMass();
  A.openMassChange([idOfName("Go live")]);
  assert.strictEqual(A.massOpen(), false, "the dialog opened on a milestone");
});

t("a group keeps its own dates but takes the rest", () => {
  massFixture();
  const g = row("Wave");
  const was = { start: g.start, end: g.end };
  A.openMassChange([idOfName("Wave"), idOfName("Solo")]);
  massSet({ mStart: "2026-06-01", mEnd: "2026-06-30", mSystem: "Edge", mAcc: "Anna Kovacs:2" });

  const after = row("Wave");
  assert.strictEqual(after.start, was.start, "a group's start was overwritten");
  assert.strictEqual(after.end, was.end, "a group's end was overwritten");
  assert.strictEqual(after.system, "Edge", "a group did not take the system");
  assert.strictEqual(after.accountable, "Anna Kovacs:2", "a group did not take the accountable");
  assert.strictEqual(row("Solo").start, "2026-06-01", "the plain task did not move");
});

t("the dialog says what it is about to do before anything is typed", () => {
  massFixture();
  A.openMassChange([idOfName("Wave"), idOfName("Solo"), idOfName("Go live")]);
  assert.strictEqual(A.massOpen(), true);
  const intro = mEl("massIntro").innerHTML;
  assert(/Changing <b>2<\/b>/.test(intro), "the count is wrong: " + intro);
  assert(/1<\/b> milestone/.test(intro), "the milestone is not mentioned: " + intro);
  assert(/1<\/b> group/.test(intro), "the group is not mentioned: " + intro);
  // opening it never pre-fills anything: nothing changes until something is typed
  A.MASS_FIELDS.forEach(f => assert.strictEqual(mEl(f.id).value, "", f.id + " came up filled in"));
  A.closeMass();
  assert.strictEqual(A.massOpen(), false);
});

t("a change that would invalidate a row is not half-applied", () => {
  massFixture();
  const before = JSON.stringify(A.state.tasks);
  A.openMassChange([idOfName("Prep"), idOfName("Solo")]);
  massSet({ mStart: "2026-07-10", mEnd: "2026-07-01" });     // ends before it starts

  assert.strictEqual(JSON.stringify(A.state.tasks), before, "the plan was changed anyway");
  assert.strictEqual(mEl("massErrors").style.display, "block", "nothing said why");
  assert(/Nothing was changed/.test(mEl("massErrors").textContent), mEl("massErrors").textContent);
  assert.strictEqual(A.massOpen(), true, "the dialog closed over a refused change");
  A.closeMass();
});

t("a problem the plan already had does not block an unrelated change", () => {
  massFixture();
  // break one row by hand, the way an edited file can arrive
  row("Solo").end = "";
  const brokenId = idOfName("Solo");
  A.openMassChange([brokenId, idOfName("Prep")]);
  massSet({ mTeam: "Ops" });

  assert.strictEqual(A.taskById(brokenId).team, "Ops", "the pre-existing fault blocked the change");
  assert.strictEqual(row("Prep").team, "Ops");
});

t("the whole mass change is one step on the undo stack", () => {
  massFixture();
  A.resetHistory();
  const teamsBefore = ["Prep", "Build", "Solo"].map(n => row(n).team);
  A.openMassChange([idOfName("Prep"), idOfName("Build"), idOfName("Solo")]);
  massSet({ mTeam: "Ops" });
  assert.strictEqual(A.history.undo.length, 1, "not one step: " + A.history.undo.length);
  A.undo();
  eqArr(["Prep", "Build", "Solo"].map(n => row(n).team), teamsBefore, "undo did not put it all back");
});

t("read-only refuses it, like every other edit", () => {
  massFixture();
  A.state.readOnly = true;
  const before = JSON.stringify(A.state.tasks);
  A.openMassChange([idOfName("Prep"), idOfName("Solo")]);
  assert.strictEqual(A.massOpen(), false, "the dialog opened in read-only mode");
  A.applyMassChange();                          // and the apply path refuses on its own
  assert.strictEqual(JSON.stringify(A.state.tasks), before, "the plan changed");
  A.state.readOnly = false;
});

console.log("\n-- share as a read-only page (the snapshot file) --");

/* The one thing a snapshot must never do: run its own page's script before the
   plan is there to be read. Everything else about the file is cosmetic. */
t("the plan is written in ahead of the app that reads it", () => {
  const out = A.buildSnapshot("<html><head></head><body><p>x</p>\n<script>boot();</script></body></html>",
                              { made: "2026-08-05", files: [{ name: "a.csv", csv: "ID|Activity Name\n" }] });
  const data = out.indexOf("PPP_SNAPSHOT");
  const app = out.indexOf("boot();");
  assert(data > -1, "the plan is not in the page");
  assert(data < app, "the app boots before its plan is defined");
  assert(out.includes("<p>x</p>"), "the page itself was lost");
});

t("a task note cannot end the script tag early", () => {
  const nasty = `evil</script><script>alert(1)</script>`;
  const out = A.buildSnapshot("<body><script>boot();</script></body>",
                              { files: [{ name: "a.csv", csv: nasty }] });
  // exactly two script tags: the payload and the app — the note opened neither
  assert.strictEqual(out.split("<script>").length - 1, 2, "a note opened a script tag: " + out);
  assert(!out.includes("</script><script>alert"), "the note escaped its string");
  assert(out.includes("\\u003c/script>"), "the < was not escaped: " + out);
  // and it survives the trip intact
  const json = out.slice(out.indexOf("=") + 1, out.indexOf(";</script>"));
  assert.strictEqual(JSON.parse(json).files[0].csv, nasty, "the note came back changed");
});

t("a page with no app in it is refused rather than written", () => {
  assert.throws(() => A.buildSnapshot("<html><body>no script here</body></html>", { files: [] }),
                /no script/, "a page that cannot boot was built anyway");
});

/* 🔴 The other way to kill the app while every test stays green — and this one
   HAPPENED (Daniel's phone, 2026-08-23: "Open DB folder egyáltalán nem csinál
   semmit, egyik gomb sem"). The packaged app runs one line at load that the
   desktop never does: `setStorage(NativeAdapter)`. setStorage had just been
   given a call to closeBarMenus(), whose BAR_MENUS list was declared thirteen
   thousand lines further down — dead in its temporal dead zone at that moment.
   ReferenceError, the inline script stopped there, and every listener below it
   was never wired: a complete-looking app where nothing at all responds.

   Neither the browser on this desk nor this suite could see it, because both
   evaluate the script with no Capacitor object, so the native branch is never
   taken. The guard is therefore about ORDER in the source, which both can see:
   whatever a load-time branch can reach must be declared above it. */
/* 🔴 …and it happened TWICE, four hours apart, because the first guard was
   written narrower than the rule it was guarding. Moving BAR_MENUS up fixed the
   first line and the phone stayed dead: closeBarMenus() does not only walk that
   list, it also shuts the filter sheet and the pop-tip, and each of those reads
   a `let` of its own — `fSheetFor`, `popTipFor` — declared eleven and six
   thousand lines below the branch. The first guard could not see them: it read
   three named functions and only CONSTANT_LIKE names.

   So this one follows the rule instead of the incident. It walks the call graph
   OUT from the load-time branch, as deep as it goes, and checks every name in
   every body it reaches against every top-level `const`/`let` declared after
   the branch. Add another call to that chain and the guard covers it without
   being told.

   Static, because the environment that would show it at RUNTIME is the one
   neither this suite nor a desktop browser is: a page with a Capacitor bridge
   on it. `scripts/native-boot-check.js` is that environment — it loads the real
   file in a real browser with a fake bridge and reports what throws — and it is
   how both of these were actually found. This test is what runs before every
   commit; that script is what runs when a phone says something impossible. */
t("everything the native branch reaches at load is declared before it", () => {
  const at = re => { const m = src.match(re); return m ? m.index : -1; };
  const loadCall = at(/^if \(NativeAdapter\.available\(\)\) setStorage\(/m);
  assert(loadCall > 0, "the load-time native branch is gone or renamed — re-aim this guard at it");

  /* Every top-level const/let BELOW the branch: exactly the names that are in
     their temporal dead zone at the moment it runs. (Function declarations
     hoist and are safe wherever they stand — which is why the app can call
     closeBarMenus() long before it is written.) */
  const danger = new Map();
  for (const m of src.matchAll(/^(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=/gm))
    if (m.index > loadCall) danger.set(m[1], m.index);
  assert(danger.size > 50, "the scanner found the declarations: " + danger.size);

  /* The call graph, walked rather than listed. A body is everything up to the
     first line that closes at column zero — the file's own style, and enough to
     tell one top-level function from the next. */
  const bodyOf = name => {
    const i = src.search(new RegExp("^(?:async )?function " + name + "\\b", "m"));
    if (i < 0) return null;                          // an arrow, a method, or not ours
    const end = src.indexOf("\n}", i);
    return src.slice(i, end < 0 ? src.length : end);
  };
  const seen = new Set(), bodies = [];
  const walk = name => {
    if (seen.has(name)) return;
    seen.add(name);
    const body = bodyOf(name);
    if (!body) return;
    bodies.push([name, body]);
    for (const m of body.matchAll(/\b([a-zA-Z_$][\w$]*)\s*\(/g)) walk(m[1]);
  };
  walk("setStorage");
  assert(seen.has("closeBarMenus"), "the walk reaches the menus — that is the path that broke twice");

  const bad = [];
  for (const [fn, body] of bodies)
    for (const name of danger.keys())
      if (new RegExp("\\b" + name.replace(/\$/g, "\\$") + "\\b").test(body))
        bad.push(name + " (read in " + fn + "())");
  assert.strictEqual(bad.length, 0,
    "declared AFTER the load-time setStorage() that reaches them:\n  " + [...new Set(bad)].join("\n  ") +
    "\n  On a phone each one is a ReferenceError at load, and the app stops being wired.");
});

t("nothing in the app writes a closing script tag before the end of it", () => {
  /* An HTML parser ends a script at the first closing tag in its text — string
     literals and comments mean nothing to it. One written by accident cuts the
     whole app off at that point in the browser, while a Node-hosted suite, which
     reads the file with a regex rather than an HTML parser, sails on. This is
     the check that makes the difference visible. */
  const hits = [...html.matchAll(/<\/script\s*>/gi)];
  assert.strictEqual(hits.length, 1,
    hits.length + " closing script tags in the page — everything after the first is dead in a browser");
  // and the tag that opens it is spelled the same way round
  assert.strictEqual([...html.matchAll(/<script[\s>]/gi)].length, 1, "more than one script tag");
});

t("the shared file is named after the plan", () => {
  assert.strictEqual(A.snapshotName("switchover.csv"), "switchover-readonly.html");
  assert.strictEqual(A.snapshotName("switchover"), "switchover-readonly.html");
  assert.strictEqual(A.snapshotName("combined"), "combined-readonly.html");
});

t("every container a render fills is emptied before the page is copied", () => {
  /* The list drifting behind a new view costs a bigger file, not a broken one —
     but the ones that exist today must all be in it, or the copy carries a
     drawing of this session's plan. */
  for (const id of A.SNAPSHOT_HOSTS)
    assert(new RegExp(`id="${id}"`).test(html), `#${id} is in the blanking list but not in the page`);
  for (const id of ["tableView", "ganttBody", "hvBody", "resBody", "fileSelect", "rowMenu"])
    assert(A.SNAPSHOT_HOSTS.includes(id), `#${id} would be frozen into every shared copy`);
});

ta("the exported page carries the plan, and opens as one", async () => {
  resetApp();
  const root = fakeRoot({ "switchover.csv": FILE_A });
  A.state.rootHandle = root; A.state.dbHandle = root;
  await A.openFile("switchover.csv");

  const written = [];
  ctx.window.showSaveFilePicker = async o => {
    written.push(o.suggestedName);
    return { name: o.suggestedName, createWritable: async () => ({
      write: async t => { written.push(t); }, close: async () => {} }) };
  };
  await A.exportSnapshot();
  delete ctx.window.showSaveFilePicker;

  assert.strictEqual(written[0], "switchover-readonly.html");
  const page = written[1];
  assert(page.startsWith("<!DOCTYPE html>"), "the copy is not a page: " + page.slice(0, 40));
  assert(page.includes("PPP_SNAPSHOT"), "the plan did not travel");
  assert(page.includes("Gantora"), "the app did not travel");

  // and what travelled is the plan that was on screen
  const json = page.slice(page.indexOf("window.PPP_SNAPSHOT=") + 20, page.indexOf(";</script>"));
  const snap = JSON.parse(json.replace(/\\u003c/g, "<"));
  eqArr(snap.files.map(f => f.name), ["switchover.csv"]);
  assert.strictEqual(A.parseCSV(snap.files[0].csv).length, 3, "the tasks did not travel");
  assert(/^\d{4}-\d{2}-\d{2}$/.test(snap.made), "the copy is not dated: " + snap.made);

  /* The half no assertion about strings can stand in for: what the browser gets
     is two scripts, in that order, each of which is a whole program. Compiling
     them the way a browser would is what catches a page cut short by a closing
     tag written into its own text. */
  const tags = [...page.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(m => m[1]);
  assert.strictEqual(tags.length, 2, "the page has " + tags.length + " scripts, not the payload and the app");
  assert(tags[0].includes("PPP_SNAPSHOT"), "the first script is not the plan");
  assert(tags[1].includes('"use strict"'), "the second script is not the app");
  tags.forEach((t, i) => {
    try { new vm.Script(t); }
    catch (e) { assert.fail(`script ${i + 1} of the shared page does not parse (${e.message}) — it is cut short`); }
  });

  // opening it is the other half: no folder, and the plan on screen
  resetApp();
  assert.strictEqual(A.openSnapshot(snap), true);
  assert.strictEqual(A.state.tasks.length, 3);
  assert.strictEqual(A.planName(), "switchover.csv");
  assert.strictEqual(A.state.dbHandle, null, "a shared copy reached for a folder");
  assert.strictEqual(A.planRef(), null, "a shared copy has something to save to");
  assert.strictEqual(A.readOnly(), true);
  assert.strictEqual(A.state.snapshot, true);
  assert(els.tableView.innerHTML.includes("A first"), "the plan was not drawn");
  A.state.snapshot = false;
  resetApp();
});

ta("a shared copy has no way back to a folder, and does not pretend otherwise", async () => {
  resetApp();
  A.openSnapshot({ made: "2026-08-05", files: [{ name: "plan.csv", csv: FILE_A }] });

  for (const id of ["btnPickFolder", "btnNewFile", "btnImport", "btnCombine", "btnReload", "btnReadOnly", "btnShare"])
    assert.strictEqual(els[id].style.display, "none", "#" + id + " is still offered");
  assert.strictEqual(els.planWrap.style.display, "none", "the plan switcher is still there");
  assert.strictEqual(els.btnSave.disabled, true, "Save is offered");
  assert.strictEqual(els.btnNewTask.disabled, true, "New task is offered");
  assert(els.roChip.textContent.includes("Shared copy"), "the chip does not say what this is: " + els.roChip.textContent);
  assert(/2026-08-05/.test(els.roChip.title), "the copy is not dated on screen: " + els.roChip.title);
  // Export to Excel needs nothing but the plan on screen, so it stays
  assert.strictEqual(els.btnExport.disabled, false, "Export went with the folder");

  // the read-only toggle cannot undo what is not a mode
  assert.strictEqual(await A.setReadOnly(false), true, "a shared copy became editable");
  assert.strictEqual(A.readOnly(), true);
  await A.pickFolder();
  assert.strictEqual(A.state.dbHandle, null, "a shared copy opened a folder");
  // and it will not re-share itself into a second-hand copy
  await A.exportSnapshot();
  assert(/already a shared copy/.test(els.toast.textContent), els.toast.textContent);
  A.state.snapshot = false;
  resetApp();
});

ta("several plans travel in one page, as the combined view", async () => {
  resetApp();
  const ok = A.openSnapshot({ made: "2026-08-05", files: [
    { name: "a.csv", csv: FILE_A }, { name: "b.csv", csv: FILE_B }] });
  assert.strictEqual(ok, true);
  eqArr(A.state.combined, ["a.csv", "b.csv"]);
  assert.strictEqual(A.state.tasks.length, 5, "not every plan arrived");
  // the merge still keeps each file's own numbering on screen
  eqArr(A.state.tasks.filter(t => t.src === "b.csv").map(A.dispId), [1, 2]);
  assert(A.activeColumns().map(c => c.key).includes("src"), "no DB File column");
  assert.strictEqual(els.planWrap.style.display, "none", "the switcher came back");
  A.state.snapshot = false;
  resetApp();
});

ta("a shared copy carrying nothing readable says so instead of coming up blank", async () => {
  resetApp();
  assert.strictEqual(A.openSnapshot({ files: [] }), false);
  assert(/carries no plan/.test(els.toast.textContent), els.toast.textContent);
  assert.strictEqual(A.openSnapshot({ files: [{ name: "x.csv", csv: "hello;world\nnot;a;plan\n" }] }), false);
  assert(/this app can read/.test(els.toast.textContent), els.toast.textContent);
  A.state.snapshot = false;
  resetApp();
});

/* ---- A plan carried in a link: #plan=… ----
   The same shared copy, reached through the address instead of a file: an
   assistant writes a plan in a chat and hands over one link. Packed with the
   platform's deflate (CompressionStream) — Node's is the browser's, so a
   link packed here is a link Chrome unpacks. */
console.log("\n-- a plan carried in a link --");

ta("a plan packs into a link and comes back byte for byte — packed and plain both", async () => {
  const text = fs.readFileSync(__dirname + "/DB/_fixture-claude-skill.gantora", "utf8");
  const packed = await A.encodePlanLink(text);
  assert(packed.startsWith("~"), "a packed link is marked by its first character");
  assert(/^~[A-Za-z0-9_-]+$/.test(packed), "base64url after it — nothing a hash or a chat client will mangle");
  assert(packed.length < text.length, "and it is shorter than the plan (" + packed.length + " < " + text.length + ")");
  assert.strictEqual(await A.decodePlanLink(packed), text);
  /* the hand-made shape: plain UTF-8 in base64url, no marker */
  const plain = A.b64url(new TextEncoder().encode(text));
  assert.strictEqual(await A.decodePlanLink(plain), text);
  assert.strictEqual(await A.decodePlanLink(""), "");
  assert.strictEqual(await A.decodePlanLink("  " + packed + " "), text, "whitespace around it is forgiven");
});

t("the address carries the plan as `plan=`, and the name beside it as `file=`", () => {
  const p = A.parseLaunchParams("", "#plan=~abc-_&file=Kitchen&view=gantt&lang=hu");
  assert.strictEqual(p.plan, "~abc-_");
  assert.strictEqual(p.file, "Kitchen");
  assert.strictEqual(p.view, "gantt");
  assert.strictEqual(p.lang, "hu");
  assert.strictEqual(A.parseLaunchParams("?mode=readonly", "").plan, "", "absent reads as empty, not undefined");
});

ta("a plan link opens as the shared copy it is — read-only, no folder, named from the address", async () => {
  resetApp();
  const text = fs.readFileSync(__dirname + "/DB/_fixture-claude-skill.gantora", "utf8");
  const link = await A.encodePlanLink(text);
  const ok = await A.openPlanLink(A.parseLaunchParams("", "#plan=" + link + "&file=Kitchen"));
  assert.strictEqual(ok, true);
  assert.strictEqual(A.state.snapshot, true, "a linked plan is a shared copy");
  assert.strictEqual(A.readOnly(), true);
  assert.strictEqual(A.planName(), "Kitchen.gantora", "the extension is added when the address left it off");
  assert.strictEqual(A.state.tasks.length, 5);
  assert.strictEqual(A.state.tasks.find(t => t.name === "Workshop").notes, "Room booked\nAgenda: scope| risks");
  A.state.snapshot = false;
  resetApp();
  /* no name given: "plan.gantora"; a name with its extension is kept as typed */
  assert.strictEqual(await A.openPlanLink({ plan: link, file: "" }), true);
  assert.strictEqual(A.planName(), "plan.gantora");
  A.state.snapshot = false; resetApp();
  assert.strictEqual(await A.openPlanLink({ plan: link, file: "old.csv" }), true);
  assert.strictEqual(A.planName(), "old.csv");
  A.state.snapshot = false; resetApp();
});

ta("a link that is not a plan says so and opens nothing", async () => {
  resetApp();
  assert.strictEqual(await A.openPlanLink({ plan: A.b64url(new TextEncoder().encode("hello;world\nnot;a;plan\n")), file: "" }), false);
  assert(/this app can read/.test(els.toast.textContent), els.toast.textContent);
  A.state.snapshot = false;
  let threw = false;
  try { await A.decodePlanLink("~" + A.b64url(new TextEncoder().encode("not deflate at all"))); } catch { threw = true; }
  assert(threw, "a packed link that does not unpack is an error the boot branch turns into a toast, not a blank page");
  resetApp();
});

t("the boot branch: a plan in the address opens as a shared copy, and nothing asks for a folder", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const at = script.indexOf("} else if (launch.plan) {");
  assert(at > 0, "the third way in, between the embedded copy and the folder");
  const branch = script.slice(at, script.indexOf("} else {", at));
  assert(/openPlanLink\(launch\)/.test(branch));
  assert(/applyViewAndLang\(launch\)/.test(branch), "view= and lang= work on it as on any shared copy");
  assert(/This plan link could not be opened/.test(branch), "a bad link is a toast, not a dead page");
  for (const forbidden of ["restoreSavedFolder", "offerOpenDb", "offerOpenChoice", "openDropbox", "applyLaunchParams"])
    assert(!branch.includes(forbidden), "a linked plan must not reach " + forbidden);
});

ta("the folded-away columns travel with the plan", async () => {
  resetApp();
  const withView = FILE_A + "#VIEW|hiddenColumns|team;system\r\n";
  A.openSnapshot({ files: [{ name: "a.csv", csv: withView }] });
  assert(A.state.hiddenCols.has("team") && A.state.hiddenCols.has("system"),
         "the hidden columns did not travel: " + [...A.state.hiddenCols]);
  A.state.snapshot = false;
  resetApp();
});

/* ===================== Events, and the not-finalized tick =====================
   An event is a milestone with somebody else's name on the date. It is carried
   as a *flavour* of milestone rather than as a third kind of row, so most of
   what these check is that the milestone rules still hold over it — and that
   the three places it deliberately differs really do differ. */
console.log("\n-- events: a date the plan works around --");

const XMAS = { id: 1, name: "Christmas", start: "2026-12-24", end: "2026-12-24", ev: true, emoji: "🎄" };
// which cell of a written row holds a given field, read off the column definition
const cellAt = (line, key) => line.split("|")[A.CSV_COLS.findIndex(c => c.key === key)];
const lineFor = (csv, name) => csv.split("\r\n").find(l => l.includes(name));

t("an event rides the file as a milestone the file flavours", () => {
  loadRowsNow([XMAS, { id: 2, name: "Work", start: "2026-12-01", end: "2026-12-10" }]);
  const e = row("Christmas");
  assert(e.milestone, "an event is a milestone: every rule one lives under holds over it");
  assert(A.isEvent(e), "…and is one of these");
  assert.strictEqual(A.isMilestone(e), false, "…so it is never counted as one of those");
  assert.strictEqual(e.emoji, "🎄");

  const csv = A.serializeCSV(A.state.tasks);
  const line = lineFor(csv, "Christmas");
  assert.strictEqual(cellAt(line, "event"), "yes");
  assert.strictEqual(cellAt(line, "emoji"), "🎄");
  assert.strictEqual(cellAt(line, "milestone"), "yes", "so an older reader still sees a marker");
  assert.strictEqual(cellAt(line, "status"), "", "an event carries no status");
  assert.strictEqual(cellAt(line, "history"), "", "and no history");

  const back = A.parseCSV(csv).find(x => x.name === "Christmas");
  assert(back.event && back.milestone, "and it comes back whole");
  assert.strictEqual(back.emoji, "🎄");
});

t("a reader that has never heard of events sees a plain milestone", () => {
  loadRowsNow([XMAS]);
  const cut = A.CSV_COLS.findIndex(c => c.key === "event");
  // the three appended columns lopped off every line, as an older build would read it
  const older = A.serializeCSV(A.state.tasks).split("\r\n")
    .map(l => (!l || l.startsWith("#")) ? l : l.split("|").slice(0, cut).join("|")).join("\r\n");
  const back = A.parseCSV(older).find(x => x.name === "Christmas");
  assert.strictEqual(back.milestone, true, "still a marker, still on the right day");
  assert.strictEqual(back.event, false, "the flavour is what is lost, never the row");
});

t("an event has no status, and nothing can give it one", () => {
  loadRowsNow([XMAS]);
  const e = row("Christmas");
  assert.strictEqual(A.setTaskStatus(e, "Done"), false, "refused at the one door every path goes through");
  assert.strictEqual(e.status, "");
  eqArr(e.history, []);
  A.changeStatus([e.id], "In Progress");
  assert.strictEqual(row("Christmas").status, "", "the status menu cannot move it either");
  assert(/no status/.test(els.toast.textContent), els.toast.textContent);

  // a file that gave it one is read correctly rather than repaired loudly
  const tasks = A.parseCSV(CSV_HDR_NOW + "\n" +
    csvRowNow({ ...XMAS, status: "Done", history: "2026-01-01 Done" }) + "\n");
  const warn = A.sanitizeTasks(tasks);
  assert.strictEqual(tasks[0].status, "", "the field does not apply to this kind of row");
  eqArr(warn, [], "and a field that does not apply is not a repair worth reporting");
});

t("the row menu offers a status to everything except an event", () => {
  loadRowsNow([XMAS, { id: 2, name: "Work", start: "2026-12-01", end: "2026-12-10" }]);
  const evItems = A.rowActions(row("Christmas")).map(i => i.act || i.head || "").join(" ");
  assert(/no status/.test(evItems), "the event's menu explains itself instead: " + evItems);
  assert.strictEqual(/statusTo/.test(evItems), false, "and offers none of the five");
  const workItems = A.rowActions(row("Work")).map(i => i.act || "").join(" ");
  assert(workItems.includes(A.STATUS_ACT), "an ordinary task still leads with the status block");

  // a mixed selection offers the block for the rows that can take one, and says so
  const sel = A.selectionActions([row("Christmas").id, row("Work").id]);
  const head = (sel.find(i => i.head) || {}).head || "";
  assert(/1 of 2/.test(head) && /events have none/.test(head), "the head does not say what is left out: " + head);
});

t("moving an event moves nothing; a milestone still moves the plan", () => {
  loadRowsNow([{ id: 1, name: "Freeze", start: "2026-03-10", end: "2026-03-10", ev: true },
               { id: 2, name: "After", start: "2026-03-11", end: "2026-03-12", preds: [1] }]);
  const e = row("Freeze"), wasE = e.start;
  e.start = e.end = "2026-03-17";
  eqArr(A.shiftMilestoneDependents(e.id, wasE), [], "nothing hangs off a date the plan does not own");
  assert.strictEqual(row("After").start, "2026-03-11", "the work stayed where it was");

  loadRowsNow([{ id: 1, name: "Go live", start: "2026-03-10", end: "2026-03-10", ms: true },
               { id: 2, name: "After", start: "2026-03-11", end: "2026-03-12", preds: [1] }]);
  const m = row("Go live"), wasM = m.start;
  m.start = m.end = "2026-03-17";
  assert.strictEqual(A.shiftMilestoneDependents(m.id, wasM).length, 1, "a milestone still drags its plan");
  assert.strictEqual(row("After").start, "2026-03-18", "by the days it moved");
});

t("an event lives under every milestone rule", () => {
  const d = { name: "Christmas", start: "2026-12-24", end: "2026-12-24", preds: [], group: [2],
              milestone: true, event: true, accountable: "Anna", responsible: "", notFinal: true };
  const errs = A.validateTask(d, 1, []);
  assert(errs.some(e => /^An event cannot be a group/.test(e)), errs.join(" | "));
  assert(errs.some(e => /^An event is a moment/.test(e)), errs.join(" | "));
  A.normalizeMilestone(d);
  assert.strictEqual(d.accountable, "", "ticking the box clears them, exactly as for a milestone");
  assert.strictEqual(d.notFinal, false, "and a single date has nothing to leave unsettled");
  eqArr(d.group, []);
  // "an event that is not a milestone" is not a state this app has
  assert(A.validateTask({ name: "x", start: "2026-01-01", end: "2026-01-01", preds: [], group: [],
                          milestone: false, event: true }, 1, [])
    .some(e => /kind of milestone/.test(e)));
});

t("the Design view rules the day — grey for a milestone, blue for an event", () => {
  loadRowsNow([{ id: 1, name: "Work", start: "2026-03-09", end: "2026-03-20" },
               { id: 2, name: "Go live", start: "2026-03-12", end: "2026-03-12", ms: true },
               { id: 3, name: "Christmas", start: "2026-03-16", end: "2026-03-16", ev: true, emoji: "🎄" }]);
  A.state.view = "gantt"; A.renderGantt();
  const g = els.ganttBody.innerHTML;
  /* One rule per marker, in date order, and only the event's is flagged — the
     colours themselves are in the stylesheet, which is as far as this harness
     can see (see the source-order check below). */
  eqArr(g.match(/class="msRule[^"]*"/g) || [], ['class="msRule"', 'class="msRule ev"']);
  assert(g.includes('>🎄<span class="msLbl">Christmas'), "the event is drawn with its own emoji");
  assert(g.includes('>★<span class="msLbl">Go live'), "and the milestone still with the star");
  assert(/class="msWhen">2026-03-16</.test(g), "an event says when, beside its name");
  assert.strictEqual(/Go live<span class="msWhen"/.test(g), false, "a milestone does not repeat its date there");
  A.state.view = "table";
});

t("the Zoom views draw the event blue, with its emoji leading the name", () => {
  loadRowsNow([{ id: 1, name: "Work", start: "2026-03-09", end: "2026-03-20" },
               { id: 2, name: "Go live", start: "2026-03-12", end: "2026-03-12", ms: true },
               { id: 3, name: "Christmas", start: "2026-03-16", end: "2026-03-16", ev: true, emoji: "🎄" }]);
  A.state.view = "zoom"; A.state.zoomLevel = "day"; A.state.zoomBy = "task"; A.renderZoom();
  const z = els.hvBody.innerHTML;
  assert(/class="hvMsLine ev"/.test(z), "the event's rule: " + (z.match(/class="hvMsLine[^"]*"/g) || []).join(" "));
  assert(/class="hvMsFoot ev"/.test(z), "and the name under it");
  assert(z.includes("🎄 Christmas"), "the emoji leads the label");
  assert(/class="hvMsLine h\w+"/.test(z), "while a milestone still takes its health tone");

  // …and the helicopter, which lays its markers out in its own pass
  A.state.zoomLevel = "month"; A.renderZoom();
  const h = els.hvBody.innerHTML;
  assert(/class="hvMsLine ev"/.test(h), "the helicopter's event rule: " + (h.match(/class="hvMsLine[^"]*"/g) || []).join(" "));
  assert(h.includes("🎄 Christmas"));
  A.state.view = "table"; A.state.zoomLevel = "day";
});

t("the dashboard counts no events at all", () => {
  loadRowsNow([{ id: 1, name: "Work", start: dayOff(-4), end: dayOff(4) },
               { id: 2, name: "Christmas", start: dayOff(2), end: dayOff(2), ev: true },
               { id: 3, name: "Go live", start: dayOff(6), end: dayOff(6), ms: true }]);
  assert.strictEqual(A.dashTasks().length, 1, "leaf tasks only, and an event is not one");
  const mh = A.milestoneHealth();
  assert.strictEqual(mh.length, 1, "one milestone, and the event is not a second");
  assert.strictEqual(mh[0].t.name, "Go live");
  /* The discriminating part: the event falls *between* the work and the
     milestone, so an event let in here would take the window — and Go live
     would report no tasks at all. */
  assert.strictEqual(mh[0].owned.length, 1, "the work still belongs to the milestone it leads to");
});

t("an event ignores the Team filter and matches no health filter", () => {
  loadRowsNow([{ id: 1, name: "Work", start: dayOff(-4), end: dayOff(4), team: "Dev" },
               { id: 2, name: "Christmas", start: dayOff(2), end: dayOff(2), ev: true }]);
  A.state.filterTeam = "Dev";
  assert(A.displayTasks().some(r => r.t.name === "Christmas"), "an event is project-wide, as a milestone is");
  A.state.filterTeam = "";
  A.state.filterHealth = A.healthOf(row("Work"));
  assert.strictEqual(A.displayTasks().some(r => r.t.name === "Christmas"), false,
    "the drill-through hands over counted rows, and an event was never counted");
  A.state.filterHealth = "";
});

t("the emoji is a character or two, and never the delimiter", () => {
  assert.strictEqual(A.normEmoji("🎄"), "🎄", "a surrogate pair survives whole");
  assert.strictEqual(A.normEmoji("  🎄  "), "🎄");
  assert.strictEqual(A.normEmoji("a|b"), "ab", "the delimiter cannot ride inside a cell");
  assert.strictEqual(A.normEmoji("x\ny"), "xy", "nor a line break — a row is one line");
  assert.strictEqual(A.normEmoji(""), "");
  assert.strictEqual(A.normEmoji(undefined), "");
  assert(A.normEmoji("🎄🎉🚀🎂🔒📅⚠🏖🎈").length < "🎄🎉🚀🎂🔒📅⚠🏖🎈".length, "and it is capped");
  // an event that names none is still drawn
  assert.strictEqual(A.eventEmoji({ event: true }), A.EVENT_EMOJI);
  assert.strictEqual(A.msGlyph({ milestone: true }), "★");
  assert(A.EVENT_EMOJI_PRESETS.includes(A.EVENT_EMOJI), "the default is one of the offered ones");
  assert(A.EVENT_EMOJI_PRESETS.every(e => e && A.normEmoji(e) === e), "a preset that normalises away is not a preset");
});

console.log("\n-- not finalized: the plan is not sure about this row --");

t("the tick rides the file and comes back", () => {
  loadRowsNow([{ id: 1, name: "Guess", start: "2026-03-02", end: "2026-03-04", nf: true },
               { id: 2, name: "Sure", start: "2026-03-05", end: "2026-03-06" }]);
  assert.strictEqual(row("Guess").notFinal, true, "loading a plan does not clear it");
  assert.strictEqual(row("Sure").notFinal, false);
  const csv = A.serializeCSV(A.state.tasks);
  assert.strictEqual(cellAt(lineFor(csv, "Guess"), "notFinal"), "yes");
  assert.strictEqual(cellAt(lineFor(csv, "Sure"), "notFinal"), "no");
  assert.strictEqual(A.parseCSV(csv).find(x => x.name === "Guess").notFinal, true);
});

/* The tick is the reader's, not the app's: nothing sets or clears it on their
   behalf, and starting the work no longer does either. What starting the work
   changes is when the date trail opens — see "starting the work is what starts
   the record". */
t("moving the status leaves the tick exactly where the reader put it", () => {
  loadRowsNow([{ id: 1, name: "Guess", start: "2026-03-02", end: "2026-03-04", nf: true }]);
  A.changeStatus([row("Guess").id], "In Progress");
  assert.strictEqual(row("Guess").notFinal, true, "the mark survives the work starting");
  const fresh = A.seedTask({ id: 9, name: "New", start: "2026-03-02", end: "2026-03-02",
                             preds: [], group: [], notFinal: true, status: "Planned" });
  assert.strictEqual(fresh.notFinal, true, "a task created with the tick on keeps it");
  assert.strictEqual(fresh.history.length, 1, "and still starts its history");
});

// nobody has to untick anything for a new row: it arrives unticked
t("a new task is not marked not finalized unless somebody ticks it", () => {
  const fresh = A.seedTask({ id: 11, name: "Plain", start: "2026-03-02", end: "2026-03-03",
                             preds: [], group: [], status: "Planned" });
  assert.strictEqual(!!fresh.notFinal, false);
});

t("a milestone and an event have nothing to leave unsettled", () => {
  loadRowsNow([{ id: 1, name: "Go live", start: "2026-03-10", end: "2026-03-10", ms: true, nf: true },
               { id: 2, name: "Christmas", start: "2026-03-12", end: "2026-03-12", ev: true, nf: true }]);
  assert.strictEqual(row("Go live").notFinal, false, "cleared on the way in");
  assert.strictEqual(row("Christmas").notFinal, false);
  assert.strictEqual(A.canBeNotFinal(row("Go live")), false);
  A.setNotFinal(row("Go live").id, true);
  assert.strictEqual(row("Go live").notFinal, false, "and it cannot be set afterwards either");
  assert(/single date/.test(els.toast.textContent), els.toast.textContent);
});

t("the list carries the tick as a cell you click", () => {
  loadRowsNow([{ id: 1, name: "Guess", start: "2026-03-02", end: "2026-03-04", nf: true },
               { id: 2, name: "Go live", start: "2026-03-10", end: "2026-03-10", ms: true }]);
  useRef({});
  A.renderTable();
  const h = els.tableView.innerHTML;
  useRef(null);
  assert(h.includes('<th data-col="notFinal"'), "the column is in the list: " + (h.match(/data-col="\w+"/g) || []).join(" "));
  assert(h.includes('checked data-nf="' + row("Guess").id + '"'),
    "the ticked row's cell is a click target, drawn ticked: " +
    (h.match(/<input type="checkbox"[^>]*/g) || []).join(" · "));
  assert.strictEqual(new RegExp('data-nf="' + row("Go live").id + '"').test(h), false,
    "a milestone offers no tick");
  assert(h.includes("nothing here to leave unsettled"), "it says why instead");
});

t("every chart draws an unsettled row pale, over the colour it already had", () => {
  loadRowsNow([{ id: 1, name: "Guess", start: dayOff(-2), end: dayOff(6), nf: true },
               { id: 2, name: "Sure", start: dayOff(-2), end: dayOff(6) }]);
  const both = /class="bar h\w+ notFinal"/;

  A.state.view = "gantt"; A.renderGantt();
  const g = els.ganttBody.innerHTML;
  assert(both.test(g), "the Design bar keeps its health class and is faded over it: " +
    (g.match(/class="bar[^"]*"/g) || []).join(" "));
  assert.strictEqual((g.match(/ notFinal"/g) || []).length, 1, "only the ticked row");
  assert(g.includes("Not finalized"), "and the tooltip says so");

  A.state.view = "zoom"; A.state.zoomLevel = "day"; A.state.zoomBy = "task"; A.renderZoom();
  assert(both.test(els.hvBody.innerHTML), "the day view: " +
    (els.hvBody.innerHTML.match(/class="bar[^"]*"/g) || []).join(" "));

  A.state.zoomLevel = "month"; A.renderZoom();
  assert(/class="hvSeg d\d h\w+ notFinal"/.test(els.hvBody.innerHTML), "the helicopter: " +
    (els.hvBody.innerHTML.match(/class="hvSeg[^"]*"/g) || []).join(" "));

  A.state.view = "table"; A.state.zoomLevel = "day";
});

/* The one statement about colour this harness can make. The fade writes opacity
   and border-style where the health block writes background and border-colour,
   so nothing collides *today* — but a rule meant to sit on top of another and
   written above it would render as nothing at all, and every check above would
   still pass. See the health block's own order test. */
t("the pale treatment and the event's blue come after the colours they sit on", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const at = re => { const i = css.search(re); assert(i > 0, "missing rule: " + re); return i; };
  const fade = at(/\.bar\.notFinal,/);
  for (const beaten of [/\.bar\{/, /\.bar\.group\{/, /\.bar\.mainBar\{/, /\.bar\.extraBar\{/,
                        /\.hrBar\.main\{/, /\.hvSeg\.d4,/, /\.bar\.hDone,/])
    assert(at(beaten) < fade, "the notFinal block must come after " + beaten);
  const evTone = at(/\.hvMsLine\.ev,/);
  for (const beaten of [/\.hDone\{/, /\.hOverdue\{/, /\.hvMsLine\{/, /\.hvMsFoot \.n\{/])
    assert(at(beaten) < evTone, "the event tone must come after " + beaten);
  // the rule down the Design view has to exist at all, and to have both colours
  assert(/\.msRule\{[^}]*border-left:2px solid/.test(css), "the milestone rule is not drawn");
  assert(/\.msRule\.ev\{/.test(css), "the event rule takes no colour of its own");
  assert(at(/\.msRule\{/) < at(/\.msRule\.ev\{/), "and the event's must come after the grey it replaces");
});

console.log("\n-- events sink to the bottom, and may run for a while --");

t("an event sits below every task, whatever its date", () => {
  loadRowsNow([{ id: 1, name: "Early", start: "2026-03-02", end: "2026-03-03" },
               { id: 2, name: "Xmas", start: "2026-03-04", end: "2026-03-04", ev: true },
               { id: 3, name: "Late", start: "2026-03-06", end: "2026-03-08" }]);
  eqArr(A.displayTasks().map(r => r.t.name), ["Early", "Late", "Xmas"],
    "the event dated in the middle must not break the plan in half");
  // the ID is the row's place in the plan, so the bottom is where its number is
  assert(idOfName("Xmas") > idOfName("Late"), "the numbering follows the same order");
  // …and they keep their own order among themselves
  loadRowsNow([{ id: 1, name: "Work", start: "2026-03-05", end: "2026-03-06" },
               { id: 2, name: "Later event", start: "2026-03-20", end: "2026-03-20", ev: true },
               { id: 3, name: "Earlier event", start: "2026-03-01", end: "2026-03-01", ev: true }]);
  eqArr(A.displayTasks().map(r => r.t.name), ["Work", "Earlier event", "Later event"]);
});

t("a milestone keeps its place in time — only events move", () => {
  loadRowsNow([{ id: 1, name: "Early", start: "2026-03-02", end: "2026-03-03" },
               { id: 2, name: "Go live", start: "2026-03-04", end: "2026-03-04", ms: true },
               { id: 3, name: "Late", start: "2026-03-06", end: "2026-03-08" }]);
  eqArr(A.displayTasks().map(r => r.t.name), ["Early", "Go live", "Late"],
    "a milestone is the plan's own date and reads in sequence with the work");
});

t("an event may be a period; a milestone may not", () => {
  const ev = { name: "Freeze", start: "2026-03-09", hourStart: "", end: "2026-03-13", hourEnd: "",
               preds: [], group: [], milestone: true, event: true, emoji: "🔒" };
  A.normalizeMilestone(ev);
  assert.strictEqual(ev.end, "2026-03-13", "the End it was given is its own");
  assert.strictEqual(A.eventSpans(ev), true);

  const ms = { name: "Go live", start: "2026-03-09", hourStart: "", end: "2026-03-13", hourEnd: "",
               preds: [], group: [], milestone: true, event: false };
  A.normalizeMilestone(ms);
  assert.strictEqual(ms.end, ms.start, "a moment has no length");
  assert.strictEqual(A.eventSpans(ms), false);

  // an event with no End of its own is still the single point it always was
  const point = { name: "Xmas", start: "2026-12-24", hourStart: "", end: "", hourEnd: "",
                  preds: [], group: [], milestone: true, event: true };
  A.normalizeMilestone(point);
  assert.strictEqual(point.end, "2026-12-24");
  assert.strictEqual(A.eventSpans(point), false);
});

t("a period rides the file, and reads back as a period", () => {
  loadRowsNow([{ id: 1, name: "Freeze", start: "2026-03-09", end: "2026-03-13", ev: true, emoji: "🔒" }]);
  const t1 = row("Freeze");
  assert.strictEqual(t1.end, "2026-03-13", "sanitation did not flatten it onto its start");
  assert.strictEqual(A.eventSpans(t1), true);
  const back = A.parseCSV(A.serializeCSV(A.state.tasks)).find(x => x.name === "Freeze");
  assert.strictEqual(back.end, "2026-03-13");
  // the list shows a length for it, where a moment shows none
  const dur = A.COLUMNS.find(c => c.key === "duration");
  assert.strictEqual(dur.cell({ t: t1 }), "5", "five days, inclusive");
  loadRowsNow([{ id: 1, name: "Go live", start: "2026-03-09", end: "2026-03-09", ms: true }]);
  assert.strictEqual(dur.cell({ t: row("Go live") }), "0");
});

t("the Design view draws a period as two rules with a band between them", () => {
  loadRowsNow([{ id: 1, name: "Work", start: "2026-03-02", end: "2026-03-20" },
               { id: 2, name: "Go live", start: "2026-03-05", end: "2026-03-05", ms: true },
               { id: 3, name: "Freeze", start: "2026-03-09", end: "2026-03-13", ev: true, emoji: "🔒" }]);
  A.state.view = "gantt"; A.renderGantt();
  const g = els.ganttBody.innerHTML;
  const rules = g.match(/class="msRule[^"]*"/g) || [];
  eqArr(rules, ['class="msRule"', 'class="msRule ev"', 'class="msRule ev"'],
    "one rule for the milestone, one at each end of the event: " + rules.join(" "));
  const band = g.match(/class="msBand" style="left:([\d.]+)px;width:([\d.]+)px"/);
  assert(band, "no band was drawn: " + g.slice(0, 300));
  assert(+band[2] > 0, "the band has no width");
  // the two event rules sit on the band's edges
  const evX = [...g.matchAll(/class="msRule ev" style="left:([\d.]+)px"/g)].map(m => +m[1]);
  assert.strictEqual(evX[0], +band[1], "the first rule is the band's left edge");
  assert.strictEqual(evX[1], +band[1] + +band[2], "the second is its right edge");
  // and the name is centred between them rather than trailing off the left rule
  const mark = g.match(/class="msMark ev span[^"]*" data-id="\d+" style="left:([\d.]+)px/);
  assert(mark, "the event's mark is not centred on its band: " +
    (g.match(/class="msMark[^"]*"/g) || []).join(" "));
  assert.strictEqual(+mark[1], +band[1] + +band[2] / 2, "not the middle of the band");
  assert(g.includes(">🔒<span class=\"msLbl\">Freeze"), "with its own emoji");
  assert(/2026-03-09 → 2026-03-13/.test(g), "and both ends of the period beside the name");
  // a moment is still a single rule with the mark on it
  assert(/class="msMark hUpcoming"/.test(g) || /class="msMark h\w+"/.test(g), "the milestone is unchanged");
  A.state.view = "table";
});

t("the Zoom views band the period too, and centre the label in it", () => {
  loadRowsNow([{ id: 1, name: "Work", start: "2026-03-02", end: "2026-03-20" },
               { id: 2, name: "Freeze", start: "2026-03-09", end: "2026-03-13", ev: true, emoji: "🔒" }]);
  for (const level of ["day", "month"]) {
    A.state.view = "zoom"; A.state.zoomLevel = level; A.state.zoomBy = "task"; A.renderZoom();
    const z = els.hvBody.innerHTML;
    const band = z.match(/class="hvMsBand" style="left:([\d.]+)px;width:([\d.]+)px/);
    assert(band, level + ": no band — " + z.slice(0, 300));
    assert(+band[2] > 0, level + ": the band has no width");
    assert.strictEqual((z.match(/class="hvMsLine ev"/g) || []).length, 2, level + ": a rule at each end");
    const foot = z.match(/class="hvMsFoot ev" style="left:([\d.]+)px/);
    assert.strictEqual(+foot[1], +band[1] + +band[2] / 2, level + ": the label is not between the lines");
    assert(z.includes("🔒 Freeze"), level + ": the emoji leads the name");
  }
  A.state.view = "table"; A.state.zoomLevel = "day";
});

t("the editors let an event have an End, and never a milestone", () => {
  loadRowsNow([{ id: 1, name: "Freeze", start: "2026-03-09", end: "2026-03-13", ev: true },
               { id: 2, name: "Go live", start: "2026-03-16", end: "2026-03-16", ms: true }]);
  // the dialog
  A.openModal({ type: "edit", id: idOfName("Freeze") });
  assert.strictEqual(els.fEnd.disabled, false, "an event's End is its own to set");
  assert.strictEqual(els.fEvent.checked, true);
  assert.strictEqual(els.fEnd.value, "2026-03-13", "and the one it has is shown");
  A.openModal({ type: "edit", id: idOfName("Go live") });
  assert.strictEqual(els.fEnd.disabled, true, "a milestone's End mirrors its Start");
  A.closeModal();
  // the inline row
  const endInput = t2 => A.inlineRowHtml(t2).match(/<input id="iEnd"[^>]*>/)[0];
  const evEnd = endInput(row("Freeze"));
  assert(evEnd.includes('value="2026-03-13"') && !evEnd.includes("disabled"),
    "the inline End is not an event's to set: " + evEnd);
  assert(endInput(row("Go live")).includes("disabled"),
    "the inline End is editable on a milestone: " + endInput(row("Go live")));
});

console.log("\n-- the dashboard's scope: one stream of work at a time --");

/* Two top-level groups and a subgroup inside the first, so the picker can be
   asked the two questions that matter: does it list streams rather than every
   group, and does narrowing to one really reach the tasks below it. */
const streamRows = () => [
  { id: 1, name: "Stream A", group: [2, 3], start: "2026-03-02", end: "2026-03-06" },
  { id: 2, name: "A1", start: "2026-03-02", end: "2026-03-03", team: "Dev" },
  { id: 3, name: "Sub A", group: [4], start: "2026-03-04", end: "2026-03-06" },
  { id: 4, name: "A2", start: "2026-03-04", end: "2026-03-06", team: "Dev" },
  { id: 5, name: "Stream B", group: [6], start: "2026-03-09", end: "2026-03-10" },
  { id: 6, name: "B1", start: "2026-03-09", end: "2026-03-10", team: "Ops" },
  { id: 7, name: "Loose", start: "2026-03-11", end: "2026-03-12", team: "Ops" },
];

t("the picker lists the streams, not every group in the plan", () => {
  loadRowsNow(streamRows());
  eqArr(A.topGroups().map(g => g.name), ["Stream A", "Stream B"],
    "a subgroup is a detail, and a loose task is not a group");
  A.refreshDashScope();
  const opts = [...els.dashGroup.innerHTML.matchAll(/value="([^"]*)"/g)].map(m => m[1]);
  eqArr(opts, ["", "Stream A", "Stream B"], "the whole plan first, then one option per stream");
  assert(els.dashGroup.innerHTML.includes("Stream A (2)"), "each says how many tasks it holds");
});

t("narrowing the scope reaches the tasks under a subgroup too", () => {
  loadRowsNow(streamRows());
  assert.strictEqual(A.dashTasks().length, 4, "the whole plan: four leaf tasks, no groups");
  A.state.dashGroup = "Stream A";
  eqArr(A.dashTasks().map(x => x.name).sort(), ["A1", "A2"], "including the one two levels down");
  // every number on the board is derived from that same list
  assert.strictEqual(Object.values(A.healthCounts()).reduce((a, b) => a + b, 0), 2);
  eqArr(A.teamWorkload().map(r => r.team), ["Dev"], "and the tables follow it");
  assert.strictEqual(A.overdueBands().total, A.healthCounts().overdue);
  A.state.dashGroup = "";
});

t("a scope naming a group the plan no longer has is the whole plan again", () => {
  loadRowsNow(streamRows());
  A.state.dashGroup = "Stream Gone";
  assert.strictEqual(A.dashScope(), null, "rather than a board about nothing");
  assert.strictEqual(A.dashTasks().length, 4);
  // and the picker clears it rather than leaving a name nothing matches on screen
  A.refreshDashScope();
  assert.strictEqual(A.state.dashGroup, "");
});

t("the board says it is narrowed, above every number on it", () => {
  loadRowsNow(streamRows());
  assert.strictEqual(A.dashScopeHtml(4), "", "nothing to say when the board is the whole plan");
  A.state.dashGroup = "Stream A";
  const banner = A.dashScopeHtml(A.dashTasks().length);
  assert(banner.includes("Stream A"), banner);
  assert(/2 of 4 task\(s\)/.test(banner), "it says how much of the plan is being counted: " + banner);
  assert(banner.includes('data-dash-scope=""'), "and offers the way back");
  A.state.dashGroup = "";
});

console.log("\n-- a name with no hours costs nothing --");

t("naming somebody says who is on the task, not how long it takes them", () => {
  loadRowsNow([{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-04", resp: "Anna;Bela:4" }]);
  const m = A.resourceModel();
  assert.strictEqual(hoursOn(m, "Anna", "2026-03-02"), 0, "nobody has said how long it takes her");
  assert.strictEqual(hoursOn(m, "Bela", "2026-03-02"), 4, "while a quantified name still counts");
  assert(resRow(m, "Anna"), "she is still a row on the board — she is on the task");
});

t("capacity counts heads, so the team column still has something to divide by", () => {
  loadRowsNow([{ id: 1, name: "T", start: dayOff(1), end: dayOff(3), team: "Dev", resp: "Anna:4" },
               { id: 2, name: "U", start: dayOff(1), end: dayOff(3), team: "Dev", resp: "Bela" }]);
  const dev = A.teamWorkload().find(r => r.team === "Dev");
  assert.strictEqual(dev.people, 2);
  assert.strictEqual(dev.capacity, 2 * A.WORKDAY_H, "two people, whatever hours they carry");
  assert.strictEqual(dev.load, Math.round(4 * 100 / (2 * A.WORKDAY_H)),
    "loaded by the hours actually named, not by the heads");
  assert(A.WORKDAY_H > 0 && A.DEFAULT_WORKLOAD_H === 0, "the two numbers answer different questions");
});

console.log("\n-- a group's status is its deepest task's, and no group is counted --");

t("a task blocked four levels down blocks every group above it", () => {
  loadRowsNow([
    { id: 1, name: "L1", group: [2], start: "2026-03-02", end: "2026-03-06" },
    { id: 2, name: "L2", group: [3], start: "2026-03-02", end: "2026-03-06" },
    { id: 3, name: "L3", group: [4], start: "2026-03-02", end: "2026-03-06" },
    { id: 4, name: "L4", group: [5], start: "2026-03-02", end: "2026-03-06" },
    { id: 5, name: "Leaf", start: "2026-03-02", end: "2026-03-06" },
  ]);
  assert.strictEqual(A.depthOf(idOfName("Leaf"), A.state.tasks), 4, "the fixture really is four deep");
  A.changeStatus([idOfName("Leaf")], "Blocked");
  for (const n of ["L1", "L2", "L3", "L4"])
    assert.strictEqual(row(n).status, "Blocked", n + " did not follow the task inside it");
  // …and it follows back down again when the task moves on
  A.changeStatus([idOfName("Leaf")], "Done");
  for (const n of ["L1", "L2", "L3", "L4"])
    assert.strictEqual(row(n).status, "Done", n + " kept a status its member no longer has");
});

t("the loudest member wins at every level, not just the nearest one", () => {
  loadRowsNow([
    { id: 1, name: "Top", group: [2, 5], start: "2026-03-02", end: "2026-03-08" },
    { id: 2, name: "Mid", group: [3, 4], start: "2026-03-02", end: "2026-03-06" },
    { id: 3, name: "Quiet", start: "2026-03-02", end: "2026-03-03" },
    { id: 4, name: "Stuck", start: "2026-03-04", end: "2026-03-06" },
    { id: 5, name: "Busy", start: "2026-03-07", end: "2026-03-08" },
  ]);
  A.changeStatus([idOfName("Busy")], "In Progress");
  A.changeStatus([idOfName("Stuck")], "Blocked");
  assert.strictEqual(row("Mid").status, "Blocked", "one blocked member is enough");
  assert.strictEqual(row("Top").status, "Blocked",
    "and the group above reads its subgroup's rolled-up answer, not its own members' words");
});

t("no group and no subgroup is counted on the dashboard", () => {
  loadRowsNow(streamRows());
  const groups = A.state.tasks.filter(A.isGroup).length;
  assert.strictEqual(groups, 3, "the fixture has three containers");
  assert.strictEqual(A.dashTasks().filter(A.isGroup).length, 0, "and the board counts none of them");
  assert.strictEqual(A.dashTasks().length, A.state.tasks.length - groups,
    "every leaf task, and nothing but");
  // the totals the cards and the bar are built from are that same list
  assert.strictEqual(Object.values(A.healthCounts()).reduce((a, b) => a + b, 0), A.dashTasks().length);
  assert.strictEqual(A.teamWorkload().reduce((n, r) => n + r.n, 0), A.dashTasks().length,
    "a group counted in a team's row would double the work it is carrying");
});

console.log("\n-- a filter opens the plan up --");

t("while a filter is on every group is drawn open", () => {
  loadRowsNow(streamRows());
  const gid = idOfName("Stream A");
  A.state.collapsed = new Set([gid]);
  assert.strictEqual(A.displayTasks().filter(r => r.t.name === "A1").length, 0, "folded: the member is away");
  assert.strictEqual(A.isCollapsed(row("Stream A")), true);

  A.state.filterTeam = "Dev";
  assert.strictEqual(A.anyFilterOn(), true);
  assert(A.displayTasks().some(r => r.t.name === "A1"), "a filter that found it must show it");
  assert.strictEqual(A.isCollapsed(row("Stream A")), false, "so the caret says open too");
  assert(A.state.collapsed.has(gid), "and the fold itself is untouched");

  A.state.filterTeam = "";
  assert.strictEqual(A.displayTasks().filter(r => r.t.name === "A1").length, 0,
    "clearing the filter puts the plan back exactly as it was folded");
  A.state.collapsed = new Set();
});

t("the search counts as a filter, and the fold still reaches the file", () => {
  loadRowsNow(streamRows());
  A.state.collapsed = new Set([idOfName("Stream A")]);
  A.state.search = "A1";
  assert.strictEqual(A.anyFilterOn(), true);
  assert(A.displayTasks(true).some(r => r.t.name === "A1"), "found and shown");
  // what the file remembers is the fold, not what the filter is doing to it
  const line = A.serializeCSV(A.state.tasks).split("\r\n").find(l => l.startsWith("#VIEW|collapsed"));
  assert(line && line.includes(String(idOfName("Stream A"))), "the fold did not reach the file: " + line);
  A.state.search = ""; A.state.collapsed = new Set();
});

console.log("\n-- Planned vs Actual: the projection starts on the today line --");

/* Reported off a real plan: Actual sat flat on zero across the whole chart while
   the figure under it read the true percentage. Both come from progressAt(), so
   they cannot disagree about a day — they were being asked about different days.
   The chart's points were a grid stepped off the plan's own span, and the last
   one before today could be weeks old; on a plan whose history begins the day it
   was imported, every one of those days really is 0% Done. Today is a point of
   its own now, so the solid line ends on the number the figure states. */
t("the Actual line reaches today, not the last grid day before it", () => {
  /* 61 days wide, so the grid steps 5 days from day -31 and lands on day -1:
     today is deliberately between two grid points, which is where the bug is. */
  loadRowsNow([{ id: 1, name: "Done work", start: dayOff(-31), end: dayOff(-20) },
               { id: 2, name: "Running", start: dayOff(-5), end: dayOff(5) },
               { id: 3, name: "Later", start: dayOff(10), end: dayOff(30) }]);
  A.changeStatus([idOfName("Done work")], "Done");   // history dated today, like an import
  const m = A.progressModel();
  assert.strictEqual(A.fmtDate(m.rows[m.anchor].d), A.today(), "the solid half ends on today");
  assert(m.today.actual > 0, "the fixture has something Done: " + m.today.actual);
  assert.strictEqual(m.rows[m.anchor].actual, m.today.actual,
    "the chart's last solid point and the figure are the same number");
  assert.strictEqual(m.rows.filter(r => A.fmtDate(r.d) === A.today()).length, 1, "and today is a point once");

  // and it is visibly not the axis: a flat line is exactly what was reported
  const svg = A.dashProgressHtml();
  const actual = svg.match(/class="ln hDone" d="([^"]*)"/)[1];
  const ys = [...actual.matchAll(/[ML][\d.]+ ([\d.]+)/g)].map(x => Number(x[1]));
  assert(ys.length > 1 && new Set(ys).size > 1, "the Actual line never leaves zero: " + actual);
  assert(A.dashProgressFigures().includes(m.today.actual + "%"), "and the figure says the same");
});

t("the dashed lines begin where the solid ones stopped", () => {
  // a plan running from a month back to a month ahead, so both halves exist
  loadRowsNow([{ id: 1, name: "Done work", start: dayOff(-30), end: dayOff(-20) },
               { id: 2, name: "Running", start: dayOff(-5), end: dayOff(5) },
               { id: 3, name: "Later", start: dayOff(10), end: dayOff(30) }]);
  A.changeStatus([idOfName("Done work")], "Done");
  const m = A.progressModel();
  assert(m.anchor > 0, "there is a past half to anchor to: " + m.anchor);
  assert.strictEqual(m.rows[m.anchor].past, true, "the anchor is the last past point");
  assert.strictEqual(m.rows[m.anchor + 1].past, false, "and the next one is the first future point");
  assert.strictEqual(m.rows[m.anchor].proj, m.rows[m.anchor].actual,
    "the dashed line starts at the value the solid one reached");
  assert(m.rows.slice(0, m.anchor).every(r => r.proj == null), "and starts nowhere earlier");

  /* The whole point of the change, read off the drawing: every dashed path's
     first point sits on the today line rather than a week to the right of it. */
  const svg = A.dashProgressHtml();
  const todayX = svg.match(/class="today" x1="([\d.]+)"/)[1];
  const dashed = svg.match(/class="ln \w+ dash" d="M[\d.]+/g) || [];
  assert.strictEqual(dashed.length, 2, "both the planned and the projected halves are drawn: " + svg.slice(0, 200));
  for (const p of dashed)
    assert.strictEqual(p.match(/M([\d.]+)/)[1], todayX, "a dashed line starts away from today: " + p);
  // and the solid halves end on that same x
  const solid = svg.match(/class="ln \w+" d="[^"]*"/g) || [];
  assert.strictEqual(solid.length, 2);
  for (const p of solid) {
    const last = [...p.matchAll(/[ML]([\d.]+)/g)].pop()[1];
    assert.strictEqual(last, todayX, "a solid line stops short of today: " + p);
  }
});

/* ---------------------------------------------------------------------------
   The stub DOM creates an element for any id it is asked for, which is what
   makes the rest of this suite possible — and is also a hole exactly the shape
   of a removed field. `$("fGroup").addEventListener(...)` left behind after the
   box was deleted throws at load in a real browser and passes silently here:
   a green suite over a dead app, the same shape of trap as the closing script
   tag. So the ids the app asks for are checked against the markup instead. */
t("every id the app reaches for exists somewhere in the page", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const declared = new Set([...html.matchAll(/\bid="([A-Za-z][\w-]*)"/g)].map(m => m[1]));
  /* Built by the renderers with the row's id spliced in, so they never appear
     as a plain id="..." literal anywhere. Each is created immediately before it
     is read, in the same function. */
  const dynamic = new Set(["hvGhost"]);
  const missing = [...new Set([...script.matchAll(/\$\("([A-Za-z][\w-]*)"\)/g)].map(m => m[1]))]
    .filter(id => !declared.has(id) && !dynamic.has(id));
  eqArr(missing, [], "these ids are wired up but are not in the page: " + missing.join(", "));
});

console.log("\n-- predecessors by name, not by ID --");

/* Names that overlap on purpose: "Migration" is in three of them, so two
   letters is a list and more letters is a shorter list. */
const predPlan = () => loadRowsNow([
  { id: 1, name: "Migration dry run", start: "2026-03-02", end: "2026-03-06", team: "Dev" },
  { id: 2, name: "Migration rehearsal", start: "2026-03-09", end: "2026-03-13", team: "Dev" },
  { id: 3, name: "Data migration sign-off", start: "2026-03-16", end: "2026-03-17", team: "QA" },
  { id: 4, name: "Árvíztűrő tükörfúrógép", start: "2026-03-18", end: "2026-03-19", team: "Ops" },
  { id: 5, name: "Cutover", start: "2026-03-20", end: "2026-03-24", team: "Ops" },
]);
const key = k => ({ key: k, preventDefault(){}, stopPropagation(){} });

t("the run being typed is whatever follows the last semicolon", () => {
  assert.strictEqual(A.predTerm("mig"), "mig");
  assert.strictEqual(A.predTerm("3;7; cut"), "cut", "the settled IDs are not part of the search");
  eqArr(A.predKept("3;7; cut"), ["3", "7"], "and they are kept exactly as typed");
  assert.strictEqual(A.predTerm(""), "");
  eqArr(A.predKept("mig"), [], "nothing settled yet");
});

t("a number is an ID being typed, a word is a search — and two letters is the floor", () => {
  assert.strictEqual(A.predIsSearch("12"), false, "an ID opens no list");
  assert.strictEqual(A.predIsSearch("m"), false, "one letter would match half the plan");
  assert.strictEqual(A.predIsSearch("mi"), true);
  assert.strictEqual(A.predIsSearch("12 rev"), true, "a name that starts with a number is still a name");
});

t("the hits are the plan's own order, capped, and never the task itself", () => {
  predPlan();
  const hits = A.predHits("migration", null).map(t => t.name);
  eqArr(hits, ["Migration dry run", "Migration rehearsal", "Data migration sign-off"],
    "matched anywhere in the name, in time order");
  const self = idOfName("Migration rehearsal");
  assert(!A.predHits("migration", self).some(t => t.id === self), "a task cannot wait on itself");
  assert(A.predHits("", null).length === 0, "an empty term is not a match-everything");
});

t("accents and case are ignored, as everywhere else in this app", () => {
  predPlan();
  eqArr(A.predHits("arvizturo", null).map(t => t.name), ["Árvíztűrő tükörfúrógép"],
    "nobody types the accents in a hurry");
  eqArr(A.predHits("CUTOVER", null).map(t => t.name), ["Cutover"]);
});

t("no more than ten are offered, however many match", () => {
  loadRowsNow(Array.from({ length: 14 }, (_, i) => ({
    id: i + 1, name: "Wave " + (i + 1), start: "2026-03-02", end: "2026-03-03", team: "Dev" })));
  assert.strictEqual(A.predHits("wave", null).length, A.PRED_MAX_HITS);
});

t("typing opens the list with the first hit chosen; typing on narrows it", () => {
  predPlan();
  useRef({});
  A.openModal({ type: "new" });
  els.fPreds.value = "mig";
  A.predInput("fPreds", null);
  assert(A.predPopOpen(), "the list is open");
  assert.strictEqual(A.predState().sel, 0, "on the first hit");
  assert.strictEqual(A.predState().hits.length, 3);
  assert(/class="on"/.test(els.predPop.innerHTML), "and it is marked as chosen");

  els.fPreds.value = "migration r";
  A.predInput("fPreds", null);
  eqArr(A.predState().hits.map(t => t.name), ["Migration rehearsal"], "the list reloaded, narrower");
  A.closeModal();
  useRef(null);
});

t("a number typed into the box opens nothing", () => {
  predPlan();
  A.closePredPop();
  els.fPreds.value = "3";
  A.predInput("fPreds", null);
  assert(!A.predPopOpen(), "an ID is an ID");
});

t("the arrows move the choice, and wrap at both ends", () => {
  predPlan();
  els.fPreds.value = "mig";
  A.predInput("fPreds", null);
  A.predKey(key("ArrowDown"), "fPreds", null);
  assert.strictEqual(A.predState().sel, 1);
  A.predKey(key("ArrowUp"), "fPreds", null);
  assert.strictEqual(A.predState().sel, 0);
  A.predKey(key("ArrowUp"), "fPreds", null);
  assert.strictEqual(A.predState().sel, 2, "up from the first goes to the last");
  A.predKey(key("ArrowDown"), "fPreds", null);
  assert.strictEqual(A.predState().sel, 0, "and down from the last comes back");
  A.closePredPop();
});

t("Enter puts the chosen ID in, with a semicolon ready for the next", () => {
  predPlan();
  els.fPreds.value = "mig";
  A.predInput("fPreds", null);
  A.predKey(key("ArrowDown"), "fPreds", null);          // Migration rehearsal
  assert.strictEqual(A.predKey(key("Enter"), "fPreds", null), true, "the key was the picker's");
  assert.strictEqual(els.fPreds.value, idOfName("Migration rehearsal") + ";");
  assert(!A.predPopOpen(), "and the list is done with");
});

/* The point of reading only the run after the last ";": a field already naming
   two predecessors must not lose them to the third being typed. */
t("a name typed after existing IDs keeps them", () => {
  predPlan();
  els.fPreds.value = "3;5;cut";
  A.predInput("fPreds", null);
  A.predKey(key("Enter"), "fPreds", null);
  assert.strictEqual(els.fPreds.value, "3;5;" + idOfName("Cutover") + ";");
});

t("Enter on a name that matches nothing says so, and saves nothing", () => {
  predPlan();
  els.fPreds.value = "zzzz";
  A.predInput("fPreds", null);
  assert(A.predPopOpen(), "the list opens even with no hits");
  assert(/No task matches/.test(els.predPop.innerHTML), els.predPop.innerHTML);
  els.toast.textContent = "";
  assert.strictEqual(A.predKey(key("Enter"), "fPreds", null), true, "Enter is swallowed");
  assert(/No task matches “zzzz”/.test(els.toast.textContent), els.toast.textContent);
  assert.strictEqual(els.fPreds.value, "zzzz", "nothing was put in");
  A.closePredPop();
});

t("Enter on a half-typed name is caught too, with no list open", () => {
  predPlan();
  A.closePredPop();
  els.fPreds.value = "3;c";
  els.toast.textContent = "";
  assert.strictEqual(A.predKey(key("Enter"), "fPreds", null), true);
  assert(/at least 2 letters/.test(els.toast.textContent), els.toast.textContent);
});

t("Enter on plain IDs is not the picker's business", () => {
  predPlan();
  A.closePredPop();
  els.fPreds.value = "3;5";
  assert.strictEqual(A.predKey(key("Enter"), "fPreds", null), false,
    "it must fall through to whatever Enter normally does");
  els.fPreds.value = "";
  assert.strictEqual(A.predKey(key("Enter"), "fPreds", null), false, "and an empty box the same");
});

t("Escape shuts the list without touching the edit underneath", () => {
  predPlan();
  els.fPreds.value = "mig";
  A.predInput("fPreds", null);
  assert.strictEqual(A.predKey(key("Escape"), "fPreds", null), true, "swallowed");
  assert(!A.predPopOpen(), "the list is shut");
  // with no list open it is not the picker's key at all — Escape closes the dialog
  assert.strictEqual(A.predKey(key("Escape"), "fPreds", null), false);
});

t("the dialog reads the IDs back as names, and marks one that points nowhere", () => {
  predPlan();
  useRef({});
  A.openModal({ type: "edit", id: idOfName("Cutover") });
  els.fPreds.value = idOfName("Migration dry run") + ";" + idOfName("Cutover");
  A.syncPredNames();
  assert(/Migration dry run/.test(els.fPredNames.innerHTML), els.fPredNames.innerHTML);
  els.fPreds.value = "999";
  A.syncPredNames();
  assert(/class="pBad"[^>]*>999 \?/.test(els.fPredNames.innerHTML),
    "a wrong number must not read the same as no number: " + els.fPredNames.innerHTML);
  els.fPreds.value = "";
  A.syncPredNames();
  assert(/nothing waits on another task/.test(els.fPredNames.innerHTML));
  A.closeModal();
  useRef(null);
});

t("the box is sized for numbers and the names stand beside it", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/\.predBox\{[^}]*width:10ch/.test(css), "ten characters wide, as asked");
  assert(/\.predNames\{[^}]*text-overflow:ellipsis/.test(css), "and a long list of names has to cut");
  const dlg = html.slice(html.indexOf('id="modal"'), html.indexOf('id="btnModalSave"'));
  assert(/id="fPreds"[^>]*class="predBox"/.test(dlg), "the dialog's box is the short one");
  assert(dlg.indexOf('id="fPreds"') < dlg.indexOf('id="fPredNames"'), "names after the box");
});

t("the list is closed by anything that ends the edit it belongs to", () => {
  predPlan();
  useRef({});
  A.openModal({ type: "new" });
  els.fPreds.value = "mig"; A.predInput("fPreds", null);
  A.closeModal();
  assert(!A.predPopOpen(), "closing the dialog takes it with it");

  A.state.view = "table"; A.renderTable();
  A.startInline(idOfName("Cutover"));
  els.iPreds.value = "mig"; A.predInput("iPreds", idOfName("Cutover"));
  assert(A.predPopOpen(), "the inline row has the same picker");
  A.cancelInline();
  assert(!A.predPopOpen(), "and cancelling the row shuts it");
  useRef(null);
});

t("the picker works the inline row's box the same way", () => {
  predPlan();
  useRef({}); A.state.view = "table"; A.renderTable();
  const id = idOfName("Cutover");
  A.startInline(id);
  els.iPreds.value = "migration r";
  A.predInput("iPreds", id);
  assert(A.predPopOpen(), "the row's box opens the same list");
  assert.strictEqual(A.predState().inputId, "iPreds", "and the list knows which box it belongs to");
  A.predKey(key("Enter"), "iPreds", id);
  assert.strictEqual(els.iPreds.value, idOfName("Migration rehearsal") + ";");
  assert(A.state.inlineEdit, "and the row is still being edited");
  A.cancelInline();
  useRef(null);
});

/* The inline row's Enter saves the row and its Escape throws the edit away, so
   while the list is open the picker has to swallow both. predKey() returning
   true is what the handler acts on, and that is asserted above — but the
   *wiring* cannot be: the stub DOM's querySelector always returns null, so the
   block that binds the inline row's inputs never runs here. Asserted by reading
   the source instead, which is the only statement this harness can make about
   it. Whether the keys really stop there is a thing to check in a browser. */
t("the inline row binds the picker and stops the keys it uses", () => {
  const wire = html.slice(html.indexOf('editRow.addEventListener("keydown"'),
                          html.indexOf("const addRow = host.querySelector"));
  assert(/\$\("iPreds"\)\.addEventListener\("input"/.test(wire), "no input handler on the row's box");
  assert(/\$\("iPreds"\)\.addEventListener\("keydown"/.test(wire), "no keydown handler");
  assert(/predKey\(e, "iPreds"[^)]*\)\)\s*\{\s*e\.preventDefault\(\); e\.stopPropagation\(\);/.test(wire),
    "the row's own Enter/Escape must not also fire: " + wire.slice(-400));
});

console.log("\n-- schedule impact: what a move would cost --");

/* A chain with float in it, and each link holding less than the one before:
   A ends 03-06 and B starts 03-08 (two days of slack), B ends 03-12 and C
   starts 03-13 (one day). Pushing A five days therefore costs B three and C
   two — which is the whole point of the number, and why "project end" moves by
   less than the task did. */
const impactPlan = () => loadRowsNow([
  { id: 1, name: "A", start: "2026-03-02", end: "2026-03-06", team: "Dev" },
  { id: 2, name: "B", start: "2026-03-08", end: "2026-03-12", team: "Dev", preds: [1] },
  { id: 3, name: "C", start: "2026-03-13", end: "2026-03-17", team: "QA", preds: [2] },
  { id: 4, name: "Side", start: "2026-03-02", end: "2026-03-04", team: "Ops" },
]);
const impactOf = (name, days) => A.scheduleImpact(idOfName(name), days);

t("slack is absorbed, so the project end moves by less than the task did", () => {
  impactPlan();
  const im = impactOf("A", 5);
  assert.strictEqual(im.affected, 2, "B and C, and nothing else");
  assert.strictEqual(im.endDelta, 2, "five days of slip cost the plan two");
  assert.strictEqual(A.impactText(im),
    "+5 days → 2 dependent tasks affected → project end +2 days");
});

t("a slip small enough to fit in the float costs nothing at all", () => {
  impactPlan();
  const im = impactOf("A", 2);          // B has exactly two days of slack
  assert.strictEqual(im.affected, 0);
  assert.strictEqual(im.endDelta, 0);
  assert.strictEqual(A.impactText(im), "+2 days → nothing downstream is affected");
});

t("pulling a task earlier never pushes anything", () => {
  impactPlan();
  const im = impactOf("A", -4);
  assert.strictEqual(im.affected, 0);
  assert.strictEqual(A.impactText(im), "-4 days → nothing downstream is affected");
});

t("a task nothing waits on affects nothing, however far it goes", () => {
  impactPlan();
  const im = impactOf("Side", 30);
  assert.strictEqual(im.affected, 0, "no successors to push");
  // it ended 03-04, so +30 puts it at 04-03 — 17 days past C, which ended the plan
  assert.strictEqual(im.endDelta, 17, "but it is the last thing in the plan now");
});

/* The plan already had a link that does not hold. Settling it is not this
   drag's doing, so it must not be counted against it — the same rule the mass
   change uses when it diffs errors against the ones a row already had. */
t("a link that was already broken is not charged to this move", () => {
  loadRowsNow([
    { id: 1, name: "A", start: "2026-03-02", end: "2026-03-06", team: "Dev" },
    // B starts three days before A ends: broken before anybody touched anything
    { id: 2, name: "B", start: "2026-03-03", end: "2026-03-09", team: "Dev", preds: [1] },
    { id: 3, name: "Side", start: "2026-04-01", end: "2026-04-03", team: "Ops" },
  ]);
  const im = impactOf("Side", 3);
  assert.strictEqual(im.affected, 0, "B's old problem is not news about Side");
});

t("the impact rides in the drag tag, under the dates", () => {
  impactPlan();
  useRef({}); A.state.view = "gantt"; A.state.pxPerDay = 20;
  A.renderGantt();
  fireEl(els.ganttBody, "mousedown", mouse(400, 500, { target: fakeBar(idOfName("A"), ".bar") }));
  fireWin("mousemove", mouse(500, 500));            // 100px = 5 days at 20 px/day
  const tag = els.designTag.innerHTML;
  assert(/class="tagImpact"/.test(tag), "no impact line in the tag: " + tag);
  assert(/2 dependent tasks affected/.test(tag), tag);
  assert(/project end \+2 days/.test(tag), tag);
  fireWin("mouseup", mouse(400, 500));              // back where it started
  A.state.pxPerDay = 28; useRef(null); A.state.view = "table";
});

t("dragging the start end of a bar says nothing about downstream", () => {
  impactPlan();
  useRef({}); A.state.view = "gantt"; A.state.pxPerDay = 20;
  A.renderGantt();
  // the left grip moves the Start; the End, which is what successors wait on, holds
  fireEl(els.ganttBody, "mousedown", mouse(400, 500,
    { target: { dataset: { grip: "start" }, closest: q => q.includes(".grip")
        ? { dataset: { grip: "start" }, closest: () => fakeBar(idOfName("A"), ".bar") } : null } }));
  fireWin("mousemove", mouse(440, 500));
  assert(!/tagImpact/.test(els.designTag.innerHTML), "the start moving costs nobody anything");
  fireWin("mouseup", mouse(400, 500));
  A.state.pxPerDay = 28; useRef(null); A.state.view = "table";
});

/* It previews and nothing else. This app has never rescheduled behind the
   user's back, and the count on the tag must not become a licence to start. */
t("the preview moves nothing: only the dragged task ends up anywhere new", () => {
  impactPlan();
  useRef({}); A.state.view = "gantt"; A.state.pxPerDay = 20;
  A.renderGantt();
  fireEl(els.ganttBody, "mousedown", mouse(400, 500, { target: fakeBar(idOfName("A"), ".bar") }));
  fireWin("mousemove", mouse(500, 500));
  fireWin("mouseup", mouse(500, 500));
  assert.strictEqual(spanOf("A"), "2026-03-07..2026-03-11", "the task moved");
  assert.strictEqual(spanOf("B"), "2026-03-08..2026-03-12", "and B did not");
  assert.strictEqual(spanOf("C"), "2026-03-13..2026-03-17", "nor C");
  A.state.pxPerDay = 28; useRef(null); A.state.view = "table";
});

console.log("\n-- the dialog says less about a task that does not exist yet --");

t("a new task shows no ID and no history", () => {
  impactPlan();
  useRef({});
  A.openModal({ type: "new" });
  assert.strictEqual(els.fHistoryRow.style.display, "none", "a history that has not started");
  assert.strictEqual(els.fId.textContent, "", "and an ID the dates have not settled yet");
  /* 🔴 The duration line STAYS on a new task (2026-08-21) — it used to be a
     disabled box, hidden with the rest. Empty while there are no dates, and
     live from the moment there are: that is the point of keeping it. */
  assert.strictEqual(els.fDuration.textContent, "", "no dates, nothing to count");
  els.fStart.value = "2026-03-02"; els.fEnd.value = "2026-03-04";
  fire(els.fEnd, "change");   // …on the End: a new row's End follows the Start until it is typed into
  assert.strictEqual(els.fDuration.textContent, "3 day(s)", "and it counts as they are typed");
  /* 🔴 The title is two words now (the task-sheet brief §1): where the row
     lands is bookkeeping, and it used to sit in the biggest type on the screen
     above an empty form. It is still said — quietly, at the foot of Advanced. */
  assert.strictEqual(els.modalTitle.textContent, "New task", "the title says what this IS");
  assert.strictEqual(els.btnModalSave.textContent, "Add", "…and the button says what pressing it does");
  assert(/end of the plan/.test(els.advWhere.textContent),
    "where it lands moved to Advanced: " + els.advWhere.textContent);
  A.closeModal();

  A.openModal({ type: "edit", id: idOfName("B") });
  assert.strictEqual(els.fHistoryRow.style.display, "", "a task that exists has one");
  assert(/^#\d+$/.test(els.fId.textContent), "…and an ID: " + els.fId.textContent);
  A.closeModal();
  useRef(null);
});

/* Membership is carried rather than typed now, so the paths that used to read
   a box must not blank it — the bug this would be is silent and total. */
t("editing a group in the dialog leaves its members alone", () => {
  loadRowsNow([
    { id: 1, name: "Wave one", start: "2026-03-02", end: "2026-03-13", group: [2, 3] },
    { id: 2, name: "Build", start: "2026-03-02", end: "2026-03-06", team: "Dev" },
    { id: 3, name: "Test", start: "2026-03-09", end: "2026-03-13", team: "QA" },
  ]);
  useRef({});
  const gid = idOfName("Wave one");
  A.openModal({ type: "edit", id: gid });
  els.fName.value = "Wave one - renamed";
  A.saveModal();
  const g = A.taskById(idOfName("Wave one - renamed"));
  eqArr(g.group, [idOfName("Build"), idOfName("Test")], "the members survived the rename");
  assert.strictEqual(A.groupOf(idOfName("Build")).id, g.id, "and Build is still inside it");
  useRef(null);
});

t("editing a member in the inline row leaves it in its group", () => {
  loadRowsNow([
    { id: 1, name: "Wave one", start: "2026-03-02", end: "2026-03-13", group: [2, 3] },
    { id: 2, name: "Build", start: "2026-03-02", end: "2026-03-06", team: "Dev" },
    { id: 3, name: "Test", start: "2026-03-09", end: "2026-03-13", team: "QA" },
  ]);
  useRef({}); A.state.view = "table"; A.renderTable();
  const id = idOfName("Build");
  A.startInline(id);
  /* The stub keeps whatever the last test typed into these, and the row is
     read back field by field — so the ones this test does not set have to be
     put back to what the row actually holds. */
  const b = A.taskById(id);
  els.iName.value = b.name; els.iPreds.value = "";
  els.iStart.value = b.start; els.iEnd.value = b.end;
  els.iHs.value = ""; els.iHe.value = "";
  els.iMs.checked = false; els.iEv.checked = false;
  els.iTeam.value = "Platform";
  A.saveInline();
  assert.strictEqual(A.taskById(idOfName("Build")).team, "Platform", "the edit landed");
  assert.strictEqual(A.groupOf(idOfName("Build")).name, "Wave one", "and it is still a member");
  useRef(null);
});

console.log("\n-- drift: where the end date has been --");

/* Two tasks and a milestone, none of which has moved yet. The trail is written
   by the app, so every test here makes its moves the way a user does: change
   the plan, then markDirty(), which is the one place that can see it happen. */
/* **The two work rows have started.** Drift is only recorded once work has
   begun — everything before that is planning, and planning is not slipping —
   so a fixture of Planned rows would record nothing and these tests would all
   be measuring an empty trail. The tests that care about the unstarted side
   say so themselves. */
const driftFixture = () => {
  loadRowsNow([
    { id: 1, name: "Build", start: "2026-03-02", end: "2026-03-06", team: "Dev" },
    { id: 2, name: "Test",  start: "2026-03-09", end: "2026-03-13", team: "QA" },
    { id: 3, name: "Go live", start: "2026-03-20", end: "2026-03-20", ms: true },
  ]);
  for (const name of ["Build", "Test"]) A.setTaskStatus(A.taskById(idOfName(name)), "In Progress");
  A.resetHistory();
};
const moveEnd = (name, end) => {
  const t = A.taskById(idOfName(name));
  A.resetHistory();
  t.end = end;
  A.markDirty();
  return t;
};

t("a plan that has never moved has drifted nowhere", () => {
  driftFixture();
  const t = A.taskById(idOfName("Build"));
  eqArr(t.dateHist, [], "nothing recorded, because nothing happened");
  assert.strictEqual(A.baselineEndOf(t), "2026-03-06", "it answers to the end date it still has");
  assert.strictEqual(A.driftDays(t), 0, "zero is an answer: the date held");
  assert.strictEqual(A.driftText(t), "", "and zero is not worth a cell full of characters");
  assert.strictEqual(A.hasDrift(t), false);
});

t("moving an end date writes where it was, then where it went", () => {
  driftFixture();
  const t = moveEnd("Build", "2026-03-11");
  eqArr(t.dateHist.map(e => e.end), ["2026-03-06", "2026-03-11"],
    "the baseline is written at the same moment as the move that revealed it");
  assert.strictEqual(A.baselineEndOf(t), "2026-03-06");
  assert.strictEqual(A.driftDays(t), 5, "five days later than it was");
  assert.strictEqual(A.driftText(t), "+5d");
});

t("finishing early is the same record read the other way", () => {
  driftFixture();
  const t = moveEnd("Test", "2026-03-11");
  assert.strictEqual(A.driftDays(t), -2);
  assert.strictEqual(A.driftText(t), "-2d");
});

t("only the end date counts — a start that moves on its own records nothing", () => {
  driftFixture();
  const t = A.taskById(idOfName("Build"));
  A.resetHistory();
  t.start = "2026-03-03";
  A.markDirty();
  eqArr(t.dateHist, [], "a task that starts late and ends on time has cost the plan nothing");
});

t("one entry a day, and the baseline is the one entry a later edit cannot reach", () => {
  driftFixture();
  const t = A.taskById(idOfName("Build"));
  // three moves on one day: nudging a bar into place is one decision, not three
  A.recordEnd(t, "2026-03-06", "2026-03-09", "2026-03-01");
  A.recordEnd(t, "2026-03-09", "2026-03-10", "2026-03-01");
  A.recordEnd(t, "2026-03-10", "2026-03-11", "2026-03-01");
  eqArr(t.dateHist.map(e => e.end), ["2026-03-06", "2026-03-11"], "the day holds its last answer");
  // a later day is a new entry, and the baseline still is not touched
  A.recordEnd(t, "2026-03-11", "2026-03-13", "2026-03-02");
  eqArr(t.dateHist.map(e => e.end), ["2026-03-06", "2026-03-11", "2026-03-13"]);
  assert.strictEqual(A.baselineEndOf(t), "2026-03-06", "still the first end date ever recorded");
});

/* Most of a plan's date changes happen while it is being written: a task
   copied from another plan arrives with somebody else's dates, gets dragged
   twice, and is re-typed the next morning. None of that is slippage. */
t("a task nobody has started records nothing and has not drifted", () => {
  // its own fixture: driftFixture starts its rows, and this is about one that has not
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-06", team: "Dev" }]);
  const t = A.taskById(idOfName("Build"));
  assert.strictEqual(A.notStarted(t), true, "it is still Planned");
  const before = A.serializeCSV(A.state.tasks);
  A.resetHistory();
  t.end = "2026-03-20";
  A.markDirty();
  eqArr(t.dateHist, [], "planning is not slipping");
  assert.strictEqual(A.driftDays(t), 0, "nothing has moved from a baseline it does not have yet");
  assert(before !== A.serializeCSV(A.state.tasks), "the date itself did change, though");
});

/* The date a task carried when work began is the one it is answerable to —
   recordEnd() writes the *previous* end as its first entry, so that date
   becomes the baseline without anybody freezing it by hand. */
t("starting the work is what starts the record", () => {
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-06", team: "Dev" }]);
  const t = A.taskById(idOfName("Build"));
  A.resetHistory(); t.end = "2026-03-16"; A.markDirty();     // still planning: not recorded
  eqArr(t.dateHist, []);
  A.setTaskStatus(t, "In Progress");
  A.resetHistory(); t.end = "2026-03-18"; A.markDirty();
  eqArr(t.dateHist.map(e => e.end), ["2026-03-16", "2026-03-18"],
    "the baseline is where the dates stood when the work began");
  assert.strictEqual(A.driftDays(t), 2);
});

/* The tick used to decide whether a date change counted, and the app used to
   set and clear it on the reader's behalf. Both are gone: it is a mark, and
   marks do not change numbers. */
t("the Not finalized tick is a mark for the reader and changes nothing else", () => {
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-06", team: "Dev" }]);
  const t = A.taskById(idOfName("Build"));
  A.setTaskStatus(t, "In Progress");
  assert.strictEqual(!!t.notFinal, false, "starting the work does not touch the tick");

  t.notFinal = true;                       // set by hand, which is the only way it is ever set
  A.resetHistory(); t.end = "2026-03-09"; A.markDirty();
  eqArr(t.dateHist.map(e => e.end), ["2026-03-06", "2026-03-09"], "a ticked row still records its moves");
  assert.strictEqual(A.driftDays(t), 3, "and still answers how far it has travelled");
  assert.strictEqual(t.notFinal, true, "the tick is left exactly where it was put");
});

t("resetting the due dates drops the trails and leaves the ticks alone", () => {
  driftFixture();
  const t = A.taskById(idOfName("Build"));
  A.setTaskStatus(t, "In Progress");
  A.resetHistory(); t.end = "2026-03-21"; A.markDirty();
  assert(t.dateHist.length, "there is a trail to drop");

  const other = A.state.tasks.find(x => x !== t && A.notStarted(x));
  const wasTick = other ? other.notFinal : null;
  const wasConfirm = ctx.confirm;
  ctx.confirm = () => true;
  try {
    const out = A.resetDueDates();
    assert(out && out.cleared >= 1, "it says how many answered to a recorded date");
    eqArr(t.dateHist, [], "and the trail is gone");
    if (other) assert.strictEqual(other.notFinal, wasTick,
      "an unstarted row is no longer marked on the reader's behalf");
  } finally { ctx.confirm = wasConfirm; }
});

t("a typed baseline overrules the recorded one, and clearing it hands the answer back", () => {
  driftFixture();
  const t = moveEnd("Build", "2026-03-11");
  t.baselineEnd = "2026-03-09";
  assert.strictEqual(A.baselineEndOf(t), "2026-03-09", "the plan of record wins");
  assert.strictEqual(A.driftDays(t), 2);
  t.baselineEnd = "";
  assert.strictEqual(A.baselineEndOf(t), "2026-03-06", "and the trail answers again");
});

t("an event is somebody else's date and does not drift", () => {
  loadRowsNow([
    { id: 1, name: "Work", start: "2026-03-02", end: "2026-03-06", team: "Dev" },
    { id: 2, name: "Code freeze", start: "2026-03-10", end: "2026-03-12", ev: true },
  ]);
  const e = A.taskById(idOfName("Code freeze"));
  assert.strictEqual(A.canDrift(e), false);
  A.resetHistory(); e.end = "2026-03-14"; A.markDirty();
  eqArr(e.dateHist, [], "an event moving is not a promise broken");
  assert.strictEqual(A.driftDays(e), null);
  // and it writes neither cell, the way it writes neither status nor history
  const row = A.serializeCSV(A.state.tasks).split(/\r?\n/)
    .find(l => l.includes("Code freeze")).split("|");
  const at = k => row[A.CSV_COLS.findIndex(c => c.key === k)];
  assert.strictEqual(at("baselineEnd"), "");
  assert.strictEqual(at("dateHist"), "");
});

t("a milestone slipping is exactly the thing worth recording", () => {
  driftFixture();
  const t = A.taskById(idOfName("Go live"));
  A.resetHistory(); t.start = t.end = "2026-03-27"; A.markDirty();
  assert.strictEqual(A.driftDays(t), 7, "the moment moved a week");
});

/* A group's dates are its members' dates, so a member slipping moves the group
   and the group records it — which is the phase-level answer, free. It comes
   out right only because recalcGroups() has already run by the time markDirty()
   compares: the group's new end is in place before anything looks at it. */
t("a phase drifts when the work inside it does", () => {
  loadRowsNow([
    { id: 1, name: "Wave one", start: "2026-03-02", end: "2026-03-13", group: [2, 3] },
    { id: 2, name: "Build", start: "2026-03-02", end: "2026-03-06", team: "Dev" },
    { id: 3, name: "Test",  start: "2026-03-09", end: "2026-03-13", team: "QA" },
  ]);
  // the work is under way, which is what makes its dates answerable at all
  for (const name of ["Build", "Test"]) A.setTaskStatus(A.taskById(idOfName(name)), "In Progress");
  A.recalcGroups();
  const g = A.taskById(idOfName("Wave one"));
  assert.strictEqual(A.driftDays(g), 0, "nothing has moved yet");
  A.resetHistory();
  A.taskById(idOfName("Test")).end = "2026-03-18";
  A.recalcGroups();                       // as every editing path does before markDirty
  A.markDirty();
  assert.strictEqual(A.taskById(g.id).end, "2026-03-18", "the group followed its member");
  assert.strictEqual(A.driftDays(A.taskById(g.id)), 5, "and recorded the slip as its own");
});

t("an undo restores the trail rather than adding to it", () => {
  driftFixture();
  const t = moveEnd("Build", "2026-03-11");
  eqArr(t.dateHist.map(e => e.end), ["2026-03-06", "2026-03-11"]);
  A.undo();
  const back = A.taskById(idOfName("Build"));
  assert.strictEqual(back.end, "2026-03-06", "the date came back");
  eqArr(back.dateHist, [], "and so did the empty trail — an undo is not a move of its own");
});

t("both columns round-trip, and a file written before them reads as a plan that has not moved", () => {
  driftFixture();
  const t = moveEnd("Build", "2026-03-11");
  t.baselineEnd = "2026-03-05";
  const out = A.serializeCSV(A.state.tasks);
  // each entry is the day it was written, then the end date it wrote
  // (read by the header's own index: the UID column is written after this one)
  const cell = out.split(/\r?\n/).find(l => l.startsWith(t.id + "|"))
    .split("|")[A.HEADER.indexOf("Date History")];
  eqArr(cell.split(";").map(s => s.split(" ")[1]), ["2026-03-06", "2026-03-11"],
    "the trail is written as dated pairs: " + cell);
  loadTasks(out);
  const back = A.taskById(idOfName("Build"));
  eqArr(back.dateHist.map(e => e.end), ["2026-03-06", "2026-03-11"]);
  assert.strictEqual(back.baselineEnd, "2026-03-05", "the typed one came back too");
  assert.strictEqual(A.driftDays(back), 6);

  // the pre-people header: fifteen columns and nothing since
  loadRows([{ id: 1, name: "Old", start: "2026-03-02", end: "2026-03-06", team: "Dev" }]);
  const old = A.taskById(1);
  eqArr(old.dateHist, []);
  assert.strictEqual(A.driftDays(old), 0, "no trail is not the same as trouble");
});

t("rubbish in the trail is left out rather than repaired into a date", () => {
  eqArr(A.parseDateHist("2026-03-06 2026-03-11;nonsense;2026-03-07 not-a-date")
        .map(e => e.date + ">" + e.end), ["2026-03-06>2026-03-11"]);
  assert.strictEqual(A.fmtDateHist([{ date: "2026-03-06", end: "2026-03-11" }]), "2026-03-06 2026-03-11");
  assert.strictEqual(A.fmtDateHist(null), "", "a task that has never moved writes an empty cell");
});

console.log("\n-- drift: the dotted twin in the charts --");

t("nothing is drawn until it is asked for", () => {
  driftFixture();
  moveEnd("Build", "2026-03-11");
  A.state.view = "gantt"; A.state.showDrift = false;
  A.renderGantt();
  assert(!/driftBar/.test(els.ganttBody.innerHTML), "off by default, and off means nothing on the row");
  A.state.showDrift = true;
  A.renderGantt();
  assert(/driftBar/.test(els.ganttBody.innerHTML), "and on means drawn");
  A.state.showDrift = false; A.state.view = "table";
});

t("late dots the tail of the bar, early runs on past it — one segment either way", () => {
  driftFixture();
  moveEnd("Build", "2026-03-11");           // +5d
  moveEnd("Test", "2026-03-11");            // -2d
  A.state.view = "gantt"; A.state.showDrift = true;
  A.renderGantt();
  const html = els.ganttBody.innerHTML;
  const seg = id => {
    const m = html.match(new RegExp(`<div class="driftBar[^"]*" data-drift="${id}" style="left:([-\\d.]+)px;top:([-\\d.]+)px;width:([-\\d.]+)px`));
    assert(m, "no drift segment for task " + id + " in: " + html.slice(0, 400));
    return { left: +m[1], top: +m[2], width: +m[3] };
  };
  const bar = id => {
    const m = html.match(new RegExp(`<div class="bar[^"]*" data-id="${id}" style="left:([-\\d.]+)px;top:([-\\d.]+)px;width:([-\\d.]+)px`));
    assert(m, "no bar for task " + id);
    return { left: +m[1], top: +m[2], width: +m[3] };
  };
  const late = idOfName("Build"), early = idOfName("Test");

  // both sit on their own bar's line — "all the new bars on the same line"
  assert.strictEqual(seg(late).top, bar(late).top, "the late segment shares the bar's row");
  assert.strictEqual(seg(early).top, bar(early).top, "and so does the early one");

  /* Late: the segment ends where the bar now ends, so it lies over the tail the
     bar grew. Early: it starts where the bar now ends and runs on to where the
     end used to be, past the bar into empty space. */
  const lateBar = bar(late), lateSeg = seg(late);
  assert.strictEqual(lateSeg.left + lateSeg.width, lateBar.left + lateBar.width,
    "the late segment ends with the bar");
  assert(lateSeg.left > lateBar.left, "and starts inside it, at the old end date");

  const earlyBar = bar(early), earlySeg = seg(early);
  assert.strictEqual(earlySeg.left, earlyBar.left + earlyBar.width,
    "the early segment starts where the bar stops");

  // the arrow is the only thing that says which of the two happened
  assert(new RegExp(`data-drift="${late}"[^>]*>\\s*<span class="driftArw r"`).test(html), "late points right");
  assert(new RegExp(`data-drift="${early}"[^>]*>\\s*<span class="driftArw l"`).test(html), "early points left");
  A.state.showDrift = false; A.state.view = "table";
});

t("a day of drift at a tiny scale still shows as something", () => {
  driftFixture();
  moveEnd("Build", "2026-03-07");           // one day
  A.state.view = "gantt"; A.state.showDrift = true; A.state.pxPerDay = 2;
  A.renderGantt();
  const m = els.ganttBody.innerHTML.match(/class="driftBar[^"]*"[^>]*width:([\d.]+)px/);
  assert(m && +m[1] >= A.DRIFT_MIN_W, "two pixels of dotted line is nothing at all: " + (m && m[1]));
  A.state.pxPerDay = 28; A.state.showDrift = false; A.state.view = "table";
});

t("the segment carries the row's own health class, which is where its colour comes from", () => {
  driftFixture();
  const t = moveEnd("Build", "2026-03-11");
  A.state.view = "gantt"; A.state.showDrift = true;
  A.renderGantt();
  const cls = els.ganttBody.innerHTML.match(/<div class="(driftBar[^"]*)"/)[1];
  assert.strictEqual(cls, "driftBar" + A.healthCls(t),
    "it reads --edge off the health class rather than restating seven colours");
  A.state.showDrift = false; A.state.view = "table";
});

/* The suite reads classes, never pixels — so the one statement it can make
   about colour is where a rule sits in the sheet. See the health block's own
   note: these tie on specificity, and the last one written wins. */
t("the drift rules come after the colours they read and the fade they sit beside", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const at = re => { const i = css.search(re); assert(i > 0, "missing rule: " + re); return i; };
  assert(at(/\.driftBar\{/) > at(/\.hDone\{/), "the health custom properties are declared first");
  assert(at(/\.driftBar\{/) > at(/\.bar\.notFinal,/), "and it is written below the not-finalized fade");
  assert(/\.driftBar\{[^}]*border-style:dashed/.test(css), "dotted is the whole point");
  assert(/\.driftBar\{[^}]*border-color:var\(--edge/.test(css), "and the colour comes from the row");
  assert(/\.driftBar\{[^}]*pointer-events:none/.test(css),
    "it lies over a bar that can be dragged and must not swallow the grip");
});

console.log("\n-- drift: in the list, the dialog and a saved view --");

t("both columns come up folded away", () => {
  const d = A.defaultHiddenCols();
  assert(d.has("baselineEnd") && d.has("drift"),
    "they answer a question about a plan under way, not one being written");
});

t("the list says how far it went, and says nothing where the question does not apply", () => {
  driftFixture();
  moveEnd("Build", "2026-03-11");
  A.state.hiddenCols = new Set();
  A.renderTable();
  const rowOf = n => els.tableView.innerHTML.match(
    new RegExp(`<tr class="[^"]*" data-id="${idOfName(n)}"[\\s\\S]*?</tr>`))[0];
  assert(/class="driftLate"[^>]*>\+5d</.test(rowOf("Build")),
    "late reads as +5d: " + rowOf("Build").slice(-500));
  const t2 = A.taskById(idOfName("Test"));
  t2.notFinal = true;
  A.renderTable();
  assert(/still a guess/.test(rowOf("Test")), "a guess says why it is a dash, not just that it is one");
  t2.notFinal = false;
  A.state.hiddenCols = A.defaultHiddenCols();
});

t("the dialog explains where the baseline came from", () => {
  driftFixture();
  const t = moveEnd("Build", "2026-03-11");
  A.fillDriftFields(t);
  assert.strictEqual(els.fBaselineEnd.value, "", "nothing typed, so the box is empty");
  assert(/first end date recorded, 2026-03-06/.test(els.fBaselineHint.title),
    "it says which date it is using: " + els.fBaselineHint.title);
  assert(/5 day\(s\) late/.test(els.fDrift.value), els.fDrift.value);
  t.baselineEnd = "2026-03-09";
  A.fillDriftFields(t);
  assert.strictEqual(els.fBaselineEnd.value, "2026-03-09");
  assert(/clear the box/.test(els.fBaselineHint.title), "and how to undo that");
});

/* The Advanced section: the fields a plan can be written without. They were
   moved out of the dialog's main body, not deleted — every one of them still
   has to be there, or saveModal reads an input that is not on the page. */
t("the fields that moved into Advanced are still in the dialog", () => {
  const dlg = html.slice(html.indexOf('id="modal"'), html.indexOf('id="btnModalSave"'));
  const adv = dlg.slice(dlg.indexOf('id="advBox"'));
  /* Milestone and System joined them on 2026-08-21 (Daniel, tablet round). The
     milestone was argued about: it is a KIND of row rather than a field of one,
     and a three-way picker at the top was the alternative — his call was the
     fold, and it is recorded in the markup so the argument does not have to be
     had twice. */
  /* Labels, predecessors and notes joined them on 2026-08-21 with the task
     sheet: the main body is now only what EVERY task has, which is what makes
     a phone-sized form possible at all. */
  for (const id of ["fMilestone", "fEvent", "fEmoji", "fNotFinal", "fSystem", "fAcc", "fResp",
                    "fBaselineEnd", "fDrift", "fLabels", "fPreds", "fNotes"])
    assert(adv.includes(`id="${id}"`), id + " is not inside the Advanced section");
  for (const id of ["fName", "fStart", "fEnd", "fTeam", "fStatus"])
    assert(!adv.includes(`id="${id}"`), id + " belongs in the main body of the dialog");
  /* 🔴 The order of the main body is a decision, not an accident: the picture
     of the task, then the one thing it must have, then when, then how it is
     going, then who. */
  const main = dlg.slice(0, dlg.indexOf('id="advBox"'));
  const at = id => main.indexOf(`id="${id}"`);
  const order = ["fFacts", "fName", "fStart", "fEnd", "durChips", "fStatus", "fTeam"];
  for (let i = 1; i < order.length; i++)
    assert(at(order[i - 1]) < at(order[i]) && at(order[i]) > 0,
      order[i - 1] + " has to come before " + order[i]);
});

/* 🔴 The explanations are on the LABEL now, not on a mark beside the field
   (design agent's brief §5, 2026-08-21). They spent half a day as floating "?"
   marks — which was already better than a paragraph under every box, and one
   step short of this: the label is a thing that is already on screen, so the
   help costs no pixels at all. popTip() reads a title either way. */
t("the help hangs off the field labels, and the counters stay on screen", () => {
  const dlg = html.slice(html.indexOf('id="modal"'), html.indexOf('id="btnModalSave"'));
  const labelled = [...dlg.matchAll(/<label[^>]*title="([^"]{20,})"/g)];
  assert(labelled.length >= 6, "the sentences live on the labels: " + labelled.length);
  assert(!/class="qTip"/.test(dlg), "…and the floating marks are gone");
  /* One handler, on the dialog, so a label added later is covered without
     being wired up. */
  assert(/\$\("modal"\)\.addEventListener\("click", e => \{[\s\S]{0,160}?closestOf\(e\.target, "label\[title\]"\)[\s\S]{0,80}?popTip\(el\)/.test(html),
    "a tap on a label says what the field is for");
  /* What must NOT be folded away: it changes while somebody types, which makes
     it feedback rather than documentation. */
  assert(/class="hint" id="fNotesCount"/.test(dlg), "the character counter stays on screen");
  assert(/class="hint" id="fEndHint"/.test(dlg), "so does the end-date notice");
});

/* ---- The task sheet: the form draws the task as you fill it in ------------
   gantora-task-sheet-brief.md, mockup by the design agent. Everything below
   reads the FIELDS, never the task: the sheet shows what somebody is typing,
   which is not yet what is saved. */
/* 🔴 There WAS a live bar preview at the top of this sheet, for a few hours on
   2026-08-21. Daniel killed it after ten minutes on a real phone: "nagyon
   zavaró, nem érthető amíg nincs semmi beállítva, utána is csak a helyet
   viszi." The rule worth keeping: a preview earns its space only when it shows
   something you cannot already see, and this one showed the name you had just
   typed beside the dates you had just picked.

   What it did that nothing else did — the day count, and which group the row
   belongs to — had to survive it, and this is where both went. */
t("the facts line carries the day count and the group, and both stay right", () => {
  driftFixture();
  useRef({});
  A.openModal({ type: "new", atId: null });
  els.fStart.value = "2026-03-02";
  els.fEnd.value = "2026-03-06";
  A.updateTaskSheet();
  assert.strictEqual(els.fDuration.textContent, "5 day(s)", "the day count follows the dates");

  /* 🔴 The bug the preview was hiding: the count was refreshed only when a
     date field fired `change`, so a quick-length chip left it saying "1 day"
     beside dates a week apart. */
  A.setTaskDays(7);
  assert.strictEqual(els.fDuration.textContent, "7 day(s)", "…including when a chip sets them");

  assert(!/id="taskPrev"/.test(html), "the preview block is gone from the markup");
  A.closeModal();
  useRef(null);
});

/* 🔴 "Céges banki projekten jól mutat, ha új taskot akarok 😅" — Daniel, on a
   real plan, looking at a Team box that suggested "e.g. Tilers". And worse than
   looking silly: somebody may take an example literally and type it in. */
t("the Team example comes from the plan itself, and is colourless when there is none", () => {
  A.setLang("en");
  loadRowsNow([
    { id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", team: "Risk & Compliance" },
    { id: 2, name: "B", start: "2026-03-04", end: "2026-03-05", team: "Core Banking" },
  ]);
  useRef({});
  A.openModal({ type: "new", atId: null });
  assert.strictEqual(els.fTeam.placeholder, "e.g. Core Banking",
    "the plan's own most recent team, not ours: " + els.fTeam.placeholder);

  /* A plan with no teams yet gets something that cannot be mistaken for a
     suggestion about THIS project. */
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03" }]);
  A.openModal({ type: "new", atId: null });
  assert.strictEqual(els.fTeam.placeholder, "e.g. the team preparing it");
  assert(!/Tiler/i.test(els.fTeam.placeholder), "and never a trade nobody on this plan does");
  A.closeModal();
  useRef(null);
});

t("a task inside a group says so, and a group does not say it about itself", () => {
  grpFixture();
  useRef({});
  const inside = idOfName("One"), group = idOfName("Wave");
  A.openModal({ type: "edit", id: inside });
  assert(/Wave/.test(els.fParent.textContent),
    "a member names the group it is in: " + els.fParent.textContent);

  A.openModal({ type: "edit", id: group });
  assert.strictEqual(els.fParent.textContent, "",
    "🔴 a group does not name itself — \"Wave ▸ Wave\" reads as a bug");

  A.openModal({ type: "new", atId: null, groupId: group });
  assert(/Wave/.test(els.fParent.textContent), "and a row being created INTO one knows where it lands");
  A.closeModal();
  useRef(null);
});

t("a quick length sets the End from the Start, counting days inclusively", () => {
  driftFixture();
  useRef({});
  A.openModal({ type: "new", atId: null });
  els.fStart.value = "2026-03-02";
  /* One day means end = start — the way the app counts everywhere else. If this
     were exclusive, every "1 day" task would silently be two. */
  assert.strictEqual(A.setTaskDays(1), "2026-03-02");
  assert.strictEqual(A.setTaskDays(3), "2026-03-04");
  assert.strictEqual(A.setTaskDays(7), "2026-03-08");
  assert.strictEqual(els.fEnd.value, "2026-03-08");
  // …and the chip that matches the length somebody chose is the lit one
  assert.strictEqual(A.syncDurChips(), 7);
  A.closeModal();
  useRef(null);
});

t("the status chips are the control, and the select is still the value", () => {
  driftFixture();
  useRef({});
  A.openModal({ type: "new", atId: null });
  A.syncStatusChips();
  const chips = [...els.stChips.innerHTML.matchAll(/data-status="([^"]+)"/g)].map(m => m[1]);
  /* All six, in the file's own order — including Cancelled, which arrived the
     same afternoon as this design and had to be carried into it. */
  eqArr(chips, A.STATUSES);
  assert(/hCancelled/.test(els.stChips.innerHTML), "and cancelled wears its own grey");
  assert(els.stChips.innerHTML.includes(' class="st hUpcoming on"'),
    "the one that is chosen is lit: " + els.stChips.innerHTML.slice(0, 120));
  A.closeModal();
  useRef(null);
});

t("Advanced holds what a plan can be written without, and remembers its fold", () => {
  driftFixture();
  useRef({});
  ctx.localStorage.removeItem(A.ADV_OPEN_KEY);
  A.openModal({ type: "new", atId: null });
  assert.strictEqual(els.advBox.open, false, "first ever use: shut");

  A.rememberAdvOpen(true);
  A.openModal({ type: "new", atId: null });
  assert.strictEqual(els.advBox.open, true, "…and then the device's own habit");

  /* 🔴 The rule that beats the habit: what a task ACTUALLY uses is never
     hidden behind a fold. */
  A.rememberAdvOpen(false);
  const t2 = A.taskById(idOfName("Build"));
  t2.notes = "something worth reading";
  A.openModal({ type: "edit", id: t2.id });
  assert.strictEqual(els.advBox.open, true, "a task with a note opens on the note");
  t2.notes = "";
  ctx.localStorage.removeItem(A.ADV_OPEN_KEY);
  A.closeModal();
  useRef(null);
});

t("the ID and the duration are read back, not typed into", () => {
  driftFixture();
  useRef({});
  A.openModal({ type: "edit", id: idOfName("Build") });
  /* They used to be two disabled inputs taking two cells of the form grid.
     Nothing can be typed into either — the ID follows the dates and the
     duration follows from them — so they are one quiet line of text. */
  assert(/^#\d+$/.test(els.fId.textContent), "the ID reads as an ID: " + els.fId.textContent);
  assert(/day/.test(els.fDuration.textContent), "and the duration in words: " + els.fDuration.textContent);
  const dlg = html.slice(html.indexOf('id="modal"'), html.indexOf('id="btnModalSave"'));
  assert(/<span id="fId">/.test(dlg) && /<span id="fDuration">/.test(dlg),
    "…and neither is an input any more");
  A.closeModal();
  useRef(null);
});

t("Advanced opens by itself for a task that uses it, and stays shut otherwise", () => {
  driftFixture();
  useRef({});
  A.openModal({ type: "edit", id: idOfName("Build") });
  assert.strictEqual(els.advBox.open, false, "a plain task needs none of it");
  const t = A.taskById(idOfName("Build"));
  t.accountable = "Anna Kovacs:2";
  A.openModal({ type: "edit", id: t.id });
  assert.strictEqual(els.advBox.open, true, "a task with a person in it opens on the person");
  A.closeModal();
  useRef(null);
});

t("whether the drift is drawn is part of the view you saved", () => {
  driftFixture();
  A.state.view = "gantt"; A.state.showDrift = true;
  const v = A.captureView("with drift");
  assert.strictEqual(v.drift, "1");
  A.state.showDrift = false;
  A.applyView(v);
  assert.strictEqual(A.state.showDrift, true, "it came back on");
  // and a view saved before any of this existed reads as off
  A.applyView({ ...v, drift: undefined });
  assert.strictEqual(A.state.showDrift, false);
  A.state.showDrift = false; A.state.view = "table";
});

console.log("\n-- compare & merge: two copies of one plan --");

/* One plan, saved; then two people take a copy each and edit. Built through the
   file both times, so what is compared is what would really be on disk. */
const mergeFixture = () => {
  A.setMyName("Anna");
  loadRowsNow([
    { id: 1, name: "Design", start: "2026-03-02", end: "2026-03-06" },
    { id: 2, name: "Build", start: "2026-03-09", end: "2026-03-13", preds: [1] },
    { id: 3, name: "Test", start: "2026-03-16", end: "2026-03-20", preds: [2] },
  ]);
  return A.serializeCSV(A.state.tasks);
};

/* Their copy: Build renamed and stamped later than mine, a task added, Test
   deleted. Everything a merge has to have an answer for, in one file. */
const theirCopy = original => {
  const mine = A.state.tasks;
  A.state.tasks = A.parseCSV(original);
  const b = A.state.tasks.find(t => t.name === "Build");
  b.name = "Build the thing";
  b.modified = "2026-08-12T09:00:00Z bn2 Bela";
  A.state.tasks.push(A.seedTask({ id: 99, name: "Procure", start: "2026-03-04", end: "2026-03-05",
    hourStart: "", hourEnd: "", preds: [], group: [], team: "", system: "", accountable: "",
    responsible: "", notes: "", milestone: false, event: false, emoji: "", notFinal: false,
    baselineEnd: "", dateHist: [], status: "Planned" }));
  A.state.tasks = A.state.tasks.filter(t => t.name !== "Test");
  A.recalcPlan();
  const text = A.serializeCSV(A.state.tasks);
  A.state.tasks = mine;              // put my plan back; the file is the only thing kept
  return text;
};

/* The hole: rowSig() decides whether a row gets a Modified stamp and counted
   labels and the Reported tick all along, while MERGE_FIELDS did not compare
   them — so one side stamped the row as edited and the merge then called the two
   copies identical and kept whichever it had opened. A silent loss, of the one
   field a plan uses to say what a row is *about*. */
t("a row that differs only in a label is not called identical", () => {
  const original = mergeFixture();
  const mine = A.state.tasks;
  // their copy: the same plan, with a word put on Design
  A.state.tasks = A.parseCSV(original);
  A.taskById(idOfName("Design")).labels = "Cutover";
  A.taskById(idOfName("Design")).modified = "2026-08-12T10:00:00Z bx7 Bea";
  const theirs = A.serializeCSV(A.state.tasks);
  A.state.tasks = mine;

  const m = A.buildMerge(theirs, "theirs.gantora");
  const design = m.rows.find(r => (r.mine || r.theirs).name === "Design");
  assert.strictEqual(design.kind, "differs", "the label is a difference in the plan");
  assert(design.fields.some(f => f.key === "labels"), "and it is named as the field that differs");
  assert.strictEqual(design.newer, "theirs", "with their side suggested, since they stamped it later");
});

t("so is a row that differs only in the Reported tick", () => {
  A.setMyName("Anna");
  /* It has to be a row *inside a group*. serializeCSV writes isReported(), which
     is true for every main task whether anybody ticked it or not — so on a
     top-level row both files say "yes" and there is nothing to disagree about.
     One storey down the tick is the row's own. */
  loadRowsNow([
    { id: 1, name: "Stream", start: "2026-03-02", end: "2026-03-20", group: [2] },
    { id: 2, name: "Deep",   start: "2026-03-02", end: "2026-03-06" },
  ]);
  const original = A.serializeCSV(A.state.tasks);
  const mine = A.state.tasks;

  A.state.tasks = A.parseCSV(original);
  const deep = A.taskById(idOfName("Deep"));
  assert.strictEqual(A.isMainTask(deep), false, "the fixture must be a row the tick belongs to");
  deep.reported = true;
  const theirs = A.serializeCSV(A.state.tasks);
  A.state.tasks = mine;

  const m = A.buildMerge(theirs, "theirs.gantora");
  const test = m.rows.find(r => (r.mine || r.theirs).name === "Deep");
  assert.strictEqual(test.kind, "differs", "putting a row on the report is an edit like any other");
  assert(test.fields.some(f => f.key === "reported"), "named: " + test.fields.map(f => f.key).join(","));
});

/* The rule the bug broke, stated as a rule: the merge compares what the stamp
   compares, minus the two trails that are carried rather than compared. */
t("what stamps a row as edited is what the merge compares", () => {
  const compared = new Set(A.MERGE_FIELDS.map(f => f.key));
  // rowSig()'s own list, read out of the source so it cannot drift from this test
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const sig = script.slice(script.indexOf("function rowSig"), script.indexOf("function recordEdits"));
  for (const key of ["labels", "reported", "notes", "status", "baselineEnd", "emoji"])
    assert(compared.has(key), key + " stamps a row as edited but the merge cannot see it");
  assert(/normLabels\(t\.labels\)/.test(sig) && /!!t\.reported/.test(sig),
    "and rowSig still counts both, which is why the merge must");

  /* The two deliberate exceptions, named in the source so the next reader does
     not rediscover them as the bug the other two were. */
  assert(!compared.has("history") && !compared.has("dateHist"),
    "the trails are carried with a taken row, not offered as a decision");
  const fields = script.slice(script.indexOf("const MERGE_FIELDS"), script.indexOf("function mergeValue"));
  assert(/`history` and `dateHist`/.test(fields), "and the reason is written down");
});

t("a difference in spacing between two copies is not a difference in the plan", () => {
  const a = { labels: "Cutover; Risk" }, b = { labels: "Cutover;Risk" };
  const f = A.MERGE_FIELDS.find(x => x.key === "labels");
  assert.strictEqual(A.mergeValue(a, f, new Map()), A.mergeValue(b, f, new Map()),
    "normalised the same way rowSig normalises it");
});

t("rows are matched by their uid, whatever the numbers say", () => {
  const original = mergeFixture();
  const theirs = theirCopy(original);
  // my copy: I shorten Design, an hour before they touched Build
  A.taskById(idOfName("Design")).modified = "2026-08-12T08:00:00Z an1 Anna";
  row("Design").end = "2026-03-05";

  const m = A.buildMerge(theirs, "theirs.gantora");
  const kinds = {};
  for (const r of m.rows) kinds[(r.mine || r.theirs).name] = r.kind;
  assert.strictEqual(kinds["Design"], "differs", "I changed it, so the two files disagree");
  assert.strictEqual(kinds["Build"], "differs", "and they changed this one");
  assert.strictEqual(kinds["Test"], "onlyMine", "they deleted it — or I added it; the same shape");
  assert.strictEqual(kinds["Procure"], "onlyTheirs");
  eqArr(Object.values(A.mergeCounts(m)), [0, 2, 1, 1], "same, differs, only here, only there");
  // their ids are 1,2,3 and mine are 1,2,3 for different rows — the uid is what matched
  const build = m.rows.find(r => r.mine && /Build/.test(r.mine.name));
  assert.strictEqual(build.mine.uid, build.theirs.uid);
  assert.notStrictEqual(build.mine.id, build.theirs.id, "the numbers really had drifted apart");
});

t("what differs is named, field by field", () => {
  const original = mergeFixture();
  const theirs = theirCopy(original);
  const m = A.buildMerge(theirs, "theirs.gantora");
  const build = m.rows.find(r => r.mine && /Build/.test(r.mine.name));
  eqArr(build.fields.map(f => f.key), ["name"], "only the name moved");
  const same = m.rows.find(r => r.mine && r.mine.name === "Design");
  assert.strictEqual(same.kind, "same", "a row nobody touched has nothing to decide");
  assert.strictEqual(same.choice, "mine");
});

t("the newer side is suggested, and only where the stamps can say", () => {
  const original = mergeFixture();
  const theirs = theirCopy(original);
  A.taskById(idOfName("Design")).modified = "2026-08-12T08:00:00Z an1 Anna";
  row("Design").end = "2026-03-05";
  const m = A.buildMerge(theirs, "theirs.gantora");

  const design = m.rows.find(r => r.mine && r.mine.name === "Design");
  const build = m.rows.find(r => r.mine && /Build/.test(r.mine.name));
  assert.strictEqual(design.newer, "mine", "I touched Design at 08:00");
  assert.strictEqual(design.choice, "mine", "so mine is the suggestion");
  assert.strictEqual(build.newer, "theirs", "they touched Build at 09:00");
  assert.strictEqual(build.choice, "theirs");

  // a plan from before the stamps existed compares fine; it just suggests nothing
  assert.strictEqual(A.newerSide({ modified: "" }, { modified: "" }), "");
  assert.strictEqual(A.newerSide({ modified: "2026-01-01T00:00:00Z" }, { modified: "" }), "mine");
  assert.strictEqual(A.newerSide({ modified: "2026-01-01T00:00:00Z" },
                                 { modified: "2026-01-01T00:00:00Z" }), "", "the same second decides nothing");
});

t("nothing is decided for you, and the defaults never delete", () => {
  const original = mergeFixture();
  const theirs = theirCopy(original);
  const m = A.buildMerge(theirs, "theirs.gantora");
  const only = m.rows.find(r => r.kind === "onlyMine");
  assert.strictEqual(only.choice, "keep", "a row they do not have is kept: they may simply have deleted it");
  const add = m.rows.find(r => r.kind === "onlyTheirs");
  assert.strictEqual(add.choice, "add", "and a row they added is offered as an addition");
  const p = A.mergePlanned(m);
  assert.strictEqual(p.deleted, 0, "no default deletes anything");
});

ta("a merge takes what was chosen, keeps the rest, and never writes the other file", async () => {
  resetApp();
  A.setMyName("Anna");
  const original = mergeFixture();
  const theirs = theirCopy(original);
  A.taskById(idOfName("Design")).modified = "2026-08-12T08:00:00Z an1 Anna";
  row("Design").end = "2026-03-05";

  const files = { "plan.gantora": original, "theirs.gantora": theirs };
  const root = fakeRoot(files);
  A.state.rootHandle = root; A.state.dbHandle = root;
  useName("plan.gantora");
  useRef(await root.getFileHandle("plan.gantora"));

  await A.openMergePicker();
  assert(els.mergeFiles.innerHTML.includes('data-file="theirs.gantora"'), "the other file is offered");
  assert(!els.mergeFiles.innerHTML.includes('data-file="plan.gantora"'), "and a plan cannot be merged with itself");

  await A.compareWith("theirs.gantora");
  assert(A.mergeOpen(), "the report is on screen");
  assert(/1 updated, 1 added, 0 deleted/.test(els.btnMergeApply.textContent),
    "the button says what it would do: " + els.btnMergeApply.textContent);

  await A.applyMerge();
  assert(!A.mergeOpen(), "and it closes behind itself");

  assert.strictEqual(row("Build the thing").name, "Build the thing", "their newer name was taken");
  assert.strictEqual(A.parseStamp(row("Build the thing").modified).name, "Bela",
    "and their stamp travelled with it — the merge must not claim the change");
  assert.strictEqual(row("Design").end, "2026-03-05", "my newer row was kept");
  assert(A.state.tasks.some(t => t.name === "Procure"), "their new row was added");
  assert(A.state.tasks.some(t => t.name === "Test"), "and mine was not deleted");

  // the references came across as *my* numbers, not theirs
  const build = row("Build the thing");
  assert.strictEqual((A.taskById(build.preds[0]) || {}).name, "Design",
    "a predecessor points at the right row here: " + JSON.stringify(build.preds));

  assert.strictEqual(files["theirs.gantora"], theirs, "the other file was written to");
  assert(files["plan.gantora"].includes("Procure"), "and this one was saved");
  resetApp();
});

ta("a row can be let go of, and what pointed at it lets go too", async () => {
  resetApp();
  A.setMyName("Anna");
  const original = mergeFixture();
  const theirs = theirCopy(original);
  const files = { "plan.gantora": original, "theirs.gantora": theirs };
  const root = fakeRoot(files);
  A.state.rootHandle = root; A.state.dbHandle = root;
  useName("plan.gantora");
  useRef(await root.getFileHandle("plan.gantora"));

  await A.compareWith("theirs.gantora");
  // the same one call the dialog's <select> makes: delete the row only I have
  const i = A.mergeRows().findIndex(r => r.kind === "onlyMine");
  assert(i >= 0, "there is a row only this plan has");
  A.mergeAll("mine");                                   // keep my side of every difference
  A.mergeAll("skip");                                   // …take none of their new rows
  assert.strictEqual(A.setMergeChoice(i, "drop"), true); // …and let this one go
  A.renderMerge();
  assert(/0 updated, 0 added, 1 deleted/.test(els.btnMergeApply.textContent), els.btnMergeApply.textContent);

  await A.applyMerge();
  assert(!A.state.tasks.some(t => t.name === "Test"), "the row went");
  assert(!A.state.tasks.some(t => t.name === "Procure"), "and none of theirs came in");
  assert(!A.state.tasks.some(t => t.preds.length > 1 || t.preds.some(p => !A.taskById(p))),
    "nothing is left pointing at a row that is gone");
  resetApp();
});

/* Found by the owner on the first Dropbox conflict: the dialog opened, Compare
   & merge showed the two, he chose to keep his own everywhere — and Apply
   read "Nothing to apply", greyed. The only way out was Cancel and Overwrite,
   which is the same decision said more alarmingly. Against our own file,
   "keep all mine" *is* the decision: agree with their version's stamp and
   write ours over it. */
ta("in a conflict, keeping all of mine is a decision Apply carries out: my version is written over theirs", async () => {
  resetApp();
  A.setMyName("Anna");
  const original = mergeFixture();
  const mine = A.state.tasks;
  // their version of the same file, saved under us: Build renamed
  A.state.tasks = A.parseCSV(original);
  const b = A.taskById(idOfName("Build"));
  b.name = "Build (theirs)";
  b.modified = "2026-08-12T09:00:00Z bn2 Bela";
  const theirs = A.serializeCSV(A.state.tasks);
  A.state.tasks = mine;
  // and mine: Design touched
  A.taskById(idOfName("Design")).modified = "2026-08-12T08:00:00Z an1 Anna";
  row("Design").end = "2026-03-05";
  const myText = A.serializeCSV(A.state.tasks);

  const files = { "plan.gantora": theirs };
  const root = fakeRoot(files);
  A.state.rootHandle = root; A.state.dbHandle = root;
  useName("plan.gantora");
  useRef(await root.getFileHandle("plan.gantora"));
  A.state.stamp = { at: 1, text: original };          // we last agreed with the original; the disk has moved on

  await A.compareWith("plan.gantora");                // the conflict path: compared with our own file
  assert(A.mergeOpen());
  A.mergeAll("mine");
  A.renderMerge();
  assert.strictEqual(els.btnMergeApply.disabled, false, "Apply is live: keeping mine is a decision");
  assert.strictEqual(els.btnMergeApply.textContent, "Keep mine — save over theirs", "and it says what it will do");

  await A.applyMerge();
  assert(!A.mergeOpen(), "the screen closed");
  assert.strictEqual(files["plan.gantora"], myText, "my version is on disk, over theirs");
  assert.strictEqual(row("Build").name, "Build", "nothing of theirs was taken");
  assert.strictEqual(A.state.dirty, false, "and the save went through — the stamp had been brought up to date first");
  assert.strictEqual(A.state.stamp.text, myText, "we now agree with what we wrote");

  // against *another* plan, no changes chosen is still nothing to apply
  const other = { "plan.gantora": myText, "other.gantora": theirs };
  const root2 = fakeRoot(other);
  A.state.rootHandle = root2; A.state.dbHandle = root2;
  useRef(await root2.getFileHandle("plan.gantora"));
  await A.compareWith("other.gantora");
  A.mergeAll("mine"); A.mergeAll("skip"); A.renderMerge();
  assert.strictEqual(els.btnMergeApply.disabled, true);
  assert.strictEqual(els.btnMergeApply.textContent, "Nothing to apply");
  A.closeMerge();
  resetApp();
});

t("a plan with no row ids says so instead of pretending to compare", () => {
  mergeFixture();
  // an old file: every column this app appends is missing, so no uids in it
  const old = "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone|Notes\n" +
              "1|Design|4|2026-03-02||2026-03-06||||||no|\n";
  const m = A.buildMerge(old, "old.csv");
  assert(m.warn.some(w => /no row id/.test(w)), "it has to say why nothing matched: " + m.warn.join(" | "));
  assert.strictEqual(A.mergeCounts(m).onlyTheirs, 1, "and the row can still be added, just not matched");
  assert.strictEqual(A.mergeCounts(m).differs, 0);
});

console.log("\n-- tombstones: a deleted row stays deleted through a merge --");

/* D2, approved and fixed 2026-08-18. Until then a row missing on one side of a
   merge was "deleted here" or "added there" and the two were the same shape,
   so the merge kept every row it could not account for — and a row somebody
   had deleted came back with its old uid, invisible to the next merge because
   it matched again. The tombstone is the missing fact: a #DEL line per uid the
   plan has let go of. A line, not a column: a tombstone *row* would read as a
   live task in every build from before this. */

t("deleting a row writes a #DEL line; undo takes it back, redo puts it back", () => {
  loadRowsNow([
    { id: 1, name: "Design", start: "2026-03-02", end: "2026-03-06" },
    { id: 2, name: "Build", start: "2026-03-09", end: "2026-03-13" },
  ]);
  A.setMyName("Anna");
  const uid = row("Build").uid;
  assert(uid, "the fixture's rows carry uids");
  assert.strictEqual(A.tombstoneLines().length, 0, "nothing deleted yet, nothing written");

  const wasConfirm = ctx.confirm; ctx.confirm = () => true;
  try { A.deleteTasks([idOfName("Build")]); } finally { ctx.confirm = wasConfirm; }
  assert(!A.state.tasks.some(t => t.name === "Build"), "the row went");
  const tomb = A.state.tombstones[uid];
  assert(tomb, "and left a tombstone under its uid");
  assert.strictEqual(tomb.name, "Build", "carrying the name, so the merge screen can say what was deleted");
  assert.strictEqual(A.parseStamp(tomb.when).name, "Anna", "and who let it go, when");

  const out = A.serializeCSV(A.state.tasks);
  const line = out.split("\r\n").find(l => l.startsWith(A.DEL_LINE));
  assert(line, "the file carries the tombstone");
  assert.strictEqual(line.split("|")[1], uid);
  assert.strictEqual(line.split("|")[3], "Build");
  assert(A.looksLikeProjectFile(out), "and is still one of our files");
  assert(A.inspectPlanText(out).ok, "and whole, to the read guard");
  // reading it back is the same fact
  const back = A.parseTombstones(out);
  assert.strictEqual(back[uid].when, tomb.when);
  assert.strictEqual(back[uid].name, "Build");
  // and the plan's rows are untouched by the line: parseCSV never sees it
  assert.strictEqual(A.parseCSV(out).length, 1);

  assert(A.undo(), "undo");
  assert(A.state.tasks.some(t => t.name === "Build"), "the row is back");
  assert(!A.state.tombstones[uid], "and its tombstone is gone: a row that is here was not deleted");
  assert(!A.serializeCSV(A.state.tasks).includes(A.DEL_LINE), "nothing to write");
  assert(A.redo(), "redo");
  assert(!A.state.tasks.some(t => t.name === "Build"));
  assert(A.state.tombstones[uid], "deleted again, tombstone again");
});

t("a plan whose every row was deleted is still a readable plan", () => {
  loadRowsNow([{ id: 1, name: "Only", start: "2026-03-02", end: "2026-03-06" }]);
  const uid = row("Only").uid;
  const wasConfirm = ctx.confirm; ctx.confirm = () => true;
  try { A.deleteTasks([idOfName("Only")]); } finally { ctx.confirm = wasConfirm; }
  const out = A.serializeCSV(A.state.tasks);
  assert.strictEqual(A.parseCSV(out).length, 0);
  assert(A.inspectPlanText(out).ok, "a header and a #DEL line is not 'no readable rows': " + JSON.stringify(A.inspectPlanText(out)));
  assert(A.parseTombstones(out)[uid], "the deletion is still on record");
  // a #DEL line that cannot say when is not a tombstone
  assert.deepStrictEqual(Object.keys(A.parseTombstones("#DEL|abc-1-2-3||Nameless\r\n")), []);
  assert.deepStrictEqual(Object.keys(A.parseTombstones("#DEL||2026-08-12T09:00:00Z x Y|No uid\r\n")), []);
});

/* Their copy with a row *properly* deleted — through the tombstone, as this build
   deletes — rather than merely missing, as theirCopy() leaves Test. */
const theirCopyDeleting = (original, name, when) => {
  const mine = A.state.tasks, myTomb = A.state.tombstones;
  A.state.tasks = A.parseCSV(original);
  const gone = A.state.tasks.find(t => t.name === name);
  A.state.tasks = A.state.tasks.filter(t => t !== gone);
  A.recalcPlan();
  A.state.tombstones = { [gone.uid]: { when, name } };
  const text = A.serializeCSV(A.state.tasks);
  A.state.tasks = mine; A.state.tombstones = myTomb;
  return { text, uid: gone.uid };
};

t("the merge tells 'they deleted it' from 'I added it', and weighs the stamps", () => {
  const original = mergeFixture();
  const { text: theirs, uid } = theirCopyDeleting(original, "Test", "2026-08-12T09:00:00Z bn2 Bela");
  assert(theirs.includes(A.DEL_LINE + "|" + uid), "their file says they deleted Test");

  // I last touched Test before they deleted it: letting it go is the suggestion
  row("Test").modified = "2026-08-12T08:00:00Z an1 Anna";
  let m = A.buildMerge(theirs, "theirs.gantora");
  let r = m.rows.find(x => x.uid === uid);
  assert.strictEqual(r.kind, "deletedThere", "not 'only here': the file says what happened");
  assert.strictEqual(r.newer, "theirs");
  assert.strictEqual(r.choice, "drop", "their deletion is later than my last edit, so it is suggested");
  assert.strictEqual(A.mergePlanned(m).deleted, 1, "and Apply says it would delete");
  assert.strictEqual(A.mergeSideOf(r, "theirs").when, "2026-08-12T09:00:00Z bn2 Bela", "the theirs side is the tombstone");
  eqArr(Object.values(A.mergeCounts(m)), [2, 0, 0, 0], "the four counts do not count it: it has a chip of its own");
  assert.strictEqual(A.mergeDelCounts(m).deletedThere, 1);

  // I touched Test *after* they deleted it: mine is the suggestion, keep
  row("Test").modified = "2026-08-12T10:00:00Z an1 Anna";
  m = A.buildMerge(theirs, "theirs.gantora");
  r = m.rows.find(x => x.uid === uid);
  assert.strictEqual(r.newer, "mine");
  assert.strictEqual(r.choice, "keep");
  assert.strictEqual(A.mergePlanned(m).deleted, 0);

  // and a file from a build that recorded no deletions still reads as it always did
  const old = theirCopy(original);
  const rOld = A.buildMerge(old, "old.gantora").rows.find(x => x.uid === uid);
  assert.strictEqual(rOld.kind, "onlyMine", "no tombstone, no claim: kept, as before");
  assert.strictEqual(rOld.choice, "keep");
});

t("…and 'I deleted it' from 'they added it' — a deleted row stays deleted", () => {
  const original = mergeFixture();
  const theirs = A.serializeCSV(A.parseCSV(original));   // their copy: untouched
  const uid = row("Test").uid;
  // I delete Test
  const wasConfirm = ctx.confirm; ctx.confirm = () => true;
  try { A.deleteTasks([idOfName("Test")]); } finally { ctx.confirm = wasConfirm; }
  A.state.tombstones[uid].when = "2026-08-12T09:00:00Z an1 Anna";   // pinned, so the stamps can be compared

  let m = A.buildMerge(theirs, "theirs.gantora");
  let r = m.rows.find(x => x.uid === uid);
  assert.strictEqual(r.kind, "deletedHere", "not 'only there': this plan let it go");
  assert.strictEqual(r.choice, "skip", "and it stays deleted — the whole point");
  assert.strictEqual(A.mergePlanned(m).added, 0, "Apply would not bring it back");
  assert.strictEqual(A.mergeSideOf(r, "mine").name, "Test", "the mine side is the tombstone");

  // unless they touched it after I deleted it — then they have said something new about it
  const t2 = A.parseCSV(original);
  t2.find(t => t.uid === uid).modified = "2026-08-12T10:00:00Z bn2 Bela";
  const mine = A.state.tasks, myTomb = A.state.tombstones;
  A.state.tasks = t2; A.state.tombstones = {};
  const theirsLater = A.serializeCSV(A.state.tasks);
  A.state.tasks = mine; A.state.tombstones = myTomb;
  m = A.buildMerge(theirsLater, "theirs.gantora");
  r = m.rows.find(x => x.uid === uid);
  assert.strictEqual(r.newer, "theirs");
  assert.strictEqual(r.choice, "add", "offered back, since their edit is newer than my deletion");
  assert.strictEqual(A.mergePlanned(m).added, 1);

  // the choice text says what it is: not "add", but bring back / leave deleted
  assert(/Bring it back/.test(A.mergeChoiceHtml(r, 0)) && /Leave it deleted/.test(A.mergeChoiceHtml(r, 0)));
});

ta("applying it: a deletion taken carries their stamp, a row brought back drops the tombstone, and their deletions are learnt", async () => {
  resetApp();
  A.setMyName("Anna");
  const original = mergeFixture();
  const testUid = row("Test").uid, designUid = row("Design").uid;
  /* Their copy: Test deleted (by Bela, at 09:00), and a tombstone for a row
     neither of us holds any more — a deletion they learnt from a third copy. */
  const { text: theirs0 } = theirCopyDeleting(original, "Test", "2026-08-12T09:00:00Z bn2 Bela");
  const theirs = theirs0 + "#DEL|zzz-third-copy-row|2026-08-11T12:00:00Z cc3 Csaba|Something else\r\n";
  // and I deleted Design, at 08:00 — but they edited it at 10:00
  const wasConfirm = ctx.confirm; ctx.confirm = () => true;
  try {
    A.deleteTasks([idOfName("Design")]);
    A.state.tombstones[designUid].when = "2026-08-12T08:00:00Z an1 Anna";
    row("Test").modified = "2026-08-12T08:30:00Z an1 Anna";
    const t2 = A.parseCSV(theirs);
    t2.find(t => t.uid === designUid).modified = "2026-08-12T10:00:00Z bn2 Bela";
    const keepMine = A.state.tasks, keepTomb = A.state.tombstones;
    A.state.tasks = t2; A.state.tombstones = A.parseTombstones(theirs);
    const theirsText = A.serializeCSV(A.state.tasks);
    A.state.tasks = keepMine; A.state.tombstones = keepTomb;
    const mineText = A.serializeCSV(A.state.tasks);

    const files = { "plan.gantora": mineText, "theirs.gantora": theirsText };
    const root = fakeRoot(files);
    A.state.rootHandle = root; A.state.dbHandle = root;
    useName("plan.gantora");
    useRef(await root.getFileHandle("plan.gantora"));

    await A.compareWith("theirs.gantora");
    const rows = A.mergeRows();
    const rTest = rows.find(r => r.uid === testUid), rDesign = rows.find(r => r.uid === designUid);
    assert.strictEqual(rTest.kind, "deletedThere"); assert.strictEqual(rTest.choice, "drop");
    assert.strictEqual(rDesign.kind, "deletedHere"); assert.strictEqual(rDesign.choice, "add");
    assert(/0 updated, 1 added, 1 deleted/.test(els.btnMergeApply.textContent), els.btnMergeApply.textContent);

    // the bulk buttons reach both: "keep all mine" keeps my row and my deletion, "take all theirs" the reverse
    A.mergeAll("mine");   assert.strictEqual(rTest.choice, "keep"); assert.strictEqual(rDesign.choice, "skip");
    A.mergeAll("add");    assert.strictEqual(rDesign.choice, "skip", "'add all of theirs' is not about a deletion");
    A.mergeAll("theirs"); assert.strictEqual(rTest.choice, "drop"); assert.strictEqual(rDesign.choice, "add");
    A.mergeAll("mine");   A.mergeAll("newer");
    assert.strictEqual(rTest.choice, "drop"); assert.strictEqual(rDesign.choice, "add");   // theirs is newer on both
    A.renderMerge();
    assert(/Deleted there/.test(els.mergeSummary.innerHTML) && /Deleted here/.test(els.mergeSummary.innerHTML),
      "the two deletion chips are shown, since there is something to count");
    assert(!/disabled>Take the newer side/.test(els.mergeSummary.innerHTML), "the bulk buttons are live for deletions too");

    await A.applyMerge();
    assert(!A.state.tasks.some(t => t.uid === testUid), "Test went, on their say-so");
    assert.strictEqual(A.parseStamp(A.state.tombstones[testUid].when).name, "Bela",
      "and its tombstone is theirs — the deletion is not re-dated in my name");
    assert(A.state.tasks.some(t => t.uid === designUid), "Design is back");
    assert(!A.state.tombstones[designUid], "and no longer counts as deleted here");
    assert(A.state.tombstones["zzz-third-copy-row"], "the deletion they knew of is known here now too");
    const saved = files["plan.gantora"];
    assert(saved.includes(A.DEL_LINE + "|" + testUid + "|2026-08-12T09:00:00Z bn2 Bela"), "written: " + saved.split("\r\n").filter(l => l.startsWith("#DEL")).join(" / "));
    assert(saved.includes(A.DEL_LINE + "|zzz-third-copy-row|"));
    assert(!saved.includes(A.DEL_LINE + "|" + designUid), "a row that is here has no tombstone in the file");
    // and the next merge with the same file has nothing left to say about either row
    const again = A.buildMerge(theirsText, "theirs.gantora");
    assert(!again.rows.some(r => r.uid === testUid), "Test is deleted on both sides: not a row any more");
    assert.strictEqual(again.rows.find(r => r.uid === designUid).kind, "same", "Design is the same row on both");
  } finally { ctx.confirm = wasConfirm; }
  resetApp();
});

t("a deletion counts as a change by the one who made it", () => {
  const text = "ID|Activity Name|Duration|Start|Hour (Start)|End|Hour (End)|Predecessors|Team|System|Group|Milestone\r\n" +
               "#DEL|abc-1-2-3|2026-08-12T09:00:00Z bn2 Bela|Gone\r\n";
  eqArr(A.whoChanged(text, 0), ["Bela"], "the conflict dialog can name who deleted, not only who edited");
  eqArr(A.whoChanged(text, Date.parse("2026-08-12T09:00:00Z")), [], "a deletion we already knew of is not news");
});

console.log("\n-- Dropbox: the way in (P2, session-only) --");

/* A recording Dropbox: every request is logged, and answered from a script.
   Installed for one test at a time; the throwing fetch goes back afterwards. */
const fakeDropbox = (answers) => {
  const log = [];
  /* A response: JSON, or a raw body (`text`) with a Dropbox-API-Result header,
     which is how files/download answers. `headers.get` because that is what a
     real Response has, and the app reads it that way. */
  const res = (status, json, text, result) => {
    const h = result ? { "Dropbox-API-Result": JSON.stringify(result) } : {};
    return { status, ok: status >= 200 && status < 300, headers: { get: n => h[n] || null },
             text: async () => (text != null ? text : JSON.stringify(json)) };
  };
  ctx.fetch = async (url, opt) => {
    const body = opt && opt.body ? String(opt.body) : "";
    const headers = (opt && opt.headers) || {};
    log.push({ url, method: opt && opt.method, headers, body,
               arg: headers["Dropbox-API-Arg"] ? JSON.parse(headers["Dropbox-API-Arg"]) : null });
    const a = answers.find(x => url.includes(x.url) && (!x.when || x.when(body, headers)));
    if (!a) return res(404, { error_summary: "no script for " + url });
    const json = typeof a.json === "function" ? a.json(body, headers) : a.json;
    return res(a.status || 200, json, a.text, a.result);
  };
  return log;
};
const noNetwork = () => { ctx.fetch = () => { throw new Error("the suite makes no network calls"); }; };
const opened = [];
ctx.window.open = (url) => { opened.push(url); };
const TOKEN_OK = { access_token: "at-1", refresh_token: "rt-1", expires_in: 14400,
                   scope: "account_info.read files.metadata.read files.content.read files.content.write sharing.read" };
const ACCOUNT = { email: "anna@example.com", name: { display_name: "Anna Kovacs" },
                  root_info: { root_namespace_id: "111", home_namespace_id: "111" } };
const formOf = body => Object.fromEntries(body.split("&").map(kv => kv.split("=").map(decodeURIComponent)));

t("base64url by hand agrees with Node's, and the form body with URLSearchParams", () => {
  for (const n of [0, 1, 2, 3, 4, 31, 32, 33, 64]) {
    const bytes = require("crypto").randomBytes(n);
    assert.strictEqual(A.b64url(bytes), bytes.toString("base64url"), "length " + n);
  }
  assert.strictEqual(A.formBody({ a: "1 2", "b&c": "x=y&z", code: "ÁÉ" }), new URLSearchParams({ a: "1 2", "b&c": "x=y&z", code: "ÁÉ" }).toString().replace(/\+/g, "%20"));
});

ta("step 1: a PKCE challenge, offline access, no redirect — and the tab is opened", async () => {
  A.DropboxAuth.disconnect();
  opened.length = 0;
  const url = await A.DropboxAuth.begin();
  assert.strictEqual(opened[0], url, "window.open was handed the same URL");
  assert(url.startsWith(A.DBX_AUTH_URL + "?"), url);
  const q = Object.fromEntries(url.slice(url.indexOf("?") + 1).split("&").map(kv => kv.split("=").map(decodeURIComponent)));
  assert.strictEqual(q.client_id, A.DBX_APP_KEY);
  assert.strictEqual(q.response_type, "code");
  assert.strictEqual(q.token_access_type, "offline", "without it there is no refresh token — measured");
  assert.strictEqual(q.code_challenge_method, "S256");
  assert(/^[A-Za-z0-9_-]{43}$/.test(q.code_challenge), "a base64url SHA-256: " + q.code_challenge);
  assert(!("redirect_uri" in q), "no redirect: a file:// page has no address to come back to");
  assert(!("scope" in q), "the app's console decides the scopes; the answer is checked instead");
  // a second begin() makes a fresh challenge — the old code would not match it
  const again = await A.DropboxAuth.begin();
  assert.notStrictEqual(again, url);
});

ta("step 2: the code buys the tokens; the account is read; nothing lands on state or in storage", async () => {
  A.DropboxAuth.disconnect();
  await A.DropboxAuth.begin();
  const log = fakeDropbox([
    { url: "/oauth2/token", json: TOKEN_OK },
    { url: "users/get_current_account", json: ACCOUNT },
  ]);
  try {
    const acct = await A.DropboxAuth.finish("  the-code  ");
    assert.strictEqual(acct.email, "anna@example.com");
    assert.strictEqual(acct.name, "Anna Kovacs");
    assert.strictEqual(acct.rootNs, "111");
    assert(A.DropboxAuth.connected());
    assert.strictEqual(A.DropboxAuth.label(), "Anna Kovacs · anna@example.com");
    assert.strictEqual(await A.DropboxAuth.token(), "at-1");

    assert.strictEqual(log.length, 2, "two requests: the exchange and the account");
    const ex = log[0];
    assert.strictEqual(ex.url, A.DBX_TOKEN_URL);
    assert.strictEqual(ex.headers["Content-Type"], "application/x-www-form-urlencoded");
    const f = formOf(ex.body);
    assert.strictEqual(f.code, "the-code", "trimmed");
    assert.strictEqual(f.grant_type, "authorization_code");
    assert.strictEqual(f.client_id, A.DBX_APP_KEY);
    assert(/^[A-Za-z0-9_-]{86}$/.test(f.code_verifier), "the verifier from step 1: " + f.code_verifier);
    // the challenge that went out was the SHA-256 of this verifier
    const digest = require("crypto").createHash("sha256").update(f.code_verifier).digest("base64url");
    assert(opened[opened.length - 1].includes("code_challenge=" + digest), "challenge and verifier are a pair");
    const acc = log[1];
    assert.strictEqual(acc.url, A.DBX_API_URL + "users/get_current_account");
    assert.strictEqual(acc.headers.Authorization, "Bearer at-1");
    /* Contract changed 2026-08-23, with the first live Android round: an
       argument-less endpoint sends JSON `null` WITH its Content-Type. The old
       bare POST (no body, no header) was the desktop's measured shape, but
       CapacitorHttp's Android wire mangles a bodiless POST into a 400 — and
       JSON null with the header is the example Dropbox's own HTTP docs give
       for get_current_account, so it is the one shape both platforms send
       intact. (A Content-Type with an EMPTY body is still a 500 — `null` is
       a body, four bytes of valid JSON, not an empty one.) */
    assert.strictEqual(acc.headers["Content-Type"], "application/json");
    assert.strictEqual(acc.body, "null", "an argument-less endpoint sends JSON null, never an empty body");

    // where the tokens are NOT
    assert(!JSON.stringify(A.state).includes("rt-1") && !JSON.stringify(A.state).includes("at-1"), "nothing on state");
    for (let i = 0; ; i++) {   // the fake localStorage is a Map behind getItem; walk what the app could have written
      const k = ["ppp.dbx", "ppp.dropbox", "dbx", "dropbox"][i]; if (!k) break;
      assert.strictEqual(ctx.localStorage.getItem(k), null, "nothing under " + k);
    }
    assert(!A.dbxTokenStore.has(), "nothing is kept unless asked for — connecting alone stores nothing");
  } finally { noNetwork(); }
});

ta("a wrong or used code, or a token missing a permission, says so and leaves nothing behind", async () => {
  A.DropboxAuth.disconnect();
  await assert.rejects(A.DropboxAuth.finish("abc"), /Open Dropbox first/, "no step 1, no exchange");
  await A.DropboxAuth.begin();
  await assert.rejects(A.DropboxAuth.finish("   "), /Paste the code/);
  fakeDropbox([{ url: "/oauth2/token", status: 400, json: { error: "invalid_grant", error_description: "code has already been used" } }]);
  try {
    await assert.rejects(A.DropboxAuth.finish("used"), /did not accept the code \(code has already been used\)/);
    assert(!A.DropboxAuth.connected());
    // a token without one of the five scopes would fail later, in a way that names nothing — refused now
    fakeDropbox([{ url: "/oauth2/token", json: { ...TOKEN_OK, scope: "account_info.read files.metadata.read" } }]);
    await A.DropboxAuth.begin();
    await assert.rejects(A.DropboxAuth.finish("ok"), /missing permission\(s\): files\.content\.read, files\.content\.write, sharing\.read/);
    assert(!A.DropboxAuth.connected(), "and it is not half-connected");
    // and one with no refresh token would be a four-hour session pretending to be a connection
    fakeDropbox([{ url: "/oauth2/token", json: { ...TOKEN_OK, refresh_token: undefined } }]);
    await A.DropboxAuth.begin();
    await assert.rejects(A.DropboxAuth.finish("ok"), /no refresh token/);
    assert(!A.DropboxAuth.connected());
  } finally { noNetwork(); }
});

ta("the access token is renewed before it runs out, and a dead refresh token ends the session", async () => {
  A.DropboxAuth.disconnect();
  await A.DropboxAuth.begin();
  // an access token that is already inside the renewal window
  const short = { ...TOKEN_OK, expires_in: 60 };
  let log = fakeDropbox([
    { url: "/oauth2/token", when: b => /authorization_code/.test(b), json: short },
    { url: "/oauth2/token", when: b => /refresh_token/.test(b), json: { access_token: "at-2", expires_in: 14400 } },
    { url: "users/get_current_account", json: ACCOUNT },
  ]);
  try {
    await A.DropboxAuth.finish("code");
    // finish() itself already had to renew to read the account — the token endpoint was asked twice
    const grants = log.filter(r => r.url === A.DBX_TOKEN_URL).map(r => formOf(r.body).grant_type);
    eqArr(grants, ["authorization_code", "refresh_token"], "renewed before the account call, since 60 s is inside the window");
    const rf = log.find(r => /refresh_token/.test(r.body));
    assert.strictEqual(formOf(rf.body).refresh_token, "rt-1");
    assert.strictEqual(formOf(rf.body).client_id, A.DBX_APP_KEY, "PKCE apps send the key, no secret");
    assert.strictEqual(await A.DropboxAuth.token(), "at-2", "and the fresh token is the one handed out");
    assert.strictEqual(log.filter(r => r.url === A.DBX_TOKEN_URL).length, 2, "token() did not renew again: at-2 is good for four hours");
    assert(A.DropboxAuth.connected(), "a refresh grant that repeats no refresh token does not lose the one we have");

    // the refresh token revoked on Dropbox's side: 401 → forget everything, say so
    A.DropboxAuth.disconnect();
    await A.DropboxAuth.begin();
    log = fakeDropbox([
      { url: "/oauth2/token", when: b => /authorization_code/.test(b), json: short },
      { url: "/oauth2/token", when: b => /refresh_token/.test(b), status: 401, json: { error: "invalid_grant" } },
    ]);
    await assert.rejects(A.DropboxAuth.finish("code"), /would not renew the session \(HTTP 401\)/);
    assert(!A.DropboxAuth.connected(), "forgotten: retrying would loop");
    await assert.rejects(A.DropboxAuth.token(), /not connected/);
  } finally { noNetwork(); }
});

ta("resume(): nothing handed over, nothing resumed — silently", async () => {
  A.DropboxAuth.disconnect();
  assert.strictEqual(await A.DropboxAuth.resume(null), false);
  assert.strictEqual(await A.DropboxAuth.resume({}), false);
  assert(!A.DropboxAuth.connected());
  /* The store's contract: is there one, unlock it with a password, keep one,
     forget it — plus the two the native store needs and this one satisfies for
     free (M4b slice 0). `settled`/`ready()` are the seam's answer to a Keychain
     lookup that cannot be synchronous while has() must be. */
  eqArr(Object.keys(A.dbxTokenStore).sort(), ["clear", "has", "key", "needs", "ready", "save", "settled", "unlock"]);
  assert.strictEqual(A.dbxTokenStore.needs, "password", "desktop's one value — the mobile plugin is a second value for the same seam");
  assert.strictEqual(A.dbxTokenStore.settled, true, "localStorage is synchronous: this store is settled before anything can ask");
  assert.strictEqual(await A.dbxTokenStore.ready(), true, "and its one asynchronous moment has nothing to do");
});

/* Every filter said "Team: all" on a Hungarian screen until 2026-08-20, and on
   a German and a French one too — the option text was built from literals that
   never reached trans(). Daniel found it on the phone; it was never a phone
   bug. */
/* 🔴 This entry began on 2026-08-20 as "In Progress + Upcoming" and was widened
   on 08-21 to **Focus — everything not finished and not cancelled**, the same
   word the cockpit uses for the same idea.

   Why the widening was right, and worth keeping in the test: the old pair
   quietly dropped overdue, blocked and on-hold work. Work that is LATE is
   exactly the work somebody looking for focus needs to see, and a filter that
   hid it was answering a narrower question than the one it appeared to. */
t("Status: Focus — everything still in the air, including what is late", () => {
  /* Dated relative to today, like every other health fixture: hard-coded dates
     would start passing for the wrong reason the moment the calendar moved. */
  const d = n => A.fmtDate(A.addDays(new Date(), n));
  loadRowsNow([
    { id: 1, name: "Running",     start: d(-2), end: d(2) },
    { id: 2, name: "Starts soon", start: d(10), end: d(12) },
    { id: 3, name: "Finished",    start: d(-9), end: d(-8) },
    { id: 4, name: "Late",        start: d(-9), end: d(-5) },
    { id: 5, name: "Dropped",     start: d(1),  end: d(3) },
  ]);
  A.setTaskStatus(row("Running"), "In Progress");
  A.setTaskStatus(row("Finished"), "Done");
  A.setTaskStatus(row("Dropped"), "Cancelled");
  const names = () => A.displayTasks().map(r => r.t.name).sort();
  try {
    A.state.filterHealth = "focus";
    eqArr(names(), ["Late", "Running", "Starts soon"],
      "🔴 the late one is IN — that is the whole point of the widening");
    A.state.filterHealth = "progress";
    eqArr(names(), ["Running"], "each bucket on its own still answers only its own question");
    A.state.filterHealth = "cancelled";
    eqArr(names(), ["Dropped"], "…and cancelled work is still reachable when it is asked for");
    A.state.filterHealth = "";
    assert.strictEqual(names().length, 5);

    /* Stated as a subtraction, so a bucket added later joins Focus by itself:
       the only way out of it is to be terminal, decided once. */
    assert(!A.HEALTH_SETS.focus.includes("done") && !A.HEALTH_SETS.focus.includes("cancelled"),
      "the two terminal states are the only ones left out");
    eqArr(A.HEALTH_SETS.focus.slice().sort(),
      A.HEALTH_KEYS.filter(k => k !== "done" && k !== "cancelled").sort(),
      "and everything else is in, without being listed by hand");
    assert(A.HEALTH_FILTER_KEYS.includes("focus") && A.HEALTH_FILTER_KEYS.includes("overdue"),
      "the dropdown keeps its buckets AND accepts the set");

    // and it is offered second, not buried under seven buckets
    A.refreshFilterSelects();
    const opts = [...els.filterHealth.innerHTML.matchAll(/value="([^"]*)"/g)].map(m => m[1]);
    assert.strictEqual(opts[0], "", "…: all first");
    assert.strictEqual(opts[1], "focus", "then the one most people want");

    /* A view saved before the rename still knows what it was for. */
    A.state.filterHealth = "active";
    A.refreshFilterSelects();
    assert.strictEqual(A.state.filterHealth, "focus", "the old key is carried across, not reset");
  } finally { A.state.filterHealth = ""; }
});

/* ================= The filter chips ========================================
   gantora-szurok-ujraterv-brief.md, approved 2026-08-22, from three sentences
   of Daniel's: a filter need not be full width once chosen, only while
   choosing; the little ⌄ is always being hunted for; and there is a needless
   extra row nearly everywhere. The six <select>s stay in the DOM holding the
   value and the list — this draws a second face on them, the way the plan
   switcher does, so nothing else in the app had to change. */
console.log("\n-- the filter chips: quiet at rest, the value when it matters --");

const chipFixture = () => {
  loadRowsNow([
    { id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", team: "Bontás", labels: ["risk"] },
    { id: 2, name: "B", start: "2026-03-02", end: "2026-03-03", team: "Burkolók" },
  ]);
  for (const f of A.FILTER_CHIPS) { A.state[f.id] = f.all === "0" ? 0 : ""; els[f.id].style.display = ""; }
  A.state.filterLevel = 0;
  A.refreshFilterSelects();
};

/* 🔴 Daniel's 08-23 round, findings 3 and 4. */
t("the board's scope is a chip in the same flow, not a row of its own", () => {
  const was = A.myLang();
  try {
    A.setLang("en");
    loadRowsNow([
      { id: 1, name: "Wave one", start: "2026-03-02", end: "2026-03-09", group: [2] },
      { id: 2, name: "A", start: "2026-03-02", end: "2026-03-03" },
    ]);
    A.refreshDashScope();
    const html = A.syncDashChips();
    assert(/data-filter="dashGroup"/.test(html), "the scope wears a chip: " + html);
    assert(/>Scope</.test(html), "…the head word at rest, with no “Scope:” label beside it");
    assert(!/: all/i.test(html), "and no “: all”");

    A.setFilter("dashGroup", "Wave one");
    const on = A.syncDashChips();
    assert(/Scope: Wave one/.test(on), "chosen, it says which: " + on);
    assert(/data-clear="dashGroup"/.test(on), "and offers the way back");
    A.setFilter("dashGroup", "");
  } finally { A.setLang(was || "en"); }
});

t("the three strings that were English on a Hungarian screen are not any more", () => {
  const was = A.myLang();
  try {
    A.setLang("hu");
    loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", resp: "Anna:4" }]);
    A.refreshDashScope();
    assert(!/Whole plan/.test(els.dashGroup.innerHTML), "the board's “Whole plan”: " + els.dashGroup.innerHTML);

    /* The Depth chip wears this one, so it was the most visible of them. */
    A.state.groupLevel = A.GROUP_LEVELS;
    assert(!/everything/.test(A.groupLvlLabel(A.GROUP_LEVELS)), "“Lvl N · everything”: " + A.groupLvlLabel(A.GROUP_LEVELS));
    assert(!/main groups/.test(A.groupLvlLabel(1)), "…and level 1: " + A.groupLvlLabel(1));

    A.state.view = "res";
    A.renderResources();
    assert(!/task\(s\)/.test(els.resLeftBody.innerHTML),
      "the resource view's “N task(s) · N h”: " + els.resLeftBody.innerHTML.slice(0, 200));
    /* And the count carries its own plural: a bracketed "(s)" is an English
       habit, not a translation. */
    assert.notStrictEqual(A.trans("{0} task", 1), A.trans("{0} tasks", 2), "one and many are different words");
    A.state.view = "table";
  } finally { A.setLang(was || "en"); }
});

t("at rest a chip is the head word alone — no “: all”, and no chevron anywhere", () => {
  const was = A.myLang();
  try {
    A.setLang("en");
    chipFixture();
    const html = A.syncFilterChips();
    for (const f of A.FILTER_CHIPS) {
      assert(new RegExp('data-filter="' + f.id + '"').test(html), f.id + " has no chip");
      assert.strictEqual(A.filterChipText(f), A.trans(f.head), f.id + " reads: " + A.filterChipText(f));
    }
    assert(!/: all/i.test(html), "🔴 “: all” is what the resting chip exists to stop saying: " + html);
    /* Daniel: „A lenyitó gombot mindig keresem." The answer is not a bigger
       chevron, it is no chevron — the whole chip is the target. */
    assert(!/[⌄▾▼⇕⌃]/.test(html), "🔴 a chevron is back in the filter row: " + html);
    assert(!/<select/.test(html), "the chips are buttons, not boxes");
  } finally { A.setLang(was || "en"); }
});

t("a chosen filter wears its value and an ✕, and the ✕ puts it back", () => {
  const was = A.myLang();
  try {
    A.setLang("en");
    chipFixture();
    A.setFilter("filterTeam", "Bontás");
    assert.strictEqual(A.state.filterTeam, "Bontás", "the value went through");
    assert.strictEqual(els.filterTeam.value, "Bontás", "…and the hidden select is still the truth");
    const on = A.syncFilterChips();
    assert(/Team: Bontás/.test(on), "the chip says what it is doing: " + on);
    assert(/data-clear="filterTeam"/.test(on), "and offers the way out");
    assert(/class="fChip on"/.test(on), "…and looks different while it is on");

    /* The level filter is not offered in the list — the list shows the
       hierarchy itself — so there is no chip for it there either. A chip for a
       filter you cannot use is a chip that has to be explained. */
    assert(!/data-filter="filterLevel"/.test(A.syncFilterChips()), "no level chip in the list view");

    /* On a chart, where it IS offered: its options do not carry the head word,
       so the chip puts it in front. Every active chip says what KIND of thing
       is narrowing the view, not just the value. */
    const wasView = A.state.view;
    A.state.view = "gantt";
    A.setFilter("filterLevel", String(A.LEVEL_LEAF));
    assert(/Level: /.test(A.syncFilterChips()), "the level chip names itself: " + A.syncFilterChips());
    A.setFilter("filterLevel", "0");
    A.state.view = wasView;

    A.setFilter("filterTeam", A.filterChipOf("filterTeam").all);
    assert.strictEqual(A.state.filterTeam, "", "cleared");
    assert(!/data-clear="filterTeam"/.test(A.syncFilterChips()), "and the ✕ went with it");
  } finally { A.setLang(was || "en"); }
});

t("the sheet offers “All” first, ticks what is chosen, and a second press closes it", () => {
  const was = A.myLang();
  try {
    A.setLang("en");
    chipFixture();
    A.setFilter("filterTeam", "Burkolók");
    assert.strictEqual(A.openFilterSheet("filterTeam", els.fChips), true, "it opened");
    const html = els.fSheet.innerHTML;
    /* "All" as the words, not "Team: all" — that reads like a value, and this
       row is the absence of one. */
    assert(/data-pick=""[^>]*>All</.test(html), "“All” leads: " + html);
    assert(/data-pick="Burkolók"[^>]*class="on"/.test(html), "the chosen one is ticked: " + html);
    assert(/Bontás/.test(html), "and the other values are all there");

    assert.strictEqual(A.openFilterSheet("filterTeam", els.fChips), false, "a second press on the same chip closes it");
    assert.strictEqual(A.fSheetOpen(), false);
    A.setFilter("filterTeam", "");
  } finally { A.setLang(was || "en"); }
});

/* 🔴 Yesterday's lesson, applied before it could bite twice (design-spec
   §13.8): anything anchored to an element must not outlive the page it is
   anchored to. The chip row is rebuilt per view — the level filter is not even
   offered in the list — so a sheet left open would be asking about a filter
   that is no longer on screen. */
t("the sheet does not outlive the view it was opened on, and Escape closes it", () => {
  chipFixture();
  const wasView = A.state.view;
  A.openFilterSheet("filterTeam", els.fChips);
  assert(A.fSheetOpen(), "it opened");
  A.setView("gantt");
  assert(!A.fSheetOpen(), "🔴 the sheet outlived the view it hung off");

  A.openFilterSheet("filterTeam", els.fChips);
  keyPress("Escape");
  assert(!A.fSheetOpen(), "Escape closes it, like every other panel on the bar");
  A.setView(wasView);
});

t("a long list gets a box to narrow it; a short one does not", () => {
  chipFixture();
  const short = A.filterSheetHtml("filterTeam", "");
  assert(!/id="fSheetFind"/.test(short), "two teams do not need searching");

  loadRowsNow(Array.from({ length: A.FSHEET_FIND_AT + 2 }, (_, i) =>
    ({ id: i + 1, name: "T" + i, start: "2026-03-02", end: "2026-03-03", team: "Team " + i })));
  A.state.filterTeam = "";
  A.refreshFilterSelects();
  const long = A.filterSheetHtml("filterTeam", "");
  assert(/id="fSheetFind"/.test(long), "a list you scroll gets one");
  const narrowed = A.filterSheetHtml("filterTeam", "team 3");
  assert(/Team 3/.test(narrowed) && !/Team 5/.test(narrowed), "and it narrows: " + narrowed);
  assert(!/data-pick/.test(A.filterSheetHtml("filterTeam", "zzz")), "nothing matching says so rather than showing an empty panel");
});

/* 🔴 Filters brief §6/5. The option texts carry the head word because a native
   select shows nothing but the chosen one; the sheet has a title over the list,
   and rows that repeat it say the same thing twice — "Csapat" over a column of
   "Csapat: Anna" (Daniel, 08-23: "a sorban csak az érték álljon"). */
t("the sheet's rows say the value, since the title already said the question", () => {
  chipFixture();
  const sheet = A.filterSheetHtml("filterTeam", "");
  const head = (sheet.match(/class="fsHead">([^<]*)</) || [])[1];
  assert(head, "the sheet is titled with the head word: " + sheet);
  const rows = [...sheet.matchAll(/data-pick="[^"]*"[^>]*>([^<]*)</g)].map(m => m[1]);
  assert(rows.length > 1, "there are rows to check: " + sheet);
  for (const r of rows) assert(!r.startsWith(head + ":"), "a row repeats the title: " + r);
  /* …and the chip is the other way about on purpose: it stands alone on a bar
     with nothing above it to say what kind of thing is narrowing the view. */
  A.state.filterTeam = "Bontás";
  A.refreshFilterSelects();
  els.filterTeam.value = "Bontás";
  const chip = A.filterChipText(A.filterChipOf("filterTeam"));
  assert(chip.startsWith(head + ":"), "the chip still names its kind: " + chip);
  A.state.filterTeam = ""; els.filterTeam.value = ""; A.refreshFilterSelects();
});

t("the chips speak the app's language too, heads and values alike", () => {
  const was = A.myLang();
  try {
    for (const [lang, head] of [["hu", "Csapat"], ["de", "Team"], ["fr", "Équipe"]]) {
      A.setLang(lang);
      chipFixture();
      assert.strictEqual(A.filterChipText(A.filterChipOf("filterTeam")), head, lang + " reads: " + A.filterChipText(A.filterChipOf("filterTeam")));
      A.setFilter("filterTeam", "Bontás");
      assert(A.filterChipText(A.filterChipOf("filterTeam")).includes("Bontás"),
        lang + ": a value keeps its own spelling behind the translated head");
      A.setFilter("filterTeam", "");
    }
  } finally { A.setLang(was || "en"); }
});

/* The search is a chip as well, and the only one allowed to grow — but a box
   with a term in it must never fold away, or the view is filtered by something
   invisible. */
/* 🔴 Slice 2's acceptance is a MEASUREMENT, and it is measured by adding the
   widths up — the lesson the top bar cost three reports to learn (design-spec
   §13.4): "it looks like it fits" is not a check, and the stub DOM has no
   layout to ask. These are estimates, deliberately generous ones, so the test
   fails on a real regression (a “: all” creeping back, a chevron, a filter
   whose resting chip wears its value) rather than on a font metric.

   The budget is the brief's: tablet ONE row, phone at most TWO, at rest, in
   every language. French is the mercy-less one — Étiquette, Personne. */
const CHIP_PAD = 28 + 3;            // 14px each side, plus the 1.5px borders
const CHIP_CH  = 8.2;               // a generous per-character width at 15px semibold
const CHIP_GAP = 8;
const CHIP_SQ  = 44;                // the 🔍 chip, one --ctl-h square
/* The phone gives four pixels of padding back on each side and two of gap —
   room handed back rather than a limit imposed, which is the top bar's lesson
   (design-spec §13.4). */
const CHIP_PAD_PHONE = 20 + 3, CHIP_GAP_PHONE = 6, CHIP_CH_PHONE = 7.7;   // 14px, not 15
const chipRowWidth = (phone) => {
  const pad = phone ? CHIP_PAD_PHONE : CHIP_PAD, gap = phone ? CHIP_GAP_PHONE : CHIP_GAP;
  const ch = phone ? CHIP_CH_PHONE : CHIP_CH;
  const html = A.syncFilterChips();
  const texts = [...html.matchAll(/class="fTxt">([^<]*)</g)].map(m => m[1]);
  const w = texts.reduce((s, t) => s + pad + t.length * ch, 0) + CHIP_SQ;
  return { w: w + gap * texts.length, n: texts.length, texts };
};

/* 🔴 View brief §5.2, and it is a measurement: on the Design view the control
   block must be at most THREE rows at rest on a 390px phone — the chips in at
   most two, plus one shared line for the zoom and the ☆. It was five or more,
   because the Lvl picker took a full-width row, the Drift another and the ☆ a
   third. */
t("the Design view opens with at most three rows of controls on a phone", () => {
  const was = A.myLang();
  const wasView = A.state.view;
  try {
    for (const lang of ["en", "hu", "de", "fr"]) {
      A.setLang(lang);
      chipFixture();
      /* The Design view is where all of them apply at once: six filters, the
         Depth chip and the Drift switch. */
      A.state.view = "gantt";
      els.groupLvlBox.style.display = "flex";
      els.driftBox.style.display = "flex";
      const { w, n, texts } = chipRowWidth(true);
      assert.strictEqual(n, A.FILTER_CHIPS.length + A.EXTRA_CHIPS.length,
        lang + ": the Design view is missing a chip — " + texts.join(" · "));
      const usable = 390 - 20;
      const rows = Math.ceil(w / usable);
      assert(rows <= 2, lang + ": 🔴 the chips need " + rows + " phone rows (" + Math.round(w) + "px) — " + texts.join(" · "));
      /* …and the third row is the zoom and the ☆ together, which the phone
         block above asserts. Three in total, as the brief asks. */
    }
  } finally { A.setLang(was || "en"); A.state.view = wasView; }
});

t("at rest the chips fit one tablet row and at most two phone rows, in all four languages", () => {
  const was = A.myLang();
  const wasView = A.state.view;
  try {
    A.state.view = "gantt";                       // the view that offers all six
    const worst = [];
    for (const lang of ["en", "hu", "de", "fr"]) {
      A.setLang(lang);
      chipFixture();
      A.state.view = "gantt";
      const { w, n, texts } = chipRowWidth();
      assert.strictEqual(n, A.FILTER_CHIPS.length, lang + ": a filter lost its chip");
      /* 820px is the tablet bar's own breakpoint, less the bar's padding. */
      assert(w <= 820 - 20, lang + ": 🔴 " + Math.round(w) + "px will not make one tablet row — " + texts.join(" · "));
      /* Two phone rows means the row may be up to twice the usable width; the
         chips wrap, so this is the honest form of "at most two". */
      assert(w <= (393 - 20) * 2, lang + ": 🔴 " + Math.round(w) + "px is a third phone row — " + texts.join(" · "));
      worst.push(lang + " " + Math.round(w));
    }
    /* Printed, because the number is the point: a later change that eats the
       headroom should be visible in the log before it is visible on a phone. */
    console.log("     resting chip row: " + worst.join(" · ") + " px");
  } finally { A.setLang(was || "en"); A.state.view = wasView; }
});

t("the search folds to an icon, and refuses to fold away over a live term", () => {
  chipFixture();
  A.state.search = "";
  assert.strictEqual(A.syncSearchChip(false), false, "at rest it is the magnifier alone");
  assert.strictEqual(A.syncSearchChip(true), true, "asked for, it takes the row");
  A.state.search = "tile";
  assert.strictEqual(A.syncSearchChip(false), true, "🔴 a live search must stay visible");
  A.state.search = "";
});

t("the filter dropdowns speak the app's language, heads and values alike", () => {
  const was = A.state.lang;
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", team: "Bontás", labels: ["risk"] }]);
  try {
    A.setLang("hu");
    A.refreshFilterSelects();
    assert(/Csapat: mind/.test(els.filterTeam.innerHTML), "the head is translated AND the 'all': " + els.filterTeam.innerHTML);
    assert(/Csapat: Bontás/.test(els.filterTeam.innerHTML), "and a value keeps its own spelling behind the translated head");
    assert(/Állapot: mind|Státusz: mind/.test(els.filterHealth.innerHTML), els.filterHealth.innerHTML);
    assert(!/Status: all|Label: all|Level: all/.test(
      els.filterHealth.innerHTML + els.filterLabel.innerHTML + els.filterLevel.innerHTML),
      "and no English is left behind in any of them");
    assert(/Csak feladatok/.test(els.filterLevel.innerHTML), "the level list too: " + els.filterLevel.innerHTML);

    A.setLang("de");
    A.refreshFilterSelects();
    assert(/Team: alle/.test(els.filterTeam.innerHTML), els.filterTeam.innerHTML);
    assert(/Etikett: alle/.test(els.filterLabel.innerHTML), els.filterLabel.innerHTML);

    A.setLang("en");
    A.refreshFilterSelects();
    assert(/Team: all/.test(els.filterTeam.innerHTML), "and English is still English");
  } finally { A.setLang(was || "en"); }
});

/* ---- M4b/4: the same four operations, kept by the phone ----
   The seam's second implementation. These run against a stub GantoraSecret, so
   what they prove is the JS half: that the device decides `needs`, that the
   three failures which are not faults are told apart by code, and that nothing
   here needs the page to know which store it is talking to. */
function fakeSecret(over) {
  const box = Object.assign({ value: null, state: { has: false, biometry: "face", deviceLock: true }, calls: [] }, over || {});
  box.api = {
    async state() { box.calls.push("state"); if (box.throwOn === "state") throw new Error("bridge"); return box.state; },
    async save(o) { box.calls.push("save"); box.value = o.value; box.state.has = true; },
    async unlock(o) {
      box.calls.push("unlock:" + (o && o.reason));
      if (box.rejectWith) { const e = new Error("no"); e.code = box.rejectWith; throw e; }
      return { value: box.value };
    },
    clear() { box.calls.push("clear"); box.value = null; box.state.has = false; },
  };
  return box;
}
function withSecret(box, fn) {
  const was = ctx.window.Capacitor;
  ctx.window.Capacitor = { Plugins: { GantoraSecret: box.api } };
  const store = A.dbxTokenStoreNative;
  store.settled = false; store.kept = false; store.needs = "session";
  return Promise.resolve(fn(store)).finally(() => { ctx.window.Capacitor = was; });
}

ta("the device decides what the dialog asks for — and state() is asked once, at boot", async () => {
  const box = fakeSecret();
  await withSecret(box, async store => {
    assert.strictEqual(store.needs, "session", "before the answer, nothing is promised");
    assert.strictEqual(store.has(), false);
    await store.ready();
    assert.strictEqual(store.needs, "biometry", "a lock and a face: one touch is the way in");
    assert(store.settled, "and the seam can stop redrawing the dialog");
    await store.ready();
    eqArr(box.calls, ["state"], "asked once — a second launch-time prompt is what state() exists to avoid");
  });

  // a lock, no biometry: it simply opens by itself
  await withSecret(fakeSecret({ state: { has: true, biometry: "none", deviceLock: true } }), async store => {
    await store.ready();
    assert.strictEqual(store.needs, "silent");
    assert.strictEqual(store.has(), true, "and there is something kept to open");
  });

  /* 🔴 No screen lock is the one case the Keychain cannot answer: a
     WhenPasscodeSet item cannot be created without a passcode, and a weaker
     class would leave the token unguarded. Daniel, 2026-08-20: "ha nem használ
     biometriát akkor … mindig újra be kell lépnie? Ha így van, adjunk neki
     esélyt." The chance is the desktop's own construction — a password. */
  await withSecret(fakeSecret({ state: { has: false, biometry: "none", deviceLock: false } }), async store => {
    await store.ready();
    assert.strictEqual(store.needs, "password", "the platform protects nothing here, so a password does");
    await store.save({ refresh: "rt-nolock" }, "correct horse battery");
    assert.strictEqual(store.has(), true, "and it is kept — the point of the fallback");
    assert.strictEqual(box.value, null, "…in the password store, NOT in a Keychain that would refuse it");
    const back = await store.unlock("correct horse battery");
    assert.strictEqual(back.refresh, "rt-nolock");
    await assert.rejects(store.unlock("wrong one entirely"), e => e.wrong === true,
      "and a wrong password fails as a wrong password, not as a broken device");
    store.clear();
    assert.strictEqual(store.has(), false);
  });

  // the bridge itself refusing promises nothing: "session" is the honest answer
  await withSecret(fakeSecret({ throwOn: "state" }), async store => {
    await store.ready();
    assert.strictEqual(store.needs, "session");
    assert(store.settled, "settled either way, or the dialog waits for ever");
  });
});

/* 🔴 "It saved" is not the same as "it is there". SecItemAdd reported success
   three times over on Megatron and the next launch found nothing, and nothing
   in the write alone could tell those apart — so the write reads itself back
   and the answer travels with it. */
ta("a save that cannot be read back is not a save, and says so at once", async () => {
  const box = fakeSecret();
  box.api.save = async (o) => { box.calls.push("save"); box.value = o.value; return { check: -25300 }; };
  await withSecret(box, async store => {
    await store.ready();
    await assert.rejects(store.save({ refresh: "rt" }), e => e.code === "unverified" && /-25300/.test(e.message),
      "the person hears it while looking at the button they pressed, not by silence next launch");
    assert.strictEqual(store.has(), false, "and nothing claims to be kept");
    assert.strictEqual(store.code, -25300, "the raw code is kept for the dialog to show");
  });

  // …and the two answers that mean "it is there" are both accepted
  for (const ok of [0, -25308]) {
    const good = fakeSecret();
    good.api.save = async (o) => { good.value = o.value; return { check: ok }; };
    await withSecret(good, async store => {
      await store.ready();
      await store.save({ refresh: "rt" });
      assert.strictEqual(store.has(), true, "check " + ok + " means the item is there");
    });
  }
});

/* Android's save prompts too (M4b plan §5.2: an auth-per-use key authenticates
   encryption as well as decryption), and the system sheet shows whatever the
   app hands it. The plugin writes no words of its own — the title and the
   cancel word must arrive with the call, already through trans(), exactly as
   unlock's reason always has. iOS ignores both fields. */
ta("the save call carries the words the Android sheet will wear", async () => {
  const box = fakeSecret();
  let got = null;
  box.api.save = async (o) => { got = o; box.value = o.value; box.state.has = true; return { check: 0 }; };
  await withSecret(box, async store => {
    await store.ready();
    await store.save({ refresh: "rt" });
    assert.strictEqual(got.reason, "Keep the Dropbox connection on this phone",
      "the sheet's title travels translated — the plugin has no dictionary");
    assert.strictEqual(got.cancel, "Cancel");
  });
});

ta("keeping, opening and forgetting — and the password argument goes nowhere", async () => {
  const box = fakeSecret();
  await withSecret(box, async store => {
    await store.ready();
    await store.save({ refresh: "rt-secret", folder: { ns: "sf-1", name: "DB" } }, "a password nobody asked for");
    assert.strictEqual(store.has(), true);
    assert(!/password/.test(box.value), "the password is not part of what is kept");
    assert.deepStrictEqual(JSON.parse(box.value).folder.ns, "sf-1", "the folder travels with the token, as on desktop");

    const got = await store.unlock();
    assert.strictEqual(got.refresh, "rt-secret");
    assert(box.calls.some(c => c.startsWith("unlock:") && c.length > 8), "the system sheet is given a reason to show");

    store.clear();
    assert.strictEqual(store.has(), false);
    assert(box.calls.includes("clear"));
  });
});

ta("three of the five failures are not faults, and are told apart by code", async () => {
  // cancelled: the sheet was dismissed. Nothing is said, and nothing is lost.
  const box = fakeSecret({ rejectWith: "cancelled" });
  await withSecret(box, async store => {
    await store.ready();
    box.state.has = true; store.kept = true;
    await assert.rejects(store.unlock(), e => e.code === "cancelled" && !e.wrong);
    assert.strictEqual(store.has(), true, "a cancel does not throw away the saved connection");
  });

  // invalidated: a new fingerprint, or the passcode taken off. The item is gone,
  // so what we know has to change with it — this is the "connect again" branch.
  const gone = fakeSecret({ rejectWith: "invalidated" });
  await withSecret(gone, async store => {
    await store.ready();
    store.kept = true;
    await assert.rejects(store.unlock(), e => e.code === "invalidated");
    assert.strictEqual(store.has(), false, "nothing is kept any more, and the dialog must stop saying there is");
  });

  // denied: Face ID switched off for the app. The item is intact.
  const denied = fakeSecret({ rejectWith: "denied" });
  await withSecret(denied, async store => {
    await store.ready();
    store.kept = true;
    await assert.rejects(store.unlock(), e => e.code === "denied");
    assert.strictEqual(store.has(), true, "Settings can give it back — the connection is still there");
  });

  // failed: the authentication genuinely did not succeed. `wrong` is the word
  // the password store already uses, so the dialog's one branch fits both.
  const failed = fakeSecret({ rejectWith: "failed" });
  await withSecret(failed, async store => {
    await store.ready();
    store.kept = true;
    await assert.rejects(store.unlock(), e => e.code === "failed" && e.wrong === true);
  });
});

/* ---- M4b/5: one dialog, four truths ----
   The dialog asks the store what it needs and dresses itself accordingly. These
   drive it through all four, including the two an iPhone will never show — the
   texts have to exist before Android arrives, or they get written in a hurry. */
function asStore(needs, biometry, kept) {
  const was = A.dbxTokenStore;
  const fake = { needs, biometry, kept: !!kept, settled: true,
    has() { return this.kept; }, async ready() { return true; },
    async save() { this.kept = true; }, async unlock() { return {}; }, clear() { this.kept = false; } };
  A.setDbxTokenStore(fake);
  return { fake, restore: () => A.setDbxTokenStore(was) };
}

t("the unlock row: a password field where there is a password, a face where there is a face", () => {
  const g = asStore("password", "", true);
  try {
    A.syncDbxUnlockRow();
    assert.strictEqual(els.dbxPass0.style.display, "", "the field is the way in on a desktop");
    assert(/password/i.test(els.btnDbxUnlock.textContent) === false, "the button is still just Reconnect");
    assert(/Forgot the password/i.test(els.dbxUnlockHint.textContent), "and the way out of a forgotten one is said");
  } finally { g.restore(); }

  const b = asStore("biometry", "face", true);
  try {
    A.syncDbxUnlockRow();
    assert.strictEqual(els.dbxPass0.style.display, "none", "nothing to type: the field would be a lie");
    assert.strictEqual(els.btnDbxUnlock.textContent, "Unlock with Face ID", "the device's own word, not 'biometrics'");
    assert(!/Forgot the password/i.test(els.dbxUnlockHint.textContent),
      "there is no password to have forgotten — only something to let go of");
  } finally { b.restore(); }

  const t2 = asStore("biometry", "touch", true);
  try { A.syncDbxUnlockRow(); assert.strictEqual(els.btnDbxUnlock.textContent, "Unlock with Touch ID"); }
  finally { t2.restore(); }

  const si = asStore("silent", "none", true);
  try {
    A.syncDbxUnlockRow();
    assert.strictEqual(els.dbxPass0.style.display, "none");
    assert.strictEqual(els.btnDbxUnlock.textContent, "Reconnect", "nothing to unlock with, so nothing is claimed");
  } finally { si.restore(); }
});

t("the keep offer: a field, a face, or nothing at all — and \"session\" says why", () => {
  const pw = asStore("password", "", false);
  try {
    A.syncDropboxKeepBox();
    assert.strictEqual(els.dbxKeepBox.style.display, "");
    assert.strictEqual(els.dbxPass.style.display, "");
    assert.strictEqual(els.dbxNoKeep.style.display, "none");
    assert(/password of your choosing/i.test(els.dbxKeepWhy.textContent));
  } finally { pw.restore(); }

  const bio = asStore("biometry", "face", false);
  try {
    A.syncDropboxKeepBox();
    assert.strictEqual(els.dbxPass.style.display, "none", "no password on a phone — decided, not forgotten");
    assert(/Face ID/.test(els.dbxKeepWhy.textContent), "it says which face: " + els.dbxKeepWhy.textContent);
    assert(/backup/i.test(els.dbxKeepHint.textContent), "and that it never leaves this device");
  } finally { bio.restore(); }

  const si = asStore("silent", "none", false);
  try {
    A.syncDropboxKeepBox();
    assert.strictEqual(els.dbxPass.style.display, "none");
    assert(/by itself/i.test(els.dbxKeepWhy.textContent));
  } finally { si.restore(); }

  const none = asStore("session", "none", false);
  try {
    A.syncDropboxKeepBox();
    assert.strictEqual(els.dbxKeepBox.style.display, "none", "an offer we cannot keep is worse than no offer");
    assert.strictEqual(els.dbxNoKeep.style.display, "", "but silence is worse still: one sentence says why");
  } finally { none.restore(); }
});

/* ---- Round 2/2: the fold, and where a failure is written ----
   From the live phone test: under the saved-connection prompt stood the whole
   two-step connect flow, and the error was written at the foot of the dialog,
   below the fold of a 390px screen. */
t("the connect steps fold away while something is kept, and come back when nothing is", () => {
  const kept = asStore("password", "", true);
  try {
    A.DropboxAuth.disconnect();
    A.openDropbox();
    assert.strictEqual(els.dbxFreshToggle.style.display, "", "one quiet line offers the whole flow");
    assert(!A.dbxFreshOpen(), "shut: getting at your plans is what you came for");
    A.dbxFresh(true);
    assert(A.dbxFreshOpen(), "and a tap opens it");
    assert.strictEqual(els.dbxFreshToggle.getAttribute("aria-expanded"), "true",
      "said out loud too, not only drawn");
    A.closeDropbox();
    A.openDropbox();
    assert(!A.dbxFreshOpen(), "each time the dialog opens, the fold starts shut again");
  } finally { kept.restore(); A.closeDropbox(); }

  const none = asStore("password", "", false);
  try {
    A.openDropbox();
    assert.strictEqual(els.dbxFreshToggle.style.display, "none", "nothing kept: nothing to fold");
    assert(A.dbxFreshOpen(), "and the two steps are simply there, as they always were");
  } finally { none.restore(); A.closeDropbox(); }
});

/* The dialog's body is a two-column grid: anything that is not `full` takes one
   column. The fold's wrapper is a container of full-width cards, so it has to
   span — without that it laid the two steps out in half the dialog and left the
   other half empty (Megatron, 2026-08-20). */
/* 🔴 Three of the five ways an unlock can fail are not faults, and the M4b plan
   is explicit about each. This is where that map reaches the screen — and it is
   what rows 4, 5 and 10 of the hardware matrix are really checking. */
t("a failed unlock says the right thing — and says nothing at all when nothing went wrong", () => {
  /* A cancel is an answer. Somebody dismissed a Face ID sheet; they know what
     they did, and telling them about it is noise. */
  assert.strictEqual(A.unlockFailureText({ code: "cancelled" }), "",
    "🔴 a cancelled prompt must produce NO message");

  const gone = A.unlockFailureText({ code: "invalidated" });
  assert(/new face or fingerprint|screen lock/i.test(gone), "it names the two real causes: " + gone);
  assert(/nothing on Dropbox has changed/i.test(gone),
    "…and settles the only question that frightens anybody: are my plans safe");
  assert(!/error|failed/i.test(gone), "it must not read as a fault — it is the access control working");

  const denied = A.unlockFailureText({ code: "denied" });
  assert(/Settings/.test(denied) && /still here/i.test(denied),
    "the item survives a refused permission, and the way back is Settings: " + denied);

  assert(/Try again/i.test(A.unlockFailureText({ code: "failed" })));

  // the password branch keeps the sentence it earned on Megatron, 2026-08-19
  const wrong = A.unlockFailureText({ message: "That is not the password this connection was saved with.", wrong: true });
  assert(/Forgotten it\?/.test(wrong), "a wrong password still names the way out: " + wrong);
  assert(!/Forgotten it\?/.test(A.unlockFailureText({ message: "Something else" })),
    "…and nothing else pretends to be one");
});

/* A plan that had to be repaired on the way in was written by something that
   got it wrong — and that something can usually do better if it is told how.
   The list used to live nine seconds in a toast and then be gone; Daniel was
   handing it back to the AI with screenshots (2026-08-20). */
ta("the repair report is kept, and reads as a request the writer can act on", async () => {
  resetApp();
  A.state.lastRepairs = null;
  assert.strictEqual(A.syncRepairReport(), false, "a clean plan offers nothing");
  assert.strictEqual(els.btnCopyRepairs.style.display, "none");
  assert.strictEqual(A.repairReportText(), "");

  A.state.lastRepairs = { plan: "Terasz.gantora", list: ["row 4: Milestone in a group", "row 7: Status was a sentence"] };
  assert.strictEqual(A.syncRepairReport(), true);
  assert.strictEqual(els.btnCopyRepairs.style.display, "", "…and a repaired one puts it within reach");
  const text = A.repairReportText();
  assert(/Terasz\.gantora/.test(text), "it names the plan: " + text);
  assert(/Milestone in a group/.test(text) && /Status was a sentence/.test(text), "every repair, not the first six");
  assert(/write the file again/i.test(text),
    "🔴 and it ENDS as a request — that sentence is what turns a notice into a round trip");

  const was = ctx.navigator;
  try {
    let put = "";
    ctx.navigator = { clipboard: { writeText: async t => { put = t; } } };
    assert.strictEqual(await A.copyRepairReport(), true);
    assert.strictEqual(put, text);

    ctx.navigator = {};                       // a browser that will not allow it
    assert.strictEqual(await A.copyRepairReport(), false, "and it says so rather than pretending");
  } finally { ctx.navigator = was; A.state.lastRepairs = null; A.syncRepairReport(); }
});

/* 🔴 The picker names the plan on screen, however that plan got there. Four
   things can open one without anybody touching the picker — the plan a folder
   was left in, the first plan in a new folder, a name in the address, the free
   edition's fixed file — and all four used to leave "— select a file —" sitting
   over an open plan (Daniel, 2026-08-20). */
t("the file picker says which plan is open, even when nobody chose it there", () => {
  resetApp();
  A.setStorage(A.FsaAdapter);
  els.fileSelect.innerHTML = '<option value="">— select a file —</option>' +
    '<option value="Kitchen.gantora">Kitchen</option><option value="Roadmap.gantora">Roadmap</option>';
  els.fileSelect.value = "";
  els.fileSelect.querySelector = q => (/Roadmap|Kitchen/.test(q) ? {} : null);
  useName("Roadmap.gantora");
  A.syncPlanName();
  assert.strictEqual(els.fileSelect.value, "Roadmap.gantora", "the picker follows the plan, not the other way round");

  // a name the list does not carry is not forced into it
  els.fileSelect.value = "Roadmap.gantora";
  useName("Gone.gantora");
  A.syncPlanName();
  assert.strictEqual(els.fileSelect.value, "Roadmap.gantora", "a plan the folder no longer lists leaves the picker alone");
  delete els.fileSelect.querySelector;
  useName(""); resetApp();
});

/* ---- The keep offer, asked instead of left lying about ----
   Daniel, 2026-08-20: "ne úgy, hogy 2-es betűmérettel esetleg rákattint — ha
   nem pipálja be, akkor Kész megnyomásakor kérdezzen rá". */
t("Done asks about keeping the connection; closing, when the app closes it, just closes", () => {
  const store = asStore("biometry", "face", false);
  const wasConn = A.DropboxAuth.connected, wasOpen = A.DropboxAdapter.open, wasStore = A.storage;
  try {
    A.DropboxAuth.connected = () => true;
    A.setStorage(A.DropboxAdapter);
    A.DropboxAdapter.open = () => true;
    assert(A.dbxShouldAsk(), "connected, a folder open, nothing kept: this is the moment");
    A.openDropbox();
    assert.strictEqual(A.dropboxDone(), false, "Done does not close — it asks");
    assert.strictEqual(els.dbxKeepAsk.style.display, "", "and the question is on screen");
    assert(A.dropboxOpen(), "the dialog is still up behind it");
    /* 🔴 …but a close the APP performs is a close. Getting this the wrong way
       round gives a dialog that refuses to go away when the app puts it away. */
    A.closeDropbox();
    assert(!A.dropboxOpen(), "closeDropbox() closes, always");
    assert.strictEqual(els.dbxKeepAsk.style.display, "none", "and takes the question with it");

    store.fake.kept = true;                       // already kept: there is nothing to ask
    A.openDropbox();
    assert(!A.dbxShouldAsk());
    assert.strictEqual(A.dropboxDone(), true, "so Done simply closes");
    assert(!A.dropboxOpen());
  } finally {
    A.DropboxAuth.connected = wasConn; store.restore();
    A.DropboxAdapter.open = wasOpen; A.setStorage(wasStore || A.FsaAdapter);
    A.closeDropbox(); resetApp();
  }
});

t("nothing is asked where nothing can be kept", () => {
  const none = asStore("session", "none", false);
  const wasConn = A.DropboxAuth.connected, wasOpen = A.DropboxAdapter.open, wasStore = A.storage;
  try {
    A.DropboxAuth.connected = () => true;
    A.setStorage(A.DropboxAdapter); A.DropboxAdapter.open = () => true;
    assert(!A.dbxShouldAsk(), "\"session\" means there is nowhere to keep it — so it is not offered");
  } finally {
    A.DropboxAuth.connected = wasConn; none.restore(); A.DropboxAdapter.open = wasOpen;
    A.setStorage(wasStore || A.FsaAdapter); resetApp();
  }
});

/* ---- The AI button, slice 1: the core ----
   One tap opens the reader's OWN assistant with the instruction written, and
   the plan travels on the clipboard. Three conversations, and each needs the
   assistant to do something different — the third one (an empty plan) is the
   one that was nearly missed, and it is the most common consumer moment. */
t("three flows, and the app knows which one it is in from what is on screen", () => {
  resetApp();
  useRef(null); useName("");
  assert.strictEqual(A.aiFlow(), "none", "nothing open: there is nothing to send");
  useRef({}); useName("Terasz.gantora"); A.state.tasks = [];
  assert.strictEqual(A.aiFlow(), "empty",
    "🔴 a plan file with no tasks in it — the moment an assistant is worth most");
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03" }]);
  useRef({}); useName("Terasz.gantora");
  assert.strictEqual(A.aiFlow(), "plan");
  useRef(null); useName(""); resetApp();
});

t("every prompt opens by telling the assistant to READ the format first", () => {
  const was = A.state.lang;
  try {
    for (const lang of ["en", "hu", "de", "fr"]) {
      A.setLang(lang);
      for (const [flow, opts] of [["none", {}], ["empty", { plan: "Terasz" }], ["plan", {}]]) {
        const p = A.aiPrompt(flow, opts);
        assert(p.includes("https://gantora.net/skill.md"),
          lang + "/" + flow + ": the one rule that was learnt the hard way is missing");
        /* 🔴 The length frame the brief asks for: a prefilled chat URL stops
           working somewhere past four thousand characters, and every language
           has to fit — a prompt that fits in English and not in German is a
           bug that only some readers ever see. */
        assert(p.length <= 1500, lang + "/" + flow + ": prompt is " + p.length + " characters");
        assert(!/\{\d\}/.test(p), lang + "/" + flow + ": an unfilled placeholder is showing: " + p);
      }
      assert(A.aiPrompt("empty", { plan: "Terasz" }).includes("Terasz"), lang + ": the empty flow names the file");
      /* Dropbox mode adds one paragraph, and only to the flow that has a plan
         to save — NEW file, because an assistant that overwrites is worse than
         one that hands back a code block. */
      const dbx = A.aiPrompt("plan", { folder: "gantora-spike" });
      assert(dbx.includes("gantora-spike") && dbx.length > A.aiPrompt("plan", {}).length,
        lang + ": the Dropbox paragraph is missing");
      assert(!A.aiPrompt("none", { folder: "x" }).includes("x"), lang + ": nothing to save, nothing to say about saving");
    }
  } finally { A.setLang(was || "en"); }
});

t("the address is built from the chosen assistant, and everything in it is encoded", () => {
  eqArr(A.AI_KEYS, ["claude", "chatgpt", "grok", "gemini"]);
  const u = A.aiUrl("claude", "a & b?c=d é");
  assert(u.startsWith("https://claude.ai/new?q="), u);
  assert(u.includes("%26") && u.includes("%3F") && !u.includes(" "),
    "🔴 a plan named with an & would otherwise cut the prompt in half: " + u);
  assert.strictEqual(A.aiUrl("nope", "x"), "", "an assistant we do not know is not a URL");
  assert.strictEqual(A.aiProvider("gemini").name, "Gemini");
});

ta("the plan goes on the clipboard, and a refusal stops the whole thing", async () => {
  loadRowsNow([{ id: 1, name: "Bontás", start: "2026-03-02", end: "2026-03-03" }]);
  const was = ctx.navigator;
  try {
    let put = "";
    ctx.navigator = { clipboard: { writeText: async t => { put = t; } } };
    assert.strictEqual(await A.aiCopyPlan(), true);
    assert(put.includes("Bontás") && put.includes("|"), "the whole plan, in the app's own format");
    ctx.navigator = { clipboard: { writeText: async () => { throw new Error("no"); } } };
    assert.strictEqual(await A.aiCopyPlan(), false, "…and a refusal is reported, not swallowed");
  } finally { ctx.navigator = was; }
});

t("the consent is asked once, and it says what leaves the machine", () => {
  A.state.cfg.aiOk = false;
  const was = ctx.confirm;
  try {
    let asked = "";
    ctx.confirm = m => { asked = m; return false; };
    assert.strictEqual(A.aiConsent(), false, "a no is a no");
    assert(/none of it/i.test(asked) && /own account/i.test(asked),
      "🔴 it has to say BOTH: what goes, and that we cannot see it: " + asked);
    assert.strictEqual(A.state.cfg.aiOk, false, "and a no is not remembered as a yes");
    ctx.confirm = () => true;
    assert.strictEqual(A.aiConsent(), true);
    assert.strictEqual(A.state.cfg.aiOk, true);
    let again = 0;
    ctx.confirm = () => { again++; return true; };
    A.aiConsent();
    assert.strictEqual(again, 0, "asked once, not at every tap");
  } finally { ctx.confirm = was; A.state.cfg.aiOk = false; }
});

/* ---- The AI button, slice 3: the way back ----
   Without this the button is a one-way street. What comes out of a chat is text
   in a code block; these are the doors it comes in through. */
t("a fenced code block gives up the plan inside it, and plain text is taken as it is", () => {
  const plan = "ID|Activity Name\n1|A";
  assert.strictEqual(A.unfencePlan("Here you go:\n```\n" + plan + "\n```\nHope that helps!"), plan,
    "the sentences around it are somebody talking, not part of the file");
  assert.strictEqual(A.unfencePlan("```gantora\n" + plan + "\n```"), plan, "a language tag is allowed");
  assert.strictEqual(A.unfencePlan("  " + plan + "  "), plan, "…and text with no fence at all is the file");
  assert.strictEqual(A.unfencePlan(""), "");
});

t("which door the way back offers depends on what is open — and never overwrites work", () => {
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.dbHandle = null;
  assert.strictEqual(A.aiPasteTarget(), "", "no folder: nowhere to put a plan");

  A.state.dbHandle = fakeRoot({});
  useRef(null); useName("");
  assert.strictEqual(A.aiPasteTarget(), "new", "a folder and nothing open: a new file");

  /* 🔴 Changed 2026-08-21, on Daniel's reading of the live round: a plan WITH
     tasks IS a target now. The old rule ("always a new file") was the safe one
     on paper and produced a folder full of junk in practice — and the app has
     no delete. What makes the update safe is that on THIS road the app stands
     between the file and the answer: forgiving loader, repair report, Undo, and
     nothing on disk until Save. */
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03" }]);
  useRef({}); useName("Terasz.gantora");
  assert.strictEqual(A.aiPasteTarget(), "update", "a plan with tasks takes an update");
  /* Since 2026-08-22 the ✨ menu owns the way back and there is ONE item, so
     what used to be checked on two buttons is checked on the menu. */
  A.syncAiMenu();
  assert.strictEqual(els.mAiPaste.disabled, false, "and the way back is offered");

  /* 🔴 A plan that declares itself read-only is still not written into — that
     mark was meant by whoever wrote it. But it no longer takes the ITEM away
     (menu brief §7/1, 2026-08-23): what is on the clipboard may have come from
     another device or another person's chat, so whether a conversation started
     here can never decide whether an answer can come back. The offer simply
     narrows to one — as a new plan — which is this app's "the way out is Save
     As", said by the AI menu. */
  A.state.planReadOnly = true;
  assert.strictEqual(A.aiPasteTarget(), "new", "🔴 read-only narrows the offer; it does not remove it");
  A.syncAiMenu();
  assert.strictEqual(els.mAiPaste.disabled, false, "…so the item is still pressable");
  A.state.planReadOnly = false;

  /* Read-only MODE answers the same way, for the same reason. */
  A.state.readOnly = true;
  assert.strictEqual(A.aiPasteTarget(), "new", "the mode narrows it too, rather than closing the door");
  A.state.readOnly = false;

  A.state.tasks = [];
  assert.strictEqual(A.aiPasteTarget(), "into", "an EMPTY open plan can be filled — nothing to lose");
  A.syncAiMenu();
  assert.strictEqual(els.mAiPaste.disabled, false, "…and then it is offered");
  useRef(null); useName(""); resetApp();
});

ta("pasting into the open empty plan fills it, keeps its name, and drops any read-only mark", async () => {
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.dbHandle = fakeRoot({});
  A.state.tasks = [];
  useRef({}); useName("Terasz.gantora");
  /* An ordinary open plan. A plan that declares itself read-only is refused
     instead — asserted in the door test above. */
  A.state.planReadOnly = false;
  const was = ctx.navigator;
  try {
    /* Built from the app's own header rather than typed out: a hand-counted
       row of 28 cells is a test that fails for its own reasons. */
    const head = A.HEADER.join("|");
    const cells = A.HEADER.map(h => h === "Seq" ? "1" : h === "Activity Name" ? "Bontás"
      : h === "Start" ? "2026-03-02" : h === "End" ? "2026-03-03" : h === "Status" ? "Planned" : "");
    const text = head + "\r\n" + cells.join("|") + "\r\n";
    ctx.navigator = { clipboard: { readText: async () => "Itt van:\n```\n" + text + "```" } };
    assert.strictEqual(await A.pastePlan(true), true);
    assert.strictEqual(A.planName(), "Terasz.gantora", "the file it went into keeps its name");
    assert(A.state.tasks.some(t => t.name === "Bontás"), "and it has the plan in it now");
    /* 🔴 A plan that has been through somebody's clipboard is their own copy.
       The read-only mark belonged to the file it was set on. */
    assert.strictEqual(A.state.planReadOnly, false, "a pasted plan does not inherit read-only");
    assert.strictEqual(A.state.dirty, true, "and it is unsaved until somebody saves it");
  } finally { ctx.navigator = was; useRef(null); useName(""); resetApp(); }
});

/* ---- The AI button, slice 2: where it actually sits ----
   The core and the way back are both invisible until the button is somewhere a
   thumb can reach. Three places, and each one is a decision:
   ✨ beside the ➕ · a quiet second offer on the empty screen · the ⋯ menu. */
t("\u2728 sits beside the \u2795, and comes first because it is the other way to fill a plan", () => {
  const bar = html.slice(html.indexOf('id="toolbar"'), html.indexOf('id="mMoreMenu"'));
  const ai = bar.indexOf('id="btnAi"'), plus = bar.indexOf('id="btnNewTask"');
  assert(ai > 0 && plus > 0 && ai < plus, "\u2728 is on the bar, in front of \u2795");
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  /* Same order number, so DOM order decides: one rule, and the pair can never
     drift apart when a later order is renumbered. */
  /* The ✨ is a menuWrap since 2026-08-22 — it opens the AI menu rather than
     starting a chat — so the bar addresses the wrapper and the sizing rules
     reach the button inside it. */
  assert(/#toolbar\.compact > #aiWrap, #toolbar\.compact > #btnNewTask\{order:4;\}/.test(css),
    "and on the compact bar they share one order");
});

/* 🔴 ✨ is ON the phone bar, and the story of why is worth keeping: it was
   taken off on 08-21 because a 393px bar clips and there was no room for a
   seventh control — then the plan's ⋮ left the bar for the switcher's rows and
   gave the room back. Daniel found it missing in ten seconds. It is the
   entrance to a whole area of the app, and an entrance nobody can see is not
   one. */
t("\u2728 is on the phone bar, sized on the token like every other icon there", () => {
  /* The phone block by hand rather than through mobileCss(), which is declared
     with the phone-layout tests further down and is not initialised yet here. */
  const sheet = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const block = sheet.slice(sheet.lastIndexOf("@media (max-width: " + A.PHONE_W + "px)"));
  assert(!/#toolbar > #aiWrap\{display:none;\}/.test(block), "\u2728 is not hidden on a phone any more");
  assert(/#toolbar > #aiWrap > #btnAi\{min-width:var\(--ctl-h\)/.test(block),
    "…and it is a 44px icon there, not a labelled button");
  /* The room it took: the plan's operations are on the switcher's rows now, so
     the bar carries no ⋮ of its own. */
  assert(/id="planOpsWrap" style="display:none"/.test(html),
    "the plan operations do not sit on the bar");
  /* And the ⋯ menu no longer offers the assistant: one function, one place. */
  const menu = html.slice(html.indexOf('id="mMoreMenu"'), html.indexOf('id="mMoreMenu"') + 1400);
  assert(!/data-mirrors="btnAi"/.test(menu), "the ⋯ menu is the app's things only");
});

/* 🔴 The empty screen is flow B's home: no plan open, so the assistant starts
   the conversation. But the offer waits for a folder — what comes back is a
   code block, and a code block needs somewhere to land. */
t("the empty screen offers the assistant only once there is a folder to paste into", () => {
  const line = html.split("\n").find(l => l.includes('$("emptyAiBtn").style.display'));
  assert(line, "one line decides whether the empty screen offers it");
  assert(/openButEmpty/.test(line) && /isFree\(\)/.test(line),
    "a folder is open and empty, and this is not the free edition: " + line.trim());
  /* The same condition the way back uses — otherwise the screen would invite
     somebody into a round trip whose second half is switched off. */
  const door = html.slice(html.indexOf("function aiPasteTarget()"));
  assert(/folderOpen\(\)/.test(door.slice(0, 300)), "and the paste door agrees");
});

/* ---- The AI button, slice 5: 🔒 the lock, and the answer written back -------
   Daniel, after the live ChatGPT round: every answer became another file, and
   the app cannot delete files. His own fix is the lock — and the lock is what
   makes writing back into the SAME file safe rather than reckless. */
/* 🔴 There was an AI LOCK in the app for a few hours on 2026-08-22, and both
   of its tests went with it. Daniel hit it the same day on his own plan — "AI
   tesztet indítottam talán? De csak megnyomtam a gombot" — because pressing
   "start a conversation" wrote #META|readOnly|yes into the file, and on a
   shared Dropbox folder that flag reaches every device and every teammate.

   His call on where it belongs is the better one: **the lock is not ours to
   set.** Whoever writes the file raises it — an assistant editing through its
   own connector — and clears it when it is done. If a person is pasting, they
   are here anyway and nothing needs locking. This app shows the flag, refuses
   to write over it, and gets out of the way. */

/* 🔴 The change itself: the answer goes back into the plan it came from. What
   makes that safe is not trust — it is that on this road the app is between
   the file and the assistant. */
ta("the answer is written back into the same file, and Undo still holds the old plan", async () => {
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.dbHandle = fakeRoot({});
  loadRowsNow([
    { id: 1, name: "Bontás", start: "2026-03-02", end: "2026-03-03" },
    { id: 2, name: "Burkolás", start: "2026-03-04", end: "2026-03-06" },
  ]);
  useRef({}); useName("Terasz.gantora");
  A.state.planReadOnly = false;    // an assistant's own flag would have been cleared by it
  const was = ctx.navigator;
  try {
    const head = A.HEADER.join("|");
    const row = (id, name, st, en) => A.HEADER.map(h => h === "Seq" ? String(id) : h === "Activity Name" ? name
      : h === "Start" ? st : h === "End" ? en : h === "Status" ? "Planned" : "").join("|");
    const text = head + "\r\n" + row(1, "Bontás", "2026-03-02", "2026-03-03") + "\r\n"
      + row(2, "Burkolás", "2026-03-09", "2026-03-11") + "\r\n";     // the assistant moved one
    ctx.navigator = { clipboard: { readText: async () => "Kész:\n```gantora\n" + text + "```" } };

    assert.strictEqual(A.aiPasteTarget(), "update");
    assert.strictEqual(await A.pastePlan(true), true);
    assert.strictEqual(A.planName(), "Terasz.gantora", "🔴 the SAME file — no new plan is manufactured");
    assert.strictEqual(A.state.tasks.length, 2);
    assert.strictEqual(A.state.tasks.find(t => t.name === "Burkolás").start, "2026-03-09",
      "and it carries what the assistant changed");
    assert.strictEqual(A.state.planReadOnly, false, "accepting the update lifts the lock");
    assert.strictEqual(A.state.dirty, true, "🔴 nothing has reached the disk — Save is still ahead");

    /* The safety net Daniel is really relying on, asserted rather than assumed. */
    assert.strictEqual(A.undo(), true);
    assert.strictEqual(A.state.tasks.find(t => t.name === "Burkolás").start, "2026-03-04",
      "Undo puts the plan back as it stood before the paste");
  } finally { ctx.navigator = was; useRef(null); useName(""); resetApp(); }
});

/* 🔴 The lock is an OFFER, and opening a chat is not one. It used to be
   automatic — pressing "start a conversation" wrote #META|readOnly|yes into the
   file — and it lasted less than a day: Daniel, on his own plan, "AI tesztet
   indítottam talán? De csak megnyomtam a gombot". On a shared Dropbox folder
   that flag then travels to every device and every teammate. A plan is not
   handed over by pressing a button to look at it.

   What the lock protected is protected anyway: the way back runs through the
   forgiving loader, the repair report, Undo, and nothing reaches the disk until
   somebody saves. */
/* 🔴 The app does not write the read-only flag, at all, anywhere. It shows it,
   refuses to write over it, and leaves it to whoever raised it. */
t("nothing in the app raises a plan's read-only flag", () => {
  const code = html.replace(/\/\*[\s\S]*?\*\//g, "").replace(/<!--[\s\S]*?-->/g, "");
  /* The only assignments left are the ones that CLEAR it, or that read it back
     off a file — never one that decides a plan should become read-only. */
  const sets = [...code.matchAll(/state\.planReadOnly\s*=\s*([^;]+);/g)].map(m => m[1].trim());
  for (const v of sets)
    assert(/^false$/.test(v) || /planReadOnlyOf\(/.test(v),
      "the app decides a plan is read-only: state.planReadOnly = " + v);
  /* And one padlock with one meaning: the plan declares it, the chip says so,
     and the way out is Save As. */
  assert(/id="planRoChip"[^>]*title="This plan is read-only\. Use Save As/.test(html),
    "the chip explains itself and names the way out");
});

/* The other road, where there is no app between the assistant and the disk. */
t("the connector is sent to ONE fixed draft file, in every language", () => {
  assert.strictEqual(A.aiDraftPath("/gantora-spike/DB", "Terasz"), "/gantora-spike/DB/Terasz (AI).gantora");
  assert.strictEqual(A.aiDraftPath("/DB/", "Terasz.gantora"), "/DB/Terasz (AI).gantora",
    "the extension is not doubled up");
  /* 🔴 setLang(), not state.lang: trans() reads the config, so assigning the
     field left every round of this loop testing the English text. The path
     assertion below passed anyway — it is the same in every language — which is
     exactly how a loop like this goes unnoticed. */
  const wasLang = A.myLang();
  try {
    for (const lang of ["en", "hu", "de", "fr"]) {
      A.setLang(lang);
      const p = A.aiPrompt("plan", { plan: "Terasz", folder: "/DB" });
      assert(p.includes("/DB/Terasz (AI).gantora"), lang + ": the one file it may write is named");
      /* 🔴 And the ban that goes with it. Without this sentence the fixed name
         is only a suggestion, and the round that follows writes a third file. */
      assert(!/\bNEW file\b/.test(p), lang + ": the old 'always a new file' rule is gone");
      /* 🔴 Daniel's final wording, from the 08-22 move round (AI-button brief,
         slice 5.3). Two nets, and the prompt is worth nothing without either:
         READ before you write — there is no conflict dialog out on this road to
         catch a blind overwrite — and the whole file in a code block after
         every change as WELL as writing it, so the clipboard road back still
         works when the connector's write fails. */
      const say = { en: [/read it immediately before you write/i, /code block as well/i],
                    hu: [/írás előtt olvasd be/i, /kódblokkban is/i],
                    de: [/unmittelbar vor dem Schreiben/i, /als Codeblock/i],
                    fr: [/juste avant d'écrire/i, /bloc de code/i] }[lang];
      for (const re of say) assert(re.test(p), lang + ": missing — " + re + "\n" + p);
    }
  } finally { A.setLang(wasLang); }
});

ta("a refused clipboard, an empty one, and a foreign file are each said out loud", async () => {
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.dbHandle = fakeRoot({});
  const was = ctx.navigator;
  try {
    ctx.navigator = {};
    assert.strictEqual(await A.pastePlan(false), false, "no clipboard API: said, not silent");
    ctx.navigator = { clipboard: { readText: async () => "   " } };
    assert.strictEqual(await A.pastePlan(false), false, "nothing on it: said");
    ctx.navigator = { clipboard: { readText: async () => "Dear Daniel, here is my plan for next week." } };
    assert.strictEqual(await A.pastePlan(false), false,
      "prose is not a plan, and the same gate every other way in uses says so");
  } finally { ctx.navigator = was; resetApp(); }
});

/* 🔴 The packaged WebView's clipboard is the same story as its file picker:
   the web API's read is permission-gated with no way to grant it, so it
   answers emptiness while the clipboard is full — measured on the first live
   Android round (2026-08-23), a finished plan sitting there, pasteable into
   Notes, invisible to the page. The Clipboard plugin asks the system itself,
   and it must win over a navigator.clipboard that would also answer —
   wrongly — in the same WebView. One doorway, both directions. */
ta("on a phone the clipboard goes through the plugin, both ways", async () => {
  const wasCap = ctx.window.Capacitor, wasNav = ctx.navigator;
  try {
    let wrote = null;
    ctx.window.Capacitor = { Plugins: { Clipboard: {
      read: async () => ({ value: "from-the-system", type: "text/plain" }),
      write: async (o) => { wrote = o.string; },
    } } };
    ctx.navigator = { clipboard: {
      readText: async () => { throw new Error("the WebView would refuse this"); },
      writeText: async () => { throw new Error("and this"); },
    } };
    assert.strictEqual(await A.readClipboard(), "from-the-system", "read prefers the plugin");
    resetApp();
    A.state.tasks = [];
    assert.strictEqual(await A.aiCopyPlan(), true, "the copy road out uses the same doorway");
    assert(typeof wrote === "string" && wrote.includes("ID|"), "and what it wrote is the serialized plan");

    // an empty native clipboard REJECTS rather than answering "" — same outcome
    ctx.window.Capacitor.Plugins.Clipboard.read = async () => { throw new Error("There is no data on the clipboard"); };
    assert.strictEqual(await A.readClipboard(), "", "an empty native clipboard reads as empty, not as an error");
  } finally { ctx.window.Capacitor = wasCap; ctx.navigator = wasNav; resetApp(); }
});

/* 🔴 A packaged app has no file picker and no working `<a download>`: the
   anchor is SILENT in a WKWebView, which is what "the share button does nothing
   on the phone" means. The phone's one gesture for handing a file to somebody
   is the system share sheet. */
ta("on a phone the shared page goes to the share sheet; on a desktop nothing changes", async () => {
  const was = ctx.window.Capacitor;
  try {
    ctx.window.Capacitor = undefined;
    assert.strictEqual(await A.shareSnapshotNative("p.html", "<html>"), false,
      "no Capacitor: the desktop road is untouched");

    ctx.window.Capacitor = { Plugins: { Filesystem: {} } };
    assert.strictEqual(await A.shareSnapshotNative("p.html", "<html>"), false,
      "…and half the plugins is the same as none");

    const seen = {};
    ctx.window.Capacitor = { Plugins: {
      Filesystem: {
        async writeFile(o) { seen.write = o; },
        async getUri(o) { seen.uri = o; return { uri: "file:///cache/" + o.path }; },
      },
      Share: { async share(o) { seen.share = o; } },
    } };
    assert.strictEqual(await A.shareSnapshotNative("Terasz-readonly.html", "<html>x</html>"), true);
    assert.strictEqual(seen.write.path, "Terasz-readonly.html");
    /* 🔴 CACHE, not Documents: this is a copy made to be sent, not a plan to be
       kept, and Documents is where the person's own plans live. */
    assert.strictEqual(seen.write.directory, "CACHE", "a page made to be sent does not belong among the plans");
    assert.strictEqual(seen.share.url, "file:///cache/Terasz-readonly.html", "the sheet is given the file, not the text");

    // backing out of a share is not a failure, and must not fall through
    ctx.window.Capacitor.Plugins.Share.share = async () => { throw new Error("Share canceled"); };
    assert.strictEqual(await A.shareSnapshotNative("p.html", "<html>"), true,
      "a cancelled share must not start a second attempt at saving the same file");
  } finally { ctx.window.Capacitor = was; }
});

/* 🔴 The app now lets itself in — unlock, connect, open the folder, read the
   last plan — and on a phone that is seconds of an empty screen offering a
   button nobody should press. Daniel, 2026-08-21. */
t("while the app lets itself in, it says so — and stops offering the way in", () => {
  resetApp();
  for (const id of ["emptyOpen", "emptyOpenBtn", "emptyDbx"]) els[id].style.display = "";
  assert.strictEqual(A.showBooting(""), "", "nothing to say when nothing is happening");
  assert.strictEqual(els.booting.style.display, "none");

  A.showBooting("Opening the saved connection…");
  assert.strictEqual(els.booting.style.display, "", "the line is up");
  assert.strictEqual(els.bootWhat.textContent, "Opening the saved connection…",
    "…and it says WHAT, not just that something is happening");
  /* 🔴 The offers go while it runs: a press during the reconnect starts a
     second way in, which is how two folders end up half-open at once. */
  for (const id of ["emptyOpen", "emptyOpenBtn", "emptyDbx"])
    assert.strictEqual(els[id].style.display, "none", id + " is still inviting a press");

  A.showBooting("");
  assert.strictEqual(els.booting.style.display, "none");
  assert.notStrictEqual(els.emptyOpenBtn.style.display, "none", "and the screen is somebody else's again");
});

/* 🔴 A kept connection opens itself, and the dialog is what failure looks like.
   Daniel, once it worked: "külön rá kell nyomni, hogy nyitás Face ID-val … a
   mezei user nem így használja." */
ta("a kept connection opens itself at launch; the dialog is what failure looks like", async () => {
  A.DropboxAuth.disconnect();
  A.closeDropbox();
  const store = asStore("biometry", "face", true);
  const wasUnlock = null;
  try {
    let tried = 0;
    A.setDropboxUnlock(async () => { tried++; return true; });
    assert.strictEqual(await A.dbxAutoStart(), true, "it just opens");
    assert.strictEqual(tried, 1);
    assert(!A.dropboxOpen(), "🔴 and nobody is shown a dialog they had nothing to decide in");

    A.setDropboxUnlock(async () => { tried++; return false; });   // cancelled, invalidated, offline…
    assert.strictEqual(await A.dbxAutoStart(), false);
    assert(A.dropboxOpen(), "…but a failure leaves a choice, and the dialog is where choices are made");
    A.closeDropbox();

    // nothing kept: straight to the dialog, as before
    store.fake.kept = false;
    assert.strictEqual(await A.dbxAutoStart(), false);
    assert(A.dropboxOpen());
    A.closeDropbox();
  } finally { A.setDropboxUnlock(wasUnlock); store.restore(); A.closeDropbox(); }
});

ta("a password is a question, and a question needs the box it is asked in", async () => {
  A.DropboxAuth.disconnect();
  A.closeDropbox();
  const store = asStore("password", "", true);
  const wasUnlock = null;
  try {
    let tried = 0;
    A.setDropboxUnlock(async () => { tried++; return true; });
    assert.strictEqual(await A.dbxAutoStart(), false);
    assert.strictEqual(tried, 0, "nothing is attempted with a password nobody has typed yet");
    assert(A.dropboxOpen(), "the dialog opens, exactly as it did before");
  } finally { A.setDropboxUnlock(wasUnlock); store.restore(); A.closeDropbox(); }
});

/* Two very different things looked identical until 2026-08-20: a device with
   nothing kept, and a device whose store could not be read. Both said has:false
   and both asked for the whole connection again, every launch. */
t("a secure store that cannot be READ says so; one that is simply empty says nothing", () => {
  const store = asStore("biometry", "face", false);
  try {
    store.fake.code = -25300;                 // errSecItemNotFound: the ordinary case
    assert.strictEqual(A.syncDbxDiag(), "", "nothing kept is not a fault, and must not read as one");
    assert.strictEqual(els.dbxDiag.style.display, "none");

    store.fake.code = 0;                      // found
    assert.strictEqual(A.syncDbxDiag(), "");

    /* 🔴 The third normal answer, and the one that showed up as a red line on
       a working phone (Daniel, 2026-08-21). errSecInteractionNotAllowed means
       the item IS there and will cost a face — under a silent probe that is
       the item saying yes, which is what the whole probe exists to hear. */
    store.fake.code = -25308;
    assert.strictEqual(A.syncDbxDiag(), "",
      "an item behind biometry is not a fault — it is the point");
    assert.strictEqual(els.dbxDiag.style.display, "none");

    store.fake.code = -50;                    // errSecParam: the query itself was wrong
    const said = A.syncDbxDiag();
    assert(/-50/.test(said), "the raw code travels to the person who can quote it: " + said);
    assert(/Nothing is lost/.test(said), "…and it says the plans are safe, because they are");
    assert.strictEqual(els.dbxDiag.style.display, "");

    delete store.fake.code;                   // a store that does not report one (the desktop's)
    assert.strictEqual(A.syncDbxDiag(), "");
  } finally { store.restore(); }
});

t("where the plans were is remembered — and it is a place, never a secret", () => {
  ctx.localStorage.removeItem("ppp.dbxlast");
  assert.strictEqual(A.dbxRemembered(), null);
  A.dbxRemember({ ns: "sf-1", path: "/DB", name: "gantora-spike" }, "Roadmap.gantora");
  const back = A.dbxRemembered();
  assert.strictEqual(back.folder.ns, "sf-1");
  assert.strictEqual(back.plan, "Roadmap.gantora");
  const raw = ctx.localStorage.getItem("ppp.dbxlast");
  assert(!/token|refresh|sl\./i.test(raw), "🔴 a folder name says WHERE, never what — and never how to get in: " + raw);
  A.dbxRemember(null, "Other.gantora");
  assert.strictEqual(A.dbxRemembered().folder.ns, "sf-1", "a new plan in the same folder keeps the folder");
  assert.strictEqual(A.dbxRemembered().plan, "Other.gantora");
  ctx.localStorage.removeItem("ppp.dbxlast");
});

/* ---- Round 2/3: the code that comes back by itself ----
   The Dropbox console took gantora://auth on 2026-08-20, so on a phone the
   person allows access and lands back in the app with the code already in
   hand. The desktop cannot have this — file:// is not a place to be redirected
   to — so the two roads have to stay apart, and that is what these check. */
t("the redirect is asked for only where there is an app to come back to", () => {
  const src = html.slice(html.indexOf("async begin()"), html.indexOf("async begin()") + 900);
  assert(/redirected = !!dbxCapApp\(\)/.test(src),
    "whether we asked has to be remembered — the exchange must repeat the same redirect_uri");
  assert(/redirected \? "&redirect_uri=" \+ encodeURIComponent\(DBX_REDIRECT\) : ""/.test(src),
    "and it is only added where the plugin exists: " + src.slice(0, 200));
  const fin = html.slice(html.indexOf("async finish(code)"), html.indexOf("async finish(code)") + 900);
  assert(/redirected \? \{ redirect_uri: DBX_REDIRECT \} : \{\}/.test(fin),
    "Dropbox refuses an exchange whose redirect_uri does not match the request's");
  assert.strictEqual(A.DBX_REDIRECT, "gantora://auth");
});

t("a returning URL gives up its code, and anything else gives up nothing", () => {
  assert.strictEqual(A.dbxAuthCode("gantora://auth?code=abc123"), "abc123");
  assert.strictEqual(A.dbxAuthCode("gantora://auth?state=x&code=a%2Fb"), "a/b", "…decoded, because a code can carry /");
  assert.strictEqual(A.dbxAuthCode("GANTORA://AUTH?code=up"), "up", "the scheme is not case-sensitive");
  assert.strictEqual(A.dbxAuthCode("gantora://auth?error=access_denied"), "",
    "a refusal is not a code — and must not be mistaken for one");
  assert.strictEqual(A.dbxAuthCode("gantora://auth"), "");
  assert.strictEqual(A.dbxAuthCode("https://example.com/?code=stolen"), "",
    "🔴 and no other scheme is listened to at all");
  assert.strictEqual(A.dbxAuthCode(""), "");
  assert.strictEqual(A.dbxAuthCode(null), "");
});

ta("nothing at all happens on a URL that is not ours", async () => {
  assert.strictEqual(await A.dropboxReturn("https://gantora.net/?code=no"), false);
  assert.strictEqual(await A.dropboxReturn(""), false);
});

/* Pasting on a phone is a long press, a magnifier and a menu, in a field the
   person has just scrolled back to (Daniel, 2026-08-20). One button instead —
   and only ever on a press: reading a clipboard nobody offered is not ours to do. */
ta("the code can be pasted with one press, and the button is hidden where there is no clipboard", async () => {
  const was = ctx.navigator;
  try {
    ctx.navigator = { clipboard: { readText: async () => "  a-pasted-code  " } };
    assert.strictEqual(A.syncDbxPaste(), true);
    assert.strictEqual(els.btnDbxPaste.style.display, "");
    els.dbxCode.value = "";
    assert.strictEqual(await A.dropboxPasteCode(), true);
    assert.strictEqual(els.dbxCode.value, "a-pasted-code", "trimmed, because a copied code carries whitespace");
    assert(!A.DropboxAuth.connected(), "filling the field is the whole job — connecting stays the person's press");

    // a refused clipboard is an answer, not a fault
    ctx.navigator = { clipboard: { readText: async () => { throw new Error("denied"); } } };
    els.dbxCode.value = "kept";
    assert.strictEqual(await A.dropboxPasteCode(), false);
    assert.strictEqual(els.dbxCode.value, "kept", "and nothing is thrown away over it");

    ctx.navigator = {};
    assert.strictEqual(A.syncDbxPaste(), false);
    assert.strictEqual(els.btnDbxPaste.style.display, "none", "a button that cannot work is worse than none");
    assert.strictEqual(await A.dropboxPasteCode(), false);
  } finally { ctx.navigator = was; }
});

/* A step card makes every button in it full width — which is right for the one
   button that IS the step, and wrong the moment a button shares a row with the
   field it belongs to. */
t("in a step card's row the field is the elastic one, not the button beside it", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const row = /#dbxOverlay \.dbxStepCard \.pwrow input\{([^}]*)\}/.exec(css);
  assert(row && /flex:1 1 auto/.test(row[1]), "the code box takes the room: " + (row && row[1]));
  const btn = /#dbxOverlay \.dbxStepCard \.pwrow button\{([^}]*)\}/.exec(css);
  assert(btn && /width:auto/.test(btn[1]) && /flex:0 0 auto/.test(btn[1]),
    "and the button is its own width, not the card's: " + (btn && btn[1]));
});

t("the fold's wrapper spans the dialog, like everything else that holds a step card", () => {
  const box = html.slice(html.indexOf('id="dbxFresh"'), html.indexOf('id="dbxFresh"') + 60);
  assert(/class="full /.test(box) || /class="[^"]*\bfull\b/.test(box),
    "a wrapper that is not full-width squeezes its cards into one grid column: " + box);
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/\.modalBox \.full\{grid-column:1 \/ -1;\}/.test(css), "and that is what full means here");
});

t("the error under the field is cleared before the next try, not left to go stale", () => {
  A.dbxUnlockError("that did not fit");
  assert.strictEqual(els.dbxUnlockErr.style.display, "");
  A.dbxUnlockError("");
  assert.strictEqual(els.dbxUnlockErr.textContent, "");
  assert.strictEqual(els.dbxUnlockErr.style.display, "none",
    "an empty error box under a field is still a box");
});

ta("\"silent\" opens by itself — a button pressed before the inevitable is theatre", async () => {
  A.DropboxAuth.disconnect();
  const si = asStore("silent", "none", true);
  try {
    assert.strictEqual(A.dbxSilentUnlock(), true, "something is kept and nothing asks for anything: open it");
    si.fake.kept = false;
    assert.strictEqual(A.dbxSilentUnlock(), false, "nothing kept, nothing to open");
  } finally { si.restore(); }

  const bio = asStore("biometry", "face", true);
  try { assert.strictEqual(A.dbxSilentUnlock(), false, "a face is a question, and questions are asked, not answered for"); }
  finally { bio.restore(); }
});

t("the two stores are the same shape, and the desktop one is what file:// gets", () => {
  const a = Object.keys(A.dbxTokenStorePassword), b = Object.keys(A.dbxTokenStoreNative);
  for (const k of ["needs", "settled", "has", "ready", "save", "unlock", "clear"])
    assert(a.includes(k) && b.includes(k), "both stores must carry " + k);
  assert.strictEqual(A.dbxTokenStore, A.dbxTokenStorePassword,
    "no Capacitor here: the seam picks the password store, and every existing test is about that one");
});

/* The point of slice 0, stated as a test: a store that comes up not knowing
   what it holds — the Keychain, across the bridge — must not leave the dialog
   drawn as though nothing were saved. */
ta("a store that has not settled yet gets the dialog drawn again when it has", async () => {
  A.DropboxAuth.disconnect();
  const store = A.dbxTokenStore;
  const wasSettled = store.settled, wasHas = store.has;
  let kept = false, release;
  const settling = new Promise(r => { release = r; });
  store.settled = false;
  store.ready = async () => { await settling; store.settled = true; return true; };
  store.has = () => kept;
  try {
    assert(A.openDropbox(), "the dialog opens while the store is still thinking");
    assert.strictEqual(els.dbxUnlock.style.display, "none", "nothing is claimed to be saved before the store knows");
    kept = true;                       // what the Keychain will come back with
    release();
    await store.ready();
    await new Promise(r => setTimeout(r, 0));
    assert.strictEqual(els.dbxUnlock.style.display, "", "and the saved connection appears the moment the store settles");
  } finally {
    A.closeDropbox();
    store.settled = wasSettled; store.has = wasHas;
    delete store.ready; store.ready = async function () { return this.settled; };
  }
});

ta("the dialog: two steps, then who is connected; Disconnect keeps nothing; not in the free edition", async () => {
  A.DropboxAuth.disconnect();
  A.state.snapshot = null;
  assert(A.openDropbox(), "opens in the full edition");
  assert(A.dropboxOpen());
  assert.strictEqual(els.dbxStep1.style.display, "", "step 1 is shown");
  assert.strictEqual(els.dbxStep2.style.display, "", "so is step 2");
  assert.strictEqual(els.dbxWho.style.display, "none");
  assert.strictEqual(els.btnDropbox.textContent, "☁️ Dropbox…");

  opened.length = 0;
  await A.dropboxBegin();
  assert.strictEqual(opened.length, 1, "step 1 opened the tab");
  assert(/paste the code/.test(els.dbxStatus.textContent), "and said what to do next: " + els.dbxStatus.textContent);

  fakeDropbox([{ url: "/oauth2/token", json: TOKEN_OK }, { url: "users/get_current_account", json: ACCOUNT }]);
  try {
    els.dbxCode.value = "the-code";
    await A.dropboxConnect();
    assert(A.DropboxAuth.connected());
    assert.strictEqual(els.dbxCode.value, "", "the code is single-use and is not left in the box");
    assert.strictEqual(els.dbxStep1.style.display, "none", "connected: the steps go");
    assert.strictEqual(els.dbxStep2.style.display, "none");
    assert.strictEqual(els.dbxWho.style.display, "");
    assert.strictEqual(els.dbxWhoName.textContent, "Anna Kovacs · anna@example.com");
    assert.strictEqual(els.dbxStatus.textContent, "Connected as Anna Kovacs · anna@example.com.");
    assert.strictEqual(els.btnDropbox.textContent, "☁️ Dropbox connection…", "the menu item reads as the connection's own entry, not as a status line");
    assert(!/mWarn/.test(els.dbxStatus.className));

    await A.dropboxDisconnect();
    assert(!A.DropboxAuth.connected());
    assert.strictEqual(els.dbxStep1.style.display, "", "the steps are back");
    assert(/Nothing of the connection is kept/.test(els.dbxStatus.textContent));
    assert.strictEqual(els.btnDropbox.textContent, "☁️ Dropbox…");

    // a failure is shown in the dialog, in the warning box, and the button comes back
    fakeDropbox([{ url: "/oauth2/token", status: 400, json: { error: "invalid_grant" } }]);
    await A.dropboxBegin();
    els.dbxCode.value = "bad";
    await A.dropboxConnect();
    assert(/did not accept the code/.test(els.dbxStatus.textContent), els.dbxStatus.textContent);
    assert(/mWarn/.test(els.dbxStatus.className));
    assert.strictEqual(els.btnDbxConnect.disabled, false);
  } finally { noNetwork(); }

  A.closeDropbox();
  assert(!A.dropboxOpen());
  // Escape closes it, unlike the conflict dialog: nothing is pending behind it
  A.openDropbox(); press("Escape"); assert(!A.dropboxOpen());

  // a shared copy has no second place to keep plans, and neither has the free edition
  A.state.snapshot = { made: "x" };
  assert.strictEqual(A.openDropbox(), false);
  A.state.snapshot = null;
  const edition = html.slice(html.indexOf("function applyEdition"), html.indexOf("function applyEdition") + 3000);
  for (const id of ["btnDropbox", "dbxHelp", "emptyDbx"])
    assert(new RegExp('"' + id + '"[,\\]]').test(edition), "applyEdition() hides " + id + " in the free edition");
  A.DropboxAuth.disconnect();
});

const eqJson = (a, b, msg) => assert.strictEqual(JSON.stringify(a), JSON.stringify(b), msg);
console.log("\n-- Dropbox: the store (P3) — the same seam, answered by a namespace --");

/* A connected account, ready for the adapter: the token exchange has happened
   and the auth module holds a live access token, so no test below sees the
   token endpoint unless it means to. */
const dbxSignedIn = async (extra) => {
  A.DropboxAuth.disconnect();
  await A.DropboxAuth.begin();
  const log = fakeDropbox([
    { url: "/oauth2/token", json: TOKEN_OK },
    { url: "users/get_current_account", json: ACCOUNT },
    ...(extra || []),
  ]);
  await A.DropboxAuth.finish("code");
  log.length = 0;                     // what follows is the adapter's traffic alone
  return log;
};
// a whole plan file with these rows, written by the app itself so it is the app's own shape
const planText = names => {
  loadRowsNow(names.map((n, i) => ({ id: i + 1, name: n, start: "2026-03-02", end: "2026-03-0" + (2 + i) })));
  return A.serializeCSV(A.state.tasks);
};
const meta = (name, rev, when) => ({ ".tag": "file", name, rev, server_modified: when || "2026-08-16T10:00:00Z", size: 1 });
const FOLDER = { ns: "ns-shared-1", path: "/DB", label: "Team plans" };

t("the ASCII rule for the header that carries a path", () => {
  assert.strictEqual(A.dbxArg({ path: "/DB/plan.gantora" }), '{"path":"/DB/plan.gantora"}');
  assert.strictEqual(A.dbxArg({ path: "/DB/Átállás.gantora" }), '{"path":"/DB/\\u00c1t\\u00e1ll\\u00e1s.gantora"}',
    "escaped after stringify — not percent-encoded, not base64");
  assert(!/[^\x00-\x7f]/.test(A.dbxArg({ path: "/日本/😀" })), "nothing above ASCII survives, whatever the plane");
  assert.strictEqual(JSON.parse(A.dbxArg({ path: "/DB/Átállás" })).path, "/DB/Átállás", "and it still means the same thing");
  // and the two conditions the server's answers are matched on
  assert(A.dbxConflict({ status: 409, json: { error: { ".tag": "path", reason: { ".tag": "conflict", conflict: { ".tag": "file" } } } } }));
  assert(!A.dbxConflict({ status: 409, json: { error: { ".tag": "path", reason: { ".tag": "no_write_permission" } } } }));
  assert(!A.dbxConflict({ status: 500, json: { error_summary: "path/conflict/file/" } }), "the summary string is not matched — its shape is not promised");
  assert(A.dbxNotFound({ status: 409, json: { error: { ".tag": "path", path: { ".tag": "not_found" } } } }));
});

ta("the folder is listed through the namespace, plans only, across a continued listing", async () => {
  const log = await dbxSignedIn([
    { url: "files/list_folder/continue", json: { entries: [meta("zeta.gantora", "r3"), meta("_lists.csv", "r4")], has_more: false } },
    { url: "files/list_folder", json: { entries: [meta("plan.csv", "r1"), { ".tag": "folder", name: "old" }, meta("alpha.gantora", "r2"), meta("notes.txt", "r5")], cursor: "c1", has_more: true } },
  ]);
  try {
    await A.DropboxAdapter.attach(FOLDER);
    assert.strictEqual(A.DropboxAdapter.open(), true);
    assert.strictEqual(A.DropboxAdapter.label, "Team plans");
    assert.strictEqual(A.DropboxAdapter.available(), true, "an account is connected");
    eqArr(await A.DropboxAdapter.list(), ["alpha.gantora", "plan.csv", "zeta.gantora"],
      "plan files only, no folders, no _ files, no strangers, in name order");
    assert.strictEqual(log.length, 2, "one listing and one continuation");
    assert.strictEqual(log[0].url, A.DBX_API_URL + "files/list_folder");
    eqJson(JSON.parse(log[0].body), { path: "/DB", recursive: false, include_deleted: false });
    eqJson(JSON.parse(log[1].body), { cursor: "c1" });
    for (const r of log) {
      assert.strictEqual(r.headers["Dropbox-API-Path-Root"], JSON.stringify({ ".tag": "namespace_id", namespace_id: "ns-shared-1" }),
        "every files/* call names the namespace — a shared folder's path differs per member, its id does not");
      assert.strictEqual(r.headers.Authorization, "Bearer at-1");
    }
    // a shared folder called DB is the store itself: its root lists "" — Dropbox's spelling of the root, not "/"
    await A.DropboxAdapter.attach({ ns: "ns-2", path: "/", name: "DB", label: "DB" });
    const log2 = fakeDropbox([{ url: "files/list_folder", json: { entries: [], has_more: false } }]);
    await A.DropboxAdapter.list();
    assert.strictEqual(JSON.parse(log2[0].body).path, "");
    assert.strictEqual((await A.DropboxAdapter.refFor("p.gantora")).path, "/p.gantora", "and a file in it is /name");
  } finally { noNetwork(); A.DropboxAdapter.disconnect(); }
});

ta("a read is one download, whole, and the rev that came with it answers stampFor() for free", async () => {
  const text = planText(["Design", "Build"]);
  const log = await dbxSignedIn([
    { url: "files/download", when: (b, h) => /plan\.gantora/.test(h["Dropbox-API-Arg"]), text, result: meta("plan.gantora", "rev-A", "2026-08-16T10:00:00Z") },
    { url: "files/download", when: (b, h) => /u00c1/.test(h["Dropbox-API-Arg"]), text, result: meta("Átállás.gantora", "rev-B") },
    { url: "files/download", when: (b, h) => /half/.test(h["Dropbox-API-Arg"]), text: text.slice(0, text.length - 40), result: meta("half.gantora", "rev-C") },
    { url: "files/download", when: (b, h) => /missing/.test(h["Dropbox-API-Arg"]), status: 409, json: { error: { ".tag": "path", path: { ".tag": "not_found" } } } },
  ]);
  try {
    await A.DropboxAdapter.attach(FOLDER);
    const ref = await A.DropboxAdapter.refFor("plan.gantora");
    eqJson(ref, { path: "/DB/plan.gantora" }, "the ref is a path in the namespace — opaque to the app, which only hands it back");
    const res = await A.DropboxAdapter.readHandle(ref);
    assert(res.ok, JSON.stringify(res));
    assert.strictEqual(res.text, text);
    assert.strictEqual(log.length, 1, "one request: no settle-and-retry, a rev is one version");
    assert.strictEqual(log[0].url, A.DBX_CONTENT_URL + "files/download");
    eqJson(log[0].arg, { path: "/DB/plan.gantora" });
    assert(!log[0].body, "no body: the argument is a header");
    assert(log[0].headers["Dropbox-API-Path-Root"], "the namespace, on a content call too");

    // the stamp: the rev and when — from what the download said, no second request
    const stamp = await A.DropboxAdapter.stampFor(ref, text);
    eqJson(stamp, { rev: "rev-A", at: Date.parse("2026-08-16T10:00:00Z") });
    assert.strictEqual(log.length, 1, "stampFor() cost nothing");
    assert.strictEqual(A.DropboxAdapter.stampTime(stamp), Date.parse("2026-08-16T10:00:00Z"));
    assert.strictEqual(await A.DropboxAdapter.readText("plan.gantora"), text, "the raw read is the same download");

    // a name with accents goes out escaped
    const r2 = await A.DropboxAdapter.read("Átállás.gantora");
    assert(r2.ok);
    assert(!/[^\x00-\x7f]/.test(log[log.length - 1].headers["Dropbox-API-Arg"]), "ASCII on the wire");
    assert.strictEqual(log[log.length - 1].arg.path, "/DB/Átállás.gantora", "and the same path when read back");

    // half a file — a build from before the whole-file rule left it — is refused, not adopted
    const r3 = await A.DropboxAdapter.read("half.gantora");
    assert.strictEqual(r3.ok, false); assert.strictEqual(r3.why, "partial");
    // a file that is not there reads as unreadable, and never throws
    const r4 = await A.DropboxAdapter.read("missing.gantora");
    assert.strictEqual(r4.ok, false); assert.strictEqual(r4.why, "unreadable");
    assert.strictEqual(await A.DropboxAdapter.exists("missing.gantora"), false, "…which exists() also says, through get_metadata");
  } finally { noNetwork(); A.DropboxAdapter.disconnect(); }
});

ta("changed(): one metadata call says whether the file moved on; the text is fetched only when it did", async () => {
  const text = planText(["Design"]), later = planText(["Design", "Build"]);
  const log = await dbxSignedIn([
    { url: "files/get_metadata", when: b => /same\.gantora/.test(b), json: meta("same.gantora", "rev-A") },
    { url: "files/get_metadata", when: b => /moved\.gantora/.test(b), json: meta("moved.gantora", "rev-B", "2026-08-16T12:00:00Z") },
    { url: "files/download", when: (b, h) => /moved/.test(h["Dropbox-API-Arg"]), text: later, result: meta("moved.gantora", "rev-B", "2026-08-16T12:00:00Z") },
    { url: "files/get_metadata", when: b => /gone\.gantora/.test(b), status: 409, json: { error: { ".tag": "path", path: { ".tag": "not_found" } } } },
  ]);
  try {
    await A.DropboxAdapter.attach(FOLDER);
    const stamp = { rev: "rev-A", at: 1 };
    assert.strictEqual(await A.DropboxAdapter.changed({ path: "/DB/same.gantora" }, stamp), null, "same rev: as we left it");
    assert.strictEqual(log.length, 1, "and it cost one metadata call, no download — the Cockpit's poll is cheaper here");
    eqJson(JSON.parse(log[0].body), { path: "/DB/same.gantora" });

    const moved = await A.DropboxAdapter.changed({ path: "/DB/moved.gantora" }, stamp);
    assert.strictEqual(moved.text, later, "the new text comes back with the answer, for the dialog and the merge");
    assert.strictEqual(moved.when, Date.parse("2026-08-16T12:00:00Z"));
    eqJson(moved.stamp, { rev: "rev-B", at: Date.parse("2026-08-16T12:00:00Z") });
    assert.strictEqual(log.length, 3, "metadata, then the download");

    assert.strictEqual(await A.DropboxAdapter.changed({ path: "/DB/gone.gantora" }, stamp), A.CHECK_FAILED,
      "a file that is no longer there is not 'unchanged'");
    assert.strictEqual(await A.DropboxAdapter.changed({ path: "/DB/x" }, null), null, "no stamp, no question — as on the folder");
    noNetwork();
    assert.strictEqual(await A.DropboxAdapter.changed({ path: "/DB/same.gantora" }, stamp), A.CHECK_FAILED,
      "and a network that fails is CHECK_FAILED, never 'nothing changed'");
  } finally { noNetwork(); A.DropboxAdapter.disconnect(); }
});

ta("a write is a compare-and-swap: over the rev we saw, no autorename, strict — and a 409 conflict is a conflict", async () => {
  const text = planText(["Design"]);
  const log = await dbxSignedIn([
    { url: "files/upload", when: (b, h) => /"update":"rev-A"/.test(h["Dropbox-API-Arg"]), json: meta("plan.gantora", "rev-B", "2026-08-16T11:00:00Z") },
    { url: "files/upload", when: (b, h) => /"update":"rev-old"/.test(h["Dropbox-API-Arg"]), status: 409,
      json: { error: { ".tag": "path", reason: { ".tag": "conflict", conflict: { ".tag": "file" } } }, error_summary: "path/conflict/file/" } },
    { url: "files/upload", when: (b, h) => /"update":"rev-noperm"/.test(h["Dropbox-API-Arg"]), status: 409,
      json: { error: { ".tag": "path", reason: { ".tag": "no_write_permission" } } } },
    { url: "files/upload", when: (b, h) => /overwrite/.test(h["Dropbox-API-Arg"]), json: meta("_lists.csv", "rev-L") },
  ]);
  try {
    await A.DropboxAdapter.attach(FOLDER);
    const ref = { path: "/DB/plan.gantora" };
    const stamp = await A.DropboxAdapter.writeHandle(ref, text, { rev: "rev-A", at: 1 });
    eqJson(stamp, { rev: "rev-B", at: Date.parse("2026-08-16T11:00:00Z") }, "the new version comes back from the write");
    const up = log[0];
    assert.strictEqual(up.url, A.DBX_CONTENT_URL + "files/upload");
    eqJson(up.arg, { path: "/DB/plan.gantora", mode: { ".tag": "update", update: "rev-A" }, autorename: false, strict_conflict: true, mute: true },
      "update over the rev we agreed with; autorename off, or a silent 'conflicted copy'; strict, or a same-content write passes");
    assert.strictEqual(up.headers["Content-Type"], "application/octet-stream");
    assert.strictEqual(up.body, text, "the text is the body, as is");
    assert(up.headers["Dropbox-API-Path-Root"]);
    // and stampFor(), asked right after with the same text, answers from the write
    eqJson(await A.DropboxAdapter.stampFor(ref, text), stamp);
    assert.strictEqual(log.length, 1, "no metadata call after a write");

    // over a version we did not see: refused by the server, and named as a conflict here
    await assert.rejects(A.DropboxAdapter.writeHandle(ref, text, { rev: "rev-old", at: 1 }), e => e.conflict === true && /changed in Dropbox/.test(e.message));
    // some other 409 is a failure, not a conflict
    await assert.rejects(A.DropboxAdapter.writeHandle(ref, text, { rev: "rev-noperm", at: 1 }), e => !e.conflict && /refused/.test(e.message));
    // no stamp at all: never an unconditional write — the app's rule
    await assert.rejects(A.DropboxAdapter.writeHandle(ref, text, null), e => e.conflict === true);
    assert.strictEqual(log.filter(r => /upload/.test(r.url)).length, 3, "the stampless write never reached the network");

    // writeText: a name in the folder, overwritten — the registry and a new plan come this way
    const made = await A.DropboxAdapter.writeText("_lists.csv", "Team|Dev\r\n");
    eqJson(made, { path: "/DB/_lists.csv" });
    eqJson(log[log.length - 1].arg.mode, { ".tag": "overwrite" });
  } finally { noNetwork(); A.DropboxAdapter.disconnect(); }
});

ta("in the app: a save over a moved-on version opens the conflict dialog, not a 'save failed' toast", async () => {
  resetApp();
  A.setMyName("Anna");
  const text = planText(["Design"]), theirs = planText(["Design", "Build"]);
  /* The server as it is in the race: the check before the write still sees
     rev-A, somebody's save lands, the write is refused, and the re-check sees
     rev-B. A stateful answer, because that is what the window *is*. */
  let looks = 0;
  const log = await dbxSignedIn([
    { url: "files/get_metadata", json: () => meta("plan.gantora", ++looks === 1 ? "rev-A" : "rev-B", "2026-08-16T12:00:00Z") },
    { url: "files/upload", status: 409, json: { error: { ".tag": "path", reason: { ".tag": "conflict", conflict: { ".tag": "file" } } } } },
    { url: "files/download", text: theirs, result: meta("plan.gantora", "rev-B", "2026-08-16T12:00:00Z") },
  ]);
  try {
    A.setStorage(A.DropboxAdapter);
    await A.DropboxAdapter.attach(FOLDER);
    loadTasks(text);
    useName("plan.gantora"); useRef({ path: "/DB/plan.gantora" });
    A.state.stamp = { rev: "rev-A", at: 1 };
    // the pre-write check sees rev-A — as we left it — then the write itself is refused: the window between the two
    A.state.dirty = true;
    await A.saveFileNow(true);
    assert.strictEqual(els.conflictOverlay.style.display, "flex", "the conflict dialog is up");
    assert(/was changed/.test(els.conflictWho.textContent), els.conflictWho.textContent);
    const urls = log.map(r => r.url.replace(/^.*\/2\//, ""));
    eqArr(urls, ["files/get_metadata", "files/upload", "files/get_metadata", "files/download"],
      "check, write (refused), re-check, and the other version fetched for the dialog");
    A.closeConflict();

    // and the happy path: the write lands and the new rev is the stamp — no metadata call after it
    const log2 = fakeDropbox([
      { url: "files/get_metadata", json: meta("plan.gantora", "rev-A") },
      { url: "files/upload", json: meta("plan.gantora", "rev-C", "2026-08-16T13:00:00Z") },
    ]);
    A.state.stamp = { rev: "rev-A", at: 1 }; A.state.dirty = true;
    await A.saveFileNow(true);
    assert.strictEqual(A.state.dirty, false, "saved");
    assert.strictEqual(A.state.stamp.rev, "rev-C", "the stamp is the version just written");
    eqArr(log2.map(r => r.url.replace(/^.*\/2\//, "")), ["files/get_metadata", "files/upload"], "and nothing was fetched to learn it");
  } finally {
    noNetwork();
    A.setStorage(A.FsaAdapter);
    A.DropboxAdapter.disconnect();
    A.DropboxAuth.disconnect();
    resetApp();
  }
});

console.log("\n-- Dropbox: choosing the folder and switching the store (P4) --");

const NOT_FOUND = { status: 409, json: { error: { ".tag": "path", path: { ".tag": "not_found" } } } };

ta("attach() keeps the folder's rule: a folder called DB is the store, any other gets a DB folder inside it", async () => {
  const log = await dbxSignedIn([
    { url: "files/get_metadata", when: b => /"\/Team plans\/DB"/.test(b) || /"\/DB"/.test(b), ...NOT_FOUND },
    { url: "files/get_metadata", when: b => /"\/Has\/DB"/.test(b), json: { ".tag": "folder", name: "DB" } },
    { url: "files/create_folder_v2", json: { metadata: { ".tag": "folder", name: "DB" } } },
  ]);
  try {
    // a shared folder called "Team plans": the plans go in /DB inside it, made because it is not there
    await A.DropboxAdapter.attach({ ns: "ns-1", path: "", name: "Team plans", label: "Team plans" }, false);
    const f = A.DropboxAdapter.folder();
    eqJson([f.ns, f.path, f.name, f.label], ["ns-1", "/DB", "Team plans", "Team plans"]);
    // and the choice as it was made, kept beside it — what a saved connection hands back to attach()
    eqJson(A.DropboxAdapter.chosen(), { ns: "ns-1", path: "", name: "Team plans", label: "Team plans" });
    eqArr(log.map(r => r.url.replace(/^.*\/2\//, "")), ["files/get_metadata", "files/create_folder_v2"], "looked, not there, made");
    eqJson(JSON.parse(log[1].body), { path: "/DB", autorename: false });
    assert.strictEqual(log[0].headers["Dropbox-API-Path-Root"], JSON.stringify({ ".tag": "namespace_id", namespace_id: "ns-1" }),
      "the namespace header, before cfg exists to say it");
    // one that already has a DB folder: looked, found, nothing made
    log.length = 0;
    await A.DropboxAdapter.attach({ ns: "ns-1", path: "/Has", name: "Has", label: "Has" }, false);
    eqArr(log.map(r => r.url.replace(/^.*\/2\//, "")), ["files/get_metadata"]);
    assert.strictEqual(A.DropboxAdapter.folder().path, "/Has/DB");
    // a folder called DB is the store itself: no lookup at all
    log.length = 0;
    await A.DropboxAdapter.attach({ ns: "ns-1", path: "/x/db", name: "db", label: "db" }, false);
    assert.strictEqual(log.length, 0);
    assert.strictEqual(A.DropboxAdapter.folder().path, "/x/db");
    // read-only cannot create, and says which folder it was
    await assert.rejects(A.DropboxAdapter.attach({ ns: "ns-1", path: "", name: "Team plans", label: "Team plans" }, true),
      /no DB folder inside "Team plans"\. Read-only mode cannot create one/);
  } finally { noNetwork(); A.DropboxAdapter.disconnect(); }
});

ta("choices(): the account's shared folders, each a namespace, plus the root namespace for a typed path", async () => {
  const log = await dbxSignedIn([
    { url: "sharing/list_folders/continue", json: { entries: [{ name: "Alpha", shared_folder_id: "sf-3" }] } },
    { url: "sharing/list_folders", json: { entries: [{ name: "Team plans", shared_folder_id: "sf-1", path_lower: "/team plans" }, { name: "Zed", shared_folder_id: "sf-2" }], cursor: "c" } },
  ]);
  try {
    const got = await A.DropboxAdapter.choices();
    assert.strictEqual(got.rootNs, "111", "the account's root namespace, from get_current_account");
    eqArr(got.shared.map(f => f.label), ["Alpha", "Team plans", "Zed"], "in name order, across the continued page");
    eqJson(got.shared[1], { ns: "sf-1", path: "", name: "Team plans", label: "Team plans", shared: true });
    assert.strictEqual(log[0].headers["Dropbox-API-Path-Root"], JSON.stringify({ ".tag": "namespace_id", namespace_id: "111" }),
      "sharing/* calls carry the header too");
    eqJson(JSON.parse(log[0].body), { limit: 100 });
  } finally { noNetwork(); }
});

t("the typed path is a folder in the account's own namespace, tidied", () => {
  els.dbxPath.dataset.rootNs = "111";
  els.dbxPath.value = "Projects\\Gantora\\";
  eqJson(A.dropboxOwnFolder(), { ns: "111", path: "/Projects/Gantora", name: "Gantora", label: "/Projects/Gantora", shared: false });
  els.dbxPath.value = "  /DB  ";
  assert.strictEqual(A.dropboxOwnFolder().name, "DB");
  els.dbxPath.value = "";
  eqJson(A.dropboxOwnFolder(), { ns: "111", path: "", name: "", label: "/", shared: false }, "the root itself, which then gets a /DB");
  els.dbxPath.dataset.rootNs = "";
});

ta("choosing a folder switches the store: the plans are listed from Dropbox, one opens, and the DB button says so", async () => {
  resetApp();
  A.setMyName("Anna");
  const text = planText(["Design", "Build"]);
  A.state.tasks = []; A.resetHistory();
  /* What the dialog said while each request was in the air: the folder being
     read, then the first plan being loaded — the owner asked to see "what is
     happening (or not)" during the few seconds this takes on a real line. */
  const saidWhile = {};
  const log = await dbxSignedIn([
    { url: "sharing/list_folders", json: { entries: [{ name: "Team plans", shared_folder_id: "sf-1" }] } },
    { url: "files/get_metadata", when: b => /"\/DB"/.test(b), json: () => { saidWhile.metadata = els.dbxStatus.textContent; return { ".tag": "folder", name: "DB" }; } },
    { url: "files/list_folder", json: () => { saidWhile.list = saidWhile.list || els.dbxStatus.textContent; return { entries: [{ ".tag": "file", name: "plan.gantora", rev: "r1" }], has_more: false }; } },
    { url: "files/download", when: (b, h) => /_lists/.test(h["Dropbox-API-Arg"]), ...NOT_FOUND },
    { url: "files/download", when: (b, h) => /plan\.gantora/.test(h["Dropbox-API-Arg"]), json: () => { saidWhile.download = els.dbxStatus.textContent; saidWhile.busyClass = els.dbxStatus.classList.contains("dbxBusy"); saidWhile.locked = els.dbxFolders.style.pointerEvents === "none"; return null; }, text, result: meta("plan.gantora", "r1") },
  ]);
  try {
    assert(A.openDropbox(), "the dialog opens");
    await new Promise(r => setTimeout(r, 0));          // fillDropboxFolders() is async
    assert(/Team plans/.test(els.dbxFolders.innerHTML), "the shared folder is offered: " + els.dbxFolders.innerHTML);
    assert.strictEqual(els.dbxStep3.style.display, "", "step 3 is shown once connected");

    const ok = await A.useDropboxFolder({ ns: "sf-1", path: "", name: "Team plans", label: "Team plans", shared: true });
    assert.strictEqual(ok, true);
    assert.strictEqual(saidWhile.metadata, "Opening Team plans… reading the folder.", "while the folder was looked into");
    assert.strictEqual(saidWhile.list, "Opening Team plans… reading the folder.", "and while the plans were listed");
    assert.strictEqual(saidWhile.download, "Opening Team plans… loading the first plan.", "then while the plan came down");
    assert.strictEqual(saidWhile.busyClass, true, "with the breathing dot in front of it");
    assert.strictEqual(saidWhile.locked, true, "and the choices held meanwhile");
    assert.strictEqual(els.dbxFolders.style.pointerEvents, "", "released afterwards");
    assert(!els.dbxStatus.classList.contains("dbxBusy"));
    assert.strictEqual(A.folderOpen(), true, "a store is open");
    assert.strictEqual(A.planName(), "plan.gantora", "and the first plan in it is on screen");
    assert.strictEqual(A.state.tasks.length, 2);
    assert.strictEqual(A.state.stamp.rev, "r1", "with the rev the download carried as its stamp");
    // slice 3: success no longer closes the dialog — it shows state C instead,
    // which is the one place the reader is offered to stay connected
    assert(A.dropboxOpen(), "the dialog stays open on the choice");
    assert.strictEqual(els.dbxKeepRow.style.display, "", "state C: the keep offer is on screen");
    assert.strictEqual(els.dbxStep3.style.display, "none", "and the folder question is gone");
    assert(/Team plans/.test(els.dbxDoneLine.textContent), "the success line names the folder: " + els.dbxDoneLine.textContent);
    assert.strictEqual(els.dbxKeepHead.style.display, "", "nothing kept yet: the checkbox offer, not the saved-row");
    assert.strictEqual(els.dbxSavedRow.style.display, "none");
    A.closeDropbox();
    /* The icon is its own element since 2026-08-22, so the word "DB" can leave
       on a phone — the icon already says which store the plans are in. */
    assert.strictEqual(els.dbIcon.textContent, "☁️", "the DB button wears the cloud");
    assert.strictEqual(els.btnDbMenu.title, "Dropbox: Team plans", "and names the folder");
    /* Connected, the menu carries the pair — the store in use ticked — so
       switching is one tap either way (Megatron, 2026-08-19). */
    assert.strictEqual(els.btnPickFolder.textContent, "📁 Local DB folder", "the local item, not ticked: Dropbox is in use");
    assert.strictEqual(els.btnDbxFolder.style.display, "", "the Dropbox item is on the menu while connected");
    assert.strictEqual(els.btnDbxFolder.textContent, "☁️ Dropbox folder ✓", "and is the one in use");
    const urls = log.map(r => r.url.replace(/^.*\/2\//, ""));
    assert(urls.includes("files/list_folder") && urls.includes("files/download"), urls.join(","));
    assert(!urls.includes("files/create_folder_v2"), "the DB folder was there, so nothing was made");

    // the folder button now opens the dialog rather than a picker
    A.closeDropbox();
    await A.pickFolder();
    assert(A.dropboxOpen(), "pickFolder() in Dropbox mode is the way back to the dialog");
    await new Promise(r => setTimeout(r, 0));
    assert(/Connected as .* — plans in Team plans/.test(els.dbxStatus.textContent), els.dbxStatus.textContent);
    A.closeDropbox();

    // the pair: to the local store and back, the connection untouched and the folder remembered
    A.state.dirty = false;
    assert.strictEqual(await A.useLocalFolder(), true);
    assert(!A.DropboxAdapter.open(), "back on the local store — the Dropbox adapter is closed");
    assert(A.DropboxAuth.connected(), "still connected — this was a switch, not a disconnect");
    assert.strictEqual(A.planName(), "", "the Dropbox plan closed");
    assert.strictEqual(els.dbIcon.textContent, "📁");
    assert.strictEqual(els.btnPickFolder.textContent, "📁 Local DB folder ✓", "now the local item wears the tick");
    assert.strictEqual(els.btnDbxFolder.textContent, "☁️ Dropbox folder", "and the Dropbox one is the way back");
    assert(!A.dropboxOpen(), "no dialog was opened by the switch");
    assert.strictEqual(await A.useDropboxAgain(), true, "one tap back: the folder chosen this session opens again");
    assert(A.DropboxAdapter.open());
    assert.strictEqual(A.DropboxAdapter.label, "Team plans", "the same folder, without the dialog");
    assert.strictEqual(A.planName(), "plan.gantora", "and the plan is back on screen");
    assert(!A.dropboxOpen(), "the dialog stayed shut — nothing to ask");
    assert.strictEqual(els.btnDbxFolder.textContent, "☁️ Dropbox folder ✓");

    // unsaved work is not dropped by a switch
    A.state.dirty = true;
    const wasConfirm = ctx.confirm; ctx.confirm = () => false;
    try {
      assert.strictEqual(await A.useDropboxFolder({ ns: "sf-9", path: "", name: "Other", label: "Other" }), false);
      assert.strictEqual(A.planName(), "plan.gantora", "still open");
      assert.strictEqual(await A.useLocalFolder(), false, "nor by the switch to local");
      assert(A.DropboxAdapter.open(), "still on Dropbox");
      assert.strictEqual(await A.leaveDropbox(), false, "and neither does leaving");
      assert(A.DropboxAuth.connected());
    } finally { ctx.confirm = wasConfirm; }
    A.state.dirty = false;

    // Disconnect: back to the local folder, nothing open, the account forgotten
    assert.strictEqual(await A.leaveDropbox(), true);
    assert(!A.DropboxAuth.connected(), "the account is forgotten");
    assert(!A.DropboxAdapter.open(), "and so is the folder");
    assert.strictEqual(A.folderOpen(), false, "nothing is open");
    assert.strictEqual(A.planName(), "", "no plan");
    assert.strictEqual(els.dbIcon.textContent, "📁", "the button is the folder's again");
    /* 🔴 The PAIR stays after disconnecting (2026-08-22): the DB menu answers
       "where do my plans live", and it cannot answer that only once you have
       already answered it elsewhere. Pressing the Dropbox one while
       disconnected IS the request to connect. */
    assert.strictEqual(els.btnPickFolder.textContent, "📁 Local DB folder ✓",
      "disconnected: the local store is the one in use, and says so");
    assert.strictEqual(els.btnDbxFolder.style.display, "", "and the Dropbox item is still the way there");
    assert.strictEqual(els.btnDbxFolder.textContent, "☁️ Dropbox folder", "…without a tick, because it is not in use");
  } finally {
    noNetwork();
    A.setStorage(A.FsaAdapter);
    A.DropboxAdapter.disconnect();
    A.DropboxAuth.disconnect();
    resetApp();
  }
});

console.log("\n-- Dropbox: the automatic saves are gathered, and a 429 waits its turn (P5) --");

/* The app on the Dropbox store with one plan open and a stamp, and a fake that
   accepts every write. What each test then does with the saves is the point. */
const onDropbox = async (extra) => {
  resetApp();
  A.setMyName("Anna");
  const text = planText(["Design", "Build"]);
  const log = await dbxSignedIn([
    { url: "files/get_metadata", json: meta("plan.gantora", "rev-A") },
    { url: "files/upload", json: (b, h) => meta("plan.gantora", "rev-" + (log.filter(r => /upload/.test(r.url)).length), "2026-08-16T11:00:00Z") },
    ...(extra || []),
  ]);
  A.setStorage(A.DropboxAdapter);
  await A.DropboxAdapter.attach({ ns: "sf-1", path: "/DB", name: "DB", label: "DB" });
  loadTasks(text);
  useName("plan.gantora"); useRef({ path: "/DB/plan.gantora" });
  A.state.stamp = { rev: "rev-A", at: 1 };
  return log;
};
const offDropbox = () => {
  noNetwork();
  A.setStorage(A.FsaAdapter);
  A.DropboxAdapter.disconnect();
  A.DropboxAuth.disconnect();
  resetApp();
};
const uploads = log => log.filter(r => /files\/upload/.test(r.url)).length;

ta("the Dropbox folder list: one row is picked, not opened, and the preview matches attach()'s own DB rule", async () => {
  await dbxSignedIn([
    { url: "sharing/list_folders", json: { entries: [{ name: "Team plans", shared_folder_id: "sf-1" }] } },
  ]);
  try {
    assert(A.openDropbox());
    await new Promise(r => setTimeout(r, 0));      // fillDropboxFolders() is async
    assert(/Team plans/.test(els.dbxFolders.innerHTML), "the shared folder is a row in the list");
    assert(/Another folder in your own Dropbox/.test(els.dbxFolders.innerHTML), "and the own-path option is the list's own last row, not a separate control");
    assert.strictEqual(els.btnDbxOpen.disabled, true, "nothing picked yet");
    assert.strictEqual(els.dbxOwnRow.style.display, "none", "the path field waits for its own row to be picked");

    // picking a shared folder: Open wakes up, the path field stays shut
    A.pickDbxFolder({ id: "someSharedRow" }, FOLDER);
    assert.strictEqual(els.btnDbxOpen.disabled, false, "a folder is picked");
    assert.strictEqual(els.dbxOwnRow.style.display, "none", "still not the own-path row");

    // picking the own-path row instead: the field opens, and Open waits on typed text
    A.pickDbxFolder({ id: "dbxOwnPick" }, null);
    assert.strictEqual(els.dbxOwnRow.style.display, "", "the path field opens for its own row");
    assert(els.dbxPath._focused, "and takes focus, since typing is the next thing to do");
    assert.strictEqual(els.btnDbxOpen.disabled, true, "empty path: nothing to open yet");

    els.dbxPath.value = "/Projects/Gantora";
    A.dbxPathPreview();
    assert.strictEqual(els.btnDbxOpen.disabled, false, "typed path: Open wakes up");
    assert(/The plans will live in \/Projects\/Gantora\/DB/.test(els.dbxPathPreview.textContent),
      "the preview names the DB subfolder attach() @9186 would make: " + els.dbxPathPreview.textContent);

    // a path already ending in /DB is not doubled — the same rule attach() applies
    els.dbxPath.value = "/Projects/Gantora/DB";
    A.dbxPathPreview();
    assert(/\/Projects\/Gantora\/DB/.test(els.dbxPathPreview.textContent) && !/DB\/DB/.test(els.dbxPathPreview.textContent),
      "no double DB: " + els.dbxPathPreview.textContent);

    // a blank path disables Open again rather than leaving the last state stale
    els.dbxPath.value = "";
    A.dbxPathPreview();
    assert.strictEqual(els.btnDbxOpen.disabled, true, "blank again: nothing to open");
    assert.strictEqual(els.dbxPathPreview.textContent, "", "and the preview clears with it");
  } finally { offDropbox(); }
});

ta("the keep offer reads dbxTokenStore.needs, not the platform — \"session\" means no offer at all", async () => {
  // desktop's one value today; a native build (M4b) without a screen lock
  // is the other real case for this branch, not tested here
  await onDropbox();
  try {
    A.openDropbox();
    assert.strictEqual(A.dbxTokenStore.needs, "password", "the only value file:// ever has");
    assert.strictEqual(els.dbxKeepBox.style.display, "", "password: the checkbox offer is on screen");

    A.dbxTokenStore.needs = "session";
    try {
      A.syncDropboxKeepBox();
      assert.strictEqual(els.dbxKeepBox.style.display, "none", "session: no offer, because there is nothing to protect it with");
    } finally { A.dbxTokenStore.needs = "password"; }

    A.syncDropboxKeepBox();
    assert.strictEqual(els.dbxKeepBox.style.display, "", "back to password: the box returns");
  } finally { offDropbox(); }
});

ta("on Dropbox, thirty automatic saves are one write — asked for by hand, it goes now and takes the pending one with it", async () => {
  const log = await onDropbox();
  try {
    assert.strictEqual(A.savePending(), false);
    const waits = [];
    for (let i = 0; i < 30; i++) { A.markViewDirty(); waits.push(A.saveFile(true)); }   // as every edit does
    assert.strictEqual(A.savePending(), true, "gathered, not sent");
    assert.strictEqual(uploads(log), 0, "nothing has gone yet");
    assert.strictEqual(A.DBX_SAVE_DELAY_MS, 2500, "two and a half seconds after the last change");
    assert.strictEqual(els.status.className, "dirty", "the dot is amber while a save is only pending: it is unsaved until it lands");

    await A.flushSave();
    assert.strictEqual(uploads(log), 1, "one write for thirty changes");
    assert.strictEqual(A.savePending(), false);
    await Promise.all(waits);                       // every caller that awaited is answered
    assert.strictEqual(A.state.dirty, false);
    assert.strictEqual(els.status.className, "saved");
    assert.strictEqual(A.state.stamp.rev, "rev-1", "and the stamp is the version just written");

    // a save by hand: no waiting, and the gathered one is folded into it
    A.state.dirty = true; A.saveFile(true); A.saveFile(true);
    assert(A.savePending());
    await A.saveFile(false);
    assert.strictEqual(A.savePending(), false, "the pending save went with the manual one");
    assert.strictEqual(uploads(log), 2, "one more write, not two");

    // the dot breathes while the write is in the air
    let sawSaving = false;
    fakeDropbox([
      { url: "files/get_metadata", json: meta("plan.gantora", "rev-2") },
      { url: "files/upload", json: () => { sawSaving = els.status.className === "saving"; return meta("plan.gantora", "rev-3"); } },
    ]);
    A.state.stamp = { rev: "rev-2", at: 1 }; A.state.dirty = true;
    await A.saveFile(false);
    assert(sawSaving, "the dot said 'saving' during the write");
    assert.strictEqual(els.status.className, "saved", "and 'saved' after it");
  } finally { offDropbox(); }
});

ta("on the local folder nothing is gathered: a save is immediate, as it always was", async () => {
  resetApp();
  const files = { "plan.gantora": mergeFixture() };
  const root = fakeRoot(files);
  A.state.rootHandle = root; A.state.dbHandle = root;
  useName("plan.gantora"); useRef(await root.getFileHandle("plan.gantora"));
  A.state.dirty = true;
  const p = A.saveFile(true);
  assert.strictEqual(A.savePending(), false, "not gathered on the folder");
  await p;
  assert.strictEqual(A.state.dirty, false, "written at once");
  resetApp();
});

ta("a gathered save is never lost: leaving the plan, hiding the tab or unloading the page writes it first", async () => {
  const log = await onDropbox();
  try {
    // opening another plan: the pending write goes before the plan closes
    A.state.dirty = true; A.saveFile(true);
    assert(A.savePending());
    // the download answers with a plan, so openFile can adopt it
    const other = planText(["Only"]);
    A.state.tasks = A.parseCSV(A.serializeCSV(A.state.tasks));   // planText() replaced state.tasks; put a plan back
    fakeDropbox([
      { url: "files/get_metadata", json: meta("plan.gantora", "rev-A") },
      { url: "files/upload", json: () => meta("plan.gantora", "rev-1") },
      { url: "files/download", text: other, result: meta("other.gantora", "rev-o") },
    ]).length;
    await A.openFile("other.gantora");
    assert.strictEqual(A.planName(), "other.gantora");
    assert.strictEqual(A.savePending(), false, "nothing left pending");
    // (the write went: the fake answered an upload before the download)

    // the tab hidden: flushed
    useName("plan.gantora"); useRef({ path: "/DB/plan.gantora" }); A.state.stamp = { rev: "rev-1", at: 1 };
    const log2 = fakeDropbox([
      { url: "files/get_metadata", json: meta("plan.gantora", "rev-1") },
      { url: "files/upload", json: () => meta("plan.gantora", "rev-2") },
    ]);
    A.state.dirty = true; A.saveFile(true);
    assert(A.savePending());
    ctx.document.hidden = true;
    (docOn.visibilitychange || []).forEach(fn => fn({}));
    ctx.document.hidden = false;
    assert.strictEqual(A.savePending(), false, "visibilitychange flushed it");
    await new Promise(r => setTimeout(r, 0)); await new Promise(r => setTimeout(r, 0));
    assert.strictEqual(uploads(log2), 1);

    // the page on its way out: flushed too, and beforeunload kicks it as well
    A.state.dirty = true; A.saveFile(true);
    (winOn.pagehide || []).forEach(fn => fn({}));
    assert.strictEqual(A.savePending(), false, "pagehide flushed it");
    A.state.dirty = true; A.saveFile(true);
    (winOn.beforeunload || []).forEach(fn => fn({ preventDefault(){} }));
    assert.strictEqual(A.savePending(), false, "beforeunload flushed it");
    await new Promise(r => setTimeout(r, 0)); await new Promise(r => setTimeout(r, 0));

    // and leaving everything drops what is left, resolving whoever waited
    A.state.dirty = true; const w = A.saveFile(true);
    assert(A.savePending());
    A.leaveEverything();
    assert.strictEqual(A.savePending(), false);
    await w;                                          // answered, not left hanging
  } finally { offDropbox(); }
});

ta("a 429 waits Retry-After and asks again — twice at most", async () => {
  const log = await onDropbox();
  try {
    let hits = 0, tries = 0;
    const log3 = fakeDropbox([
      { url: "files/get_metadata", json: meta("plan.gantora", "rev-A") },
      { url: "files/upload", status: 429, when: () => ++tries <= 2, json: { error: { ".tag": "too_many_write_operations" } } },
      { url: "files/upload", json: () => meta("plan.gantora", "rev-1") },
    ]);
    // Retry-After: 0 keeps the test quick; the header is read as seconds
    const origFetch = ctx.fetch;
    ctx.fetch = async (u, o) => { const r = await origFetch(u, o); if (r.status === 429) { hits++; r.headers = { get: n => n === "Retry-After" ? "0" : null }; } return r; };
    A.state.dirty = true;
    await A.saveFile(false);
    assert.strictEqual(hits, 2, "refused twice");
    assert.strictEqual(uploads(log3), 3, "…and the third try was the one that landed");
    assert.strictEqual(A.state.dirty, false);
    assert.strictEqual(A.state.stamp.rev, "rev-1");

    // three refusals in a row: given up, reported, the plan kept in memory
    hits = 0;
    fakeDropbox([
      { url: "files/get_metadata", json: meta("plan.gantora", "rev-1") },
      { url: "files/upload", status: 429, json: { error: { ".tag": "too_many_write_operations" } } },
    ]);
    const orig2 = ctx.fetch;
    ctx.fetch = async (u, o) => { const r = await orig2(u, o); if (r.status === 429) { hits++; r.headers = { get: n => n === "Retry-After" ? "0" : null }; } return r; };
    A.state.dirty = true;
    await A.saveFile(false);
    assert.strictEqual(hits, 1 + A.DBX_RETRY_MAX, "one try and DBX_RETRY_MAX more");
    assert.strictEqual(A.state.dirty, true, "not saved, and not pretending to be");
    assert.strictEqual(A.state.tasks.length, 2, "nothing in memory was dropped");
    assert(/Save failed/.test(els.toast.textContent), "and it was said: " + els.toast.textContent);

    // the wait itself: Retry-After in seconds, a default when it is missing
    assert.strictEqual(A.DBX_RETRY_DEFAULT_MS, 3000);
    let n = 0;
    const r = await A.dbxRetrying(async () => ({ status: n++ < 1 ? 429 : 200, headers: { get: () => "0" } }));
    assert.strictEqual(r.status, 200); assert.strictEqual(n, 2);
  } finally { offDropbox(); }
});

console.log("\n-- Dropbox: no connection (P6) — the plan stays here, unsaved, and tries again --");

/* The network gone: fetch throws instead of answering, which is all "offline"
   means to the app. It is a state to be in, not a question to ask. */
const noConnection = () => { ctx.fetch = async () => { throw new TypeError("Failed to fetch"); }; };

ta("offline, a save says so once, asks nothing, keeps the plan, and the dot stays amber with the reason", async () => {
  await onDropbox();
  try {
    noConnection();
    let asked = 0;
    const wasConfirm = ctx.confirm; ctx.confirm = () => { asked++; return true; };
    try {
      els.toast.textContent = "";
      A.markViewDirty();
      await A.saveFile(false);                        // by hand, so it goes now
      assert.strictEqual(asked, 0, "the 'save unchecked?' question is not asked when there is no network — the write would not go either");
      assert.strictEqual(A.state.dirty, true, "unsaved");
      assert.strictEqual(A.state.tasks.length, 2, "and nothing was dropped");
      assert.strictEqual(A.DropboxAdapter.offline(), true);
      assert(/No connection to Dropbox — nothing was lost/.test(els.toast.textContent), els.toast.textContent);
      assert.strictEqual(els.status.className, "dirty");
      assert(/no connection to Dropbox/.test(els.status.title), "the dot's tooltip says why: " + els.status.title);

      // said once: the next automatic attempt is quiet, a manual one gets a short reminder
      els.toast.textContent = "";
      A.markViewDirty(); A.saveFile(true);            // an automatic one, gathered
      await A.flushSave();
      assert(!/nothing was lost/.test(els.toast.textContent), "the long sentence is not repeated: " + els.toast.textContent);
      await A.saveFile(false);
      assert(/Still no connection/.test(els.toast.textContent), els.toast.textContent);
    } finally { ctx.confirm = wasConfirm; }

    // the network back: the retry writes it, and says so
    const log = fakeDropbox([
      { url: "files/get_metadata", json: meta("plan.gantora", "rev-A") },
      { url: "files/upload", json: () => meta("plan.gantora", "rev-1") },
    ]);
    els.toast.textContent = "";
    (winOn.online || []).forEach(fn => fn({}));       // the browser's own word for it
    assert(A.savePending(), "the retry is a gathered save, like any automatic one");
    await A.flushSave();
    assert.strictEqual(A.state.dirty, false, "saved");
    assert.strictEqual(uploads(log), 1);
    assert(/Back online — saved to Dropbox/.test(els.toast.textContent), els.toast.textContent);
    assert.strictEqual(A.DropboxAdapter.offline(), false);
    assert.strictEqual(els.status.className, "saved");
    assert.strictEqual(A.DBX_OFFLINE_RETRY_MS, 15000, "and left alone, it would have tried again on its own every fifteen seconds");
  } finally { offDropbox(); }
});

ta("offline, a read says it is the connection, not the file — and offers no cloud-client steps", async () => {
  await onDropbox();
  try {
    noConnection();
    const res = await A.DropboxAdapter.read("plan.gantora");
    assert.strictEqual(res.ok, false);
    assert.strictEqual(res.why, "offline");
    assert(/No connection to Dropbox, so DB\/plan\.gantora could not be read/.test(A.readFailText(res.why, "DB/plan.gantora", 0)));
    // whereas a placeholder on a synced disk still gets the steps that fix it
    assert(/only in the cloud/.test(A.readFailText("unreadable", "DB/x", 0)));
    // the metadata check is CHECK_FAILED, and the store says why
    assert.strictEqual(await A.DropboxAdapter.changed({ path: "/DB/plan.gantora" }, { rev: "rev-A", at: 1 }), A.CHECK_FAILED);
    assert(A.DropboxAdapter.offline());
  } finally { offDropbox(); }
});

console.log("\n-- Dropbox: staying connected — the kept connection behind a password (P2b) --");

ta("the store: a connection kept behind a password, unreadable without it, refused when the password is short", async () => {
  ctx.localStorage.removeItem(A.DBX_STORE_KEY);
  assert(!A.dbxTokenStore.has());
  // the codec the blob uses
  for (const n of [0, 1, 2, 3, 16, 33]) {
    const bytes = require("crypto").randomBytes(n);
    assert.strictEqual(Buffer.from(A.b64urlBytes(A.b64url(bytes))).toString("hex"), bytes.toString("hex"), "b64url round trip " + n);
  }
  await assert.rejects(A.dbxTokenStore.save({ refresh: "rt-secret" }, "short"), /at least 8 characters/);
  assert(!A.dbxTokenStore.has(), "nothing stored on a refusal");

  await A.dbxTokenStore.save({ refresh: "rt-secret", folder: { ns: "sf-1", path: "/DB", name: "DB", label: "Team plans" } }, "correct horse battery");
  assert(A.dbxTokenStore.has());
  const raw = ctx.localStorage.getItem(A.DBX_STORE_KEY);
  assert(!raw.includes("rt-secret") && !raw.includes("Team plans"), "nothing readable is in storage: " + raw.slice(0, 80));
  const blob = JSON.parse(raw);
  assert.strictEqual(blob.v, 1); assert.strictEqual(blob.kdf, "PBKDF2-SHA256"); assert.strictEqual(blob.iter, A.DBX_KDF_ITER);
  assert.strictEqual(A.DBX_KDF_ITER, 600000, "six hundred thousand rounds: a wordlist against the blob is slow");
  assert(/^[A-Za-z0-9_-]{22}$/.test(blob.salt) && /^[A-Za-z0-9_-]{16}$/.test(blob.iv), "a 16-byte salt and a 12-byte iv, base64url");
  for (const k of ["refresh", "folder", "password", "token"]) assert(!(k in blob), "no field called " + k);

  const got = await A.dbxTokenStore.unlock("correct horse battery");
  assert.strictEqual(got.refresh, "rt-secret");
  assert.strictEqual(got.folder.label, "Team plans");
  await assert.rejects(A.dbxTokenStore.unlock("correct horse batterx"), e => e.wrong === true && /not the password/.test(e.message),
    "a wrong password fails on the tag and is named as wrong, not as garbage");
  // a second save has a fresh salt and iv, so two saves of the same secret do not look alike
  await A.dbxTokenStore.save({ refresh: "rt-secret" }, "correct horse battery");
  const blob2 = JSON.parse(ctx.localStorage.getItem(A.DBX_STORE_KEY));
  assert.notStrictEqual(blob2.salt, blob.salt); assert.notStrictEqual(blob2.iv, blob.iv); assert.notStrictEqual(blob2.ct, blob.ct);
  // a blob a later build wrote, or a hand-edited one, is refused as unreadable, not as a wrong password
  ctx.localStorage.setItem(A.DBX_STORE_KEY, JSON.stringify({ ...blob2, v: 2 }));
  await assert.rejects(A.dbxTokenStore.unlock("correct horse battery"), e => !e.wrong && /cannot be read by this build/.test(e.message));
  A.dbxTokenStore.clear();
  assert(!A.dbxTokenStore.has());
  assert.strictEqual(ctx.localStorage.getItem(A.DBX_STORE_KEY), null);
});

ta("keep and unlock through the dialog: saved once, one password reconnects and opens the folder next time", async () => {
  resetApp();
  A.setMyName("Anna");
  ctx.localStorage.removeItem(A.DBX_STORE_KEY);
  const text = planText(["Design"]);
  A.state.tasks = []; A.resetHistory();
  // connected and on a folder, as after P4
  const log = await dbxSignedIn([
    { url: "sharing/list_folders", json: { entries: [] } },
    { url: "files/get_metadata", when: b => /"\/DB"/.test(b), json: { ".tag": "folder", name: "DB" } },
    { url: "files/list_folder", json: { entries: [{ ".tag": "file", name: "plan.gantora", rev: "r1" }], has_more: false } },
    { url: "files/download", when: (b, h) => /_lists/.test(h["Dropbox-API-Arg"]), ...NOT_FOUND },
    { url: "files/download", when: (b, h) => /plan\.gantora/.test(h["Dropbox-API-Arg"]), text, result: meta("plan.gantora", "r1") },
    { url: "/oauth2/token", when: b => /refresh_token/.test(b), json: { access_token: "at-9", expires_in: 14400 } },
  ]);
  try {
    assert(A.openDropbox());
    assert.strictEqual(els.dbxKeepRow.style.display, "none", "connected but no folder yet: the keep offer waits for it");
    assert.strictEqual(els.dbxStep3.style.display, "", "the one question right now is which folder");
    assert.strictEqual(els.dbxUnlock.style.display, "none", "and there is nothing to unlock");
    await A.useDropboxFolder({ ns: "sf-1", path: "", name: "Team plans", label: "Team plans", shared: true });
    assert.strictEqual(A.planName(), "plan.gantora");

    // keeping: short password refused in the dialog; a good one saves the connection *and the folder*
    A.openDropbox();
    els.dbxPass.value = "short";
    assert.strictEqual(await A.dropboxKeep(), false);
    assert(/at least 8 characters/.test(els.dbxStatus.textContent), els.dbxStatus.textContent);
    assert(!A.dbxTokenStore.has());
    els.dbxPass.value = "correct horse battery";
    assert.strictEqual(await A.dropboxKeep(), true);
    assert(A.dbxTokenStore.has(), "kept");
    assert.strictEqual(els.dbxPass.value, "", "the password is not left in the box");
    assert.strictEqual(els.dbxSavedRow.style.display, "flex", "the checkbox offer becomes the saved-row");
    assert.strictEqual(els.dbxKeepHead.style.display, "none");
    assert(/Saved\. Next time, the password reconnects and opens Team plans/.test(els.dbxStatus.textContent), els.dbxStatus.textContent);
    const raw = ctx.localStorage.getItem(A.DBX_STORE_KEY);
    assert(!raw.includes("rt-1"), "the refresh token is not readable in storage");
    A.closeDropbox();

    // Disconnect is for the session: the kept connection stays, and the dialog offers to unlock it
    await A.leaveDropbox();
    assert(!A.DropboxAuth.connected());
    assert(A.dbxTokenStore.has(), "Disconnect does not forget the kept connection");
    A.openDropbox();
    assert.strictEqual(els.dbxUnlock.style.display, "", "the unlock step is shown");
    /* Round 2/2 (dbx-dialog-mobil-brief): connecting afresh is still there, but
       folded — with a connection kept here, the two steps are the rare answer,
       not the first thing on the screen. */
    assert.strictEqual(els.dbxFreshToggle.style.display, "", "the fold's one line is offered");
    assert(!A.dbxFreshOpen(), "and the two steps start folded away");
    assert(els.dbxPass0._focused, "the password box has focus: one password is the way in");

    // a wrong password says so and changes nothing
    els.dbxPass0.value = "wrong password here";
    assert.strictEqual(await A.dropboxUnlock(), false);
    /* 🔴 The error now sits UNDER the field, not at the foot of the dialog:
       on a phone the foot is off the screen, so a wrong password looked like
       nothing happening at all. */
    assert(/not the password/.test(els.dbxUnlockErr.textContent), els.dbxUnlockErr.textContent);
    assert.strictEqual(els.dbxUnlockErr.style.display, "", "and it is visible, not just written");
    assert(!A.dbxFreshOpen(), "a wrong password is not a reason to unfold the whole connect flow");
    /* …and points at the way out — "Forget it" did not read as "what you do
       when you have forgotten it" (Megatron, 2026-08-19), so the error says so */
    assert(/Forgotten it\? Press “Forget it”/.test(els.dbxUnlockErr.textContent), "the wrong-password error names the way out: " + els.dbxUnlockErr.textContent);
    assert(/Forgot the password\?/.test(html.match(/id="dbxUnlockHint">([^<]*)</)[1]), "and the hint under the box says it before anything goes wrong");
    assert(!A.DropboxAuth.connected());

    // the right one: reconnects with the refresh grant, and opens the folder it was saved on
    els.dbxPass0.value = "correct horse battery";
    log.length = 0;
    const unlocked = await A.dropboxUnlock();
    assert.strictEqual(unlocked, true, "unlock failed: " + els.dbxStatus.textContent);
    assert(A.DropboxAuth.connected(), "reconnected");
    assert.strictEqual(els.dbxPass0.value, "");
    const grants = log.filter(r => r.url === A.DBX_TOKEN_URL).map(r => formOf(r.body).grant_type);
    eqArr(grants, ["refresh_token"], "no code, no dropbox.com: the kept refresh token bought a new access token");
    assert.strictEqual(formOf(log.find(r => r.url === A.DBX_TOKEN_URL).body).refresh_token, "rt-1", "the very token that was kept");
    assert.strictEqual(A.folderOpen(), true, "and the folder is open");
    assert.strictEqual(A.planName(), "plan.gantora", "with the plan on screen");
    assert.strictEqual(els.btnDbMenu.title, "Dropbox: Team plans");
    assert(!A.dropboxOpen(), "the dialog closed behind it");

    // forgetting the kept connection: gone from storage, the dialog stops offering it
    await A.leaveDropbox();
    A.openDropbox();
    A.dropboxForgetKept();
    assert(!A.dbxTokenStore.has());
    assert.strictEqual(els.dbxUnlock.style.display, "none");
    assert(/removed from this computer/.test(els.dbxStatus.textContent));
    A.closeDropbox();
  } finally { offDropbox(); ctx.localStorage.removeItem(A.DBX_STORE_KEY); }
});

/* 🔴 …and the same road needs to SAY it is working. Daniel, minutes after the
   folder fix: "nem biztos hogy történik valami, eltelik jó néhány másodperc —
   azt hittem rossz jelszót adtam meg". The seconds are real (600 000 PBKDF2
   rounds, then the reconnect and the folder), and the dialog's status line is
   at the foot where nobody looks while waiting for a password to be taken. */
ta("unlocking puts the loading strip up before the key derivation blocks anything", async () => {
  resetApp();
  ctx.localStorage.removeItem(A.DBX_STORE_KEY);
  await A.dbxTokenStore.save({ refresh: "rt-1", folder: null }, "correct horse battery");
  A.DropboxAuth.disconnect();
  let whenUnlockRan = "", whenSpinRan = "x";
  const realUnlock = A.dbxTokenStore.unlock.bind(A.dbxTokenStore);
  A.dbxTokenStore.unlock = async pw => {
    whenUnlockRan = els.busyBar.style.display;
    whenSpinRan = els.dbxSpin.style.display;      // "" is shown: the inline "none" was cleared
    return realUnlock(pw);
  };
  fakeDropbox([{ url: "/oauth2/token", status: 401, json: { error: "invalid_grant" } }]);
  try {
    A.state.snapshot = null;
    A.openDropbox();
    els.dbxPass0.value = "correct horse battery";
    await A.dropboxUnlock();                       // the token is dead; what matters is the strip
    assert.strictEqual(whenUnlockRan, "block",
      "the strip has to be painted BEFORE the slow part — put up after it, it is a flicker at the end");
    assert.strictEqual(els.busyBar.style.display, "none", "and it goes when the work does, however it ended");
    /* 🔴 …and the strip alone was not enough (Daniel, on the desktop, after
       testing the first version: "van egy lézercsík, de eltér a többitől — az
       ablakok elé kéne a szokásos forgó"). The strip is the same 3px line
       wherever you are, so beside a dialog it does not say that THIS window is
       the thing working. The ring the empty screen already uses goes over the
       dialog for the same seconds. */
    assert.strictEqual(whenSpinRan, "", "the ring is up before the slow part, not after it");
    assert.strictEqual(els.dbxSpin.style.display, "none", "and it is gone when the work is");
  } finally {
    A.dbxTokenStore.unlock = realUnlock;
    offDropbox(); ctx.localStorage.removeItem(A.DBX_STORE_KEY);
  }
});

/* 🔴 Daniel on the desktop, 2026-08-23: "ha kiválasztok egy Dropbox
   könyvtárat, jegyezze meg — ez működik lokálisnál, de Dropboxnál nem, mindig
   újra kell választani password után." The kept connection only carries a
   folder when one was open at the moment Save was pressed; the OTHER memory —
   the one every successful folder-open writes, and the one the fresh two-step
   connect has always read — was not consulted on the password road. So a
   connection saved a moment too early sent somebody back to a folder list they
   had answered before. */
ta("unlocking opens the last folder even when the kept connection carries none", async () => {
  resetApp();
  ctx.localStorage.removeItem(A.DBX_STORE_KEY);
  const text = planText(["Design"]);
  // kept WITHOUT a folder — saved before one was open, which the app allows and says
  await A.dbxTokenStore.save({ refresh: "rt-1", folder: null }, "correct horse battery");
  // …but a folder was opened at some point, and that is remembered in the clear
  ctx.localStorage.setItem("ppp.dbxlast", JSON.stringify({
    folder: { ns: "sf-1", path: "", name: "Team plans", label: "Team plans" }, plan: "plan.gantora" }));
  A.DropboxAuth.disconnect();
  fakeDropbox([
    { url: "/oauth2/token", json: { access_token: "at-9", expires_in: 14400 } },
    { url: "users/get_current_account", json: ACCOUNT },
    { url: "files/get_metadata", when: b => /"\/DB"/.test(b), json: { ".tag": "folder", name: "DB" } },
    { url: "files/list_folder", json: { entries: [{ ".tag": "file", name: "plan.gantora", rev: "r1" }], has_more: false } },
    { url: "files/download", when: (b, h) => /_lists/.test(h["Dropbox-API-Arg"]), ...NOT_FOUND },
    { url: "files/download", when: (b, h) => /plan\.gantora/.test(h["Dropbox-API-Arg"]), text, result: meta("plan.gantora", "r1") },
  ]);
  try {
    A.state.snapshot = null;
    A.openDropbox();
    els.dbxPass0.value = "correct horse battery";
    assert.strictEqual(await A.dropboxUnlock(), true, els.dbxStatus.textContent);
    assert(A.DropboxAuth.connected(), "the password reconnected");
    assert.strictEqual(A.folderOpen(), true, "…and the folder opened by itself: no list to answer again");
    assert.strictEqual(A.planName(), "plan.gantora", "with the plan that was open in it");
    assert(!A.dropboxOpen(), "the dialog closed behind it, as it does when the blob carries the folder");
  } finally { offDropbox(); ctx.localStorage.removeItem(A.DBX_STORE_KEY); ctx.localStorage.removeItem("ppp.dbxlast"); }
});

ta("a kept connection Dropbox no longer accepts is removed, and the dialog says to connect afresh", async () => {
  resetApp();
  ctx.localStorage.removeItem(A.DBX_STORE_KEY);
  await A.dbxTokenStore.save({ refresh: "rt-dead", folder: null }, "correct horse battery");
  A.DropboxAuth.disconnect();
  fakeDropbox([{ url: "/oauth2/token", status: 401, json: { error: "invalid_grant" } }]);
  try {
    A.state.snapshot = null;
    A.openDropbox();
    els.dbxPass0.value = "correct horse battery";
    assert.strictEqual(await A.dropboxUnlock(), false);
    assert(!A.DropboxAuth.connected());
    assert(!A.dbxTokenStore.has(), "the dead token was forgotten — it would only fail again");
    /* Round 2/2: this failure is the CONNECTION, not the password — and the two
       steps are the answer to it, so the folded block opens by itself rather
       than leaving somebody to find a line they have no reason to suspect. */
    assert(/no longer accepts the saved connection — it was removed/.test(els.dbxStatus.textContent), els.dbxStatus.textContent);
    assert(A.dbxFreshOpen(), "a dead connection unfolds the way to make a new one");
    assert.strictEqual(els.dbxUnlockErr.style.display, "none",
      "and the sentence is NOT put under a field that has just left the screen");
    assert.strictEqual(els.dbxUnlock.style.display, "none", "and nothing is offered to unlock any more");
    A.closeDropbox();

    // no network: the kept connection stays, and it says to try again
    await A.dbxTokenStore.save({ refresh: "rt-ok", folder: null }, "correct horse battery");
    noConnection();
    A.openDropbox();
    els.dbxPass0.value = "correct horse battery";
    assert.strictEqual(await A.dropboxUnlock(), false);
    assert(A.dbxTokenStore.has(), "kept: the token is not known to be dead");
    assert(/Could not reach Dropbox to reconnect/.test(els.dbxUnlockErr.textContent), els.dbxUnlockErr.textContent);
    assert(A.dbxFreshOpen(), "no network is a connection failure too, and the steps are what it points at");
    A.closeDropbox();
  } finally { offDropbox(); ctx.localStorage.removeItem(A.DBX_STORE_KEY); }
});

console.log("\n-- where to open from at start: ask, Dropbox, or the local folder --");

t("the setting: ask by default, either place remembered, and nothing else accepted", () => {
  eqArr(A.OPENS, ["", "local", "dbx"]);
  assert.strictEqual(A.cleanCfg(null).open, "", "ask, until somebody says");
  assert.strictEqual(A.cleanCfg({ open: "dbx" }).open, "dbx");
  assert.strictEqual(A.cleanCfg({ open: "local" }).open, "local");
  assert.strictEqual(A.cleanCfg({ open: "onedrive" }).open, "", "a value this build does not know reads as ask");
  // a config from before the setting existed carries the other fields and asks
  const old = A.cleanCfg({ name: "Anna", lang: "hu", mode: "normal" });
  assert.strictEqual(old.open, ""); assert.strictEqual(old.lang, "hu");
});

t("what the start does: the setting decides — except where there is only one place to open from", () => {
  const was = A.state.cfg.open;
  A.state.snapshot = null;
  A.state.cfg.open = "";    assert.strictEqual(A.startupOpen(), "", "unset: ask");
  A.state.cfg.open = "dbx"; assert.strictEqual(A.startupOpen(), "dbx");
  A.state.cfg.open = "local"; assert.strictEqual(A.startupOpen(), "local");
  A.state.cfg.open = "dbx";
  A.state.snapshot = { made: "x" };
  assert.strictEqual(A.startupOpen(), "local", "a shared page has no second store, whatever the setting says");
  A.state.snapshot = null;
  A.state.cfg.mode = "restricted";
  assert.strictEqual(A.startupOpen(), "local", "restricted mode asks for no store at all — the local path knows that");
  A.state.cfg.mode = "normal";
  A.state.cfg.open = was;
  // and the boot code reads it: Dropbox opens the dialog, local reconnects, unset asks
  const boot = html.slice(html.indexOf("const how = startupOpen();"), html.indexOf("const how = startupOpen();") + 600);
  assert(/how === "dbx"[\s\S]*openDropbox\(\)/.test(boot), "dbx: the Dropbox dialog");
  assert(/how === "local"[\s\S]*restoreSavedFolder\(true\)/.test(boot), "local: the silent reconnect, as before");
  assert(/else \{\s*offerOpenChoice\(\)/.test(boot), "unset: the question");
});

t("the question: both ways in, and a tick that remembers the answer", () => {
  resetApp();
  A.state.cfg.open = "";
  ctx.localStorage.removeItem("ppp.cfg");
  assert(A.offerOpenChoice(), "asked in the full edition with nothing open");
  assert.strictEqual(els.openOverlay.style.display, "flex");
  assert.strictEqual(els.openChoice.style.display, "", "the question is shown");
  assert.strictEqual(els.openRememberRow.style.display, "", "with the tick");
  assert.strictEqual(els.btnOpenDbx.style.display, "", "and the Dropbox button beside the folder one");
  assert.strictEqual(els.openFirst.style.display, "none", "not the first-run folder text");
  assert.strictEqual(els.openRemember.checked, false, "unticked: asking again is the default");

  // Dropbox, remembered: the setting is written, the dialog gives way to the Dropbox one
  els.openRemember.checked = true;
  A.chooseOpen("dbx");
  assert.strictEqual(A.state.cfg.open, "dbx");
  assert.strictEqual(JSON.parse(ctx.localStorage.getItem("ppp.cfg")).open, "dbx", "kept for the next start");
  assert.strictEqual(els.openOverlay.style.display, "none");
  assert(A.dropboxOpen(), "and the Dropbox dialog is up");
  A.closeDropbox();
  assert.strictEqual(A.startupOpen(), "dbx", "so the next start opens Dropbox without asking");

  // local, not remembered: nothing written, the folder path taken
  A.state.cfg.open = ""; ctx.localStorage.removeItem("ppp.cfg");
  A.offerOpenChoice();
  els.openRemember.checked = false;
  A.chooseOpen("local");
  assert.strictEqual(A.state.cfg.open, "", "not remembered: still asks next time");
  assert.strictEqual(ctx.localStorage.getItem("ppp.cfg"), null);
  assert.strictEqual(els.openOverlay.style.display, "none");
  assert(!A.dropboxOpen());
  // (pickFolder() ran: with no picker in the harness it says so and stops, which is its own test)

  // the plain folder prompt — a lapsed permission — does not show the choice
  A.state.cfg.open = "local";
  resetApp();
});

ta("…and its two other faces: the lapsed-permission prompt hides the choice, the free edition never asks", async () => {
  resetApp();
  A.state.cfg.open = "";
  ctx.window.showDirectoryPicker = () => {};       // a picker exists, so the folder prompt applies
  try {
    assert(await A.offerOpenDb(), "the folder prompt");
    assert.strictEqual(els.openChoice.style.display, "none", "no question in it");
    assert.strictEqual(els.btnOpenDbx.style.display, "none");
    assert.strictEqual(els.openRememberRow.style.display, "none");
    A.closeOpenPrompt();
    // the free edition hides the setting, the Dropbox button and asks nothing
    const edition = html.slice(html.indexOf("function applyEdition"), html.indexOf("function applyEdition") + 3000);
    for (const id of ["cfgRowOpen", "btnOpenDbx"])
      assert(new RegExp('"' + id + '"[,\\]]').test(edition), "applyEdition() hides " + id);
  } finally { delete ctx.window.showDirectoryPicker; resetApp(); }
});

t("About: the setting is a select that writes the config and says when it takes effect", () => {
  A.state.snapshot = null;
  A.state.cfg.open = "";
  A.syncCfgUI();
  assert.strictEqual(els.cfgOpen.value, "");
  assert.strictEqual(els.cfgRowOpen.style.display, "", "shown where there are two places");
  els.toast.textContent = "";
  assert(A.setDefaultOpen("dbx"));
  assert.strictEqual(A.state.cfg.open, "dbx");
  assert.strictEqual(JSON.parse(ctx.localStorage.getItem("ppp.cfg")).open, "dbx");
  assert(/Next time Gantora starts, it opens the Dropbox dialog/.test(els.toast.textContent), els.toast.textContent);
  assert(!A.setDefaultOpen("nowhere"), "a value this build does not know is refused");
  A.setDefaultOpen("");
  assert(/asks which to open/.test(els.toast.textContent));
  A.syncCfgUI();
  assert.strictEqual(els.cfgOpen.value, "");
  // a shared page: the row is not shown, since there is only one place there
  A.state.snapshot = { made: "x" };
  A.syncCfgUI();
  assert.strictEqual(els.cfgRowOpen.style.display, "none");
  A.state.snapshot = null;
  A.syncCfgUI();
  ctx.localStorage.removeItem("ppp.cfg");
});

t("the help says where the second store is, and only where it exists", () => {
  A.state.snapshot = null;
  A.openAbout();
  assert.strictEqual(els.dbxHelp.style.display, "", "the About box carries the Dropbox note in the full edition");
  const note = html.slice(html.indexOf('id="dbxHelp"'), html.indexOf("</p>", html.indexOf('id="dbxHelp"')));
  for (const must of ["Dropbox", "shared folder", "Nothing is stored between sessions", "conflict", "Offline"])
    assert(note.includes(must), "the note says: " + must);
  A.state.snapshot = { made: "x" };
  A.openAbout();
  assert.strictEqual(els.dbxHelp.style.display, "none", "not on a shared page, which has no second store");
  A.state.snapshot = null;
  els.aboutOverlay.style.display = "none";
  assert(/id="emptyDbx"/.test(html) && /Dropbox…/.test(html.slice(html.indexOf('id="emptyDbx"'), html.indexOf('id="emptyDbx"') + 200)),
    "and the empty state names the way in");
});

console.log("\n-- team planning: what a team, or a person, is on this week --");

/* Dates relative to *this* Monday, because the board is relative to now: a
   fixture with hard dates would answer a different question every week. */
const mon = A.weekStartOf(new Date());
const wd = n => A.fmtDate(A.addDays(mon, n));

/* Two people, two teams' worth of work, and one task in each of the three
   sections the board draws. `acc`/`resp` carry the hours, as in the file. */
const teamFixture = () => {
  loadRowsNow([
    { id: 1, name: "Wave one", start: wd(0), end: wd(4), group: [2, 3] },
    { id: 2, name: "Running now", start: wd(0), end: wd(3), team: "Dev", acc: "Anna:4", resp: "Bela:2" },
    { id: 3, name: "Nobody's", start: wd(1), end: wd(2), team: "Dev" },
    { id: 4, name: "Next week", start: wd(8), end: wd(10), team: "Dev", resp: "Anna:6" },
    { id: 5, name: "Far group", start: wd(30), end: wd(60), group: [6] },
    { id: 6, name: "Far task", start: wd(30), end: wd(60), team: "Dev", resp: "Anna:8" },
  ]);
  A.state.people = ["Anna", "Bela", "Csilla"];
  A.state.personTeam = {};
  A.state.teamMode = "team"; A.state.teamPick = ""; A.state.teamPerson = ""; A.state.teamWeek = 0;
};

t("the week the board is about starts on Monday, and knows its number", () => {
  assert.strictEqual(A.weekStartOf(new Date("2026-09-10T12:00:00")).getDay(), 1, "a Thursday's week starts on Monday");
  assert.strictEqual(A.fmtDate(A.weekStartOf(new Date("2026-09-14T00:00:00"))), "2026-09-14", "a Monday is its own week start");
  assert.strictEqual(A.fmtDate(A.weekStartOf(new Date("2026-09-13T00:00:00"))), "2026-09-07", "and a Sunday belongs to the week before");
  // ISO: the week holding the first Thursday of the year is week 1
  assert.strictEqual(A.isoWeekNum(new Date("2026-01-01T00:00:00")), 1);
  assert.strictEqual(A.isoWeekNum(new Date("2026-09-10T00:00:00")), 37, "the mock's Week 37");
});

t("a team is who is assigned to it plus whoever its tasks name", () => {
  teamFixture();
  eqArr(A.teamMembers("Dev"), ["Anna", "Bela"], "named on the team's tasks, with nobody assigned yet");
  A.setPersonTeam("Csilla", "Dev");
  eqArr(A.teamMembers("Dev"), ["Anna", "Bela", "Csilla"], "and anybody assigned on the People page");
  A.setPersonTeam("Anna", "Dev");
  eqArr(A.teamMembers("Dev"), ["Anna", "Bela", "Csilla"], "somebody who is both is still one person");
  assert.strictEqual(A.teamOfPerson("anna"), "Dev", "the lookup ignores case, like every name here");
  A.state.personTeam = {};
});

/* 🔴 "Nobody's" was excluded here until 2026-08-22 — the board asked only
   whether somebody in scope was NAMED on a row. Daniel's live plan showed what
   that costs: two teams, work in both, nobody named on anything, and every
   section said "nothing in this week for them". In Team mode a task belongs to
   the team its Team cell names, named people or not. */
t("this week is the work of the team, whether or not anybody is named on it", () => {
  teamFixture();
  const b = A.teamBoard();
  eqArr(b.thisWeek.map(r => r.t.name), ["Running now", "Nobody's"],
    "the group above them is not work, but a team's unnamed task still is");
  eqArr(b.nextWeek.map(r => r.t.name), ["Next week"]);
  assert.strictEqual(b.week, A.isoWeekNum(new Date()), "and the week is the one we are in");

  /* Narrowing to one person is the exception: that question really is about a
     person, so the team's unnamed rows are not theirs to answer for. */
  A.state.teamPerson = "Anna";
  eqArr(A.teamBoard().thisWeek.map(r => r.t.name), ["Running now"], "one person's week is their own");
  A.state.teamPerson = "";

  // People mode is unchanged: there, a name is the only thing that puts a row on the board
  A.state.teamMode = "people";
  eqArr(A.teamBoard().thisWeek.map(r => r.t.name), ["Running now"], "nobody is named on Nobody's");
  A.state.teamMode = "team";
});

/* The shape of Daniel's move plan: teams on the tasks, not a person anywhere.
   Every number on the board was zero and the picker offered "Család (0)". */
t("a plan organised by team and not by person still has a board", () => {
  loadRowsNow([
    { id: 1, name: "Leltár, selejtezés", start: wd(0), end: wd(2), team: "Család" },
    { id: 2, name: "Költöztető egyeztetés", start: wd(1), end: wd(3), team: "Költöztető" },
  ]);
  A.state.people = []; A.state.personTeam = {};
  A.state.teamMode = "team"; A.state.teamPick = ""; A.state.teamPerson = ""; A.state.teamWeek = 0;

  let b = A.teamBoard();
  assert.strictEqual(b.thisWeek.length, 2, "🔴 the board came up empty on a plan that has work in it");
  eqArr(b.scope.teams, ["Család", "Költöztető"], "and the board knows which teams it is about");
  assert.strictEqual(b.load, null, "no names, so no hours — and '—' is not the same claim as '0%'");

  A.state.teamPick = "Család";
  b = A.teamBoard();
  eqArr(b.thisWeek.map(r => r.t.name), ["Leltár, selejtezés"], "picking a team shows that team's work");
  eqArr(b.scope.teams, ["Család"]);

  /* The picker's number is the team's WORK. As a headcount it read "(0)"
     beside every team on this plan — an empty-looking picker in front of a
     board full of tasks. */
  A.state.teamPick = "";
  A.refreshTeamControls();
  assert(/Család \(1\)/.test(els.teamPick.innerHTML), "the picker counts work: " + els.teamPick.innerHTML);
  assert(/Költöztető \(1\)/.test(els.teamPick.innerHTML));
  A.state.people = ["Anna", "Bela", "Csilla"];
});

t("a task counts for a week it merely runs through", () => {
  loadRowsNow([{ id: 1, name: "Long haul", start: wd(-20), end: wd(20), resp: "Anna:2" }]);
  A.state.teamMode = "team"; A.state.teamPick = ""; A.state.teamPerson = ""; A.state.teamWeek = 0;
  const b = A.teamBoard();
  eqArr(b.thisWeek.map(r => r.t.name), ["Long haul"], "started before, ends after: it is running now");
  eqArr(b.nextWeek.map(r => r.t.name), ["Long haul"]);
  assert.strictEqual(A.runsInWeek(A.taskById(1), A.addDays(mon, 21), A.addDays(mon, 27)), false,
    "and not in a week it has finished before");
});

t("load is the hours the people in scope are booked for, that week only", () => {
  teamFixture();
  const t2 = row("Running now");
  // Mon–Thu of this week, two people: 4 h/day + 2 h/day over four working days
  assert.strictEqual(A.taskLoadIn(t2, ["Anna", "Bela"], mon, A.addDays(mon, 6)), 24);
  assert.strictEqual(A.taskLoadIn(t2, ["Anna"], mon, A.addDays(mon, 6)), 16, "one person's share of it");
  assert.strictEqual(A.taskLoadIn(t2, ["Csilla"], mon, A.addDays(mon, 6)), 0, "somebody not on it is not booked");
  // a name with no ":h" costs nothing — the rule the resource view already keeps
  assert.strictEqual(A.taskLoadIn(row("Nobody's"), ["Anna"], mon, A.addDays(mon, 6)), 0);
});

t("later is groups, not tasks, and only the ones still to come", () => {
  teamFixture();
  const b = A.teamBoard();
  eqArr(b.later.map(r => r.t.name), ["Far group"],
    "the group running now is not 'later', and a task is never a row here");
  assert.strictEqual(b.later[0].kids, 1, "with how many of its tasks are theirs");
  assert(b.later[0].who.some(p => p.name === "Anna"), "and who of them is in it");
});

/* A week's board is a flat list pulled out of every group there is, and half of
   them are called Testing, Handover or Sign-off. */
t("a week's task says which group it belongs to, before its name", () => {
  teamFixture();
  A.state.view = "team"; useRef({});
  A.renderTeam();
  const page = els.teamView.innerHTML;
  const sec = s => page.slice(page.indexOf('tSec ' + s), page.indexOf('</section>', page.indexOf('tSec ' + s)));
  const now = sec("tThis");

  assert(/<span class="tGrp">Wave one<\/span>Running now/.test(now),
    "the group comes first, then the task: " + now.slice(0, 600));
  // …and the row's tooltip carries the pair too, for a name the cell has to cut
  assert(/title="[^"]*Wave one \/ Running now/.test(now), "the tooltip says it as well");

  /* The Later section lists groups, so a group name in front of a group name
     would be the same word twice. */
  assert(!/tGrp/.test(sec("tLater")), "not in Later, which is already a list of groups");
  useRef(null);
});

t("a task in no group is drawn without a prefix", () => {
  loadRowsNow([{ id: 1, name: "On its own", start: wd(0), end: wd(3), team: "Dev", resp: "Anna:2" }]);
  A.state.people = ["Anna"]; A.state.personTeam = {};
  A.state.teamMode = "team"; A.state.teamPick = ""; A.state.teamPerson = ""; A.state.teamWeek = 0;
  A.state.view = "team"; useRef({});
  A.renderTeam();
  const page = els.teamView.innerHTML;
  assert(/On its own/.test(page), "the task is there");
  assert(!/tGrp/.test(page), "with nothing in front of it: " + page.slice(0, 400));
  useRef(null);
});

/* What you want to know about work three months out is *when it starts*, not
   how many hours it adds up to — a whole group's span is a number in the
   hundreds sitting beside this week's tens, comparable to neither. */
t("the Later section says when the group starts, where a week says how much", () => {
  teamFixture();
  A.state.view = "team"; useRef({});
  A.renderTeam();
  const page = els.teamView.innerHTML;
  const sec = s => page.slice(page.indexOf('tSec ' + s), page.indexOf('</section>', page.indexOf('tSec ' + s)));
  const later = sec("tLater"), now = sec("tThis");
  assert(/<th class="num">Starts<\/th>/.test(later), "the Later column is Starts: " + later.slice(0, 400));
  assert(!/<th class="num">Load<\/th>/.test(later), "and not Load");
  assert(later.includes(row("Far group").start), "with the group's own start date in it");
  assert(!/class="mini"/.test(later), "no load bar out there either");
  assert(!/class="wh"/.test(later), "and no hours beside a name: they belong to a task, not to a group");
  assert(/class="wh"/.test(now), "which is where they are shown");
  assert(/<th class="num">Load<\/th>/.test(now), "this week still asks how much");
  assert(/class="mini"/.test(now), "and still draws the bar");
  assert.strictEqual(A.teamBoard().later[0].load, undefined, "the number is not even worked out");
  useRef(null); A.state.view = "table";
});

t("the tiles are of the people in scope, and capacity is five days each", () => {
  teamFixture();
  const b = A.teamBoard();
  assert.strictEqual(b.scope.people.length, 3, "Anna, Bela and Csilla are all named somewhere");
  assert.strictEqual(b.capacity, 3 * A.WORKDAY_H * 5);
  assert.strictEqual(b.booked, 24, "the one task running this week");
  assert.strictEqual(b.load, Math.round(24 * 100 / b.capacity));
  // nobody in scope is not "0% loaded", it is a board with nobody on it
  A.state.teamMode = "people"; A.state.teamPerson = "";
  A.state.tasks.forEach(t => { t.accountable = ""; t.responsible = ""; });
  A.state.people = [];
  const empty = A.teamBoard();
  assert.strictEqual(empty.load, null);
  assert.strictEqual(empty.thisWeek.length, 0);
});

t("the Team and People modes ask about different scopes", () => {
  teamFixture();
  A.state.teamMode = "team"; A.state.teamPick = "Dev";
  eqArr(A.teamScope().people, ["Anna", "Bela"], "everyone on the team");
  A.state.teamPerson = "Anna";
  eqArr(A.teamScope().people, ["Anna"], "narrowed to one of them, still in Team mode");
  A.state.teamMode = "people"; A.state.teamPerson = "Bela";
  eqArr(A.teamScope().people, ["Bela"], "and People mode is one person, whatever the team picker says");
  assert.strictEqual(A.teamScope().team, "", "which is why the team picker is not read at all there");
  A.state.teamMode = "team"; A.state.teamPick = ""; A.state.teamPerson = "";
});

t("the board is drawn as three sections, and every row can be found in the list", () => {
  teamFixture();
  A.state.view = "team"; useRef({});
  A.refreshTeamControls();
  A.renderTeam();
  const page = els.teamView.innerHTML;
  assert.strictEqual((page.match(/class="card tSec/g) || []).length, 3, "this week, next week, later");
  for (const cls of ["tThis", "tNext", "tLater"]) assert(page.includes(cls), "missing section: " + cls);
  assert(page.includes("Running now") && page.includes("Next week") && page.includes("Far group"));
  assert(page.includes("Nobody&#39;s") || page.includes("Nobody's"),
    "the team's unnamed task is its work too — see the board test above");
  assert.strictEqual((page.match(/data-goto-task/g) || []).length, 4, "each row goes back to the list");
  const kpis = [...page.matchAll(/class="kNum">([^<]*)</g)].map(m => m[1]);
  /* In Team mode the second card counts TEAMS, not people: "People 2" over a
     board of two teams and no names was read as a lie (Daniel, 2026-08-22). */
  eqArr(kpis.slice(0, 2), ["2", "1"], "tasks this week, then teams: " + kpis.join(","));
  assert(els.teamWeekLbl.textContent.startsWith("Week "), els.teamWeekLbl.textContent);
  assert(/\(now\)/.test(els.teamWeekLbl.textContent), "and says so while it is this week");
  useRef(null); A.state.view = "table";
});

/* 🔴 Filters brief §6/3 and §7/1. The long label — week number, a date range
   and "(now)" — is 42 characters standing between two arrows, and it pushed the
   ⌕ onto a row of its own on the tablet and the This-week button onto one more
   on the phone. Short where a finger drives, whole where a mouse does, and the
   dates never lost: they are on the title, which popTip() reads on a tap. */
t("the cockpit's week label is short where a finger drives it", () => {
  teamFixture();
  const wasW = ctx.window.innerWidth;
  try {
    ctx.window.innerWidth = 390;                             // a phone
    A.state.teamWeek = 0;
    A.refreshTeamControls();
    assert.strictEqual(els.teamWeekLbl.textContent, "Week " + A.isoWeekNum(A.weekStartOf(new Date())) + " (now)",
      "the week and where we are in it, nothing else: " + els.teamWeekLbl.textContent);
    assert(/\d{4}-\d\d-\d\d – \d{4}-\d\d-\d\d/.test(els.teamWeekLbl.title),
      "and the range is one tap behind it: " + els.teamWeekLbl.title);
    assert.strictEqual(els.btnTeamToday.style.display, "none",
      "the This-week button is gone while we are in this week — the label already says so");

    A.state.teamWeek = 2;
    A.refreshTeamControls();
    assert(!/\(now\)/.test(els.teamWeekLbl.textContent), "two weeks on it no longer says now");
    assert.strictEqual(els.btnTeamToday.style.display, "", "…and the way back is there to be pressed");

    ctx.window.innerWidth = 1400;                            // a desk, where it fits
    A.state.teamWeek = 0;
    A.refreshTeamControls();
    assert(/ · \d{4}-\d\d-\d\d – \d{4}-\d\d-\d\d \(now\)$/.test(els.teamWeekLbl.textContent),
      "the whole sentence survives where there is room for it: " + els.teamWeekLbl.textContent);
  } finally {
    ctx.window.innerWidth = wasW; A.state.teamWeek = 0;
  }
});

t("…and the label answers a tap, since a phone has no hover", () => {
  assert(/\$\("teamWeekLbl"\)\.addEventListener\("click", e => \{[^}]*popTip\(\$\("teamWeekLbl"\)\)/.test(html),
    "the week label is a popTip call site of its own");
  /* A span in the shell, written into on every draw but never rebuilt — which
     is what makes a direct listener right rather than a delegated one. */
  assert(/<span id="teamWeekLbl"/.test(html) && !/id="teamWeekLbl"[^>]*style=/.test(html),
    "and its width is the stylesheet's business, so the phone can take the 150px back");
  assert(/#teamWeekLbl\{[^}]*min-width:150px/.test(html) &&
    /#teamWeekLbl\{min-width:0;\}/.test(html), "150px on a desk against jitter, nothing reserved on a phone");
});

t("the arrows walk whole weeks, and the label follows", () => {
  teamFixture();
  const now = A.teamBoard();
  A.state.teamWeek = 1;
  const next = A.teamBoard();
  assert.strictEqual(A.daysBetween(now.from, next.from), 7, "one week on");
  eqArr(next.thisWeek.map(r => r.t.name), now.nextWeek.map(r => r.t.name),
    "so next week's rows are this week's rows a click later");
  A.state.teamWeek = 0;
});

ta("a person's team travels in the shared list file", async () => {
  resetApp();
  const files = {};
  const root = fakeRoot(files);
  A.state.rootHandle = root; A.state.dbHandle = root;
  A.state.people = ["Anna Kovacs", "Bela Nagy"];
  A.state.teams = ["Development"];
  A.setPersonTeam("Anna Kovacs", "Development");
  await A.saveLists();
  assert(/^Person\|Anna Kovacs\|Development$/m.test(files["_lists.csv"]), files["_lists.csv"]);
  assert(/^Person\|Bela Nagy$/m.test(files["_lists.csv"]), "somebody on no team writes two cells, as before");

  A.state.people = []; A.state.personTeam = {};
  await A.loadLists();
  assert.strictEqual(A.teamOfPerson("Anna Kovacs"), "Development", "read back");
  assert.strictEqual(A.teamOfPerson("Bela Nagy"), "", "and no team is still no team");
  eqArr(A.state.people, ["Anna Kovacs", "Bela Nagy"], "the third cell is not part of the name");
  resetApp();
});

ta("assigning a team writes the list and nothing else", async () => {
  resetApp();
  const files = {};
  const root = fakeRoot(files);
  A.state.rootHandle = root; A.state.dbHandle = root;
  A.state.people = ["Anna Kovacs"];
  A.state.dirty = false;
  await A.assignPersonTeam("Anna Kovacs", "Brand New Team");
  assert.strictEqual(A.teamOfPerson("Anna Kovacs"), "Brand New Team");
  assert(A.state.teams.includes("Brand New Team"), "a team nobody had heard of joins the list");
  assert(/Person\|Anna Kovacs\|Brand New Team/.test(files["_lists.csv"]));
  assert.strictEqual(A.state.dirty, false, "the plan did not change, so the plan file is not dirty");
  await A.assignPersonTeam("Anna Kovacs", "");
  assert.strictEqual(A.teamOfPerson("Anna Kovacs"), "", "and it can be taken off again");
  resetApp();
});

t("the People page is where the team is set", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03", acc: "Anna Kovacs:3" }]);
  A.state.people = ["Anna Kovacs"]; A.state.teams = ["Development"];
  A.setPersonTeam("Anna Kovacs", "Development");
  A.state.readOnly = false;
  A.renderManage("person");
  const page = els.manageView.innerHTML;
  assert(/<th style="width:200px">Team<\/th>/.test(page), "the column is there");
  assert(/data-person-team="Anna Kovacs"/.test(page), "with a picker on her row");
  assert(/<option value="Development" selected>/.test(page), "showing the team she is on");
  // and the other two pages have no such column
  A.renderManage("team");
  assert(!/data-person-team/.test(els.manageView.innerHTML));
  A.state.people = []; A.state.teams = []; A.state.personTeam = {};
});

t("the board has a view button of its own, and its own controls", () => {
  for (const id of ["btnViewTeam", "teamView", "teamCtrls", "teamPick", "teamPerson", "teamWeekLbl",
                    "btnTeamPrev", "btnTeamNext", "btnTeamToday", "btnTeamModeTeam", "btnTeamModePeople"])
    assert(html.includes(`id="${id}"`), "missing from the page: " + id);
  A.setView("team");
  assert.strictEqual(A.state.view, "team");
  assert(els.btnViewTeam.classList.contains("active"));
  assert.strictEqual(els.teamCtrls.style.display, "none", "…and with no file open there is nothing to show");
  A.setView("table");
});

/* The corner is **the dot and nothing else**. It used to carry a text label,
   which was asked to go more than once and kept coming back, so the absence is
   asserted here rather than left to be noticed again: no #statusTxt element, no
   words drawn, and the sentences kept only as the dot's tooltip. */
/* The order you read them in: the board, then the plan six ways, then the plan
   written up. Asserted as positions in the markup because the bar is markup —
   there is no list in the script to check instead. */
t("the view switcher runs board, list, the charts, then the report", () => {
  const order = ["btnViewDash", "btnViewTable", "btnViewGantt", "btnViewZoom",
                 "btnViewRes", "btnViewTeam", "btnViewReport"];
  const at = order.map(id => html.indexOf('id="' + id + '"'));
  for (const [i, p] of at.entries()) assert(p > 0, order[i] + " is not on the bar");
  for (let i = 1; i < at.length; i++)
    assert(at[i] > at[i - 1], order[i] + " must come after " + order[i - 1]);
});

/* A long press is a gesture nobody can see, and on iOS the browser's own
   selection callout fires on the same press. So the menu also gets a button. */
t("every row carries a menu button for a finger", () => {
  nested();
  A.state.view = "table"; useRef({}); A.state.readOnly = false;
  A.renderTable();
  const out = els.tableView.innerHTML;
  const btns = (out.match(/class="rowMenuBtn" data-menu="\d+"/g) || []).length;
  const rows = (out.match(/<tr[^>]*data-id="/g) || []).length;
  assert.strictEqual(btns, rows, `one per row: ${btns} buttons for ${rows} rows`);
  useRef(null);
});

t("read-only rows carry no menu button, because there is no menu", () => {
  nested();
  A.state.view = "table"; useRef({});
  A.state.readOnly = true;                     // what readOnly() actually reads
  A.renderTable();
  assert(!/rowMenuBtn/.test(els.tableView.innerHTML),
    "a button that opens nothing is worse than no button");
  A.state.readOnly = false;
  useRef(null);
});

t("the touch menu is shown by a media query, not guessed at in script", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/\.rowMenuBtn\{display:none;\}/.test(css), "hidden by default");
  assert(/@media \(hover: none\) and \(pointer: coarse\)/.test(css),
    "and shown only where a finger is driving");
  /* Without these, iOS starts a selection on the long press and the gesture
     never reaches the handler at all. */
  assert(/-webkit-touch-callout:none/.test(css) && /user-select:none/.test(css),
    "the row suppresses iOS's own long-press callout");
});

/* A tablet has no right button, so the row actions were unreachable there. */
t("a row answers a long press the way it answers a right-click", () => {
  nested();
  A.state.view = "table"; useRef({});
  A.renderTable();
  // the stub DOM records listeners per element, so the binding is what we check
  const bound = (html.match(/addEventListener\("touch(start|move|end|cancel)"/g) || []).length;
  assert(bound >= 4, "touchstart, touchmove, touchend and touchcancel are all bound, got " + bound);
  assert(/LONG_PRESS_MS/.test(html) && /LONG_PRESS_SLOP/.test(html),
    "the delay and the slop are named constants, not numbers buried in a handler");

  /* The gesture lives in one place now, and its three rules are what make it a
     press rather than a fight with the page. */
  const fn = html.slice(html.indexOf("function onLongPress"));
  const body = fn.slice(0, fn.indexOf("\n}") + 2);
  assert(/touchmove[\s\S]{0,400}drop\(\)/.test(body), "moving past the slop cancels the press");
  assert(/if \(fired\)[\s\S]{0,90}preventDefault\(\)/.test(body),
    "the click that follows the press is swallowed");
  assert(/touchcancel", drop/.test(body), "and a cancelled touch drops it too");
  useRef(null);
});

/* The gesture itself, driven rather than read. `querySelectorAll` answers `[]`
   in this stub, so the header wiring can only be checked as source — but the
   function it wires is an ordinary one and can be pressed for real. */
ta("a long press fires where the finger was, and a scroll is not a press", async () => {
  const el = document.getElementById("holdProbe");
  let fired = null;
  A.onLongPress(el, (x, y) => { fired = { x, y }; });

  fire(el, "touchstart", { touches: [{ clientX: 40, clientY: 90 }] });
  await new Promise(r => setTimeout(r, A.LONG_PRESS_MS + 40));
  assert.deepStrictEqual(fired, { x: 40, y: 90 }, "the menu opens under the finger, not under the element");

  // a flick down the list must not spring a menu
  fired = null;
  fire(el, "touchstart", { touches: [{ clientX: 10, clientY: 10 }] });
  fire(el, "touchmove", { touches: [{ clientX: 10, clientY: 10 + A.LONG_PRESS_SLOP + 5 }] });
  await new Promise(r => setTimeout(r, A.LONG_PRESS_MS + 40));
  assert.strictEqual(fired, null, "past the slop it is a scroll and the timer is dropped");

  // and a tap stays a tap
  fired = null;
  fire(el, "touchstart", { touches: [{ clientX: 5, clientY: 5 }] });
  fire(el, "touchend", {});
  await new Promise(r => setTimeout(r, A.LONG_PRESS_MS + 40));
  assert.strictEqual(fired, null, "letting go before the delay cancels it");
});

ta("a long press asks its guard first, the way the rows ask about a half-typed row", async () => {
  const el = document.getElementById("holdProbe2");
  let count = 0, allow = false;
  A.onLongPress(el, () => { count++; }, () => allow);

  fire(el, "touchstart", { touches: [{ clientX: 1, clientY: 1 }] });
  await new Promise(r => setTimeout(r, A.LONG_PRESS_MS + 40));
  assert.strictEqual(count, 0, "the guard said no");

  allow = true;
  fire(el, "touchstart", { touches: [{ clientX: 1, clientY: 1 }] });
  await new Promise(r => setTimeout(r, A.LONG_PRESS_MS + 40));
  assert.strictEqual(count, 1, "and yes lets it through");

  // two fingers is a pinch, which the charts use — never a press
  fire(el, "touchstart", { touches: [{ clientX: 1, clientY: 1 }, { clientX: 9, clientY: 9 }] });
  await new Promise(r => setTimeout(r, A.LONG_PRESS_MS + 40));
  assert.strictEqual(count, 1, "two fingers is not a long press");
});

/* The bug: on a phone the column chooser had no door at all. A right-click was
   the only way in, and the one thing only that menu can do is bring a folded
   column *back* — so a column hidden on a phone was hidden for good. */
t("the column chooser is reachable with a finger, three ways", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));

  /* The headers answer the hold with the same function the right-click calls, so
     the two cannot drift into doing different things. */
  const wiring = script.slice(script.indexOf('host.querySelectorAll("th[data-col], th.colGap")'));
  const block = wiring.slice(0, 1200);
  assert(/const openFor = \(x, y\) =>/.test(block), "one opener");
  assert(/addEventListener\("contextmenu"[\s\S]{0,220}openFor\(e\.clientX, e\.clientY\)/.test(block),
    "the right-click goes through it");
  assert(/onLongPress\(th, openFor\)/.test(block), "and so does the long press");

  // the gap marker is a thin double rule: the poorest target on the page for a finger
  assert(block.includes("th.colGap") || wiring.includes("th.colGap"),
    "the marker that brings a run back answers the hold as well");

  /* 🔴 There used to be a button for this too, and it is gone (Daniel, tablet
     round 2026-08-21): "az oszlopra nyomva előjön, redundáns és foglalja a
     helyet". It was put there because a long press is a gesture nobody can
     see — but measured on the tablet, the press on a header IS how people
     reach for it, and the button was a second door onto the same room while
     taking width from the filters. */
  assert(!html.includes('id="btnColsMenu"'), "the second door is gone");
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/table\.grid tbody tr, table\.grid thead th\{-webkit-touch-callout:none/.test(css),
    "the headers suppress iOS's own callout too, or the gesture never arrives");
});

t("the save-state corner is the dot alone, with no label beside it", () => {
  assert(!/id="statusTxt"/.test(html), "the label element is gone from the markup");
  // …and nothing reaches for it. Matched as a lookup rather than as the bare
  // word, which is also a local variable in the import warnings.
  assert(!/\$\("statusTxt"\)/.test(html), "nothing writes to it either");

  A.state.readOnly = false;
  useName("plan.gantora");
  A.markViewDirty();
  assert.strictEqual(els.status.className, "dirty", "the dot still says unsaved");
  assert.strictEqual(els.status.title, "Unsaved changes", "the words moved to the tooltip");
  assert(!/plan\.gantora/.test(els.status.title), "and still never carry the file name");

  A.state.dirty = false;
  A.setStatus("", "saved");
  assert.strictEqual(els.status.className, "saved");
  assert.strictEqual(els.status.title, "Saved", "an empty message still leaves the dot explainable");

  // and the two strings that used to live there are gone from the app entirely
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  assert(!/"Saved — "/.test(script) && !/"Loaded — "/.test(script),
    "the file name and the task count are said elsewhere, twice already");
});

/* The handle is a wide, shallow tab under the app bar rather than a button up
   among the toolbar's own — it is the edge of the strip it folds away. */
t("the filter bar's handle is a tab under the app bar, not a toolbar button", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/#ctxBarHandle\{[^}]*justify-content:center/.test(css), "centred under the bar");
  /* …and it is the colour of a control rather than of the bar it sits on. There
     is more than one `#ctxBarHandle button` rule since the touch size joined it
     (§8/1), so the base is found by the thing that makes it the base — it is
     the one that paints the tab — rather than by being first in the file. */
  const rules = [...css.matchAll(/#ctxBarHandle button\{([^}]*)\}/g)].map(m => m[1]);
  const btn = rules.find(s => /background:var\(--accent-soft\)/.test(s));
  assert(btn, "the handle has a painted button of its own: " + rules.length + " rules");
  assert(/color:var\(--accent\)/.test(btn) && /border:1px solid var\(--accent\)/.test(btn),
    "with the accent's own outline and glyph: " + btn);
  const w = +btn.match(/width:(\d+)px/)[1], ht = +btn.match(/height:(\d+)px/)[1];
  assert(w >= 80 && ht <= 14 && w > ht * 6, "wide and shallow, got " + w + "x" + ht);
  // and it sits between the two bars in the markup, which is where its edge is
  assert(html.indexOf('id="ctxBarHandle"') > html.indexOf('id="toolbar"'), "after the app bar");
  assert(html.indexOf('id="ctxBarHandle"') < html.indexOf('id="ctxBar"'), "and before the bar it folds");
});

/* 🔴 Filters brief §8/1. Daniel's red arrow drew the journey the panel causes:
   open it, and hand and eye are at its LOWER edge — but the tab that shuts it
   is back at the top, where the panel began. So the tab follows the edge that
   matters: the bar's bottom while it is open, the app bar's bottom while it is
   shut, where the folded edge is. */
t("the fold tab is on the edge that matters: under the bar while it is open", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const order = sel => {
    const m = css.match(new RegExp(sel.replace(/[#.]/g, "\\$&") + "\\{[^}]*order:(\\d+)"));
    assert(m, sel + " has no order of its own — the four in the page's flow are numbered together");
    return Number(m[1]);
  };
  const [toolbar, shut, bar, open, main] =
    ["#toolbar", "#ctxBarHandle", "#ctxBar", "#ctxBarHandle.below", "#main"].map(order);
  assert(toolbar < shut && shut < bar, "shut, the tab is between the app bar and the strip it opens");
  assert(bar < open && open < main, "open, it is under the strip and still above the plan");

  /* And which of the two it is, is one class the stylesheet reads — the app
     does not move anything in the DOM for it. */
  const was = A.state.ctxBar;
  A.state.ctxBar = true; A.syncCtxBarToggle();
  assert(els.ctxBarHandle.classList.contains("below"), "open: the tab is on the bar's bottom edge");
  assert.strictEqual(els.btnCtxBar.textContent, "⌃", "…and points the way it folds");
  A.state.ctxBar = false; A.syncCtxBarToggle();
  assert(!els.ctxBarHandle.classList.contains("below"), "shut: back under the app bar, which is the only way in");
  assert.strictEqual(els.btnCtxBar.textContent, "⌄");
  A.state.ctxBar = was; A.syncCtxBarToggle();
});

/* Shut, this tab is the ONLY way the filter bar comes back. 11px is findable
   with a pointer, which is a pixel; a thumb has neither that precision nor a
   hover to hunt with. */
t("…and on a touch screen it is twice as tall, with a 44px reach", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const at = css.indexOf("@media (hover: none) and (pointer: coarse)");
  assert(at > 0, "the touch block is where a finger is answered");
  const block = css.slice(at, css.indexOf("table.grid td.name", at));
  const h = +block.match(/#ctxBarHandle button\{[^}]*height:(\d+)px/)[1];
  const strip = +block.match(/#ctxBarHandle\{[^}]*height:(\d+)px/)[1];
  assert(h >= 20 && h <= 24, "the tab is 20–24px of real tab, as the brief asks: " + h);
  assert(strip >= h, "and the strip is tall enough to hold it: " + strip);
  /* The hit area is 44px without the strip being 44px — and it grows DOWNWARDS
     only. Below the tab is the plan in both states; above it are the toolbar
     and the filter chips, controls that are 44px themselves and were fought
     for. A strip lying over those would steal their taps. */
  const reach = block.match(/#ctxBarHandle button::after\{([^}]*)\}/);
  assert(reach, "the tab reaches past its own edges for a thumb");
  assert(/top:0/.test(reach[1]) && !/bottom:/.test(reach[1]),
    "downwards only, onto the plan — never up onto the bars: " + reach[1]);
  assert(Number(reach[1].match(/height:(\d+)px/)[1]) >= 44, "…and the reach is a finger's: " + reach[1]);
});

t("the empty state names the folder the app is called after", () => {
  // plain text since the sentence became a translatable leaf; the name is what matters
  assert(html.includes("select the Gantora folder"), "it is not the PPPlus folder any more");
  assert(!/select the (<b>)?PPPlus/.test(html));
});

console.log("\n-- Due dates: when a date becomes a commitment --");

/* Most of a plan's date changes happen while it is being written. Holding a row
   to the first end date it ever had measures drift against a guess. */
function datedTask(extra) {
  return Object.assign({ id: 1, name: "Work", start: "2026-03-02", end: "2026-03-10",
    group: [], preds: [], status: "Planned", history: [{ date: "2026-03-01", status: "Planned" }],
    notes: "", notFinal: true, dateHist: [], baselineEnd: "" }, extra || {});
}

/* The tick is nobody's business but the reader's: no status move sets it and
   none clears it. What the status decides is when the *date trail* opens. */
t("no status move touches the not-final tick, in either direction", () => {
  const planned = datedTask();
  A.setTaskStatus(planned, "Planned");
  assert.strictEqual(planned.notFinal, true, "re-stating Planned leaves it");

  const started = datedTask();
  A.setTaskStatus(started, "In Progress");
  assert.strictEqual(started.notFinal, true, "and so does starting the work");

  const done = datedTask({ notFinal: false });
  A.setTaskStatus(done, "Done");
  assert.strictEqual(!!done.notFinal, false, "an unticked row is not ticked either");
});

t("a row already carrying a trail keeps it when the work starts", () => {
  const t1 = datedTask({ dateHist: [{ date: "2026-01-05", end: "2026-02-01" }], baselineEnd: "2026-02-01" });
  /* Starting the work used to wipe this, because the tick was what decided
     whether a change counted. Nothing is recorded before the start any more,
     so a trail that *is* there was put there deliberately — by an older build,
     a merge, or by hand — and throwing it away would be the app overruling it. */
  A.setTaskStatus(t1, "In Progress");
  eqArr(t1.dateHist.map(e => e.end), ["2026-02-01"], "the trail is left alone");
  assert.strictEqual(t1.baselineEnd, "2026-02-01", "and so is the overruled baseline");
});

t("a row already settled keeps the record it has", () => {
  const t1 = datedTask({ notFinal: false, dateHist: [{ date: "2026-01-05", end: "2026-02-01" }] });
  A.setTaskStatus(t1, "Done");
  assert.strictEqual(A.baselineEndOf(t1), "2026-02-01",
    "a commitment already made is not quietly re-made on the next status change");
});

t("due date reset drops every recorded change and leaves the ticks alone", () => {
  const before = A.state.tasks;
  const wasConfirm = ctx.confirm;
  A.state.tasks = [
    datedTask({ id: 1, notFinal: false, dateHist: [{ date: "2026-01-05", end: "2026-02-01" }], baselineEnd: "2026-02-01" }),
    datedTask({ id: 2, notFinal: false, status: "In Progress", dateHist: [{ date: "2026-01-05", end: "2026-02-02" }] }),
    datedTask({ id: 3, notFinal: false, milestone: true }),
  ];
  /* markDirty() diffs against the last undo snapshot, which here is whatever
     the previous test left behind - reset it so the diff is against this plan. */
  A.resetHistory();
  ctx.confirm = () => true;
  try {
    A.resetDueDates();
    for (const t1 of A.state.tasks) {
      eqArr(t1.dateHist, [], "#" + t1.id + " keeps no recorded change");
      assert.strictEqual(t1.baselineEnd, "", "#" + t1.id + " keeps no overruled baseline");
    }
    /* It used to tick everything nobody had started, because that tick was
       what stopped a change being recorded. Recording waits for the work to
       start on its own now, so the reset has nothing to mark. */
    assert.strictEqual(A.taskById(1).notFinal, false, "the Planned one is left as the reader had it");
    assert.strictEqual(A.taskById(2).notFinal, false, "and so is the one under way");
    assert.strictEqual(A.taskById(3).notFinal, false, "a milestone is a single date and has no tick");
    assert.strictEqual(A.baselineEndOf(A.taskById(1)), "2026-03-10", "today's dates are the record now");
  } finally { ctx.confirm = wasConfirm; A.state.tasks = before; }
});

t("backing out of the reset changes nothing", () => {
  const before = A.state.tasks;
  const wasConfirm = ctx.confirm;
  A.state.tasks = [datedTask({ notFinal: false, baselineEnd: "2026-02-01" })];
  A.resetHistory();
  ctx.confirm = () => false;
  try {
    assert.strictEqual(A.resetDueDates(), null, "it stops");
    assert.strictEqual(A.state.tasks[0].baselineEnd, "2026-02-01", "and the plan is untouched");
  } finally { ctx.confirm = wasConfirm; A.state.tasks = before; }
});

t("the reset is in the DB menu, and off until there is a plan", () => {
  assert(html.includes('id="btnResetDue"'), "the menu has it");
  assert(/id="btnResetDue"[^>]*disabled/.test(html), "and it starts disabled, like everything else there");
  assert(/btnResetDue"\)\.disabled = ro \|\| !state\.tasks\.length/.test(html),
    "a read-only plan cannot be reset, and an empty one has nothing to reset");
});

console.log("\n-- Management report --");

/* A plan built around today, so the health buckets it lands in are the ones a
   reader would see. `d(n)` is n days from today. */
const d = n => A.fmtDate(A.addDays(new Date(), n));

function reportPlan() {
  const mk = (id, name, extra) => Object.assign(
    { id, name, start: d(-5), end: d(5), group: [], preds: [], status: "Planned", history: [],
      notes: "", team: "", system: "", accountable: "", responsible: "" }, extra || {});
  return [
    mk(1, "Analysis", { group: [2, 3] }),
    mk(2, "Requirements", { status: "In Progress" }),                    // progress
    mk(3, "Design", { status: "Done" }),                                 // done
    mk(10, "Security", { group: [11, 12] }),
    mk(11, "Threat modelling", { status: "Blocked" }),                   // blocked
    mk(12, "Pen test", { start: d(-20), end: d(-2) }),                   // overdue
    mk(20, "Rollout", { group: [21], notes: "Waiting on the vendor." }),
    mk(21, "Cutover", { start: d(9), end: d(12) }),                      // upcoming
  ];
}

function withReportPlan(fn) {
  const before = A.state.tasks;
  A.state.tasks = reportPlan();
  try { return fn(); } finally { A.state.tasks = before; }
}

t("a group is judged on the work under it, not on its own row", () => {
  withReportPlan(() => {
    const g = A.taskById(1);
    const leaves = A.leavesUnder(g).map(t => t.id);
    eqArr(leaves, [2, 3], "the group itself is not one of its own leaves");
  });
});

t("the worst thing under a group is what the group reports", () => {
  withReportPlan(() => {
    /* Security holds one blocked task and one overdue one. Blocked wins, and
       that ordering is the whole point of REPORT_RULES being a list. */
    assert.strictEqual(A.reportRuleFor(A.leavesUnder(A.taskById(10))).key, "blocked");
    assert.strictEqual(A.reportRuleFor(A.leavesUnder(A.taskById(1))).key, "progress");
    assert.strictEqual(A.reportRuleFor(A.leavesUnder(A.taskById(20))).key, "upcoming");
  });
});

t("a group whose every task is done reads Complete, not On Track", () => {
  withReportPlan(() => {
    A.taskById(2).status = "Done";
    assert.strictEqual(A.reportRuleFor(A.leavesUnder(A.taskById(1))).key, "done");
  });
});

t("the project takes the worst status any stream reports", () => {
  withReportPlan(() => {
    const m = A.reportModel();
    assert.strictEqual(m.overall.key, "blocked", "Security is blocked, so the project is At Risk");
    assert.strictEqual(m.overall.head, "At Risk");
  });
});

/* Printing the date in both columns made every row look like it had slipped,
   and the reader had to compare two dates a line to find the ones that had. */
t("a due date that has not moved leaves the new-date cell empty", () => {
  const before = A.state.tasks;
  A.state.tasks = [
    { id: 1, name: "Held", start: "2026-03-02", end: "2026-03-10", group: [], preds: [],
      status: "Planned", history: [], notes: "", dateHist: [], baselineEnd: "" },
    { id: 2, name: "Slipped", start: "2026-03-02", end: "2026-03-20", group: [], preds: [],
      status: "Planned", history: [], notes: "", dateHist: [], baselineEnd: "2026-03-10" },
  ];
  try {
    const rows = A.reportModel().rows;
    const held = rows.find(r => r.t.name === "Held"), slipped = rows.find(r => r.t.name === "Slipped");
    assert.strictEqual(held.moved, false, "it never moved");
    assert.strictEqual(slipped.moved, true, "and this one did");

    const out = A.reportTableHtml(A.reportModel());
    // the row that held: its original is there, its new-date cell is empty
    assert(/2026-03-02|2026-03-10/.test(out), "the original is still shown");
    assert(/<td class="repDate"><\/td>/.test(out), "an unmoved row leaves the cell blank: " + out);
    assert(/repMoved[^"]*"[^>]*>2026-03-20</.test(out), "and a moved one fills it, marked: " + out);
    assert.strictEqual((out.match(/2026-03-20/g) || []).length, 1,
      "the new date appears once, in the column that means it");
  } finally { A.state.tasks = before; }
});

t("the table is the top-level streams", () => {
  withReportPlan(() => {
    assert.strictEqual(A.reportModel().rows.length, 3, "only the three top-level groups are rows");
  });
});

t("a typed note is the summary, word for word", () => {
  withReportPlan(() => {
    const row = A.reportModel().rows.find(r => r.t.name === "Rollout");
    assert.strictEqual(A.reportSummaryCell(row), "Waiting on the vendor.",
      "nothing is synthesised over the top of somebody's own words");
  });
});

t("an empty note is written from the tasks underneath", () => {
  withReportPlan(() => {
    const row = A.reportModel().rows.find(r => r.t.name === "Security");
    const cell = A.reportSummaryCell(row);
    assert(/Threat modelling is .*blocked/.test(cell), "it names what is blocked: " + cell);
    assert(/Pen test is .*overdue/.test(cell), "and what is overdue: " + cell);
  });
});

/* "Something is stopped and needs a decision" is true of every blocked plan
   ever written. The summary has to name the work. */
t("the summary names each blocked task and the stream it sits in", () => {
  withReportPlan(() => {
    const out = A.reportSummaryHtml(A.reportModel());
    assert(/Blocked:/.test(out), "it leads on what is stopped: " + out);
    assert(/hBlockedTx">Threat modelling</.test(out), "the task, in red: " + out);
    assert(/repIn">\(Security\)/.test(out), "and the stream it sits in, quieter: " + out);
    assert(!/Something is stopped/.test(out), "and not the sentence that named nothing");
  });
});

t("overdue work is what Needs attention names, with its stream", () => {
  withReportPlan(() => {
    const out = A.reportSummaryHtml(A.reportModel());
    assert(/Needs attention:/.test(out));
    assert(/hOverdueTx">Pen test</.test(out), "the overdue task: " + out);
    assert(/repIn">\(Security\)/.test(out), "and where to find it: " + out);
  });
});

/* A summary that spends four lines on what is fine buries the one line that is
   not, and the longest part of a healthy plan is the part nobody reads. */
t("the summary does not list the work that is going fine", () => {
  withReportPlan(() => {
    const out = A.reportSummaryHtml(A.reportModel());
    assert(!/Proceeding as expected/.test(out), "no roll-call of the healthy: " + out);
    assert(!/Analysis/.test(out), "and no stream named just for being fine: " + out);
  });
});

/* Work that should have begun and has not is not a slip yet, but it is not
   "according to plan" either. */
t("late to start is named, and denies the plan a clean bill of health", () => {
  const before = A.state.tasks;
  const d2 = n => A.fmtDate(A.addDays(new Date(), n));
  A.state.tasks = [
    { id: 1, name: "Stream", start: d2(-9), end: d2(9), group: [2, 3], preds: [], status: "Planned", history: [], notes: "" },
    { id: 2, name: "Should have begun", start: d2(-9), end: d2(9), group: [], preds: [], status: "Planned", history: [], notes: "" },
    { id: 3, name: "Under way", start: d2(-9), end: d2(9), group: [], preds: [], status: "In Progress", history: [], notes: "" },
  ];
  try {
    const out = A.reportSummaryHtml(A.reportModel());
    assert(/Late to start:/.test(out), "it is named: " + out);
    assert(/hLateStartTx">Should have begun</.test(out), "with the task: " + out);
    assert(/repIn">\(Stream\)/.test(out), "and its stream: " + out);
    assert(/Everything else is going well\./.test(out), "and the rest is accounted for: " + out);
    assert(!/Everything is going according to plan/.test(out),
      "which it is not, and the page must not say so: " + out);
  } finally { A.state.tasks = before; }
});

t("with something blocked, the rest is not claimed to be going well", () => {
  withReportPlan(() => {
    const out = A.reportSummaryHtml(A.reportModel());
    assert(!/Everything else is going well/.test(out),
      "that sentence would be contradicting the two lines above it: " + out);
  });
});

/* The bar answers "what is every task doing"; the circle answers "how is the
   project". Those are different questions and give different answers. */
t("the status circle has its own three colours", () => {
  const dot = k => A.reportRule(k).dot;
  assert.strictEqual(dot("blocked"), "repRed", "stopped work is red");
  assert.strictEqual(dot("overdue"), "repOrange", "a missed date is amber, not red");
  assert.strictEqual(dot("lateStart"), "repGreen", "work that has not begun leaves it green");
  assert.strictEqual(A.reportRule("lateStart").head, "On Track, with conditions",
    "with the word saying why");
  assert.strictEqual(dot("progress"), "repGreen");
  // and the health bucket colours are untouched, because the dashboard shares them
  assert.strictEqual(A.reportRule("overdue").cls, "hOverdue");
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  for (const c of ["repRed", "repOrange", "repGreen"])
    assert(new RegExp("\\.repStatus\\." + c + "\\{--ink:").test(css), "no colour for ." + c);
});

t("the card takes the circle's colour, not the bucket's", () => {
  withReportPlan(() => {
    A.renderReport();
    const out = ctx.document.getElementById("reportView").innerHTML;
    assert(/repStatus repRed/.test(out), "blocked plan, red circle: " + out.slice(0, 400));
  });
});

t("with nothing wrong, the summary is one sentence", () => {
  withReportPlan(() => {
    A.taskById(11).status = "In Progress";                        // the blocked one
    /* Both dates, not just the end: moving the end alone leaves a start in the
       past on a Planned row, which is late to start — and that is now something
       the summary refuses to call "according to plan". */
    A.taskById(12).start = A.fmtDate(A.addDays(new Date(), 20));
    A.taskById(12).end = A.fmtDate(A.addDays(new Date(), 30));
    const out = A.reportSummaryHtml(A.reportModel());
    assert(/Everything is going according to plan\./.test(out), "one line, and that is the page: " + out);
    assert(!/Blocked:|Needs attention:|Late to start:/.test(out), "with no heading at all: " + out);
  });
});

/* A plan is mostly future. Listing the part that has not begun filled the
   column with the one thing nobody needs telling. */
t("the written summary leaves out work that has simply not started", () => {
  withReportPlan(() => {
    const cell = A.reportSummaryCell(A.reportModel().rows.find(r => r.t.name === "Rollout"));
    assert(!/not started yet/.test(cell), "silence is the right amount to say: " + cell);
  });
});

t("but work that should have started is still named", () => {
  const before = A.state.tasks;
  A.state.tasks = [
    { id: 1, name: "Stream", start: d(-9), end: d(9), group: [2], preds: [], status: "Planned", history: [], notes: "" },
    { id: 2, name: "Late thing", start: d(-9), end: d(9), group: [], preds: [], status: "Planned", history: [], notes: "" },
  ];
  try {
    const cell = A.reportSummaryCell(A.reportModel().rows[0]);
    assert(/Late thing is .*late to start/.test(cell), "the one that should have begun is named: " + cell);
  } finally { A.state.tasks = before; }
});

t("the synthesised sentence leads with what is going well", () => {
  withReportPlan(() => {
    const row = A.reportModel().rows.find(r => r.t.name === "Analysis");
    const cell = A.reportSummaryCell(row);
    assert(cell.indexOf("running as expected") < cell.indexOf("complete") || !/complete/.test(cell),
      "good news first: " + cell);
    assert(/Requirements is/.test(cell) && /Design is/.test(cell), "both tasks are named: " + cell);
  });
});

t("a long group names a few tasks and counts the rest", () => {
  assert.strictEqual(A.repNames(["a", "b", "c"]), "a, b and c");
  assert.strictEqual(A.repNames(["a", "b", "c", "d", "e"]), "a, b and c and 2 more");
});

t("the two weeks run Monday to Sunday, and do not overlap", () => {
  withReportPlan(() => {
    const m = A.reportModel();
    assert.strictEqual(A.weekStartOf(m.thisWeek.from).getTime(), m.thisWeek.from.getTime(), "starts on a Monday");
    assert.strictEqual(A.daysBetween(m.thisWeek.from, m.thisWeek.to), 6, "and runs six days on");
    assert.strictEqual(A.daysBetween(m.thisWeek.from, m.nextWeek.from), 7, "next week is the week after");
  });
});

/* A date in that column left the reader working out whether it was a start or
   an end, and whether it even fell inside the week. */
t("the week says what the week is about, not a date", () => {
  const before = A.state.tasks;
  const from = A.weekStartOf(new Date());
  const day = n => A.fmtDate(A.addDays(from, n));
  A.state.tasks = [
    { id: 1, name: "Stream", start: day(-20), end: day(20), group: [2, 3, 4, 5], preds: [], status: "Planned", history: [], notes: "" },
    { id: 2, name: "Opens", start: day(2), end: day(20), group: [], preds: [], status: "Planned", history: [], notes: "" },
    { id: 3, name: "Lands", start: day(-20), end: day(3), group: [], preds: [], status: "Planned", history: [], notes: "" },
    { id: 4, name: "Whole", start: day(1), end: day(4), group: [], preds: [], status: "Planned", history: [], notes: "" },
    { id: 5, name: "Through", start: day(-20), end: day(20), group: [], preds: [], status: "Planned", history: [], notes: "" },
  ];
  try {
    const says = new Map(A.reportModel().thisWeek.rows.map(r => [r.t.name, r.says]));
    /* Said as an expectation, not an assertion: a blocked task with its end
       date in this week is exactly the row where "ends" would be a lie. */
    assert.strictEqual(says.get("Opens"), "due to start");
    assert.strictEqual(says.get("Lands"), "due to end");
    assert.strictEqual(says.get("Whole"), "due to start and end");
    assert.strictEqual(says.get("Through"), "runs through");

    const out = A.reportWeekHtml(A.reportModel().thisWeek);
    assert(!/\d{4}-\d{2}-\d{2}/.test(out), "no dates left in the list: " + out);
    assert(/Stream/.test(out), "and the stream each task belongs to is named");
  } finally { A.state.tasks = before; }
});

/* Real plans repeat a name across streams. Four rows called "Testing" that look
   identical until you read the small line under each is not a list. */
t("one name run by two streams is one line, naming both", () => {
  const before = A.state.tasks;
  const from = A.weekStartOf(new Date());
  const day = n => A.fmtDate(A.addDays(from, n));
  A.state.tasks = [
    { id: 1, name: "Security", start: day(-20), end: day(20), group: [2], preds: [], status: "Planned", history: [], notes: "" },
    { id: 2, name: "Testing", start: day(1), end: day(3), group: [], preds: [], status: "Planned", history: [], notes: "" },
    { id: 3, name: "Development", start: day(-20), end: day(20), group: [4], preds: [], status: "Planned", history: [], notes: "" },
    { id: 4, name: "Testing", start: day(-20), end: day(20), group: [], preds: [], status: "Blocked", history: [], notes: "" },
    { id: 5, name: "Handover", start: day(2), end: day(4), group: [], preds: [], status: "Planned", history: [], notes: "" },
  ];
  try {
    const rows = A.reportModel().thisWeek.rows;
    const testing = rows.filter(r => r.t.name === "Testing");
    assert.strictEqual(testing.length, 1, "two Testings became one row");
    // earliest start first, which is the order the list itself is in
    assert.strictEqual(testing[0].main, "Development, Security", "and it names both streams");
    assert.strictEqual(testing[0].rule.key, "blocked",
      "the bullet takes the worst of them, or a blocked task hides behind a green dot");
    assert.strictEqual(testing[0].says, "due to start and end / runs through",
      "both answers are listed rather than one being picked: " + testing[0].says);
    assert(/Security — due to start and end/.test(testing[0].detail), "the breakdown survives: " + testing[0].detail);

    assert.strictEqual(rows.filter(r => r.t.name === "Handover").length, 1, "a name used once is untouched");
    const out = A.reportWeekHtml(A.reportModel().thisWeek);
    assert.strictEqual((out.match(/>Testing</g) || []).length, 1, "and it is drawn once: " + out);
  } finally { A.state.tasks = before; }
});

t("two tasks that share a name and a stream still merge to one", () => {
  const before = A.state.tasks;
  const from = A.weekStartOf(new Date());
  const day = n => A.fmtDate(A.addDays(from, n));
  A.state.tasks = [
    { id: 1, name: "Stream", start: day(-20), end: day(20), group: [2, 3], preds: [], status: "Planned", history: [], notes: "" },
    { id: 2, name: "Review", start: day(1), end: day(2), group: [], preds: [], status: "Planned", history: [], notes: "" },
    { id: 3, name: "Review", start: day(3), end: day(4), group: [], preds: [], status: "Planned", history: [], notes: "" },
  ];
  try {
    const rows = A.reportModel().thisWeek.rows.filter(r => r.t.name === "Review");
    assert.strictEqual(rows.length, 1);
    assert.strictEqual(rows[0].main, "Stream", "the one stream is named once, not twice");
  } finally { A.state.tasks = before; }
});

/* This gets copied into Excel. As a list with the task on one line and its
   stream on the next, the two arrived in one cell with a line break in it. */
t("a week is a table, so each fact lands in its own column", () => {
  const before = A.state.tasks;
  const from = A.weekStartOf(new Date());
  const day = n => A.fmtDate(A.addDays(from, n));
  A.state.tasks = [
    { id: 1, name: "Wave one", start: day(-2), end: day(5), group: [2], preds: [], status: "Planned", history: [], notes: "" },
    { id: 2, name: "Running now", start: day(1), end: day(3), group: [], preds: [], status: "Planned", history: [], notes: "" },
  ];
  try {
    const out = A.reportWeekHtml(A.reportModel().thisWeek);
    assert(/<table[^>]*repWeekTbl/.test(out), "a table, not a list: " + out.slice(0, 200));
    assert(!/<ul|<li/.test(out), "and nothing left of the list it was");

    // three headings, and three cells in the row under them
    const heads = (out.match(/<th>/g) || []).length;
    assert.strictEqual(heads, 4, "Task, Main task, Status, Due");
    const row = out.slice(out.indexOf("<tbody>"), out.indexOf("</tbody>"));
    assert.strictEqual((row.match(/<td/g) || []).length, 4, "one fact per cell: " + row);

    /* The three facts are in three different cells - which is the whole point,
       because that is what a paste turns into three columns. */
    assert(/<td class="repName">.*Running now<\/td>/.test(row), "the task in the first");
    assert(/<td class="repWkMain">Wave one<\/td>/.test(row), "its stream in the second, alone");
    /* The status is a word in its own colour, not a dot: a dot says nothing
       when the row is printed in grey or pasted into a spreadsheet. */
    assert(/<td class="repWkStat h\w+Tx"[^>]*>[A-Za-z ]+<\/td>/.test(row),
      "its status in the third, coloured on the word: " + row);
    assert(!/repBullet/.test(row), "and the dot it replaces is gone");
    assert(/<td class="repWkWhen">due to start and end<\/td>/.test(row), "what the plan expects in the fourth");
  } finally { A.state.tasks = before; }
});

t("a task that is itself a main task does not repeat its own name", () => {
  const before = A.state.tasks;
  const from = A.weekStartOf(new Date());
  A.state.tasks = [{ id: 1, name: "Standalone", start: A.fmtDate(from), end: A.fmtDate(A.addDays(from, 3)),
                     group: [], preds: [], status: "Planned", history: [], notes: "" }];
  try {
    assert.strictEqual(A.reportModel().thisWeek.rows[0].main, "", "no second line saying the same thing twice");
  } finally { A.state.tasks = before; }
});

t("the week lists work, not groups or milestones", () => {
  withReportPlan(() => {
    A.state.tasks.push(Object.assign({}, A.taskById(2), { id: 99, name: "Sign-off", milestone: true }));
    const names = A.reportModel().thisWeek.rows.map(r => r.t.name);
    assert(!names.includes("Analysis"), "a group is not work somebody does");
    assert(!names.includes("Sign-off"), "and neither is a milestone");
    assert(names.includes("Requirements"), "the task running today is there");
  });
});

t("a plan with no groups still gets a table of its top-level tasks", () => {
  const before = A.state.tasks;
  A.state.tasks = [
    { id: 1, name: "Only task", start: d(-1), end: d(1), group: [], status: "In Progress", history: [], notes: "" },
  ];
  try {
    const m = A.reportModel();
    assert.strictEqual(m.rows.length, 1, "the report is not blank just because nobody grouped anything");
    assert.strictEqual(m.rows[0].t.name, "Only task");
  } finally { A.state.tasks = before; }
});

t("the report is a view of its own, and carries no drawing into a shared page", () => {
  assert(html.includes('id="reportView"'), "it has a host");
  assert(html.includes('id="btnViewReport"'), "and a button on the bar");
  assert(A.SNAPSHOT_HOSTS.includes("reportView"),
    "emptied before a share is written, like every other rendered host");
});

t("the page draws, with every section the design asks for", () => {
  withReportPlan(() => {
    A.renderReport();
    const out = ctx.document.getElementById("reportView").innerHTML;
    for (const heading of ["Executive summary", "Project health", "Main tasks overview",
                           "Project status", "This week", "Next week", "Planned vs actual progress"])
      assert(out.includes(heading), "missing section: " + heading);
    assert(out.includes("Threat modelling"), "the blocked task is named in the summary column");
    assert(out.includes("Waiting on the vendor."), "and the typed note is carried through");
    assert(/stPill hBlockedPill/.test(out), "the blocked stream gets its pill");
  });
});

t("nothing a user typed reaches the page as markup", () => {
  withReportPlan(() => {
    A.taskById(20).notes = '<img src=x onerror=alert(1)>';
    A.taskById(20).name = '<b>Rollout</b>';
    A.renderReport();
    const out = ctx.document.getElementById("reportView").innerHTML;
    assert(!out.includes("<img src=x"), "the note is escaped");
    assert(!out.includes("<b>Rollout</b>"), "and so is the name");
    assert(out.includes("&lt;img src=x"), "it is shown, not run");
  });
});

console.log("\n-- Settings: the config --");

const readCfg = () => JSON.parse(ctx.localStorage.getItem("ppp.cfg") || "null");

t("a fresh browser starts in the language it reads", () => {
  ctx.localStorage.clear();
  ctx.navigator.languages = ["hu-HU", "hu", "en"];
  A.loadMe();
  assert.strictEqual(A.myLang(), "hu", "a Hungarian machine opens a Hungarian app");
  ctx.navigator.languages = ["pt-BR"];
  ctx.localStorage.clear();
  A.loadMe();
  assert.strictEqual(A.myLang(), "en", "and a language this build does not have falls back");
  ctx.navigator.languages = ["en"];
});

t("a language somebody chose beats the browser for ever after", () => {
  ctx.localStorage.clear();
  ctx.navigator.languages = ["en"];
  A.loadMe();
  A.setLang("de");
  ctx.navigator.languages = ["hu"];        // a different machine, or a changed setting
  A.loadMe();
  assert.strictEqual(A.myLang(), "de", "the choice is not re-asked");
  ctx.navigator.languages = ["en"];
});

t("the name moves into the config, and an old one is carried over", () => {
  ctx.localStorage.clear();
  ctx.localStorage.setItem("ppp.meName", "Anna Reed");   // written by a build before the config
  A.loadMe();
  assert.strictEqual(A.myName(), "Anna Reed", "a month of use is not thrown away");
  A.setMyName("Ben Carter");
  assert.strictEqual(readCfg().name, "Ben Carter", "and from now on it lives in the config");
});

t("a config file that is not one is refused rather than swallowed", () => {
  ctx.localStorage.clear();
  A.loadMe();
  A.setLang("fr");
  const was = A.myLang();
  A.applyCfg({ lang: "klingon", mode: "sudo", name: "A|B;C" });
  assert.strictEqual(A.myLang(), "en", "an unknown language falls back rather than blanking the app");
  assert.strictEqual(A.appMode(), "normal", "and an unknown mode does too");
  assert.strictEqual(A.myName(), "A B C", "the delimiters are cleaned out of the name");
  assert.notStrictEqual(was, "en", "…and the test really did change it first");
});

t("the config round-trips through the file it writes", () => {
  ctx.localStorage.clear();
  A.loadMe();
  A.setMyName("Rita Coleman");
  A.setLang("hu");
  const text = JSON.stringify(A.state.cfg);
  ctx.localStorage.clear();
  A.loadMe();
  assert.strictEqual(A.myName(), "", "cleared");
  A.applyCfg(JSON.parse(text), "gantora-config.json");
  assert.strictEqual(A.myName(), "Rita Coleman");
  assert.strictEqual(A.myLang(), "hu", "carried to another machine by hand");
});

t("the settings live in the browser, and the file is one you save by hand", () => {
  /* Not a preference: a page on file:// cannot fetch a sibling, so a config
     that read itself on the way up could not exist. */
  assert(html.includes('id="cfgLang"'), "the language picker is in About");
  assert(html.includes('id="btnCfgSave"') && html.includes('id="btnCfgLoad"'), "with save and load");
  assert(html.includes('id="cfgFileInput"'), "reading through a file picker, which needs no permission");
  assert(!/fetch\(\s*["']gantora-config/.test(html), "nothing tries to fetch the config");
});

console.log("\n-- The file changed while we were working --");

/* A handle that answers like a real one, with a modification time we control. */
const fakeFileHandle = (text, at) => ({
  name: "switchover.gantora",
  getFile: async () => ({ lastModified: at, text: async () => text }),
  createWritable: async () => ({ write: async () => {}, close: async () => {} }),
});

const stampedPlan = (name, who, when) =>
  A.HEADER.join("|") + "\r\n" +
  A.CSV_COLS.map(c => ({ id: "1", name, duration: "1", start: "2026-03-02", end: "2026-03-03",
    milestone: "no", status: "Planned", event: "no", notFinal: "no",
    modified: when ? `${when} a5fy ${who}` : "" }[c.key] || "")).join("|") + "\r\n";

/* A stamp of the shape the local adapter makes: when we last agreed with the
   file, and the text it held then. Written through a helper because the *shape*
   is the adapter's business — a test that spelt `{at, text}` everywhere would
   have to be swept again the day a second adapter arrives. */
const stampAt = (at, text) => ({ at, text: text === undefined ? "" : text });

ta("a file nobody has touched is saved without a word", async () => {
  const keep = { fh: A.planRef(), st: A.state.stamp };
  try {
    useRef(fakeFileHandle("x", 1000));
    A.state.stamp = stampAt(1000);
    assert.strictEqual(await A.fileChangedUnderUs(), null, "as we left it");
    A.state.stamp = stampAt(2000);                // our own write was later still
    assert.strictEqual(await A.fileChangedUnderUs(), null, "and never a conflict with ourselves");
  } finally { useRef(keep.fh); A.state.stamp = keep.st; }
});

ta("a file written since we opened it is caught before the save", async () => {
  const keep = { fh: A.planRef(), st: A.state.stamp };
  try {
    useRef(fakeFileHandle("x", 5000));
    A.state.stamp = stampAt(1000);                // somebody saved after we read it
    const found = await A.fileChangedUnderUs();
    assert(found, "the change is seen");
    /* The guard hands back the disk's version, not a File: when it was written,
       for the message, and its text, so the dialog does not have to read it
       again — see handleConflict. */
    assert.strictEqual(found.when, 5000);
    assert.strictEqual(found.text, "x", "and the text comes with it");
  } finally { useRef(keep.fh); A.state.stamp = keep.st; }
});

ta("with nothing to compare against, saving is never blocked", async () => {
  const keep = { fh: A.planRef(), st: A.state.stamp };
  try {
    useRef(null); A.state.stamp = stampAt(1000);
    assert.strictEqual(await A.fileChangedUnderUs(), null, "no handle, no question");
    useRef(fakeFileHandle("x", 5000)); A.state.stamp = null;
    assert.strictEqual(await A.fileChangedUnderUs(), null, "a file we never stamped is not a conflict");
  } finally { useRef(keep.fh); A.state.stamp = keep.st; }
});

/* ---- The guard's third answer: "I could not tell" ------------------------
   It used to be folded into "unchanged", which is a guard failing *open*. The
   case that makes it matter is the one a synced folder produces: metadata that
   reads, contents that do not, and a write that is still allowed. */

// a handle whose stat works and whose contents will not come
const unreadableHandle = at => ({
  name: "switchover.gantora",
  getFile: async () => ({ lastModified: at, size: 10, text: async () => {
    const e = new Error("nope"); e.name = "NotReadableError"; throw e;
  } }),
  createWritable: async () => ({ write: async () => {}, close: async () => {} }),
});

ta("a file we cannot read is not reported as a file nobody touched", async () => {
  const keep = { fh: A.planRef(), st: A.state.stamp, unk: A.state.stampUnknown };
  try {
    A.state.stampUnknown = false;
    useRef(unreadableHandle(5000));
    A.state.stamp = stampAt(1000, "what we last wrote");   // the stamp says it moved
    const answer = await A.fileChangedUnderUs();
    assert.strictEqual(answer, A.CHECK_FAILED,
      "the contents would not come, so the question was not answered — and that is not a no");
    assert.notStrictEqual(answer, null, "null here is what let a save land on somebody's work");
  } finally {
    useRef(keep.fh); A.state.stamp = keep.st; A.state.stampUnknown = keep.unk;
  }
});

/* The worse half: this one switched the check off for the whole session. */
ta("a stamp that could not be recorded makes the guard careful, not blind", async () => {
  const keep = { fh: A.planRef(), st: A.state.stamp, unk: A.state.stampUnknown };
  try {
    const throwing = { getFile: async () => { throw new Error("gone"); } };
    useRef(throwing);
    A.state.stamp = stampAt(4321, "what we last wrote");
    await A.rememberFileStamp();

    assert.strictEqual(A.state.stampUnknown, true, "the failure is recorded");
    assert.strictEqual(A.FsaAdapter.stampTime(A.state.stamp), 4321,
      "and what could not be replaced was not blanked either — zeroing it is what read as 'no question to ask'");

    /* The bug, exactly: with nothing recorded the guard returned null for every
       save that followed, for the rest of the session. */
    useRef(fakeFileHandle("somebody else's plan", 9999));
    assert.strictEqual(await A.fileChangedUnderUs(), A.CHECK_FAILED,
      "until a read succeeds, every save is asked about rather than waved through");

    // …and a good read clears it
    await A.rememberFileStamp("fresh text");
    assert.strictEqual(A.state.stampUnknown, false, "one successful read and the guard is itself again");
    assert.strictEqual(A.state.stamp.text, "fresh text", "with the text it was handed");
  } finally {
    useRef(keep.fh); A.state.stamp = keep.st; A.state.stampUnknown = keep.unk;
  }
});

ta("an automatic save never writes unchecked, and says so once rather than thirty-one times", async () => {
  const keep = { toastEl: els.toast && els.toast.textContent, confirm: ctx.confirm };
  try {
    // silent: the answer is no, whatever the reader would have said
    ctx.confirm = () => true;
    assert.strictEqual(await A.okToSaveUnchecked(true), false,
      "one of the thirty-one automatic saves does not get to overwrite blind");
    const said = document.getElementById("toast").textContent;
    assert(/cannot be checked/.test(said), "and it says why: " + said);

    document.getElementById("toast").textContent = "";
    assert.strictEqual(await A.okToSaveUnchecked(true), false, "still no");
    assert.strictEqual(document.getElementById("toast").textContent, "",
      "and it does not say it again — thirty-one toasts is noise, not information");

    // asked for by hand, the reader decides
    ctx.confirm = () => false;
    assert.strictEqual(await A.okToSaveUnchecked(false), false, "a no means no");
    ctx.confirm = () => true;
    assert.strictEqual(await A.okToSaveUnchecked(false), true, "and a yes is allowed to overwrite");
  } finally { ctx.confirm = keep.confirm; }
});

t("the save asks the guard for three answers, not two", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const save = script.slice(script.indexOf("async function saveFileNow"));
  const body = save.slice(0, save.indexOf("createWritable"));
  assert(/moved === CHECK_FAILED/.test(body), "the middle answer is handled on its own");
  assert(/okToSaveUnchecked\(silent\)/.test(body), "and it decides by whether anybody asked for this save");
  /* The shape that made the bug: a bare `if (moved)` treats CHECK_FAILED as a
     conflict and `null` as permission, and there is no third branch at all. */
  assert(/else if \(moved\)/.test(body), "a real conflict still goes to the dialog");
});

/* The Modified column already carries who, so the warning can name them. */
t("the warning can say who, where the file says so", () => {
  const text = stampedPlan("Work", "Anna Reed", "2026-08-12T10:00:00Z");
  eqArr(A.whoChanged(text, Date.parse("2026-08-12T09:00:00Z")), ["Anna Reed"],
    "a row stamped after we last read the file names its author");
  eqArr(A.whoChanged(text, Date.parse("2026-08-12T11:00:00Z")), [],
    "and one stamped before is a change we already had");
});

t("a change nobody signed is still a change, just an unnamed one", () => {
  const text = stampedPlan("Work", "", "");
  eqArr(A.whoChanged(text, 0), [], "no name to give");
  // …and the caller still warns, because the file moved whether or not it says who
  assert(/was changed\$\{by\}|was changed/.test(html.replace(/\s+/g, " ")) ||
         /was changed/.test(html), "the sentence works with an empty by");
});

t("the check happens before the write, and our own write resets it", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const save = script.slice(script.indexOf("async function saveFileNow"));
  const guard = save.indexOf("fileChangedUnderUs");
  const write = save.indexOf("createWritable");
  assert(guard > 0 && write > guard, "the conflict is looked for before anything is written");
  // every way of ending up in step with the file records it, and says what it wrote
  assert((script.match(/rememberFileStamp\([^)]/g) || []).length >= 5,
    "each writer hands over the text it just wrote");
});

/* The bug this replaces: nothing had changed, the guard said it had, and the
   plan could not be saved at all. */
ta("our own write is never mistaken for somebody else's", async () => {
  const keep = { fh: A.planRef(), st: A.state.stamp };
  try {
    const text = "the plan as we last wrote it";
    /* The file carries a later time than our stamp — which is what happens when
       two of the thirty-one auto-saves overlap — but the same text. */
    useRef(fakeFileHandle(text, 9000));
    A.state.stamp = stampAt(1000, text);
    assert.strictEqual(await A.fileChangedUnderUs(), null,
      "same text, so it was us, however the clock reads");
    assert.strictEqual(A.FsaAdapter.stampTime(A.state.stamp), 9000,
      "and the stamp is brought up to date");
  } finally { useRef(keep.fh); A.state.stamp = keep.st; }
});

ta("a different text is still a conflict, however close the times", async () => {
  const keep = { fh: A.planRef(), st: A.state.stamp };
  try {
    useRef(fakeFileHandle("somebody else's plan", 1001));
    A.state.stamp = stampAt(1000, "the plan as we last wrote it");
    const found = await A.fileChangedUnderUs();
    assert(found, "one millisecond and a different text is a change");
  } finally { useRef(keep.fh); A.state.stamp = keep.st; }
});

t("saves run one at a time, so two cannot interleave", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  assert(/let saveQueue = Promise\.resolve\(\)/.test(script), "there is a queue");
  assert(/saveQueue = saveQueue\.then\(/.test(script), "and every save joins it");
  /* A rejected save must not stop the queue for ever, or one failed write ends
     saving for the session. */
  const line = script.split("\n").find(l => l.includes("saveQueue = saveQueue.then("));
  assert(/,\s*\(\)\s*=>/.test(line), "a failed save does not block the ones behind it: " + line);
});

/* Without this the save at the end of a merge would find the file still newer
   than our stamp, warn again, and offer the merge we have just finished. */
t("a merge against our own file accepts it, so the save does not loop", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const fn = script.slice(script.indexOf("async function applyMerge"));
  const body = fn.slice(0, fn.indexOf("async function openMergePicker"));

  const accept = body.indexOf("rememberFileStamp");
  const save = body.indexOf("await saveFile(");
  assert(accept > 0, "the merged-from version is accepted");
  assert(save > accept, "before the save, or the warning comes straight back");
  assert(/if \(m\.name === planName\(\)\) await rememberFileStamp\(\)/.test(body),
    "and only for our own file: merging from another plan says nothing about ours");
});

t("a merge is written out, in either mode", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const fn = script.slice(script.indexOf("async function applyMerge"));
  const body = fn.slice(0, fn.indexOf("async function openMergePicker"));
  assert(/await saveFile\(!restricted\(\)\)/.test(body),
    "silent where saves write through, explicit where they do not — a merge must land either way");
});

t("the conflict asks, and writes nothing while it is asking", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const fn = script.slice(script.indexOf("async function handleConflict"));
  const body = fn.slice(0, fn.indexOf("function closeConflict"));
  assert(/conflictOverlay"\)\.style\.display = "flex"/.test(body),
    "it puts the question on screen rather than choosing for you");
  assert(!/createWritable/.test(body), "and writes nothing on the way");
  assert(/let checkingConflict/.test(script), "one dialog, however many saves queue behind it");

  /* The flag is what stops the other thirty-one automatic saves walking into
     the same question, so the save has to ask about it. */
  const save = script.slice(script.indexOf("async function saveFileNow"));
  const guard = save.indexOf("if (checkingConflict) return;");
  const write = save.indexOf("createWritable");
  assert(guard > 0 && write > guard, "an unanswered conflict blocks the write");
});

/* Three ways out, and each of them has to be the thing it says it is. */
t("the three answers are the three the brief asks for, and the safe one is the default", () => {
  assert(/id="btnConflictMerge" class="primary"/.test(html), "comparing is the highlighted answer");
  for (const id of ["btnConflictMerge", "btnConflictCopy", "btnConflictOver"])
    assert(html.includes('id="' + id + '"'), id + " is not on the dialog");
  // and nothing can dismiss it: a waved-away conflict would leave saving off
  assert(!/conflictOverlay"\)\.addEventListener/.test(html), "the backdrop must not close it");
  assert(!html.includes('id="btnConflictCancel"'), "there is deliberately no way out but an answer");

  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const merge = script.slice(script.indexOf("function conflictMerge"), script.indexOf("function deviceTag"));
  assert(/showMerge\(text, planName\(\)\)/.test(merge), "comparing opens the screen that keeps both sides");

  const copy = script.slice(script.indexOf("async function conflictSaveCopy"), script.indexOf("async function conflictOverwrite"));
  assert(/savePlanCopy\(/.test(copy), "the copy goes through the same writer as Save as");
  assert(/deviceTag\(\)/.test(copy) && /stampForName\(\)/.test(copy),
    "and its name says which machine and when");

  const over = script.slice(script.indexOf("async function conflictOverwrite"));
  const ask = over.indexOf("confirm(");
  const stamp = over.indexOf("rememberFileStamp");
  assert(ask > 0 && ask < stamp, "overwriting asks once more before it accepts the file");
  assert(/lost/.test(over.slice(0, 600)), "and the words say what is lost");
});

/* A /DB that can be written to as well as read, so the copy and the overwrite
   can be checked by what ends up in it rather than by reading the source. */
const writableDb = () => {
  const files = {};
  const db = {
    files,
    getFileHandle: async (name, opts) => {
      if (!(name in files) && !(opts && opts.create)) throw new Error("no such file: " + name);
      if (!(name in files)) files[name] = "";
      return {
        name,
        getFile: async () => ({ size: files[name].length, lastModified: 1, text: async () => files[name] }),
        createWritable: async () => ({ write: async t => { files[name] = t; }, close: async () => {} }),
      };
    },
    entries: async function* () { for (const n of Object.keys(files)) yield [n, { kind: "file" }]; },
  };
  return db;
};

/* Everything these three move, put back — the stub DOM and A.state both keep
   whatever the last test wrote into them. */
const conflictFixture = fn => async () => {
  const keep = { db: A.state.dbHandle, fh: A.planRef(), name: A.planName(),
                 tasks: A.state.tasks, dirty: A.state.dirty, stamp: A.state.stamp,
                 unk: A.state.stampUnknown, ro: A.state.readOnly, comb: A.state.combined,
                 confirm: ctx.confirm, view: A.state.view, me: ctx.localStorage.getItem("ppp.meName") };
  document.getElementById("conflictOverlay").style.display = "none";
  try { await fn(); }
  finally {
    A.closeConflict();
    A.state.dbHandle = keep.db; useRef(keep.fh); useName(keep.name);
    A.state.tasks = keep.tasks; A.state.dirty = keep.dirty; A.state.stamp = keep.stamp;
    A.state.stampUnknown = keep.unk; A.state.readOnly = keep.ro; A.state.combined = keep.comb;
    A.state.view = keep.view; ctx.confirm = keep.confirm;
    if (keep.me != null) A.setMyName(keep.me);
  }
};

const THEIRS = () => stampedPlan("Their work", "Anna Reed", "2026-08-12T10:00:00Z");

ta("the conflict puts the question on screen and names who moved the file", conflictFixture(async () => {
  A.state.readOnly = false; A.state.combined = null; A.state.view = "table";
  A.state.tasks = A.parseCSV(wholePlan(2));
  useName("switchover.gantora");
  A.state.stamp = stampAt(Date.parse("2026-08-12T09:00:00Z"), "the plan as we last wrote it");

  const theirs = THEIRS();
  const at = Date.parse("2026-08-12T10:00:00Z");
  const file = { size: theirs.length, lastModified: at, text: async () => theirs };
  // a handle that fails loudly if anything writes while the question is unanswered
  useRef({ getFile: async () => file,
           createWritable: async () => { throw new Error("wrote the file while asking about it"); } });

  /* The dialog is handed the disk's version by value — its text, when it was
     written, and the stamp that goes with it. It no longer re-reads a File it was
     given, which is what the guard already had in its hand. */
  await A.handleConflict({ text: theirs, when: at, stamp: stampAt(at, theirs) });
  assert.strictEqual(document.getElementById("conflictOverlay").style.display, "flex", "the question is on screen");
  const said = document.getElementById("conflictWho").textContent;
  assert(said.includes("switchover.gantora"), "it names the file: " + said);
  assert(said.includes("Anna Reed"), "and who moved it, off the Modified stamps: " + said);

  await A.saveFile(true);            // the next of the thirty-two, arriving mid-question
  assert.strictEqual(document.getElementById("conflictOverlay").style.display, "flex",
    "and the dialog is still the one dialog");
}));

ta("half a file is not a conflict to resolve — it is a copy still landing", conflictFixture(async () => {
  useName("switchover.gantora");
  A.state.stamp = stampAt(1);
  const half = halfPlan();
  await A.handleConflict({ text: half, when: 9, stamp: stampAt(9, half) });
  assert.strictEqual(document.getElementById("conflictOverlay").style.display, "none",
    "there is nothing here to keep, so there is nothing to ask");
}));

ta("saving mine as a copy leaves their version exactly where it was", conflictFixture(async () => {
  A.state.readOnly = false; A.state.combined = null; A.state.view = "table";
  const db = writableDb();
  const theirs = THEIRS();
  db.files["switchover.gantora"] = theirs;
  A.state.dbHandle = db;
  useRef(await db.getFileHandle("switchover.gantora"));
  useName("switchover.gantora");
  A.state.tasks = A.parseCSV(wholePlan(2));
  A.setMyName("Anna Kovacs");

  await A.conflictSaveCopy();

  const copy = Object.keys(db.files).find(n => n !== "switchover.gantora");
  assert(copy, "a copy was written: " + Object.keys(db.files).join(", "));
  assert(copy.includes("Anna-Kovacs"), "named for the machine that made it: " + copy);
  assert(copy.endsWith(A.PLAN_EXT), "and it is a plan file: " + copy);
  assert.strictEqual(db.files["switchover.gantora"], theirs, "their version is untouched — that is the whole point");
  assert.strictEqual(A.planName(), copy, "and editing goes on in the copy");
  assert(db.files[copy].startsWith(A.HEADER.join("|")), "the copy is a whole plan");
}));

ta("overwriting asks once more, and a no leaves the question standing", conflictFixture(async () => {
  A.state.readOnly = false; A.state.combined = null; A.state.view = "table";
  const db = writableDb();
  const theirs = THEIRS();
  db.files["switchover.gantora"] = theirs;
  A.state.dbHandle = db;
  useRef(await db.getFileHandle("switchover.gantora"));
  useName("switchover.gantora");
  A.state.tasks = A.parseCSV(wholePlan(2));
  document.getElementById("conflictOverlay").style.display = "flex";

  ctx.confirm = () => false;
  await A.conflictOverwrite();
  assert.strictEqual(db.files["switchover.gantora"], theirs, "a no writes nothing");
  assert.strictEqual(document.getElementById("conflictOverlay").style.display, "flex",
    "and the question is still there to be answered");

  ctx.confirm = () => true;
  await A.conflictOverwrite();
  assert.notStrictEqual(db.files["switchover.gantora"], theirs, "a yes puts ours in");
  assert(db.files["switchover.gantora"].includes("Task 1"), "and it is the plan on screen");
  assert.strictEqual(document.getElementById("conflictOverlay").style.display, "none", "the dialog is done with");
}));

t("the copy is named for the machine that made it, without a field to fill in", () => {
  const keep = ctx.localStorage.getItem("ppp.meName");
  try {
    A.setMyName("Dániel Szabó");
    const tag = A.deviceTag();
    assert(tag.includes(A.myId()), "the browser id is in it, which is what makes it unique: " + tag);
    assert(!/[\\/:*?"<>|\s]/.test(tag), "and it is usable as a file name: " + tag);
    // 20260817-131600, so two copies a minute apart never collide
    assert(/^\d{8}-\d{6}$/.test(A.stampForName(new Date(2026, 7, 17, 13, 16, 0))),
      "the stamp is sortable and to the second: " + A.stampForName());
    assert.strictEqual(A.stampForName(new Date(2026, 7, 17, 13, 16, 5)), "20260817-131605");
  } finally { if (keep != null) A.setMyName(keep); }
});

console.log("\n-- Reading a plan out of a folder somebody syncs --");

/* A plan written the way serializeCSV writes one, and long enough to cut. Built
   from the app's own header so a new column never dates these. */
const planRow = (id, name) => {
  const by = { id: String(id), name, duration: "1", start: "2026-03-02", end: "2026-03-03",
               milestone: "no", status: "Planned", event: "no", notFinal: "no", reported: "no" };
  return A.CSV_COLS.map(c => by[c.key] || "").join("|");
};
const wholePlan = (n = 2) => {
  let out = A.HEADER.join("|") + "\r\n";
  for (let i = 1; i <= n; i++) out += planRow(i, "Task " + i) + "\r\n";
  return out;
};
/* The same plan with its last row cut in half — what a copy still arriving
   looks like, and the shape parseCSV used to swallow without a word. */
const halfPlan = () => {
  const rows = wholePlan(3).split("\r\n");
  return [rows[0], rows[1], rows[2].slice(0, 20)].join("\r\n");
};
/* The four looks at the file, without the three seconds of backing off. The
   array is the app's own, so it is emptied and refilled rather than replaced. */
const noWaiting = fn => async () => {
  const keep = A.READ_WAITS.slice();
  A.READ_WAITS.length = 0; A.READ_WAITS.push(0, 0, 0, 0);
  try { await fn(); } finally { A.READ_WAITS.length = 0; keep.forEach(w => A.READ_WAITS.push(w)); }
};

t("a whole plan is accepted, and so is a plan with nothing in it yet", () => {
  assert(A.inspectPlanText(wholePlan()).ok, "a plan as serializeCSV writes one");
  assert(A.inspectPlanText("").ok, "an empty file is what a new plan is");
  assert(A.inspectPlanText(A.HEADER.join("|") + "\r\n").ok, "a header on its own is a new file");
  assert(A.inspectPlanText(A.serializeCSV(A.state.tasks)).ok, "and the plan this suite is holding, #VIEW lines and all");
});

t("a header cut in half is caught, because every row behind it reads wrong", () => {
  const rows = wholePlan().split("\r\n");
  /* Ten columns of the header is still enough for looksLikeProjectFile, which
     wants eight — so this file passes the old gate and mangles every row. */
  const look = A.inspectPlanText([A.HEADER.slice(0, 10).join("|"), ...rows.slice(1)].join("\r\n"));
  assert(!look.ok, "a row wider than its own header is not a plan we can read");
  assert.strictEqual(look.lineNo, 2, "and the first row that proves it is named");
});

t("a file that ends in the middle of a row is caught", () => {
  const look = A.inspectPlanText(halfPlan());
  assert(!look.ok, "the last row is short, and there is no line break behind it");
  assert.strictEqual(look.lineNo, 3, "the line it stops on");
});

/* A #VIEW/#DEL/#META line is skipped as "carries its own shape" everywhere
   else in this function — which would otherwise make a write cut off in the
   middle of one of those trailing lines invisible: a task row that is too
   narrow is caught above, but a marker line was not held to the same rule
   until this test. Found live: adding #META (2026-08-19) shifted where a
   fixed-offset truncation in another suite happened to land, from inside a
   task row (caught) to inside a #VIEW line (silently accepted) — this closes
   that gap generally, not just for the fixture that exposed it. */
t("a file that ends in the middle of a #VIEW/#DEL/#META line is caught too", () => {
  const whole = wholePlan();
  const cut = line => {
    const rows = whole.trim().split("\r\n");
    rows.push(line.slice(0, line.indexOf("|", line.indexOf("|") + 1))); // tag + key only, value never arrives
    return rows.join("\r\n");
  };
  assert(!A.inspectPlanText(cut("#VIEW|hiddenColumns|team;system")).ok, "a #VIEW line cut before its value");
  assert(!A.inspectPlanText(cut("#DEL|abc-1-2-3|2026-08-19T00:00:00Z|Gone")).ok, "a #DEL line cut before its stamp");
  assert(!A.inspectPlanText(cut("#META|writer|" + A.WRITER_ID)).ok, "a #META line cut before its value");
  // and a complete marker line, just missing its OWN trailing break, is not mistaken for one of these
  assert(!A.inspectPlanText(whole.trim() + "\r\n#ME").ok,
    "a tag cut mid-word is a truncated file — the format has exactly three #-lines, a fragment of one is not a comment");
  assert(A.inspectPlanText(whole.trim() + "\r\n#META|writer|" + A.WRITER_ID).ok,
    "a whole #META line with no final line break behind it is still a whole line");
});

t("a file of lines that are not rows, with no row among them, is refused", () => {
  const look = A.inspectPlanText("ID|Activity Name|c|d|e|f|g|h\r\nnot a row|nor is this|c|d|e|f|g|h\r\n");
  assert(!look.ok, "parseCSV would hand back an empty plan and say nothing");
});

/* The false positive that would matter: this check must never be the reason a
   plan somebody has been using stops opening. */
t("a headerless file still opens, so a good plan is never refused", () => {
  const legacy = csvFile([{ id: 1, name: "Hand made", start: "2026-01-01", end: "2026-01-02" }])
    .split("\n").slice(1).join("\n");
  assert(A.inspectPlanText(legacy).ok, "rows with no header are a hand-made file, not a truncated one");
});

ta("a read that never comes back is a failure rather than a wait", async () => {
  let err = null;
  try { await A.withTimeout(new Promise(() => {}), 5); } catch (e) { err = e; }
  assert(err && err.name === "TimeoutError", "it gives up instead of hanging");
  assert.strictEqual(await A.withTimeout(Promise.resolve("here"), 500), "here",
    "and an answer that arrives passes straight through");
});

ta("a file at rest is read and handed over", async () => {
  const text = wholePlan();
  const at = { size: text.length, lastModified: 100, text: async () => text };
  const res = await A.readPlanFile({ name: "plan.gantora", getFile: async () => at });
  assert(res.ok, "a still file is read");
  assert.strictEqual(res.text, text, "and the text is the file's own");
});

ta("a file being written while we read it is never handed over as a plan", noWaiting(async () => {
  const text = wholePlan();
  let n = 0;
  // every look finds a different size and time: the copy is still landing
  const fh = { name: "plan.gantora",
               getFile: async () => ({ size: 10 + n, lastModified: 100 + n++, text: async () => text }) };
  const res = await A.readPlanFile(fh);
  assert(!res.ok, "a moving file is not adopted, however well its text parses");
  assert.strictEqual(res.why, "syncing");
}));

ta("half a file is looked at again, and the whole one is what comes back", noWaiting(async () => {
  const whole = wholePlan(3), half = halfPlan();
  let call = 0;
  const fh = { name: "plan.gantora", getFile: async () => {
    const text = call++ < 2 ? half : whole;      // the first attempt is too early
    return { size: text.length, lastModified: 1, text: async () => text };
  } };
  const res = await A.readPlanFile(fh);
  assert(res.ok, "the second look found a finished file");
  assert.strictEqual(res.text, whole, "and it is the whole one that is handed over");
}));

ta("a file that will not be read at all is the cloud-placeholder case", noWaiting(async () => {
  /* Any I/O error, not one name: a placeholder fails differently across the
     three clients and across Chrome versions, and a timeout is not an error. */
  const fh = { name: "plan.gantora", getFile: async () => {
    const e = new Error("nope"); e.name = "NotReadableError"; throw e;
  } };
  const res = await A.readPlanFile(fh);
  assert(!res.ok && res.why === "unreadable", "it is not read, and it is not called corrupt either");

  let said = "";
  const keep = ctx.alert;
  ctx.alert = m => { said = String(m); };
  try { A.sayReadFailed(res, "DB/plan.gantora", false); } finally { ctx.alert = keep; }
  assert(/Google Drive/.test(said) && /OneDrive/.test(said),
    "the message names the setting that fixes it, in both clients: " + said);
  assert(said.includes("DB/plan.gantora"), "and says which file");
}));

/* The brief's acceptance criterion, and the whole point of the block. */
ta("a truncated file cannot empty the plan on screen", noWaiting(async () => {
  const keep = { db: A.state.dbHandle, fh: A.planRef(), name: A.planName(),
                 tasks: A.state.tasks, dirty: A.state.dirty, alert: ctx.alert };
  let said = "";
  ctx.alert = m => { said = String(m); };
  try {
    A.state.tasks = A.parseCSV(wholePlan(4));
    useName("real.gantora");
    A.state.dirty = false;
    assert.strictEqual(A.state.tasks.length, 4, "four rows to lose");

    const half = halfPlan();
    A.state.dbHandle = { getFileHandle: async () => ({
      getFile: async () => ({ size: half.length, lastModified: 5, text: async () => half }),
    }) };
    await A.openFile("half.gantora", false);

    assert.strictEqual(A.state.tasks.length, 4, "the plan on screen is the one that was there");
    assert.strictEqual(A.planName(), "real.gantora", "and it is still the open file");
    assert(/part of a file/.test(said), "and the reader is told why: " + said);
  } finally {
    A.state.dbHandle = keep.db; useRef(keep.fh); useName(keep.name);
    A.state.tasks = keep.tasks; A.state.dirty = keep.dirty; ctx.alert = keep.alert;
  }
}));

t("every way a plan is read goes through the guard", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  for (const fn of ["async function openFile", "async function compareWith", "async function openPlanManually"]) {
    const at = script.indexOf(fn);
    assert(at > 0, fn + " is gone");
    assert(/readPlanFile\(/.test(script.slice(at, at + 2200)), fn + " reads a plan without the guard");
  }
  /* The plain input hands over a File, which cannot be looked at twice — so its
     gate is the text check, and that is where acceptPlanText does it. */
  const gate = script.indexOf("function acceptPlanText");
  assert(/inspectPlanText\(/.test(script.slice(gate, gate + 900)),
    "the no-handle path is left with no check at all");
});

t("every writable is the atomic kind, so a reader never sees half of ours", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  /* Comments stripped first: the block above explains this in prose, and the
     prose has to name the flag it is telling you not to use. */
  const code = script.replace(/\/\*[\s\S]*?\*\//g, "");
  assert(!/keepExistingData/.test(code),
    "createWritable() writes to a temporary file and swaps at close(); keepExistingData turns that off");
  assert(/createWritable\(\)/.test(code), "and the writables are still there to be checked");
});

console.log("\n-- Translations --");

t("English is not a translation, so it can never be missing", () => {
  ctx.localStorage.clear(); A.loadMe(); A.setLang("en");
  assert.strictEqual(A.trans("Reload from disk"), "Reload from disk");
  assert.strictEqual(A.trans("a string nobody will ever translate"), "a string nobody will ever translate");
});

t("a translated string arrives in the chosen language", () => {
  A.setLang("hu");
  assert.strictEqual(A.trans("List"), "Lista");
  A.setLang("de");
  assert.strictEqual(A.trans("List"), "Liste");
  A.setLang("fr");
  assert.strictEqual(A.trans("List"), "Liste");
  A.setLang("en");
});

/* Four hundred and sixty strings will not arrive in one afternoon, so a
   half-finished language has to be a usable app rather than a page of holes. */
t("anything not yet translated falls through to its English", () => {
  A.setLang("hu");
  const untouched = "Drag sideways to reschedule";
  assert.strictEqual(A.trans(untouched), untouched, "no marker, no blank — the English");
  A.setLang("en");
});

/* 🔴 The fence the comment below has been claiming for months and nothing was
   actually holding: every `trans("literal")` in the app must have a line in the
   dictionary. Without it, adding a call and forgetting the translation is
   silent — the string simply falls through to English in all three languages,
   which is exactly what "anything not yet translated falls through" is designed
   to allow, and exactly what hides a mistake.

   Measured when it was written: 549 literals, 973 hu keys, ZERO missing. So it
   costs nothing today and catches the next one on the day it is written.

   This is the second of the three ways a string reaches a reader. The other two
   already have fences: the markup inside the translateDom() hosts, and the
   tables handed to trans() BY VARIABLE. Between them there is nowhere left for
   an English sentence to hide — which is the whole point, after the DASH_ALERTS
   reasons and the Overview's figures each went out in English unnoticed. */
t("every trans() literal in the app has a line in the dictionary", () => {
  const lines = html.split("\n");
  const at = s => lines.findIndex(l => l.trimEnd() === s);
  const a = at("  hu: {"), b = at("  de: {");
  assert(a > 0 && b > a, "the hu dictionary is where this test thinks it is");
  const hu = new Set();
  /* Per LINE and not per rule: several short pairs share a line ("new file",
     "copy"), and a key-per-line reader silently loses the second one — which
     made this fence read 411 strings as missing the first time it was run. */
  for (const l of lines.slice(a, b))
    for (const m of l.matchAll(/"((?:[^"\\]|\\.)*)"\s*:/g)) hu.add(JSON.parse('"' + m[1] + '"'));

  const script = html.slice(html.indexOf("<script>"));
  const lits = new Set();
  for (const m of script.matchAll(/\btrans\(\s*"((?:[^"\\]|\\.)*)"/g)) lits.add(JSON.parse('"' + m[1] + '"'));
  assert(lits.size > 400, "the scanner found the calls: " + lits.size);

  const missing = [...lits].filter(k => !hu.has(k));
  assert.strictEqual(missing.length, 0,
    missing.length + " string(s) are asked for by name and translated nowhere:\n  " +
    missing.slice(0, 12).map(s => JSON.stringify(s.slice(0, 80))).join("\n  "));
});

t("every language carries the same keys, or the gaps are English on purpose", () => {
  const langs = Object.keys(A.I18N);
  eqArr(langs.sort(), ["de", "fr", "hu"], "en is the source and has no dictionary");
  const hu = Object.keys(A.I18N.hu);
  for (const l of langs) {
    const missing = hu.filter(k => !(k in A.I18N[l]));
    assert.strictEqual(missing.length, 0, `${l} is missing ${missing.length}: ${missing.slice(0, 5)}`);
  }
});

/* The shell's own words — every leaf, every tooltip and placeholder inside the
   translateDom() hosts — have to be in all three dictionaries, or they are shown
   in English in every language. For four releases the tooltips of the whole
   toolbar and the task dialog's labels were exactly that, unnoticed, because
   the completeness test scans trans("…") literals and the markup is not one.
   Proper names, examples and code stay English and are named here. */
t("every word the shell shows — leaves, tooltips, placeholders — is in all three dictionaries", () => {
  const dec = s => s.replace(/&amp;/g, "&").replace(/&#9734;/g, "☆").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
  const hosts = html.match(/const I18N_HOSTS = \[([\s\S]*?)\];/)[1].match(/"[^"]+"/g).map(s => s.slice(1, -1));
  /* "DB" is the folder's name, the same three letters in every language — it is
     a path, not a word. (It used to be part of "📁 DB ▾"; the icon and the word
     became separate elements on 2026-08-22 so the word can leave on a phone.) */
  const SAME = new Set(["DB", "📁 DB ▾", "Portable Project Planning", "Gantora", "/DB", ".gantora", ".csv", "About",
    "Gantora — Portable Project Planning", "Daniel Szabo", "Online Factory", "www.onlinefactory.org", "Dropbox",
    "Anna Kovacs:2", "Enter", "Win", "name", "☁️ Dropbox", "DB → ☁️ Dropbox…", "☁️ Dropbox…", "🔀 Compare & merge…",
    "🔀 Compare & merge", "Scope:", "risk;wave-2", "e.g. Anna Kovacs:2",
    "e.g. Bela Nagy:6;Csaba Toth:4", "/Projects/Gantora", "auto", "2;5", "Read-only mode.", "“Open DB folder”"]);
  const missing = [];
  for (const id of hosts) {
    const a = html.indexOf('id="' + id + '"');
    assert(a >= 0, "host " + id + " is not in the markup");
    const start = html.lastIndexOf("<", a);
    const tag = html.slice(start + 1).match(/^[a-zA-Z]+/)[0];
    let d = 0, j = start; const re = new RegExp("<(/?)" + tag + "\\b[^>]*>", "g"); re.lastIndex = start; let m;
    while ((m = re.exec(html))) { if (m[1] === "/") { d--; if (d === 0) { j = m.index + m[0].length; break; } } else if (!/\/>$/.test(m[0])) d++; }
    const block = html.slice(start, j).replace(/<!--[\s\S]*?-->/g, "");
    const seen = [];
    const leafRe = /<([a-zA-Z][\w-]*)\b([^>]*)>([^<]*)<\/\1>/g; let l;
    while ((l = leafRe.exec(block))) { const text = dec(l[3].replace(/\s+/g, " ").trim()); if (text && /[A-Za-z]{2}/.test(text)) seen.push(text); }
    const attrRe = /\s(title|placeholder)="([^"]+)"/g;
    while ((l = attrRe.exec(block))) { const text = dec(l[2].trim()); if (/[A-Za-z]{3}/.test(text)) seen.push(text); }
    for (const text of seen) {
      if (SAME.has(text) || /^Version /.test(text)) continue;
      for (const lang of ["hu", "de", "fr"]) if (A.I18N[lang][text] === undefined) missing.push(lang + ": " + id + ": " + text.slice(0, 70));
    }
  }
  assert.strictEqual(missing.length, 0, missing.length + " shell strings are untranslated:\n" + missing.slice(0, 12).join("\n"));
});

/* 🔴 The scanner that keeps the dictionaries honest reads `trans("literal")`
   out of the source — so a string that reaches trans() through a VARIABLE is
   invisible to it. That is not theoretical: the three DASH_ALERTS reasons went
   out untranslated and nothing noticed until Daniel tapped an alert card on the
   phone on 2026-08-22 and got a line of English in the middle of a Hungarian
   tip. These are the tables whose text is handed to trans() by name; a new one
   belongs on this list the day it is written. */
t("the strings handed to trans() by variable are in the dictionaries too", () => {
  const from = [];
  for (const a of A.DASH_ALERTS) from.push(["DASH_ALERTS.why", a.why]);
  for (const b of A.HEALTH) { from.push(["HEALTH.label", b.label]); from.push(["HEALTH.why", b.why]); }
  for (const c of A.DASH_CARDS) { from.push(["DASH_CARDS.label", c.label]); from.push(["DASH_CARDS.note", c.note]); }
  /* 🔴 Added 2026-08-23, after the SAME blind spot produced a second crop:
     "Whole plan", "Lvl 4 · everything" and the resource view's "90 task(s) · 0
     h" were all English on a Hungarian screen. Every chip's head word goes
     through trans() by variable too, so the three chip lists belong here. */
  for (const f of A.FILTER_CHIPS.concat(A.EXTRA_CHIPS, A.DASH_CHIPS, A.ZOOM_CHIPS, A.TEAM_CHIPS, A.PRES_CHIPS, A.COCK_CHIPS))
    from.push(["chip head", f.head]);
  /* 🔴 The cockpit's axis labels, added 2026-08-23 the moment the third axis
     was written: they reach trans() by variable from a table, which is this
     test's whole subject. "Groups" had already gone out in English on a
     Hungarian screen this way. */
  for (const [, lbl] of A.COCK_AXIS_LABEL) from.push(["COCK_AXIS_LABEL", lbl]);
  const missing = [];
  for (const [where, key] of from) {
    if (!key) continue;
    for (const lang of ["hu", "de", "fr"])
      if (!(key in A.I18N[lang])) missing.push(lang + " " + where + ": " + key);
  }
  assert.strictEqual(missing.length, 0, "🔴 never translated:\n  " + missing.join("\n  "));
});

t("no translation is left as a copy of its own key", () => {
  /* A value identical to its key is a line somebody pasted and did not finish.
     A handful are genuinely the same word in that language, and they are named
     here rather than silently allowed. */
  /* "ID" is ID in all four, and a column called Status is Status in German.
     These are named rather than allowed as a class, so a genuinely unfinished
     line still fails. */
  /* "Cockpit" is the word in all four — it is what the design spec itself
     calls the view, written in Hungarian. */
  // "Dropbox: {0}" is a proper name and a colon in every language
  // "Code" (the Dropbox dialog's breadcrumb) is the German word too
  // "#" is the ordinal column's v2 label — a symbol, the same in every language
  const same = { de: ["Zoom", "Team", "Teams", "👥 Teams…", "Organisation", "Web", "Normal",
                      "ID", "System", "Status", "OK", "Cockpit", "Person", "Dropbox: {0}", "Code", "#"],
                 fr: ["Zoom", "Web", "Organisation", "Normal",
                      "ID", "Notes", "Actions", "OK", "Date", "Cockpit", "+{0} min", "Licence", "Code",
                      /* the cockpit's Focus switch: the French for it is the word itself */
                      "Focus", "#"],
                 // "AI" is the same two letters in Hungarian; German and French say KI / IA
                 hu: ["Web", "ID", "OK", "Cockpit", "Dropbox: {0}", "AI", "#"] };
  for (const [lang, dict] of Object.entries(A.I18N)) {
    for (const [k, v] of Object.entries(dict)) {
      if (k === v && !(same[lang] || []).includes(k))
        assert.fail(`${lang}: "${k}" is untranslated but present`);
    }
  }
});

/* A <select> is as wide as its longest option and will not shrink below it, so
   an option long enough to explain itself ran over the label beside it — and a
   translation makes any option longer without anybody choosing to. */
/* The second slice: every message that says the same thing every time. */
t("the fixed messages are translated too, not just the shell", () => {
  A.setLang("hu");
  assert.strictEqual(A.trans("Nothing to undo"), "Nincs mit visszavonni");
  assert.strictEqual(A.trans("Open the DB folder first."), "Előbb nyisd meg a DB mappát.");
  A.setLang("de");
  assert.strictEqual(A.trans("Nothing to undo"), "Nichts rückgängig zu machen");
  A.setLang("fr");
  assert.strictEqual(A.trans("Nothing to undo"), "Rien à annuler");
  A.setLang("en");
});

t("every fixed message goes through t() on its way to the screen", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  /* A double-quoted sentence handed straight to toast/confirm/alert has been
     missed. Template literals are a later slice — they carry a file name or a
     count inside them and need placeholders, which a dictionary key cannot
     have — so they are not counted here. */
  const bare = [...script.matchAll(/(?:toast|confirm|alert)\("((?:[^"\\]|\\.){6,}?)"/g)].map(m => m[1]);
  assert.strictEqual(bare.length, 0,
    bare.length + " fixed message(s) never reach a dictionary: " + JSON.stringify(bare.slice(0, 3)));
});

/* A message with a file name in it is still one message. Without placeholders
   it would be a different key every time and could never be looked up. */
t("a placeholder is filled from its argument", () => {
  A.setLang("en");
  assert.strictEqual(A.trans("Created: DB/{0}", "switchover.gantora"),
    "Created: DB/switchover.gantora");
  assert.strictEqual(A.trans("Exported {0} task(s) to {1}", 12, "plan.xml"),
    "Exported 12 task(s) to plan.xml");
});

t("the numbering lets a language put them in its own order", () => {
  A.setLang("hu");
  const out = A.trans("Task {0} removed from group \"{1}\".", 7, "Security");
  assert(/7/.test(out) && /Security/.test(out), "both arrived: " + out);
  assert(!/\{0\}|\{1\}/.test(out), "and neither slot was left behind: " + out);
  assert.notStrictEqual(out, "Task 7 removed from group \"Security\".", "in Hungarian");
  A.setLang("en");
});

/* A translation with one placeholder too many is a typo, and the typo should be
   visible rather than dressed up as data. */
t("a slot with no argument is left alone, not filled with undefined", () => {
  A.setLang("en");
  assert.strictEqual(A.trans("Exported {0} task(s) to {1}", 3), "Exported 3 task(s) to {1}");
  assert(!/undefined/.test(A.trans("Exported {0} task(s) to {1}", 3)));
});

/* t was this codebase's word for a task — the parameter of most functions that
   touch one — so inside any of them t("…") called the task object and threw. */
t("the translator is not called t, because t is a task", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  assert(/function trans\(s, \.\.\.args\)/.test(script), "it is trans()");
  assert(!/[^A-Za-z0-9_$.]t\("/.test(script), "and nothing calls a bare t( with a string any more");
});

/* The point of the slices is that they finish. Every key the script asks for is
   now in the dictionaries, so a new message that is not translated fails here
   rather than quietly reaching a Hungarian reader in English. */
t("every string the app asks for has a translation", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  const asked = new Set();
  const re = /trans\("((?:[^"\\]|\\.)*)"/g;
  let m;
  /* Unescaped the way the parser will: the source says \" where the object
     holds a real quote, and comparing the two forms finds every key missing. */
  while ((m = re.exec(script))) {
    if (!m[1]) continue;
    try { asked.add(JSON.parse(String.fromCharCode(34) + m[1] + String.fromCharCode(34))); } catch {}
  }

  /* The renderers translate these through a *variable* — trans(b.label) — so
     the scan above cannot see them, and for a long time neither could anybody
     else: the suite reported every string translated while the dashboard, the
     report and the list's own headers were entirely English. Every label in
     the data tables the views draw from is added by hand here. */
  for (const x of A.HEALTH) { asked.add(x.label); asked.add(x.why); }
  for (const k of Object.keys(A.MS_HEALTH)) asked.add(A.MS_HEALTH[k].label);
  for (const r of A.REPORT_RULES) { asked.add(r.label); asked.add(r.head); asked.add(r.says); }
  for (const c of A.DASH_CARDS) { asked.add(c.label); asked.add(c.note); }
  for (const c of A.COLUMNS) { asked.add(c.label); if (c.sub) asked.add(c.sub); if (c.name) asked.add(c.name); }
  for (const s of A.STATUSES) asked.add(s);

  assert(asked.size > 250, "the app asks for a lot of strings, got " + asked.size);
  for (const lang of ["hu", "de", "fr"]) {
    const missing = [...asked].filter(k => k && !(k in A.I18N[lang]));
    assert.strictEqual(missing.length, 0,
      `${lang} is missing ${missing.length}: ${JSON.stringify(missing.slice(0, 3))}`);
  }
});

/* 🔴 The third fence, and the one that would have caught what the other two
   missed for months (found by hand on 2026-08-21, after the task dialog's title
   turned out to be English on a Hungarian screen).

   The two fences above look at what the app ASKS FOR: one scans for calls to
   trans with a literal, the other renders the views and looks for English. Both
   are blind to the same thing — a sentence written straight into `textContent`,
   `innerHTML`, a `title` or a toast, which is never asked for at all. The file
   picker said "— select a file —" in English in every language for months, two
   inches from the plan's name, and nothing here noticed.

   So this one reads the source and looks for what a sentence LOOKS LIKE:
   three words or more, assigned to something the eye ends up on, with no
   trans() nearby. False positives are handled by naming them below rather than
   by loosening the rule — a named exception is a decision, a loose rule is a
   fence that stops working. */
t("no sentence is written straight onto the screen without going through trans()", () => {
  const src = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"))
    .replace(/\/\*[\s\S]*?\*\//g, "")
    .replace(/(^|[^:])\/\/[^\n]*/g, "$1");
  /* Sentences that are deliberately not translated, each for a stated reason. */
  const allowed = [
    // the repair report is written to be handed BACK to an assistant, which
    // reads English; the toast around it is translated
    /^Task \d|^Row \d|^IDs were renumbered|^Line \d/,
    // the app's own name, and the format's column names, which are the file
    /^Gantora|^ID\|Activity Name/,
  ];
  const bad = [];
  const re = /(textContent|innerHTML|\.title|placeholder|toast\()\s*(=|\()?\s*(["`])((?:[^"`\\]|\\.)*)\3/g;
  let m;
  while ((m = re.exec(src))) {
    const txt = m[4];
    if (txt.split(/\s+/).filter(w => /[A-Za-z]{2}/.test(w)).length < 3) continue;
    if (/^[a-z]+([A-Z][a-z0-9]*)*$/.test(txt)) continue;         // an id or a key
    if (allowed.some(re2 => re2.test(txt))) continue;
    // trans() anywhere in the expression this literal belongs to
    if (/trans\(/.test(src.slice(Math.max(0, m.index - 120), m.index + m[0].length))) continue;
    bad.push(txt.slice(0, 60));
  }
  assert.strictEqual(bad.length, 0,
    bad.length + " sentence(s) reach the screen untranslated: " + JSON.stringify(bad.slice(0, 6)));
});

/* The gap the scan could not see, checked from the other end: render each view
   and look for English that should have moved. Cheaper than enumerating every
   literal, and it fails on the thing a reader would actually notice. */
t("no view still draws its own chrome in English", () => {
  const was = { tasks: A.state.tasks, fh: A.planRef(), view: A.state.view };
  try {
    /* The fixture has to *reach* the branches: several of these tooltips are
       written only for a particular kind of row, so a plan of two plain tasks
       renders none of them and the check cannot fail. An event covers the
       "answers to nothing" and "single date" wording, and a milestone the
       rest. */
    loadRowsNow([
      { id: 1, name: "Alpha", start: dayOff(-4), end: dayOff(4), team: "Dev", acc: "Anna:4" },
      { id: 2, name: "Beta", start: dayOff(1), end: dayOff(9), team: "Ops" },
      { id: 3, name: "Karacsony", start: dayOff(6), end: dayOff(6), ev: true },
      { id: 4, name: "Atadas", start: dayOff(8), end: dayOff(8), ms: true },
    ]);
    useRef({});
    A.setLang("hu");
    /* Every column, not just the ones a fresh plan shows: half of these labels
       and most of their tooltips live on columns that come up folded away, and
       a check that never draws them is a check that cannot fail. */
    A.state.hiddenCols = new Set();
    const drawn = {
      list:      (A.renderTable(),  els.tableView.innerHTML),
      dashboard: (A.renderDash(),   els.dashView.innerHTML),
      report:    (A.renderReport(), ctx.document.getElementById("reportView").innerHTML),
      team:      (A.renderTeam(),   els.teamView.innerHTML),
      /* The cockpit renders from state.view, and its own read-only badge and
         lamp are drawn afresh on every pass — so it is set and put back. */
      cockpit:   (() => {
        const was = A.state.view;
        A.state.view = "cockpit";
        A.renderCockpit();
        const out = els.cockpitView.innerHTML;
        A.state.view = was;
        A.cockStop();
        return out;
      })(),
      /* Three panels rather than one: this view's own words are split between
         the heading, the names down the left and the chart's tooltips. */
      presentation: (() => {
        A.renderPres();
        return els.presHeadTitle.innerHTML + els.presLeftBody.innerHTML + els.presBody.innerHTML;
      })(),
    };
    /* Words that would only appear if a label had been left behind. Each is a
       heading or a column name, never a task's own text. */
    const leftBehind = ["Activity Name", "Executive summary", "Schedule Health",
                        "Total tasks", "Main tasks overview", "Team planning",
                        "Original due date", "Workload by Team",
                        "Add new task…", "Back to the dashboard",
                        "READ-ONLY", "ALL CLEAR", "CRITICAL", "Waiting", "What changed"];
    for (const [view, out] of Object.entries(drawn))
      for (const word of leftBehind)
        assert(!out.includes(">" + word + "<"),
          view + " still shows English: " + word);

    /* **Tooltips too**, and this is where the literal scan is blind: a string
       reached through `trans(cond ? "a" : "b")` is not a literal argument, so
       nothing static can see it — one of these differed from its dictionary
       key by a single full stop and sat in English through three passes. */
    const inTitles = ["it answers to nothing here", "not recorded",
                      "The first end date ever recorded", "the end was",
                      "Main group", "task(s) inside", "Click to open this group on its own"];
    for (const [view, out] of Object.entries(drawn))
      for (const phrase of inTitles)
        assert(!out.includes(phrase), view + " has English in a tooltip: " + phrase);
  } finally {
    A.setLang("en");
    A.state.hiddenCols = A.defaultHiddenCols();
    A.state.tasks = was.tasks; useRef(was.fh); A.state.view = was.view;
  }
});

/* A clause that joins onto another has to keep the space that joins them. */
/* ---- What is shown moves; what is stored does not ----
   The riskiest thing about translating a column header is that the *same
   string* is also the file's own header row. Translate that and a plan saved in
   Hungarian will not open in German, and the merge — which matches rows across
   two copies — has nothing to match on. So the two are asserted apart. */
t("the file format stays English in every language", () => {
  const wire = A.HEADER.join("|");
  for (const lang of ["hu", "de", "fr"]) {
    A.setLang(lang);
    assert.strictEqual(A.HEADER.join("|"), wire, lang + " moved the column headings");
    assert.strictEqual(A.serializeCSV([]).split("\r\n")[0], wire, lang + " wrote a different header row");
    // and the six status words are values, not labels
    eqArr(A.STATUSES, ["Planned", "In Progress", "On Hold", "Blocked", "Done", "Cancelled"], lang);
  }
  A.setLang("en");
});

t("but the same words are translated where they are read", () => {
  A.setLang("hu");
  // the column header the reader sees
  assert(A.headRowHtml().includes("Tevékenység neve"), "the header is Hungarian");
  assert(!A.headRowHtml().includes(">Activity Name<"), "and not English as well");
  // the status pill: Hungarian on screen, English in the file
  const t1 = { id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", group: [], preds: [],
               status: "In Progress", history: [{ date: "2026-03-02", status: "In Progress" }], notes: "" };
  assert(A.statusPillHtml(t1.status).includes("Folyamatban"), "the pill is read in Hungarian");
  assert(A.serializeCSV([t1]).includes("|In Progress|"), "the file still says In Progress");
  A.setLang("en");
});

/* A status the picker writes has to be a value the parser accepts — so the
   option's `value` is the English word even when its label is not. */
t("the status picker writes English whatever it displays", () => {
  A.setLang("de");
  const html2 = A.statusSelectHtml("x", "Done");
  for (const s of A.STATUSES)
    assert(html2.includes('value="' + s + '"'), "the value written is " + s);
  assert(html2.includes(">Erledigt<"), "while the reader sees German: " + html2.slice(0, 160));
  A.setLang("en");
});

/* Month and weekday names are the platform's job, not the dictionary's — but
   it has to be *asked* in the right language, and every one of these sites
   said "en" from before there was a language setting at all. */
t("the calendar is read in the same language as the app", () => {
  const day = new Date(2026, 9, 5);                 // 5 October 2026
  const month = l => { A.setLang(l); return A.dtFmt({ month: "long" }).format(day); };
  assert.strictEqual(month("en"), "October");
  assert(/október/i.test(month("hu")), "Hungarian: " + month("hu"));
  assert(/Oktober/.test(month("de")), "German: " + month("de"));
  assert(/octobre/i.test(month("fr")), "French: " + month("fr"));
  // …and the platform brings the language's own ordering with it
  A.setLang("hu");
  assert(/^2026/.test(A.dtFmt({ month: "long", year: "numeric" }).format(day)),
    "Hungarian puts the year first");
  A.setLang("en");
});

t("no date formatter is pinned to English any more", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  assert(!/new Intl\.DateTimeFormat\("en"/.test(script),
    "a hardcoded locale is a calendar that ignores the language setting");
  assert(/const dtFmt = opts => new Intl\.DateTimeFormat\(myLang\(\), opts\)/.test(script),
    "they all go through one helper that asks");
});

t("a clause keeps the spaces that hold it to its neighbour", () => {
  for (const lang of ["hu", "de", "fr"]) {
    for (const [k, v] of Object.entries(A.I18N[lang])) {
      if (/^\s/.test(k)) assert(/^\s/.test(v), `${lang}: "${k}" lost its leading space`);
      if (/\s$/.test(k)) assert(/\s$/.test(v), `${lang}: "${k}" lost its trailing space`);
      if (/^\\n/.test(k)) assert(/^\\n/.test(v), `${lang}: "${k}" lost its leading newline`);
    }
  }
});

t("a translation carries the same placeholders as its key", () => {
  const slots = s => (String(s).match(/\{\d\}/g) || []).sort().join(",");
  for (const lang of ["hu", "de", "fr"]) {
    for (const [k, v] of Object.entries(A.I18N[lang])) {
      assert.strictEqual(slots(v), slots(k),
        `${lang}: "${k}" and its translation disagree about placeholders`);
    }
  }
});

t("a message translated in one language is translated in all of them", () => {
  const hu = Object.keys(A.I18N.hu);
  for (const l of ["de", "fr"]) {
    const missing = hu.filter(k => !(k in A.I18N[l]));
    assert.strictEqual(missing.length, 0, `${l} lacks ${missing.length}: ${missing.slice(0, 3)}`);
  }
  // and the slice really did land: ~66 messages on top of the shell
  assert(hu.length > 100, "both slices are in the dictionary, got " + hu.length);
});

t("a setting's control cannot run over its own label", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const row = css.match(/\.cfgRow\{([^}]*)\}/)[1];
  assert(/flex-wrap:wrap/.test(row), "the row wraps rather than overlapping: " + row);
  const sel = css.match(/\.cfgRow select\{([^}]*)\}/)[1];
  assert(/min-width:0/.test(sel), "and the control is allowed to shrink: " + sel);
  assert(!/min-width:1\d\dpx/.test(sel), "rather than held open at a fixed width");

  // …and the options are short, with the explanation under the row instead
  const opts = [...html.matchAll(/<option value="(normal|restricted)">([^<]*)<\/option>/g)].map(m => m[2]);
  eqArr(opts, ["Normal", "Restricted"], "the option is a name, not a sentence");
  assert(html.includes('id="cfgModeHint"'), "the sentence is a hint under the row");
});

t("the shell is translated in place, with its English remembered first", () => {
  assert(/function translateDom/.test(html), "there is a pass over the shell");
  assert(/i18nText = new WeakMap/.test(html), "and it remembers the source");
  // …because otherwise the second language change would translate a translation
  assert(/if \(!i18nText\.has\(el\)\) i18nText\.set\(el, el\.textContent\)/.test(html),
    "the original is kept before the first change");
  assert(/loadMe\(\);\s*(\/\*[\s\S]*?\*\/\s*)?translateDom\(\)/.test(html),
    "and it runs after the config is read, before anything is drawn");
});

console.log("\n-- Restricted mode: no folder --");

/* Awaited, not just called: the body is async, and returning its promise let
   the finally restore the mode before the body had run in it. */
const inRestricted = async fn => {
  const was = A.state.cfg.mode;
  A.state.cfg.mode = "restricted";
  try { return await fn(); } finally { A.state.cfg.mode = was; }
};

t("restricted is a mode the config carries, and normal is the default", () => {
  ctx.localStorage.clear();
  A.loadMe();
  assert.strictEqual(A.appMode(), "normal");
  assert.strictEqual(A.restricted(), false);
  A.state.cfg.mode = "restricted"; A.saveCfg();
  A.loadMe();
  assert.strictEqual(A.restricted(), true, "and it survives a restart");
  A.state.cfg.mode = "normal"; A.saveCfg();
});

/* Thirty-odd places call saveFile(true) after a change. With no folder each one
   would put another copy in Downloads. */
ta("an automatic save does nothing in restricted mode", async () => {
  await inRestricted(async () => {
    A.state.tasks = [{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", group: [], preds: [],
                       status: "Planned", history: [], notes: "" }];
    useRef(null);
    let downloads = 0;
    const was = A.downloadText;
    /* The app calls its own downloadText, so the count is taken from what the
       stub DOM was asked to do rather than by replacing the function. */
    const anchors = ctx.document._clicked = [];
    A.state.dirty = true;
    await A.saveFile(true);
    assert.strictEqual(A.state.dirty, true, "the dot stays amber: nothing was written");
    assert.strictEqual(anchors.length, 0, "and nothing was downloaded");
  });
});

t("the plan is refused if it is not one, whichever way it arrives", () => {
  const out = A.acceptPlanText("just some notes\nand more\n", "notes.txt");
  assert.strictEqual(out, false, "a foreign file is never adopted");
  assert(/not a Gantora plan/.test(els.toast.textContent), "and it says why: " + els.toast.textContent);
  assert.strictEqual(A.acceptPlanText(A.HEADER.join("|") + "\r\n", "plan.gantora"), true);
});

/* A file picker cannot be pointed anywhere; a real picker can, through startIn. */
ta("the picker is asked to open where the last plan came from", async () => {
  const opts = await A.planPickerOpts({ multiple: false });
  assert.strictEqual(opts.multiple, false, "the caller's own options survive");
  assert(Array.isArray(opts.types) && opts.types.length, "and it filters to plan files");
  // with nothing remembered there is no hint, and the picker opens wherever it likes
  assert(!("startIn" in opts) || opts.startIn == null, "no handle, no hint");
});

ta("opening a plan remembers where it came from, for the next picker", async () => {
  const fh = { name: "switchover.gantora", kind: "file" };
  await A.rememberPlanHandle(fh);
  assert.strictEqual(await A.lastPlanHandle(), fh, "the handle is kept");
  const opts = await A.planPickerOpts();
  assert.strictEqual(opts.startIn, fh, "and handed to the picker as a place to open");
  await A.idb("del", "lastPlan");
});

t("the two ways in are never both on the menu", () => {
  assert(html.includes('id="btnOpenPlan"') && html.includes('id="btnSavePlan"'), "restricted has its own pair");
  assert(/id="btnOpenPlan"[^>]*style="display:none"/.test(html), "hidden until the mode asks for them");
  assert(html.includes('id="cfgMode"'), "and the mode is switched in About");
  assert(/id="planFileInput"/.test(html), "with a plain input as the floor");
});

t("switching mode closes what was open, so no handle outlives its mode", () => {
  ctx.localStorage.clear();
  A.loadMe();
  A.state.tasks = [{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", group: [], preds: [],
                     status: "Planned", history: [], notes: "" }];
  A.state.dbHandle = {}; useRef({}); useName("plan.gantora"); A.state.dirty = false;
  A.setMode("restricted");
  assert.strictEqual(A.state.dbHandle, null, "the folder handle is let go");
  assert.strictEqual(A.planRef(), null, "and so is the file's");
  assert.strictEqual(A.state.tasks.length, 0, "nothing is left open");
  assert.strictEqual(A.restricted(), true);
  A.setMode("normal");
});

/* The door that opens no dialog, for the managed desktop where all three File
   System Access pickers answer NotAllowedError. */
const dropEvent = (types, file) => {
  let prevented = false;
  return {
    preventDefault: () => { prevented = true; },
    dataTransfer: { types, files: file ? [file] : [], dropEffect: "" },
    wasPrevented: () => prevented,
  };
};
const planFile = (name, text) => ({ name, text: async () => text });
const PLAN_TEXT = APP_HEADER + "\r\n1|Dropped|1|2026-03-02||2026-03-03||||||no\r\n";

/* The exact shape of a managed desktop: the picker *exists*, so the feature
   test passes, and then refuses at the moment it is called. Only AbortError
   means the person changed their mind — anything else has to fall through to
   the plain dialog or the app has no way in at all. */
ta("a picker blocked by policy falls through to the plain file dialog", async () => {
  await inRestricted(async () => {
    A.state.dirty = false;
    const inp = ctx.document.getElementById("planFileInput");
    const wasClick = inp.click;
    let clicked = 0;
    inp.click = () => { clicked++; };
    ctx.window.showOpenFilePicker = async () => {
      const e = new Error("The request is not allowed by the user agent or the platform in the current context.");
      e.name = "NotAllowedError";
      throw e;
    };
    try {
      assert.strictEqual(await A.openPlanManually(), true, "the way in is still open");
      assert.strictEqual(clicked, 1, "the plain input was asked instead");
    } finally {
      delete ctx.window.showOpenFilePicker;
      inp.click = wasClick;
    }
  });
});

t("a drag carrying no file is left to the app's own row and column drags", () => {
  assert.strictEqual(A.dragHasFile(dropEvent(["text/plain"])), false);
  assert.strictEqual(A.dragHasFile(dropEvent([])), false);
  assert.strictEqual(A.dragHasFile({}), false, "and an event with no dataTransfer at all");
  assert.strictEqual(A.dragHasFile(dropEvent(["Files"])), true);
});

ta("a plan dropped on the window opens it, and takes the no-folder mode with it", async () => {
  ctx.localStorage.clear();
  A.loadMe();
  A.state.dbHandle = null; useRef(null); A.state.snapshot = null;
  A.state.tasks = []; A.state.dirty = false;
  assert.strictEqual(A.restricted(), false, "starting in normal mode, with no folder");

  const ok = await A.openDroppedPlan(planFile("dropped.gantora", PLAN_TEXT));
  assert.strictEqual(ok, true);
  assert.strictEqual(A.planName(), "dropped.gantora");
  assert.strictEqual(A.state.tasks.length, 1, "the plan is open");
  assert.strictEqual(A.state.tasks[0].name, "Dropped");
  /* The point of the switch: a plan adopted with no handle is unwritable by the
     normal-mode save, which returns silently on !state.fileHandle. */
  assert.strictEqual(A.restricted(), true, "and the mode followed it");
  assert.strictEqual(A.planRef(), null, "there is no handle to write back through");
  A.setMode("normal");
});

ta("a dropped file that is not a plan is refused rather than adopted", async () => {
  await inRestricted(async () => {
    A.state.dbHandle = null; A.state.tasks = []; useName("");
    assert.strictEqual(await A.openDroppedPlan(planFile("holiday.jpg", PLAN_TEXT)), false,
      "the extension is asked before the file is read");
    assert.strictEqual(A.state.tasks.length, 0);
    assert.strictEqual(await A.openDroppedPlan(planFile("notes.gantora", "Number;Task\n1;A\n")), false,
      "and a plan-named file this app cannot read is refused by acceptPlanText");
    assert.strictEqual(A.state.tasks.length, 0);
  });
});

ta("a drop is refused while a DB folder is open, so one session has one way to disk", async () => {
  A.state.dbHandle = {};
  A.state.tasks = []; useName("");
  assert.strictEqual(await A.openDroppedPlan(planFile("dropped.gantora", PLAN_TEXT)), false);
  assert.strictEqual(A.state.tasks.length, 0, "nothing was adopted");
  A.state.dbHandle = null;
});

t("the drop is wired to the window, and dragover is prevented or the browser keeps it", () => {
  const wired = html.slice(html.indexOf('$("planFileInput").addEventListener("change"'));
  assert(/window\.addEventListener\("dragover"/.test(wired), "dragover is listened for");
  assert(/window\.addEventListener\("drop"/.test(wired), "and drop");
  /* Without preventDefault on dragover the drop event never arrives and the
     browser navigates away from the app to the dropped file. */
  const over = wired.slice(wired.indexOf('window.addEventListener("dragover"'));
  assert(/dragHasFile\(e\)\) return;\s*e\.preventDefault\(\)/.test(over),
    "dragover prevents the default for a file drag, and only for one");
});

console.log("\n-- Leaving with work not written down --");

const someWork = () => [{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", group: [], preds: [],
                          status: "Planned", history: [], notes: "" }];

t("there is work to lose only when there is work, and somewhere to put it", () => {
  const keep = { tasks: A.state.tasks, dirty: A.state.dirty, ro: A.state.readOnly, snap: A.state.snapshot };
  try {
    A.state.tasks = someWork(); A.state.readOnly = false; A.state.snapshot = false;

    A.state.dirty = false;
    assert.strictEqual(A.unsavedWork(), false, "a saved plan has nothing to lose");
    A.state.dirty = true;
    assert.strictEqual(A.unsavedWork(), true);

    A.state.readOnly = true;
    assert.strictEqual(A.unsavedWork(), false, "read-only cannot save, so it must not warn");
    A.state.readOnly = false;

    A.state.snapshot = true;
    assert.strictEqual(A.unsavedWork(), false, "a shared copy has no file behind it either");
    A.state.snapshot = false;

    A.state.tasks = [];
    assert.strictEqual(A.unsavedWork(), false, "and an empty app has nothing in it");
  } finally {
    A.state.tasks = keep.tasks; A.state.dirty = keep.dirty;
    A.state.readOnly = keep.ro; A.state.snapshot = keep.snap;
  }
});

/* The browser's dialog cannot carry a Save button, so the offer is made after
   it - and only to somebody who is still here to take it. */
t("staying is answered with an offer to save, and leaving never sees one", () => {
  const keep = { tasks: A.state.tasks, dirty: A.state.dirty };
  const wasConfirm = ctx.confirm;
  try {
    A.state.tasks = someWork(); A.state.dirty = true;
    A.state.readOnly = false; A.state.snapshot = false;

    let asked = 0;
    ctx.confirm = () => { asked++; return false; };
    assert.strictEqual(A.offerToSave(), false, "declined, so nothing is written");
    assert.strictEqual(asked, 1, "but it did ask");

    A.state.dirty = false;
    assert.strictEqual(A.offerToSave(), false, "and with nothing outstanding it does not ask at all");
    assert.strictEqual(asked, 1);
  } finally { ctx.confirm = wasConfirm; A.state.tasks = keep.tasks; A.state.dirty = keep.dirty; }
});

t("the warning and the offer are one handler, not two", () => {
  const guards = (html.match(/addEventListener\("beforeunload"/g) || []).length;
  assert.strictEqual(guards, 1, "there were two, and one of them warned on a shared copy");
  assert(/setTimeout\(offerToSave/.test(html),
    "the offer is scheduled from the warning, so it only runs if the page survived");
});

console.log("\n-- The project's name on the report --");

t("the report is headed by the file name until somebody renames it", () => {
  const before = A.state.tasks, wasName = A.planName(), wasProj = A.state.projectName;
  A.state.tasks = reportPlan(); useName("switchover.gantora"); A.state.projectName = "";
  try {
    assert.strictEqual(A.projectTitle(), "switchover", "the file name, without its extension");
    A.renderReport();
    const out = ctx.document.getElementById("reportView").innerHTML;
    /* A heading with a pencil beside it, not an input dressed as a heading:
       the old shape looked exactly like a title, which is why nobody could
       tell it could be changed. */
    assert(/<h2 id="repName"[^>]*>switchover<\/h2>/.test(out), "the name is the heading: " + out.slice(0, 300));
    assert(/id="btnRepName"/.test(out), "and a pencil says it can be changed");
    // …and while it is only borrowing the file's name, it says so by being quieter
    assert(/class="repTitle repUnnamed"/.test(out), "an unnamed plan is shown as unnamed");
  } finally { A.state.tasks = before; useName(wasName); A.state.projectName = wasProj; }
});

t("a named plan drops the borrowed look, and read-only offers no pencil", () => {
  const before = A.state.tasks, wasName = A.planName(), wasProj = A.state.projectName;
  A.state.tasks = reportPlan(); useName("switchover.gantora");
  A.state.projectName = "Core banking migration";
  try {
    A.renderReport();
    let out = ctx.document.getElementById("reportView").innerHTML;
    assert(/<h2 id="repName" class="repTitle"[^>]*>Core banking migration<\/h2>/.test(out),
      "the name it was given: " + out.slice(0, 240));

    A.state.readOnly = true;
    A.renderReport();
    out = ctx.document.getElementById("reportView").innerHTML;
    assert(!/id="btnRepName"/.test(out), "nothing to write to, so nothing offering to");
    assert(/Core banking migration/.test(out), "but the title is still the title");
  } finally {
    A.state.readOnly = false;
    A.state.tasks = before; useName(wasName); A.state.projectName = wasProj;
  }
});

/* The size and weight are set one property at a time on purpose: written as the
   `font:` shorthand this carried `inherit` in the family slot, which is only
   legal as a property's whole value — so the browser dropped the declaration
   and the report's title rendered at the body's 14px. */
t("the report title is a heading, at a heading's size", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const rule = css.match(/\.repTitle\{([^}]*)\}/)[1].replace(/\s+/g, " ");
  assert(!/font:/.test(rule), "no shorthand — it silently dropped the whole rule once: " + rule);
  assert(/font-weight:800/.test(rule), "heavy: " + rule);
  assert(/font-size:clamp\(/.test(rule), "and sized against the viewport: " + rule);
  assert(/text-align:center/.test(rule), "centred over the page: " + rule);
  /* The trap, wherever it might be repeated: a shorthand may not take a
     CSS-wide keyword. Comments are stripped first — the rule above explains
     the bug in prose, and the prose says "font:" too. */
  const code = css.replace(/\/\*[\s\S]*?\*\//g, "");
  const shorthands = [...code.matchAll(/font:([^;]*);/g)].map(m => m[1].trim());
  for (const v of shorthands)
    assert(v === "inherit" || !/\b(inherit|initial|unset)\b/.test(v),
      "a font shorthand cannot take a CSS-wide keyword as a component: font:" + v);
});

t("a typed name replaces it, and is remembered in the plan", () => {
  const wasName = A.planName(), wasProj = A.state.projectName;
  useName("switchover.gantora"); A.state.projectName = "";
  try {
    assert.strictEqual(A.setProjectName("  Core banking migration  "), true, "it took");
    assert.strictEqual(A.state.projectName, "Core banking migration", "trimmed");
    assert.strictEqual(A.projectTitle(), "Core banking migration");
    assert(A.viewLines([]).some(l => l === "#VIEW|projectName|Core banking migration"),
      "and it is written into the plan: " + A.viewLines([]).join(" / "));
  } finally { useName(wasName); A.state.projectName = wasProj; }
});

t("clearing the name goes back to following the file", () => {
  const wasName = A.planName(), wasProj = A.state.projectName;
  useName("switchover.gantora"); A.state.projectName = "Something else";
  try {
    A.setProjectName("");
    assert.strictEqual(A.state.projectName, "", "an empty box is a value, not a mistake");
    assert.strictEqual(A.projectTitle(), "switchover");
    assert(!A.viewLines([]).some(l => /projectName/.test(l)),
      "and a plan called after its own file writes no line");
    /* Typing the file name is the same as not naming it: otherwise renaming the
       file later would leave the plan stuck under its old name. */
    A.setProjectName("switchover");
    assert.strictEqual(A.state.projectName, "");
  } finally { useName(wasName); A.state.projectName = wasProj; }
});

t("a named plan carries its name through a save and a load", () => {
  const wasProj = A.state.projectName, before = A.state.tasks;
  A.state.projectName = "Core banking migration";
  A.state.tasks = [{ id: 1, name: "T", start: "2026-03-02", end: "2026-03-03", group: [], preds: [],
                     status: "Planned", history: [], notes: "" }];
  try {
    const text = A.serializeCSV(A.state.tasks);
    assert(text.includes("#VIEW|projectName|Core banking migration"), "written");
    assert.strictEqual(A.viewValue(text, "projectName"), "Core banking migration", "and read back");
  } finally { A.state.projectName = wasProj; A.state.tasks = before; }
});

console.log("\n-- Reported: which rows the report speaks about --");

t("a main task is reported, and cannot be talked out of it", () => {
  withReportPlan(() => {
    A.sanitizeTasks(A.state.tasks);
    for (const id of [1, 10, 20]) assert(A.taskById(id).reported, "#" + id + " is a main task");
    A.taskById(1).reported = false;
    A.sanitizeTasks(A.state.tasks);
    assert(A.taskById(1).reported, "unticking a main task does not stick");
  });
});

t("a subtask is off the report until somebody asks for it", () => {
  withReportPlan(() => {
    A.sanitizeTasks(A.state.tasks);
    assert.strictEqual(A.isReported(A.taskById(11)), false, "a task inside a group is not on it by default");
    eqArr(A.reportModel().rows.map(r => r.t.id), [1, 10, 20]);
  });
});

t("a ticked row gets its own line, whatever depth it sits at", () => {
  withReportPlan(() => {
    A.sanitizeTasks(A.state.tasks);
    A.taskById(11).reported = true;              // a task two levels down
    const rows = A.reportModel().rows;
    eqArr(rows.map(r => r.t.id), [1, 10, 11, 20],
      "in plan order — the ticked row sits between the streams, not appended after them");
    const own = rows.find(r => r.t.id === 11);
    assert.strictEqual(own.rule.key, "blocked", "and it is judged on itself");
  });
});

t("the tick survives a write and a read", () => {
  withReportPlan(() => {
    A.sanitizeTasks(A.state.tasks);
    A.taskById(11).reported = true;
    const back = A.parseCSV(A.serializeCSV(A.state.tasks));
    A.sanitizeTasks(back);
    assert.strictEqual(back.find(t => t.id === 11).reported, true, "the ticked subtask came back ticked");
    assert.strictEqual(back.find(t => t.id === 12).reported, false, "and its untickeded sibling did not");
  });
});

/* A plan written before the column existed has no cell for it at all. It must
   read as "the main tasks, and nothing else" — which is what the report did
   before there was anything to tick. */
t("a plan from before the column reports on its main tasks", () => {
  const before = A.state.tasks;
  /* Built by index rather than typed out: the row is 26 cells wide and counting
     pipes by eye puts the group in the Responsible column. */
  /* A plan from before Reported is by definition a v1 file, so it speaks the
     v1 names — ID and a parent-side Group list — which the aliases still read. */
  const cell = A.CSV_COLS.slice(0, -1).map(c => c.key === "parent" ? "group" : c.key);
  const row = (id, name, group) => {
    const c = new Array(cell.length).fill("");
    const put = (key, v) => { c[cell.indexOf(key)] = v; };
    put("id", id); put("name", name); put("duration", "1");
    put("start", "2026-03-02"); put("end", "2026-03-03");
    put("group", group || ""); put("milestone", "no");
    put("status", "Planned"); put("event", "no"); put("notFinal", "no");
    return c.join("|");
  };
  const v1Header = A.HEADER.slice(0, -1).map(h => h === "Seq" ? "ID" : h === "Parent" ? "Group" : h);
  const old = v1Header.join("|") + "\r\n" + row(1, "Stream", "2") + "\r\n" + row(2, "Step", "") + "\r\n";
  try {
    A.state.tasks = A.parseCSV(old);
    A.sanitizeTasks(A.state.tasks);
    assert.strictEqual(A.taskById(1).reported, true, "the main task is on the report");
    assert.strictEqual(A.taskById(2).reported, false, "its member is not");
  } finally { A.state.tasks = before; }
});

t("ticking a row is an edit the merge can see", () => {
  withReportPlan(() => {
    const uids = new Map();
    const t11 = A.taskById(11);
    const was = A.rowSig(t11, uids);
    t11.reported = true;
    assert.notStrictEqual(A.rowSig(t11, uids), was, "the signature moved, so the row stamps as modified");
  });
});

/* ---- Cancelled: work that is not going to happen (Daniel, 2026-08-21) ------ */
t("a cancelled task is out of every count and cannot be late", () => {
  const d = n => A.fmtDate(A.addDays(new Date(), n));
  loadRowsNow([{ id: 1, name: "Dropped", start: d(-9), end: d(-5) }]);
  const t1 = row("Dropped");
  /* Its end date went by four days ago. Overdue is what any other status would
     read here — and it would be wrong: nothing is waiting for this. */
  assert.strictEqual(A.healthOf(t1), "overdue", "…as it stands");
  A.setTaskStatus(t1, "Cancelled");
  assert.strictEqual(A.healthOf(t1), "cancelled", "🔴 a cancelled task cannot be late");
  assert.strictEqual(A.isCancelled(t1), true);
  assert.strictEqual(A.cancelledCls(t1), " stCancelled", "and it is drawn struck through");

  // the class reaches the name in the list, not just the bar's colour
  A.state.view = "table"; useRef({});
  A.renderTable();
  const row1 = els.tableView.innerHTML.match(/<tr class="[^"]*" data-id="1"[\s\S]*?<\/tr>/)[0];
  assert(/class="name stCancelled"/.test(row1), "the whole name cell is struck: " + row1.slice(0, 200));
  useRef(null);
});

/* 🔴 A spinner nobody notices is the same as no spinner: this screen exists to
   say the app is busy rather than stuck. */
t("the boot spinner stands above its sentence, at a size a phone can see", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const box = css.match(/#booting\{([^}]*)\}/)[1];
  assert(/flex-direction:column/.test(box), "the spinner is above the words, not beside them: " + box);
  const dot = css.match(/#booting \.bootDot\{([^}]*)\}/)[1];
  const size = +(dot.match(/width:(\d+)px/) || [])[1];
  assert(size >= 32, "12px read as a bullet point on a phone; it is now " + size + "px");
});

/* 🔴 The dialog's own title stood in English on a Hungarian screen for months
   (found by the design agent in Daniel's screenshot, 2026-08-21) — and it was
   the same shape of mistake as the filter dropdowns a day earlier: a template
   string reads like code rather than like a sentence, and the eye slides past
   it. So this looks for the shape rather than for those three strings. */
t("no sentence in the task dialog reaches the screen untranslated", () => {
  const src = html.slice(html.indexOf("function openModal("), html.indexOf("function closeModal("))
    .replace(/\/\*[\s\S]*?\*\//g, "")
    .replace(/\/\/[^\n]*/g, "");
  /* Every literal of three words or more that is not an argument of trans().
     Three, because "auto", "New file" and the like are labels the markup
     already carries and translateDom() has already handled. */
  const bad = [];
  for (const m of src.matchAll(/(["'`])((?:[A-Z][a-z]+|[a-z]+)(?:[ ][A-Za-z#{][^"'`\n]*)?)\1/g)) {
    const text = m[2];
    if (text.split(/\s+/).filter(Boolean).length < 3) continue;
    if (/^[a-z]+([A-Z][a-z]*)*$/.test(text)) continue;             // an id or a key, not a sentence
    const before = src.slice(Math.max(0, m.index - 60), m.index);
    if (/trans\(\s*$/.test(before) || /trans\([^)]*$/.test(before)) continue;
    bad.push(text);
  }
  assert.strictEqual(bad.length, 0, "untranslated in the task dialog: " + JSON.stringify(bad));

  // …and the three that were actually wrong, by name, so they cannot come back
  for (const k of ["New task", "Edit task", "It goes at the end of the plan.",
                   "It goes in at ID {0} — everything below shifts down by one.",
                   "nothing waits on another task"])
    assert(A.I18N.hu[k], "the dialog's title has no Hungarian: " + k);
});

t("every health bucket has a pill to be shown in", () => {
  /* The report puts all seven buckets in one column; the list only ever needed
     four, so three pills were missing until it did. */
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  for (const b of A.HEALTH)
    assert(css.includes(".stPill." + b.cls + "Pill{"), "no pill for " + b.key);
});

console.log("\n-- Light or dark --");

/* The theme is a class on <html> and nothing else, which is what lets a dozen
   tokens repaint the whole app. */
const withTheme = fn => {
  const was = A.themeNow(), sys = ctx.window.mediaMatches;
  ctx.localStorage.removeItem(A.THEME_KEY);
  try { return fn(); }
  finally { ctx.window.mediaMatches = sys; ctx.localStorage.removeItem(A.THEME_KEY); A.applyTheme(was); }
};

t("the theme is one class on the root element", () => {
  withTheme(() => {
    A.applyTheme("dark");
    assert(ctx.document.documentElement.classList.contains("dark"), "dark is a class");
    assert.strictEqual(A.themeNow(), "dark");
    A.applyTheme("light");
    assert(!ctx.document.documentElement.classList.contains("dark"), "and light is its absence");
    assert.strictEqual(A.themeNow(), "light");
  });
});

/* The machine chooses first: somebody who runs their desktop dark should not
   have to be asked. */
t("with nothing stored, the app follows the machine", () => {
  withTheme(() => {
    ctx.window.mediaMatches = true;              // a dark-preferring machine
    A.initTheme();
    assert.strictEqual(A.themeNow(), "dark", "it took the system's answer");
    assert.strictEqual(ctx.localStorage.getItem(A.THEME_KEY), null,
      "and wrote nothing down — nobody has chosen yet");

    ctx.window.mediaMatches = false;
    A.initTheme();
    assert.strictEqual(A.themeNow(), "light");
  });
});

/* …and the reader chooses last: a choice a system setting could overturn is
   not a choice. */
t("a chosen theme is remembered, and outlasts the machine's preference", () => {
  withTheme(() => {
    ctx.window.mediaMatches = true;              // the machine says dark
    A.setTheme("light");                         // the reader says light
    assert.strictEqual(ctx.localStorage.getItem(A.THEME_KEY), "light", "written down");
    A.initTheme();
    assert.strictEqual(A.themeNow(), "light", "and it wins on the next start");
  });
});

t("the button says what pressing it would do, not where you are", () => {
  withTheme(() => {
    A.setLang("en");
    A.applyTheme("light");
    assert.strictEqual(els.btnTheme.textContent, "🌙", "in the light theme it offers the dark one");
    assert.strictEqual(els.btnTheme.title, "Switch to the dark theme");
    A.applyTheme("dark");
    assert.strictEqual(els.btnTheme.textContent, "☀️");
    assert.strictEqual(els.btnTheme.title, "Switch to the light theme");
  });
});

t("toggling goes both ways and records each answer", () => {
  withTheme(() => {
    A.applyTheme("light");
    assert.strictEqual(A.toggleTheme(), "dark");
    assert.strictEqual(ctx.localStorage.getItem(A.THEME_KEY), "dark");
    assert.strictEqual(A.toggleTheme(), "light");
    assert.strictEqual(ctx.localStorage.getItem(A.THEME_KEY), "light");
  });
});

/* The shell pass restores every label from the English it remembered at load,
   so the one control whose label says *which way it points* has to be redrawn
   after it — or changing language would flip the glyph back. */
t("changing language leaves the theme button pointing the right way", () => {
  withTheme(() => {
    A.setLang("en");
    A.applyTheme("dark");
    A.setLang("hu");
    assert.strictEqual(els.btnTheme.textContent, "☀️", "still offering the light theme");
    assert.strictEqual(els.btnTheme.title, "Váltás világos témára", "in Hungarian");
    A.setLang("en");
  });
});

/* A near-white fill on a dark panel is the failure dark mode actually shows up
   as, so every one of them has to be either restated for dark or named here as
   deliberate. The five below are deliberate: four sit on a *coloured* surface
   rather than on the panel — a bar, or the brand-blue switcher — and the fifth
   is a resource band, whose five colours mean the same thing in both themes. */
t("every light fill is either restated for dark or named as deliberate", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>")).replace(/\/\*[\s\S]*?\*\//g, "");
  const rules = [...css.matchAll(/([^{}]+)\{([^}]*)\}/g)];
  const light = /background:\s*(#f[0-9a-f]{2,5}\b|#e[0-9a-f]{2,5}\b|#fff\b|#ffffff\b)/i;
  const darkBlob = rules.filter(([, sel]) => /html\.dark/.test(sel)).map(([, s]) => s).join(" | ");
  const onColour = [".viewToggle button.active", ".bar .barCount", ".hrBar .barCount",
                    ".bar .grip:hover::after", ".rCell.w4",
                    /* the chosen status chip: white on the status's own colour,
                       which is the same colour in both themes */
                    ".tsStats .st.on i"];
  const orphans = rules
    .filter(([, sel, body]) => light.test(body) && !/html\.dark/.test(sel) && !/@/.test(sel))
    .map(([, sel]) => sel.trim().split(",")[0].trim())
    .filter(c => !darkBlob.includes(c) && !onColour.includes(c));
  assert.strictEqual(orphans.length, 0,
    "these would glare on a dark panel: " + orphans.join(", "));
});

t("every colour the themes disagree about is a token", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const root = css.match(/\n  :root\{([\s\S]*?)\}/)[1];
  const dark = css.match(/html\.dark\{([\s\S]*?)\}/)[1];
  const names = s => (s.match(/--[\w-]+(?=:)/g) || []).sort();
  const light = names(root);
  for (const tok of names(dark))
    assert(light.includes(tok), tok + " is set in dark mode but never declared light");
  // the ones the redesign turns on: a theme is a swap of these, not a sweep
  for (const tok of ["--th-bg", "--row-hover", "--grid-v", "--group-row", "--brand", "--shadow-xs"])
    assert(light.includes(tok), "missing token " + tok);
});

/* The cockpit carries its own twenty-odd tokens rather than putting them on
   :root, so the same rule has to hold one level down: a colour the two themes
   disagree about is a token, and every one of them is restated for dark. */
t("the cockpit's palette is restated for dark, token for token", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const light = css.match(/\n  \.cockpit\{([\s\S]*?)\n  \}/)[1];
  const dark = css.match(/\n  html\.dark \.cockpit\{([\s\S]*?)\n  \}/)[1];
  const names = s => (s.match(/--ck-[\w-]+(?=:)/g) || []);

  /* Everything except the two that are not colours: the type stack and the
     width of the group-label column mean the same thing in both themes. */
  const notColour = ["--ck-mono", "--ck-lbl"];
  const wants = names(light).filter(n => !notColour.includes(n));
  assert(wants.length > 15, "the light palette is the full one: " + wants.length);
  for (const tok of wants)
    assert(names(dark).includes(tok), tok + " is set light but never restated for dark");
  for (const tok of names(dark))
    assert(names(light).includes(tok), tok + " is set in dark mode but never declared light");

  /* And no rule may reach past the tokens for a colour of its own — that is
     how a dark-only hex survives a theme being added around it. */
  const block = css.slice(css.indexOf("  .cockpit{"));
  /* Skip only the two blocks that *declare* tokens. A declaration is
     `--ck-x:`; a use is `var(--ck-x)`, which has no colon after the name — so
     matching on "--ck-" alone would excuse every rule that uses one, which is
     nearly all of them. */
  const rules = [...block.matchAll(/\.cockpit[^{}]*\{([^}]*)\}/g)]
    .filter(m => !/--ck-[\w-]+\s*:/.test(m[1]));
  for (const [, body] of rules)
    assert(!/#[0-9a-f]{3,8}\b/i.test(body),
      "a cockpit rule names a colour instead of a token: " + body.trim().slice(0, 70));
});

console.log("\n-- The phone layout --");

/* The phone layout's own band, found by the width the script carries rather
   than by a number typed here — the two must always be the same line. */
const mobileCss = () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  /* The main phone block — the one with the top bar, the bottom nav and the
     filter bar — is the LAST ≤820px block in the sheet since 2026-08-19: an
     override has to come after what it overrides (it used to sit near the top
     and lose every tie to a later base rule). So: the last one. */
  const at = css.lastIndexOf("@media (max-width: " + A.PHONE_W + "px)");
  assert(at > 0, "there is a " + A.PHONE_W + "px block");
  const block = css.slice(at, css.indexOf("\n  }", at));
  assert(/#toolbar\{flex-wrap:nowrap/.test(block), "and the last one is the main phone block");
  return { css, at, block };
};

/* There used to be two narrow bands — a tablet one that let the app bar wrap,
   and the phone one below it — and the pair had to be kept in that order or the
   wrapping strip beat the bottom bar. There is one band now, and what has to be
   held instead is that the stylesheet and the script break at the same width:
   onPhone() decides which panels start folded and how the bar menus are placed,
   and a stylesheet disagreeing with it leaves the layout half in one shape and
   half in the other. */
t("the script and the stylesheet break at the same width", () => {
  const { css } = mobileCss();
  const widths = [...css.matchAll(/@media \(max-width: ?(\d+)px\)/g)].map(m => +m[1]);
  const narrow = widths.filter(w => w <= 900 && w !== 720);   // 720: the cockpit's own density step
  assert(narrow.length > 3, "the narrow blocks are found: " + narrow.join(","));
  for (const w of narrow)
    assert.strictEqual(w, A.PHONE_W,
      "a narrow block at " + w + "px, while the script phones at " + A.PHONE_W);
});

/* 🔴 Turn the phone sideways and the camera island moves into the LEFT edge:
   the G mark, the filter row, the ID column and the dashboard cards were all
   sliding underneath it (Daniel, 2026-08-22, landscape screenshots). The
   viewport has been `viewport-fit=cover` since 09e55b6 — the inset value was
   there all along, and nothing used it on that side. */
t("nothing on the left edge sits under the camera island", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  /* Every container that owns a left edge: the app bar, the view's own
     controls, the views themselves, and the bottom navigation. */
  for (const [what, re] of [
    ["the app bar", /#toolbar\{[^}]*padding:9px 14px 9px max\(14px, env\(safe-area-inset-left\)\)/],
    ["the context bar", /#ctxBar\{[^}]*max\(12px, env\(safe-area-inset-left\)\)/],
    ["the views", /#main\{[^}]*padding-left:env\(safe-area-inset-left\)/],
  ]) assert(re.test(css), what + " does not clear the island");
  const { block } = mobileCss();
  assert(/max\(8px, env\(safe-area-inset-left\)\)/.test(block), "…and the phone bar clears it too");
  assert(/padding-left:max\(6px, env\(safe-area-inset-left\)\)/.test(block),
    "so does the bottom navigation");

  /* 🔴 The RIGHT edge deliberately has none (Daniel's call): everything wide
     scrolls horizontally, so content runs out from under the island on that
     side by itself, and padding it would cost width on every screen that has
     no island at all. */
  assert(!/safe-area-inset-right/.test(css), "the right edge is deliberately not padded");

  /* max() rather than a sum, so the ordinary padding stands unchanged
     everywhere the inset is zero — which is every desktop. */
  assert(!/calc\([^)]*\+ env\(safe-area-inset-left\)/.test(css),
    "an inset ADDED to the padding would move the whole bar on a phone held upright");
});

t("the switcher becomes a bar fixed to the bottom of the screen", () => {
  const { block } = mobileCss();
  const rule = block.match(/#viewToggle\{([^}]*)\}/)[1];
  assert(/position:fixed/.test(rule) && /bottom:0/.test(rule), "pinned to the bottom: " + rule);
  assert(/env\(safe-area-inset-bottom\)/.test(rule), "and clear of the home indicator");
  // …and the plan can be scrolled out from under it
  assert(/#main\{padding-bottom:calc\(56px \+ env\(safe-area-inset-bottom\)\)/.test(block),
    "the plan still scrolls clear of the bar");
});

/* 🔴 Filters brief §6/4: the Zoom view's Y axis was the last full-width native
   select on this bar, taking a row of its own to say "Y: Tasks" — and saying it
   in English in every language, since the three option texts were on the
   translation test's exemption list. It speaks the chip language now: a
   content-width chip in the row with the ⌕, a sheet to choose from, and the
   select behind it holding the value and the list, exactly as the six filters
   and the board's scope do. */
t("the Zoom view's Y axis is a chip like every other question on the bar", () => {
  chipFixture();
  const was = A.state.view;
  useRef({});
  A.setView("zoom");
  const chips = A.syncZoomChips();
  assert(/data-filter="zoomBySelect"/.test(chips), "the axis is drawn as a chip: " + chips);
  assert(els.zoomBySelect.classList.contains("chipHidden"),
    "…and the select behind it is available but never drawn");
  assert.notStrictEqual(els.zoomBySelect.style.display, "none", "available: the view is the one it applies to");

  /* One rule behind both doors — the chip's sheet and the select's own change
     event go through setFilter(), which is where the axis's side effects live. */
  A.state.zoomLevel = "hour"; A.state.zoomFocus = "Tear-out";
  A.setFilter("zoomBySelect", "team");
  assert.strictEqual(A.state.zoomBy, "team");
  assert.strictEqual(A.state.zoomFocus, null, "the old focus means nothing in the new dimension");
  assert.strictEqual(A.state.zoomLevel, "day", "and the hour level is always tasks, so a lane axis steps back out");
  for (const bar of ["fChips", "dashChips", "zoomChips"])
    assert(html.includes('$("' + bar + '").addEventListener("click", chipBarClick)'),
      bar + " is wired to the one handler, not to a copy of it");

  A.setFilter("zoomBySelect", "task");
  A.setView(was);
  assert.strictEqual(A.syncZoomChips(), "", "the axis is not a question the other views ask");
  useRef(null);
});

/* 🔴 Filters brief §6/1: "nyugalomban az ikon-chip A TÖBBI VEZÉRLŐ SORÁBAN ül —
   SOHA nem kap saját sort". A 44px square alone on a line under a row of
   pickers is the textbook case of the empty space Daniel is hunting, and it
   took two things to stop it happening: the groups had to stop being boxes
   (a box that fills the line pushes the next one whole onto the next), and the
   search had to stop being LAST, since whatever the row could not hold pushed
   the item at the end of the source over the edge. */
t("the search never gets a row to itself while it is folded", () => {
  const { block } = mobileCss();
  const rule = (block.match(/#fSearchWrap\{[^}]*\}/) || [""])[0];
  assert(/order:-1/.test(rule), "it leads the flow, so the row it is on is the row everything starts on: " + rule);
  assert(/#fSearchWrap\.open\{flex:1 1 100%;\}/.test(block),
    "…and only takes a line of its own once opened, which is the one time it was asked for");
  /* Leading the flow is worth nothing if the flow is four boxes: the group in
     front of it would still fill the line and push it over whole. */
  assert(/#listCtrls, #teamCtrls, #dashCtrls, #ctxRight,\s*#listCtrls > \.fChips, #dashCtrls > \.fChips\{display:contents;\}/.test(block),
    "the groups hand their controls straight to the bar rather than standing between");
  assert(!/#(listCtrls|teamCtrls|dashCtrls)\{width:100%/.test(block),
    "and none of them claims the whole line any more");
});

/* 🔴 Filters brief §6/2, and the reason it is a rule and not a fix: the
   cockpit's Team|People switch wears `.viewToggle` because it IS a segmented
   control, and every phone rule for the navigation bar named that class. The
   switch was therefore pinned to the bottom of the screen over the navigation
   — one view swallowed the only way out of itself. What the test guards is the
   rule, not the incident: nothing inside a view may be pinned to the bottom
   edge, so on a phone the ONLY bottom-anchored element is the bar with the
   name. */
t("a switch inside a view never takes the place of the navigation", () => {
  const { block } = mobileCss();
  assert(!/\n\s*\.viewToggle[ ,{]/.test(block),
    "the phone block styles the navigation by its id, so a class-mate is not dragged along");
  for (const rule of block.match(/[^{}]+\{[^}]*position:fixed[^}]*\}/g) || []) {
    const sel = rule.slice(0, rule.indexOf("{")).trim();
    if (!/bottom:0/.test(rule)) continue;
    assert(/#viewToggle/.test(sel), "only the navigation bar is pinned to the bottom edge: " + sel);
  }
  // and the switch that was dragged along is still a switch, at a finger's size
  assert(/#teamCtrls \.viewToggle button\{[^}]*min-height:38px/.test(block),
    "the cockpit's switch keeps a 44px target where it stands (38px inside a 3px trough)");
  const toggle = html.match(/<div id="teamCtrls"[\s\S]*?<\/div>/)[0];
  assert(/class="viewToggle"/.test(toggle) && !/id="viewToggle"/.test(toggle),
    "the cockpit's switch is a viewToggle by shape, and is not the navigation");
});

t("the icon stacks over the label rather than sitting in front of it", () => {
  const { block } = mobileCss();
  assert(/#viewToggle button\{[^}]*flex-direction:column/.test(block), "the button stacks");
  assert(/#viewToggle button::before\{[^}]*margin-right:0/.test(block),
    "and the icon drops the gap it needs on a wide screen");
});

/* The two chips are not buttons you press to do a thing — they say which mode
   the app is in, and that has to survive the narrowing. */
t("the mode chips stay on the bar when the actions leave it", () => {
  const { block } = mobileCss();
  const hidden = block.match(/#toolbar > #btnSave[^}]*\{display:none;\}/);
  assert(hidden, "the four actions are hidden by name");
  for (const id of ["combinedChip", "roChip"])
    assert(!hidden[0].includes(id), id + " must not be hidden — it is a mode, not an action");
});

/* One Save in the app, not two: the menu presses the real button rather than
   carrying its own copy of what saving means. */
t("the overflow menu mirrors the toolbar instead of duplicating it", () => {
  /* 🔴 TWO menus mirror the bar since 2026-08-22 — the phone's ⋯ and the
     plan's own ⋮ — so the collection is per menu now. They are the same three
     lines of logic (syncMirrors), which is the point: a second copy is how the
     two would have drifted apart, silently. */
  /* Sliced between two ids rather than by counting </div>s: the menus hold
     separators, and a non-greedy match stops at the first of those. */
  const between = (a2, b2) =>
    [...html.slice(html.indexOf('id="' + a2 + '"'), html.indexOf('id="' + b2 + '"'))
      .matchAll(/data-mirrors="(\w+)"/g)].map(m => m[1]);
  const mirrors = between("mMoreMenu", "viewToggle");
  const planOps = between("planOpsMenu", "planName");
  /* Everything a plan can have done TO it, in the menu that hangs off the plan
     — not in the one that answers "where do my plans live". */
  /* 🔴 "Due date reset" is NOT here, and that is the point of this line.
     Daniel said take it off the menus and keep the function in the code; I took
     it off the DB menu and quietly gave it a new home in this one, which is not
     what taking something off means. The button and resetDueDates() are still
     in the page — nothing was deleted — but nothing offers it. */
  assert(!planOps.includes("btnResetDue"), "Due date reset was taken off the menus, not moved");
  assert(html.includes('id="btnResetDue"') && /function resetDueDates\(/.test(html),
    "…and the function itself is still there, as asked");
  eqArr(planOps, ["btnSaveAs", "btnShare", "btnImport", "btnExport",
                  "btnCombine", "btnMerge", "btnCopyRepairs", "btnDeletePlan", "btnTrash"]);
  /* Since 2026-08-19 the right-hand group of the bar is in here too — Lists
     (its three pages), theme and About — because on Megatron (393px) they
     were off the edge of a bar that clips (design spec §2). */
  /* btnAi is in here PERMANENTLY, unlike the rest: on a 390px bar there is no
     room for a seventh control, so the menu is the only way to the assistant
     on a phone (and a second one everywhere else). */
  /* btnAi left this menu on 2026-08-22: the ✨ is on the bar at every width
     now, and one function belongs in one place. */
  /* 🔴 And the plan's four file operations joined on 2026-08-23 (Daniel's
     desktop list, finding 6, BLOCKER). They were on a plan row's ⋮ inside the
     switcher — a place nobody found, and a menu that could not open at all
     because its wrapper carried a display:none nothing ever cleared. This menu
     is the app's own and is always on the bar, which is where a person looks
     for what to do with the file in front of them. */
  eqArr(mirrors, ["btnSave", "btnUndo", "btnRedo",
                  "btnSaveAs", "btnCombine", "btnImport", "btnExport",
                  "btnViewTeams", "btnViewSystems", "btnViewPeople", "btnTheme"]);
  for (const id of mirrors)
    assert(html.includes('id="' + id + '"'), id + " is mirrored but does not exist");
  // and nothing in the menu re-implements what the button already does
  const menu = html.slice(html.indexOf('id="mMoreMenu"'));
  assert(!/data-mirrors="\w+"[^>]*onclick/.test(menu.slice(0, 600)), "no logic of its own");
});

t("a mirrored item is unavailable exactly when its button is", () => {
  els.btnSave.disabled = true;
  els.btnUndo.disabled = false;
  els.btnSave.style.display = "";
  els.btnUndo.style.display = "";
  const seen = [];
  const fake = (id) => ({ dataset: { mirrors: id }, style: {},
                          set disabled(v){ seen.push(id + ":" + v); } });
  els.mMoreMenu.querySelectorAll = () => [fake("btnSave"), fake("btnUndo")];
  A.syncMoreMenu();
  eqArr(seen, ["btnSave:true", "btnUndo:false"]);
  delete els.mMoreMenu.querySelectorAll;
});

/* The three list pages hang off the Lists button on a desktop, and that is
   the button the app switches off until a folder is open — so their mirrors
   follow it too, not only their own (always-enabled) buttons. */
t("a gated mirror follows the gate's availability as well as its own", () => {
  els.btnViewTeams.disabled = false;
  els.btnListsMenu.disabled = true;
  els.btnViewTeams.style.display = "";
  const seen = [];
  const fake = { dataset: { mirrors: "btnViewTeams", gate: "btnListsMenu" }, style: {},
                 set disabled(v){ seen.push(v); } };
  els.mMoreMenu.querySelectorAll = () => [fake];
  A.syncMoreMenu();
  els.btnListsMenu.disabled = false;
  A.syncMoreMenu();
  eqArr(seen, [true, false]);
  delete els.mMoreMenu.querySelectorAll;
});

t("the phone bar keeps the save dot and loses the rest of the right-hand group", () => {
  const { block } = mobileCss();
  assert(/#tbRight > \.menuWrap, #tbRight > #btnTheme\{display:none;\}/.test(block),
    "Lists and the theme leave the bar — they are in the ⋯ menu");
  /* The Gantora mark stays on the bar: the owner's call — the logo is the app's
     identity, and at phone width it is the 28px square mark, which fits. */
  assert(/#tbRight > #btnAbout\{display:inline-flex;/.test(block), "the About button — the mark — stays on the bar");
  assert(!/data-mirrors="btnAbout"/.test(html), "and is not doubled in the ⋯ menu");
  /* The icon-bar brief (2026-08-20): one size token, and the mark in the left
     corner — the brand pays for its place with function (it IS About). */
  assert(/--ctl-h:44px/.test(block), "the bar declares its one size token");
  assert(/#toolbar button, #toolbar select\{min-height:var\(--ctl-h\); border-radius:10px;\}/.test(block),
    "every control in the bar lives off the token, with one radius");
  for (const sq of [/#toolbar > #btnNewTask\{min-width:var\(--ctl-h\)/, /#mMore\{[^}]*min-width:var\(--ctl-h\)/, /#tbRight > #btnAbout\{[^}]*min-width:var\(--ctl-h\)/])
    assert(sq.test(block), "a square control is token-wide too: " + sq);
  assert(!/min-height:44px|min-width:44px|width:36px/.test(block.replace(/\/\*[\s\S]*?\*\//g, "")),
    "no bar control sizes itself by hand any more — the token is the only source");
  /* 🔴 The mark led the bar until 2026-08-23 and now ENDS it (menu brief §6/2):
     the corner a thumb reaches for first should be where the plans live, not
     the app's signature. It is still the About button, and it is still on the
     bar — it moved past the save dot, it did not leave. */
  assert(/#toolbar > #tbRight\{display:contents;\}/.test(block) && /#btnAbout\{order:100;\}/.test(block) &&
         /#status\{order:99; margin-left:auto;\}/.test(block),
    "the dot then the mark close the bar — bar-level order, the group dissolved");
  /* 🔴 And in front of them, the order Daniel asked for: where the plans live,
     then what THIS plan is — its lock beside its name, not adrift among the
     buttons. */
  assert(/#toolbar > #dbWrap\{order:1;\}/.test(block), "the DB button opens the bar");
  /* 🔴 The whole line, in Daniel's own notation (menu brief §6/2):
     [DB][🔒][name][+][✨][⋯][●][G]. Read out of the rules and checked as a
     sequence, so moving one control cannot quietly step over another. ✨ has no
     rule of its own — it takes the bar's default 5 — which is exactly the kind
     of gap this reads rather than assumes. */
  const dflt = Number((block.match(/#toolbar > \*\{order:(\d+);\}/) || [])[1]);
  const orderOf = sel => {
    const m = block.match(new RegExp(sel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[^{]*\\{[^}]*order:(\\d+)"));
    return m ? Number(m[1]) : dflt;
  };
  const line = [["DB", "#toolbar > #dbWrap"], ["🔒", "#toolbar > #roChip"], ["name", "#toolbar > #planWrap"],
                ["+", "#toolbar > #btnNewTask"], ["✨", "#toolbar > #aiWrap"], ["⋯", "#toolbar > #moreWrap"],
                ["●", "#status"], ["G", "#btnAbout"]];
  const orders = line.map(([, sel]) => orderOf(sel));
  for (let i = 1; i < orders.length; i++)
    assert(orders[i] >= orders[i - 1],
      "the bar reads [DB][🔒][name][+][✨][⋯][●][G] — " + line[i][0] + " came before " + line[i - 1][0] +
      " (" + orders.join(",") + ")");
  assert(orders[orders.length - 1] > orders[orders.length - 2], "…and the mark is past the dot, not beside it");
  assert(/#toolbar > #roChip, #toolbar > #planRoChip, #toolbar > #combinedChip\{order:2;\}/.test(block),
    "then the mode chips");
  /* The plan switcher is a menu of our own since 2026-08-22 and takes the
     place the file picker had. */
  assert(/#toolbar > #planWrap\{order:3;\}/.test(block), "then the plan switcher");
  /* The chip's words never fit on a phone: "🔒 c…" says nothing. The glyph is
     the chip; the sentence is one tap behind popTip(). */
  assert(/#roChip, #planRoChip, #combinedChip\{\s*font-size:0;/.test(block),
    "the chips fold to their glyph on a phone, exactly as they do in the compact bar");
  assert(/#roChip::before, #planRoChip::before\{content:"🔒"/.test(block), "…and the padlock is drawn, not typed");
  /* The bottom bar's six cells are one size, including the one that carries no
     label — it looked stunted beside its neighbours (Daniel, 2026-08-20). */
  assert(/--nav-cell:39px/.test(block), "the bottom bar names its cell height once");
  assert(/#viewToggle button\{[^}]*min-height:var\(--nav-cell\)/.test(block),
    "and every cell takes it, labelled or not");
  assert(/\.viewMoreWrap > button\{flex:1 1 auto;\}/.test(block),
    "the overflow grip fills its cell rather than sitting small inside it");
  /* The cell was right and the glyph still was not: ⠿ is a braille pattern and
     draws small for its font size beside ☰ and 📐 (Daniel: "nem az ikon, hanem
     a pöttyök"). */
  /* 🔴 Drawn, not typed. `⠿`'s ink is a fraction of its em box, so three
     font-size increases changed nothing visible; real circles are the size
     they are told to be. */
  assert(/#viewToggle \.viewMoreWrap > button::before\{width:16px; height:24px;\}/.test(block),
    "the phone bar sizes the drawn dots");
  const css2 = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/#viewToggle \.viewMoreWrap > button::before\{[^}]*radial-gradient\(circle, currentColor/.test(css2),
    "the six dots are circles in currentColor, not a braille character");
  assert(/#viewToggle \.viewMoreWrap > button::before\{[^}]*background-size:8px 8px/.test(css2),
    "…tiled two across and three down, one dot per cell");
  /* A menu is a list. Its items came out centred because the bar's own cells
     are centred, and that rule reaches into the menu inside it. */
  const mb = /#viewToggle \.menu button\{([^}]*)\}/.exec(block);
  assert(mb && /justify-content:flex-start/.test(mb[1]) && /text-align:left/.test(mb[1]),
    "the more-views items start at the left edge: " + (mb && mb[1]));
  assert(/#toolbar > #moreWrap\{order:6;\}/.test(block) && /#toolbar #btnTheme\{order:7;\}/.test(block),
    "and the phone keeps the compact bar's order for what it still shows");
  assert(!/#status\{[^}]*display:none/.test(block), "the save dot stays");
  /* 🔴 Narrower than the token since 2026-08-22, and on purpose: a 44px square
     for an 8px dot is 11% of a 393px bar, and the plan's name needed it more
     (Daniel: "ha kell, a státuszt nyomjuk össze"). Still a real target — it is
     at the very edge, where the thumb lands anyway — and still full height, so
     popTip() has something to hang off. */
  assert(/#status\{min-width:32px; min-height:var\(--ctl-h\)/.test(block),
    "the save dot keeps a real tap target without taking a whole token of width");
  /* The two the token could not reach on its own: a <span> is not a button, and
     WebKit will not size a menulist select however loudly min-height asks. */
  assert(/#roChip, #planRoChip, #combinedChip\{height:var\(--ctl-h\)/.test(block),
    "the mode chips are spans — they need the token spelled out or they sit at 19px");
  /* 🔴 The picker is not a <select> on the bar any more (2026-08-22) — it is
     the plan switcher, a menu of our own, so the menulist workarounds are gone
     with it. What replaced them is the rule that matters here: the switcher is
     the ONE thing on this bar that gives way, and it has to give way hard.
     At 393px the ＋ ✨ ⋯ and the save dot come after it and were being pushed
     off the edge — twice, because the first fix went into the .compact block,
     which is the TABLET bar and never applies on a phone. */
  /* 🔴 It both gives way AND takes the slack. Shrink so the buttons after it
     are never pushed off; grow so the leftover width ends up in the one control
     that can use it, rather than as empty space between icons — which is what
     Daniel drew in red on the third report. The floor below keeps the shrink
     honest. */
  assert(/#toolbar > #planWrap\{min-width:0; flex:1 1 auto;\}/.test(block),
    "the switcher takes the slack and gives way, both");
  /* The separators cost ~50px of a 393px bar and are invisible against it. The
     compact bar hid them in August; the phone is not the compact bar, and never
     did — the same blind spot as the width caps, in the same block. */
  assert(/#toolbar > \.sep\{display:none;\}/.test(block), "no separator holds dead space on a phone");
  assert(/#status\{margin-left:0;\}/.test(block),
    "and no auto-margin parks the spare width in front of the save dot");
  /* 🔴 A floor AND a ceiling, and the floor is what took three reports to get
     right: the switcher is the only shrinkable thing on the bar, so it was
     absorbing the whole shortfall and collapsing to a sliver. It may never go
     below one icon's width. */
  assert(/#toolbar > #planWrap > #btnPlanMenu\{min-width:var\(--ctl-h\); max-width:34vw/.test(block),
    "…with a floor of one icon and a ceiling of a third of the screen");
  /* The width it needed had to come from somewhere real, not from the switcher:
     a tighter gap, and the DB button losing a word its icon already says. */
  assert(/#toolbar\{flex-wrap:nowrap; gap:4px;/.test(block), "the bar's gap is tightened");
  assert(/#toolbar > #dbWrap > #btnDbMenu > \.btnWord\{display:none;\}/.test(block),
    "and the DB button is icon-only on a phone");
  /* The ⋯ button itself: its desktop display:none must come BEFORE the phone
     block, or the phone's inline-flex loses the tie and the menu is gone with
     everything in it (it was, since 0.9.5 — found once About moved in). */
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const hideAt = css.indexOf("#mMore{display:none;}"), blockAt = css.indexOf("@media (max-width: 820px) {");
  assert(hideAt > 0 && blockAt > 0 && hideAt < blockAt, "the ⋯ button's display:none is written before the phone block, so the phone rule wins");
  assert(/#mMore\{display:inline-flex;/.test(block), "and the phone block shows it");
  assert(/\.dashGrid\{grid-template-columns:minmax\(0,1fr\);\}/.test(block), "the dashboard is one card per row on a phone — 430px columns do not fit 393px");
  assert(/\.hKey\.dashNote\{white-space:normal;[^}]*flex:1 1 100%/.test(css), "the sentence in the progress chart's key row wraps — a nowrap key entry ran out of its card on a phone");
  /* …and the block is where an override belongs: after the base rules it
     overrides. Seen on Megatron: the one-column rule above did nothing while
     the base .dashGrid rule came later in the sheet and won the tie. */
  const { at: mainAt } = mobileCss();
  for (const base of [".dashGrid{display:grid", "#searchInput{width:210px", "#zoomBox input[type=range]{width:130px", "#savedViewBox select{max-width:200px", "#moreViewsMenu{left:auto"])
    assert(css.indexOf(base) > 0 && css.indexOf(base) < mainAt, "the main phone block comes after the base rule it overrides: " + base);
  assert(css.indexOf("</style>") < 0 && css.lastIndexOf("@media") === css.lastIndexOf("@media (max-width: " + A.PHONE_W + "px)"), "and it is the last @media block in the sheet");
  /* iOS zooms the page in when a sub-16px field takes focus, and with the outer
     scroll view off it stayed zoomed (Megatron, 2026-08-19): 16px fields on the
     phone, and maximum-scale=1 in the viewport meta as the belt. */
  assert(/input, select, textarea\{font-size:16px;\}/.test(block), "16px fields on a phone — WebKit then does not zoom on focus");
  assert(/<meta name="viewport" content="width=device-width, initial-scale=1\.0, maximum-scale=1, viewport-fit=cover">/.test(html), "and the page cannot be zoomed at all in the WebView");
  assert(/const m = \$\("mTheme"\);\s*if \(m\) m\.textContent = \(dark \? "☀️ " : "🌙 "\) \+ say;/.test(html),
    "applyTheme() writes the menu item's words, like the button's");
});

/* Four buttons that cannot do anything are four buttons' worth of a bar that
   is already too wide — and the shared read-only page is exactly where the
   space is worth most, because nobody opening it can edit anything at all. */
t("read-only takes the editing buttons off the bar rather than greying them", () => {
  const was = { ro: A.state.readOnly, fh: A.planRef(), name: A.planName(),
                view: A.state.view, tasks: A.state.tasks };
  /* The lists menu is on this list too: Teams, Systems and People are pages
     for *maintaining* the lists a task is filled in from, and nothing in a
     copy that cannot be edited will ever be filled in from them. */
  /* 🔴 btnListsMenu left this list on 2026-08-23 (menu brief §7/3). It is the
     APP's menu, not the plan's, and on the desktop it is the only thing on the
     bar that reads as one — hiding it on a read-only plan took the whole of it
     away. Daniel: "a ⋯ menü KOMPLETTEN HIÁNYZIK read-only tervnél". The three
     pages behind it already read without editing, which the "Teams page still
     reads, and no longer edits" test has asserted since long before this. */
  const bar = ["btnSave", "btnUndo", "btnRedo", "btnNewTask"];
  useRef({}); useName("plan.gantora"); A.state.view = "table";
  A.state.tasks = [];

  A.state.readOnly = false;
  A.render();
  for (const id of bar)
    assert.notStrictEqual(els[id].style.display, "none", id + " is there when the plan can be edited");

  A.state.readOnly = true;
  A.render();
  for (const id of bar)
    assert.strictEqual(els[id].style.display, "none", id + " should be off the bar in read-only");
  assert.notStrictEqual(els.btnListsMenu.style.display, "none",
    "🔴 the app's own menu belongs to the app, not to the state of the plan");

  /* The cockpit is read-only too, by the same single question — so it clears
     the bar without a rule of its own. */
  A.state.readOnly = false;
  A.state.view = "cockpit";
  A.render();
  for (const id of bar)
    assert.strictEqual(els[id].style.display, "none", id + " should be off the bar in the cockpit");
  A.cockStop();

  A.state.readOnly = was.ro; useRef(was.fh); useName(was.name);
  A.state.view = was.view; A.state.tasks = was.tasks;
});

/* Read-only takes those buttons off the bar entirely, and the phone's overflow
   menu is a way of *pressing* the bar — so it cannot go on offering what the
   bar no longer has. */
t("a button taken off the bar is gone from the phone's overflow too", () => {
  els.btnSave.style.display = "none";
  els.btnUndo.style.display = "";
  const items = [{ dataset: { mirrors: "btnSave" }, style: {}, set disabled(v){} },
                 { dataset: { mirrors: "btnUndo" }, style: {}, set disabled(v){} }];
  els.mMoreMenu.querySelectorAll = () => items;
  A.syncMoreMenu();
  assert.strictEqual(items[0].style.display, "none", "the hidden one is hidden here too");
  assert.strictEqual(items[1].style.display, "", "and the other is left alone");
  delete els.mMoreMenu.querySelectorAll;
  els.btnSave.style.display = "";
});

/* The sender's screen preference must not ride along inside the copy. */
t("a shared copy carries the design but not the sender's theme", () => {
  withTheme(() => {
    A.applyTheme("dark");
    const src = A.appSource();
    assert(!/<html[^>]*class="[^"]*dark/.test(src.slice(0, 400)),
      "the copy is written with the theme off: " + src.slice(0, 200));
    assert.strictEqual(A.themeNow(), "dark", "and this page is put straight back");
    // the design itself does travel — the tokens are in the copy
    assert(/--th-bg:/.test(src) && /html\.dark\{/.test(src), "the copy can be dark on its own");
  });
});

console.log("\n-- regression guards: things that broke quietly once --");

/* Most of the page is built from template literals; the dashboard's half is
   built with '+' concatenation. The idiom of one pasted into the other renders
   as its own source text — four tooltips read `' + trans(` for a while, and
   nothing failed, because a tooltip is only ever read by a person. The source
   cannot tell the two apart (the dashboard's real code contains the same
   characters), so it is the *rendered* markup that has to come out clean. */
t("no rendered markup carries the concatenation idiom as text", () => {
  loadRowsNow([
    { id: 1, name: "Around today", start: dayOff(-3), end: dayOff(3), team: "Dev" },
    { id: 2, name: "Later", start: dayOff(5), end: dayOff(9), team: "Ops" },
  ]);
  useRef({}); A.state.view = "table";
  A.renderTable();
  assert(!els.tableView.innerHTML.includes("' + "), "the list");
  assert(!A.headRowHtml().includes("' + "), "its header");
  // the lane views draw the today line themselves, with a title of their own
  A.state.view = "zoom"; A.state.zoomBy = "team"; A.state.zoomLevel = "month";
  A.renderLane("team");
  assert(els.hvBody.innerHTML.includes('title="Today"'), "the today line says what it is");
  assert(!els.hvBody.innerHTML.includes("' + "), "the lane chart");
  A.state.view = "table"; A.state.zoomBy = "task"; useRef(null);
});

/* A host that names no element is skipped without a sound, and its whole
   dialog then stays English in every language — the task dialog and the
   import preview did, for as long as the list said "modalOverlay" and
   "impOverlay". */
t("every translation host is an element the page has", () => {
  for (const id of A.I18N_HOSTS)
    assert(html.includes('id="' + id + '"'),
      "#" + id + " is not in the page — its dialog would silently stay English");
});

t("the ID column explains its drop gesture", () => {
  const th = A.headRowHtml();
  assert(th.includes('data-ungroup="1" title="Drop a task here'),
    th.slice(0, th.indexOf(">") + 1));
});

/* The KPI card's edge and number read their colour off --ink, and --ink is
   declared by the bucket's own class — so the class the card wears has to be
   one the stylesheet actually inks. */
t("a KPI card wears a class the stylesheet inks", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  for (const b of A.HEALTH)
    assert(new RegExp("\\." + b.cls + "\\{--ink:").test(css), "no ink for ." + b.cls);
});

t("a duplicate keeps the note", () => {
  loadRowsNow([
    { id: 1, name: "Build", start: "2026-03-02", end: "2026-03-03", team: "Dev",
      notes: "watch the firewall change" },
  ]);
  A.duplicateTask(idOfName("Build"));
  assert.strictEqual(row("Build - duplicate").notes, "watch the firewall change",
    "the note describes the work, and the copy is a copy of the work");
  A.clearSelection();
});

t("the counter counts what the list shows, not the whole plan", () => {
  loadRowsNow([
    { id: 1, name: "Migration step one", start: "2026-03-02", end: "2026-03-03", team: "Dev" },
    { id: 2, name: "Migration step two", start: "2026-03-04", end: "2026-03-05", team: "Ops" },
  ]);
  useRef({}); A.state.view = "table";
  A.state.filterTeam = "Dev";
  search("Migration");
  assert.strictEqual(els.searchCount.textContent, "1 match(es)",
    "a match the Team filter hides is not one the reader can find: " + els.searchCount.textContent);
  A.state.filterTeam = "";
  search("");
  useRef(null);
});

/* Restricted mode's floor is the plain <input type=file>, which hands over a
   plan with no handle — and "no handle" used to read as "nothing open": the
   plan would not draw, the add row was dead, and the toolbar Save stayed grey. */
t("a plan opened by hand counts as open, in restricted mode", () => {
  const was = { mode: A.state.cfg.mode, fh: A.planRef(), name: A.planName() };
  try {
    useRef(null); useName("plan.gantora");
    A.state.cfg.mode = "normal";
    assert.strictEqual(A.planOpen(), false, "normal mode holds a handle or nothing");
    A.state.cfg.mode = "restricted";
    assert.strictEqual(A.planOpen(), true, "restricted mode has only the name to go by");
    useName("");
    assert.strictEqual(A.planOpen(), false, "and no name is no plan");
  } finally { A.state.cfg.mode = was.mode; useRef(was.fh); useName(was.name); }
});

/* The predecessor list scrolls inside itself once the hits outgrow it, and the
   dismiss-on-scroll handler used to catch that scroll too — reaching for the
   task you were looking for is what closed the list. Asserted at the source,
   like the offer-to-save timer: the exemption has to run before the close. */
t("a scroll inside the predecessor list does not dismiss it", () => {
  const at = html.indexOf("if (!predPopOpen()) return;");
  assert(at > 0, "the handler is there");
  assert(html.slice(at, at + 300).includes("p.contains"),
    "and it exempts scrolls that start inside the list");
});

console.log("\n-- Pinch, which is the wheel of a phone --");

/* fireEl hands the literal straight to the handler, so a fake event has to
   carry its own preventDefault — the pinch calls it to keep the page from
   zooming instead of the chart. */
const twoFingers = (gap) => ({
  touches: [{ clientX: 200, clientY: 400 }, { clientX: 200 + gap, clientY: 400 }],
  preventDefault(){ this._pd = true; },
});
const oneFinger = (x) => ({ touches: [{ clientX: x, clientY: 100 }], preventDefault(){} });

t("the width a pinch asks for is what it started at, times how far the fingers moved", () => {
  assert.strictEqual(A.pinchWidth(28, 100, 200), 56, "fingers twice as far apart, days twice as wide");
  assert.strictEqual(A.pinchWidth(28, 100, 50), 14, "and half as far, half as wide");
  assert.strictEqual(A.pinchWidth(28, 100, 100), 28, "no movement, no change");
  assert.strictEqual(A.pinchWidth(28, 0, 120), 28, "and a gap of nothing cannot divide by it");
});

ta("two fingers on the chart change the width of a day", async () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-20" }]);
  useRef({});
  A.setView("gantt");
  A.state.pxPerDay = 28;
  A.renderGantt();

  fireEl(els.ganttRight, "touchstart", twoFingers(100));
  fireEl(els.ganttRight, "touchmove", twoFingers(200));
  assert.strictEqual(A.state.pxPerDay, 56, "spreading the fingers widens the days");

  fireEl(els.ganttRight, "touchmove", twoFingers(50));
  assert.strictEqual(A.state.pxPerDay, 14, "and it is measured from where the pinch began, not from the last frame");

  /* Off the glass, and the next pinch starts again from wherever the chart is
     now — otherwise a second pinch would jump back to the first one's scale. */
  fireEl(els.ganttRight, "touchend", { preventDefault(){} });
  fireEl(els.ganttRight, "touchmove", twoFingers(400));
  assert.strictEqual(A.state.pxPerDay, 14, "with no fingers down, nothing moves");

  useRef(null);
});

t("a pinch cannot reach a width the slider could not", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-20" }]);
  useRef({});
  A.setView("gantt");
  A.state.pxPerDay = 28;
  A.renderGantt();
  const u = A.zoomUnit();

  fireEl(els.ganttRight, "touchstart", twoFingers(100));
  fireEl(els.ganttRight, "touchmove", twoFingers(100000));
  assert.strictEqual(A.state.pxPerDay, u.max, "it stops where the slider stops");
  fireEl(els.ganttRight, "touchmove", twoFingers(1));
  assert.strictEqual(A.state.pxPerDay, u.min, "at both ends");
  fireEl(els.ganttRight, "touchend", {});
  useRef(null);
});

/* One finger is a scroll and must stay one: the chart is longer than the
   screen in both directions and dragging it is how you read it. */
t("one finger is left alone", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-20" }]);
  useRef({});
  A.setView("gantt");
  A.state.pxPerDay = 28;
  A.renderGantt();
  fireEl(els.ganttRight, "touchstart", oneFinger(100));
  fireEl(els.ganttRight, "touchmove", oneFinger(300));
  assert.strictEqual(A.state.pxPerDay, 28, "a one-finger drag is a scroll, not a zoom");
  useRef(null);
});

/* Safari on iOS fires its own gesture events, and preventing those is what
   actually stops the page zooming instead of the chart. Both mechanisms are
   wired, so a flag has to stop them both acting on the same pinch. */
t("Safari's own gesture is wired, and the two cannot both drive one pinch", () => {
  const wiring = html.slice(html.indexOf("function pinchZoom("), html.indexOf("pinchZoom(\"ganttRight\")"));
  for (const type of ["touchstart", "touchmove", "gesturestart", "gesturechange", "gestureend"])
    assert(wiring.includes('"' + type + '"'), "listens for " + type);
  assert((wiring.match(/passive:false/g) || []).length >= 4,
    "and non-passively, or preventDefault is ignored and the page zooms");
  assert(/if \(gesturing \|\| !pinch/.test(wiring), "the touch path stands down while Safari drives");
  assert(/for \(const paneId of|pinchZoom\("hvRight"\)/.test(html), "the zoom view gets it too");
});

console.log("\n-- The bar on a small screen --");

/* "— no folder opened —" is true about the folder and useless about the plan.
   A shared copy has no folder to open, a free copy has one fixed file, and
   restricted mode opens plans one at a time: in all three the picker is wrong
   and the plan's own name is what you want to read. */
t("where there is no file picker, the toolbar names the plan instead", () => {
  const was = { snap: A.state.snapshot, mode: A.state.cfg.mode, fh: A.planRef(),
                name: A.planName(), proj: A.state.projectName };
  useRef({}); useName("switchover.gantora"); A.state.projectName = "";

  A.state.snapshot = null; A.state.cfg.mode = "normal";
  A.syncPlanName();
  assert.notStrictEqual(els.planWrap.style.display, "none", "a folder session keeps its switcher");
  assert.strictEqual(els.planName.style.display, "none", "and needs no label beside it");

  A.state.snapshot = true;
  A.syncPlanName();
  assert.strictEqual(els.planWrap.style.display, "none", "a shared copy has no folder to choose from");
  // the file's name without its extension: a toolbar is not a directory listing
  assert.strictEqual(els.planName.textContent, "switchover", "so it says which plan this is");
  assert.strictEqual(els.planName.title, "switchover.gantora", "with the file itself on the tooltip");

  /* A plan that has been given a name says that instead, and keeps the file
     name on the tooltip — the two are different facts. */
  A.state.projectName = "Core switchover";
  A.syncPlanName();
  assert.strictEqual(els.planName.textContent, "Core switchover");
  assert.strictEqual(els.planName.title, "switchover.gantora");

  A.state.snapshot = was.snap; A.state.cfg.mode = was.mode; useRef(was.fh);
  useName(was.name); A.state.projectName = was.proj;
});

/* syncModeUI runs after syncSnapshotUI and used to hand the picker straight
   back, because it only knew about restricted mode. */
t("nothing hands the picker back after a shared copy has taken it away", () => {
  /* The switcher is what appears and goes now; the <select> behind it is
     hidden always, so nothing touches its display at all. */
  assert.strictEqual((html.match(/\$\("fileSelect"\)\.style\.display/g) || []).length, 0,
    "the hidden select's display is nobody's business");
  assert.strictEqual((html.match(/\$\("planWrap"\)\.style\.display/g) || []).length, 1,
    "one place decides whether the switcher is on screen");
  const body = html.slice(html.indexOf("function syncPlanName()"));
  assert(/!restricted\(\) && !state\.snapshot && !isFree\(\)/.test(body.slice(0, 400)),
    "and it asks all three reasons there might not be one");
});

/* The right-hand group holds the theme toggle, About and the save dot. In a
   one-row bar that neither wraps nor scrolls, they went past the edge of the
   phone and there was no way to reach them at all. */
t("the toolbar's right-hand group cannot be pushed off a phone screen", () => {
  const { block } = mobileCss();
  assert(/#toolbar\{[^}]*overflow:hidden/.test(block), "the bar clips rather than overflowing");
  /* Since the icon-bar brief the right group is dissolved (display:contents)
     and its two survivors are bar children with fixed sizes of their own — the
     old "the group never shrinks" is now said per control by the token. */
  assert(/#toolbar > #tbRight\{display:contents;\}/.test(block), "the right group dissolves into the bar");
  assert(/#planName, #roChip, #planRoChip, #combinedChip\{[^}]*flex:0 1 auto/.test(block),
    "and what gives way is the middle, which can be any length");
});

console.log("\n-- The name column, folded away --");

t("the Design and Zoom name columns start folded on a phone, open on a desk", () => {
  const wasW = ctx.window.innerWidth;
  ctx.localStorage.clear();

  ctx.window.innerWidth = 390;                 // a phone
  A.loadLeftShut();
  assert.strictEqual(A.state.leftShut.gantt, true, "the chart is why you opened the view");
  assert.strictEqual(A.state.leftShut.zoom, true);

  ctx.window.innerWidth = 1400;                // a desk
  ctx.localStorage.clear();
  A.loadLeftShut();
  assert.strictEqual(A.state.leftShut.gantt, false);
  assert.strictEqual(A.state.leftShut.zoom, false);

  ctx.window.innerWidth = wasW;
});

/* The screen decides only until somebody says otherwise: an answer given by
   hand outlives the window it was given in. */
t("once you fold it yourself, the screen size stops deciding", () => {
  const wasW = ctx.window.innerWidth;
  ctx.localStorage.clear();
  ctx.window.innerWidth = 1400;
  A.loadLeftShut();
  A.toggleLeft("gantt");
  assert.strictEqual(A.state.leftShut.gantt, true, "folded by hand on a wide screen");

  A.state.leftShut = { gantt: false, zoom: false };   // as a fresh start would leave it
  A.loadLeftShut();
  assert.strictEqual(A.state.leftShut.gantt, true, "and it is still folded next time");
  assert.strictEqual(A.state.leftShut.zoom, false, "the other one is untouched");

  ctx.window.innerWidth = wasW;
  ctx.localStorage.clear();
});

t("folding writes the class the stylesheet reads, and says which way it points", () => {
  A.state.leftShut = { gantt: true, zoom: false };
  A.syncLeftShut();
  assert(els.ganttLeft.classList.contains("shut"));
  assert(!els.hvLeft.classList.contains("shut"));
  assert.strictEqual(els.btnGanttLeft.getAttribute("aria-expanded"), "false");
  assert.strictEqual(els.btnHvLeft.getAttribute("aria-expanded"), "true");
  assert.notStrictEqual(els.btnGanttLeft.textContent, els.btnHvLeft.textContent,
    "the arrow points the way pressing it would go");
  A.state.leftShut = { gantt: false, zoom: false };
  A.syncLeftShut();
});

/* The month strip is a row of absolutely-placed labels: scroll past the start
   of a month and it paints nothing at all. With the background only on the
   strip around those rows, a bar underneath showed straight through the gap. */
t("every row of the date header paints, not just the strip around them", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  for (const sel of ["#ganttHeadUpper,#ganttHeadLower", "#hvHeadUpper,#hvHeadLower"]) {
    const rule = css.match(new RegExp(sel.replace(/[#.]/g, "\\$&") + "\\{([^}]*)\\}"));
    assert(rule, sel + " not found");
    assert(/background:var\(--th-bg\)/.test(rule[1]),
      sel + " must paint its own background: " + rule[1]);
  }
});

/* The tab is the filter strip's, stood on its end: an icon button read as a
   control of the chart rather than of the panel it opens. */
t("the fold tab is the filter strip's tab, turned on its side", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const tab = css.match(/\.leftToggle\{([^}]*)\}/)[1];
  /* The painted one, not merely the first: the touch size (§8/1) is a second
     `#ctxBarHandle button` rule and it stands earlier in the file. */
  const strip = [...css.matchAll(/#ctxBarHandle button\{([^}]*)\}/g)]
    .map(m => m[1]).find(s => /background:var\(--accent-soft\)/.test(s));
  for (const prop of ["accent-soft", "font-weight:700", "font-size:9px"])
    assert(tab.includes(prop) && strip.includes(prop), "both carry " + prop);
  assert(/width:11px/.test(tab) && /height:110px/.test(tab),
    "the strip's 110×11 tab, stood on its end: " + tab);
  assert(/height:11px/.test(strip) && /width:110px/.test(strip), "and the strip's own is the other way up");
});

/* Shut, the tab is the only way back — miss it and the names are gone for
   good. 11px is precise enough for a pointer and impossible for a thumb, so
   the rail widens and the tab fills it on a phone. */
t("the fold tab is a thumb-sized target on a phone", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));

  const baseAt = css.indexOf("\n  .leftToggle{");
  const base = css.slice(baseAt).match(/\.leftToggle\{([^}]*)\}/)[1];
  assert(/width:11px/.test(base), "the pointer-sized one is the filter strip's: " + base);

  /* The phone rules must be written *after* the base ones: same weight, so the
     later of the two wins — and in the block further up the sheet the base
     rule would simply have taken the size back. */
  const phoneAt = css.indexOf("#ganttLeft.shut, #hvLeft.shut, #presLeft.shut{width:30px");
  assert(phoneAt > baseAt, "the phone override comes after what it overrides");

  const block = css.slice(phoneAt, phoneAt + 400);
  const railW = +(block.match(/#ganttLeft\.shut, #hvLeft\.shut, #presLeft\.shut\{width:(\d+)px/) || [])[1];
  const tabW = +(block.match(/\.shut > \.leftToggle\{width:(\d+)px/) || [])[1];
  const tabH = +(block.match(/\.leftToggle\{[^}]*height:(\d+)px/) || [])[1];
  assert(railW >= 28, "the rail is wide enough to aim at: " + railW);
  assert.strictEqual(tabW, railW, "and shut, the tab is the whole rail");
  assert(tabH >= 120, "tall enough to hit without looking: " + tabH);
});

/* A folded panel is 24px of rail, and the splitter beside it would otherwise
   offer to resize something that is not there. */
t("the rail survives the fold, and the splitter does not", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/#ganttLeft\.shut, #hvLeft\.shut, #presLeft\.shut\{width:24px/.test(css), "it shuts to a rail");
  assert(/#ganttLeft\.shut > \*:not\(\.leftToggle\)/.test(css), "and keeps only the way back");
  assert(/#ganttLeft\.shut ~ #ganttSplit[^}]*display:none/.test(css), "the splitter goes with it");
});

/* The Resource view is deliberately not foldable: its rows are people, and a
   grid of coloured squares with nobody's name against it says nothing. */
t("the resource view narrows instead of folding, and keeps its header", () => {
  assert(!/#resLeft\.shut/.test(html), "there is no folded state for it");
  const body = html.slice(html.indexOf("function renderResources()"));
  assert(/onPhone\(\) \? RES_LEFT_PHONE : state\.resLeftWidth/.test(body.slice(0, 900)),
    "it takes a narrow width on a phone instead");
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/#resLeftHead \.hvLegend\{flex-wrap:wrap/.test(css),
    "and the legend wraps rather than being cut off");
});

/* The Presentation view joins Design and Zoom, not Resource: its rows are
   groups and lanes, which still mean something with no names beside them —
   the opposite of Resource's grid of colour with nobody's name against it. */
t("the presentation view's name column folds like Design and Zoom's, not narrows like Resource's", () => {
  const wasW = ctx.window.innerWidth;
  ctx.localStorage.clear();

  ctx.window.innerWidth = 390;                 // a phone
  A.loadLeftShut();
  assert.strictEqual(A.state.leftShut.pres, true, "the chart is why you opened the view");

  ctx.window.innerWidth = 1400;                // a desk
  ctx.localStorage.clear();
  A.loadLeftShut();
  assert.strictEqual(A.state.leftShut.pres, false);
  assert(!("res" in A.state.leftShut), "Resource never gets a fold state at all");

  ctx.window.innerWidth = wasW;
  ctx.localStorage.clear();
});

t("folding the presentation view by hand is remembered, the same way Design's is", () => {
  const wasW = ctx.window.innerWidth;
  ctx.localStorage.clear();
  ctx.window.innerWidth = 1400;
  A.loadLeftShut();
  A.toggleLeft("pres");
  assert.strictEqual(A.state.leftShut.pres, true, "folded by hand on a wide screen");

  A.state.leftShut = { gantt: false, zoom: false, pres: false };   // as a fresh start would leave it
  A.loadLeftShut();
  assert.strictEqual(A.state.leftShut.pres, true, "and it is still folded next time");

  ctx.window.innerWidth = wasW;
  ctx.localStorage.clear();
});

t("folding the presentation view writes the class and points the arrow, the same pattern as Design's", () => {
  A.state.leftShut = { gantt: false, zoom: false, pres: true };
  A.syncLeftShut();
  assert(els.presLeft.classList.contains("shut"));
  assert.strictEqual(els.btnPresLeft.getAttribute("aria-expanded"), "false");
  A.state.leftShut = { gantt: false, zoom: false, pres: false };
  A.syncLeftShut();
  assert(!els.presLeft.classList.contains("shut"));
  assert.strictEqual(els.btnPresLeft.getAttribute("aria-expanded"), "true");
});

/* popTip(): what a title attribute says, shown on a tap rather than a hover a
   touchscreen does not have. One primitive, six call sites — see the markup
   comment above #popTip and the delegated listeners at the bottom of the
   script for where they are wired; these test the primitive itself. */
console.log("\n-- popTip: what a title attribute says, on a tap --");

t("a tap opens it with the element's own title text, and a second tap on the same element closes it", () => {
  const el = { getAttribute: k => (k === "title" ? "Saved" : null), getBoundingClientRect: () => ({ left: 10, top: 20, bottom: 34 }) };
  A.popTip(el);
  assert(els.popTip.classList.contains("open"));
  assert.strictEqual(els.popTip.textContent, "Saved");
  A.popTip(el);
  assert(!els.popTip.classList.contains("open"), "the same element again closes it");
});

t("an element with no title is not something to tap open at all", () => {
  const el = { getAttribute: () => null };
  A.popTip(el);
  assert(!els.popTip.classList.contains("open"));
});

t("tapping a different tip while one is open replaces it, rather than closing then reopening blank", () => {
  const a = { getAttribute: k => (k === "title" ? "First" : null), getBoundingClientRect: () => ({ left: 0, top: 0, bottom: 10 }) };
  const b = { getAttribute: k => (k === "title" ? "Second" : null), getBoundingClientRect: () => ({ left: 0, top: 0, bottom: 10 }) };
  A.popTip(a);
  A.popTip(b);
  assert(els.popTip.classList.contains("open"));
  assert.strictEqual(els.popTip.textContent, "Second");
  A.closePopTip();
});

/* 🔴 The dashboard's alert cards both say something on tap and DO something —
   they carry the plan into the list. Daniel tapped the overdue card on the
   phone (2026-08-22), landed in the list, and the tip stayed behind at the
   coordinates of a card that was no longer there: a white box in the top-left
   corner, under the Dynamic Island. "Mi az ott balra fent?" */
t("changing view takes the tip with it, and no tip is drawn above the app bar", () => {
  const was = A.state.view;
  A.popTip({ getAttribute: k => (k === "title" ? "Past its end date" : null),
             getBoundingClientRect: () => ({ left: 8, top: 300, bottom: 340 }) });
  assert(els.popTip.classList.contains("open"), "it opened");
  A.setView("table");
  assert(!els.popTip.classList.contains("open"), "🔴 the tip outlived the page it was anchored to");

  /* And wherever it lands, it starts below the bar — the bar is what carries
     the safe-area inset, so its lower edge is the first line the hardware is
     not sitting on. */
  els.toolbar.getBoundingClientRect = () => ({ bottom: 96 });
  A.popTip({ getAttribute: k => (k === "title" ? "Saved" : null),
             getBoundingClientRect: () => ({ left: 8, top: 0, bottom: 0 }) });
  assert.strictEqual(els.popTip.style.top, "98px", "placed at: " + els.popTip.style.top);
  A.closePopTip();
  delete els.toolbar.getBoundingClientRect;
  A.state.view = was;
});

t("closePopTip() always closes it, whatever was open", () => {
  A.popTip({ getAttribute: k => (k === "title" ? "Anything" : null), getBoundingClientRect: () => ({ left: 0, top: 0, bottom: 10 }) });
  A.closePopTip();
  assert(!els.popTip.classList.contains("open"));
});

t("all six places a title is the only carrier of a fact are wired to it", () => {
  assert(html.includes('id="popTip"'), "the panel itself is in the page");
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/\.popTip\{[^}]*white-space:pre-line/.test(css), "a multi-line title has to keep its line breaks");
  for (const sel of ['.notes[title]', '.mChip[title]', '.rCell[title]'])
    assert(html.includes(sel), JSON.stringify(sel) + " is not one of popTip()'s delegated selectors");
  assert(/\$\("status"\)\.addEventListener\("click", e => \{ e\.stopPropagation\(\); popTip/.test(html),
    "the save dot opens its own tip directly, since it is never rebuilt");
});

/* "(13 · hours / day)" was read as thirteen hours a day. 13 is how many people
   are in the view; the unit belongs to the cells, and the legend right beneath
   already carries it. */
t("the person header counts people and does not name a rate", () => {
  const body = html.slice(html.indexOf("function renderResources()"));
  const line = body.slice(body.indexOf("resHeadTitle"), body.indexOf("resHeadTitle") + 300);
  assert(!/hours \/ day/.test(line), "the bracket no longer holds a unit: " + line.slice(0, 120));
  assert(/m\.rows\.length/.test(line), "it holds the number of people");
  assert(!/hours \/ day/.test(html.slice(html.indexOf('id="resHeadTitle"'), html.indexOf('id="resHeadTitle"') + 120)),
    "and the markup it starts from does not either");
  /* The legend that used to carry the unit is gone — it explained what the
     figures in the cells already say, and the column it sat in has to hold a
     person's name on a phone. */
  assert(!/id="resLegend"/.test(html), "and no legend is left to carry one either");
});

/* The on-screen keyboard on a phone. Measured on Megatron (2026-08-19): with
   the Keyboard plugin's resize:"none" the web view keeps its height, and
   with the outer scroll view off (ios.scrollEnabled:false) WebKit cannot
   lift a focused field into view any more — so the password box of the
   Dropbox dialog, and the code box before it, sat under the keys. The fix
   is a length the plugin hands the stylesheet, `--kb`, and these hold the
   three halves of it together: the CSS var is written, every dialog overlay
   in the markup pads its bottom by it, and the modal's max-height gives by
   the same amount. */
console.log("\n-- The on-screen keyboard: --kb --");

t("setKeyboardHeight() writes the keyboard's height to --kb on <html>, and never below zero", () => {
  const root = ctx.document.documentElement, seen = [];
  root.style.setProperty = (k, v) => seen.push([k, v]);
  A.setKeyboardHeight(336);
  assert.deepStrictEqual(seen.pop(), ["--kb", "336px"]);
  A.setKeyboardHeight(0);
  assert.deepStrictEqual(seen.pop(), ["--kb", "0px"]);
  A.setKeyboardHeight(-4);
  assert.deepStrictEqual(seen.pop(), ["--kb", "0px"], "a negative height is no keyboard");
  delete root.style.setProperty;
});

t("every dialog overlay in the markup takes the keyboard off its bottom, and the modal's max-height gives by it", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const rule = css.match(/^([^{}]*\{[^{}]*padding-bottom:var\(--kb, 0px\)[^{}]*\})/m);
  assert(rule, "one rule pads the overlays by --kb");
  assert(/box-sizing:border-box/.test(rule[1]), "border-box, or the padding is added under the screen instead of taken off it");
  const ids = [...new Set([...html.matchAll(/id="([A-Za-z]*[oO]verlay)"/g)].map(m => m[1]))];
  assert(ids.length >= 10, "the overlays are where they were");
  for (const id of ids) assert(rule[1].includes("#" + id), id + " is in the rule");
  assert(/\.modalBox\{[^}]*max-height:calc\(86vh - var\(--kb, 0px\)\)/.test(css), "the modal's max-height gives by --kb");
  assert(/\.modalBox footer\{[^}]*flex-wrap:wrap/.test(css), "and a footer wraps its buttons rather than clipping the first one on a phone");
  /* 🔴 …and wrapping is not enough on its own. The modal is the scrolling box,
     so a wrapped second row fell past its max-height and the primary button
     could only be reached by scrolling (Daniel, 2026-08-23, the combined-view
     dialog: "a kék OK nem is látszik, csak scrollozva"). Stuck to the bottom of
     the box, it is on screen whatever the body does — and it needs its own
     background, because the body slides under it. */
  const foot = css.match(/\.modalBox footer\{([^}]*)\}/)[1];
  assert(/position:sticky/.test(foot) && /bottom:0/.test(foot),
    "the answer is never below the fold: " + foot);
  assert(/background:var\(--panel\)/.test(foot), "…and the body does not show through it: " + foot);
});

/* 🔴 A tick box is not a field. `width:100%` is right for what you type or
   choose in and wrong for a checkbox, which has a size of its own — and the
   damage was not that it went wide but that flex shrank it back by however much
   the text beside it needed, so no two rows in the combined-view dialog started
   at the same place (Daniel, 2026-08-23). */
t("a checkbox in a dialog keeps its own size, so rows line up under each other", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const rule = css.match(/\.modalBox input([^{]*)\{width:100%;\}/);
  assert(!rule || /:not\(\[type=checkbox\]\)/.test(rule[1]),
    "🔴 the 100%-wide field rule still catches checkboxes: " + (rule && rule[1]));
  assert(/\.modalBox input:not\(\[type=checkbox\]\):not\(\[type=radio\]\)/.test(css),
    "the exclusion names both of the boxes that have a size of their own");
  /* The list they sit in is a column of rows, and each row is the box and the
     name beside it — that part was always right and is what made the drift so
     hard to see. */
  assert(/#combineList label\{display:flex; align-items:center; gap:8px/.test(css),
    "one row per file: the box, then the name");
});

t("the Keyboard plugin is the only writer, wired to its own will-show / will-hide events, and only on a native platform", () => {
  const at = html.indexOf("function setKeyboardHeight");
  const wiring = html.slice(at, at + 1500);
  assert(/NativeAdapter\.available\(\) && window\.Capacitor\.Plugins\.Keyboard/.test(wiring), "guarded by the platform — file:// never evaluates window.Capacitor");
  assert(/addListener\("keyboardWillShow",[^)]*setKeyboardHeight\(info && info\.keyboardHeight \|\| 0\)/.test(wiring));
  assert(/addListener\("keyboardWillHide", \(\) => setKeyboardHeight\(0\)\)/.test(wiring));
  assert(/addListener\("keyboardDidHide", forceViewportRelayout\)/.test(wiring), "and the relayout stays on the fully-hidden event");
});

/* The published format description — skill/gantora-plans/SKILL.md — and the
   parser have to agree, or the spec drifts from the code in silence. The
   fixture is the SKILL's own §8 example, byte for byte (CRLF, final break),
   written as an *external* writer would write it; these hold that the app
   opens it as a clean plan, that a save changes nothing the writer wrote, and
   that the example in the document is the fixture. */
console.log("\n-- The Claude skill: an external writer's file --");

const skillFile = fs.readFileSync(__dirname + "/DB/_fixture-claude-skill.gantora", "utf8");

t("the SKILL.md example is the fixture, line for line", () => {
  const md = fs.readFileSync(__dirname + "/skill/gantora-plans/SKILL.md", "utf8");
  const m = md.match(/## 8\. A minimal example\n\n```\n([\s\S]*?)```/);
  assert(m, "the document carries its example");
  assert.strictEqual(m[1].trimEnd().split("\n").join("\r\n") + "\r\n", skillFile);
  assert(/~p is `\|`, `~n` is a line break, `~t` is a literal `~`/.test(md) || /`~p` is `\|`/.test(md),
    "and it teaches the cell escapes the parser uses, not the old '/' replacement");
  assert(md.includes("#META|readOnly|yes") && md.includes("#META|writer|claude-skill"), "and the #META lines");
});

t("the app opens an external writer's file as a clean plan — no repairs, nothing refused", () => {
  assert.strictEqual(A.inspectPlanText(skillFile).ok, true);
  assert(A.looksLikeProjectFile(skillFile));
  const tasks = A.parseCSV(skillFile);
  const warn = A.sanitizeTasks(tasks);
  eqArr(warn, [], "a file written to the spec needs no repair");
  assert.strictEqual(tasks.length, 5);
  const by = Object.fromEntries(tasks.map(t => [t.name, t]));
  eqArr(by["Design"].group, [2, 3]);
  assert.strictEqual(by["Workshop"].notes, "Room booked\nAgenda: scope| risks", "~n and ~p decoded");
  assert.strictEqual(by["Workshop"].accountable, "Anna Kovacs:4");
  assert.strictEqual(by["Spec approved"].milestone, true);
  assert.strictEqual(by["Christmas freeze"].event, true);
  assert.strictEqual(by["Christmas freeze"].status, "", "an event has no status");
  assert.strictEqual(by["Design"].history[1].by, "Anna Kovacs", "History By read in step");
  assert.strictEqual(A.planWriterOf(skillFile), "claude-skill");
  assert.strictEqual(A.planReadOnlyOf(skillFile), false);
});

t("a save changes nothing the writer wrote — the round trip is the identity, bar the writer stamp", () => {
  loadTasks(skillFile);
  /* As adoptPlan() would leave them for a file carrying no #VIEW lines — the
     harness keeps whatever the previous test folded or arranged. */
  A.state.hiddenCols = A.parseViewLine(skillFile);
  A.state.colOrder = A.parseColOrder(skillFile) || A.state.colOrder;
  A.state.collapsed = A.parseCollapsed(skillFile);
  A.state.savedViews = [];
  A.state.projectName = "";
  const out = A.serializeCSV(A.state.tasks);
  const expect = skillFile.replace("#META|writer|claude-skill", "#META|writer|gantora-app");
  assert.strictEqual(out, expect);
});

t("a read-only flag from an external writer is honoured on the way in", () => {
  const ro = skillFile.replace("#META|writer|claude-skill", "#META|readOnly|yes\r\n#META|writer|claude-skill");
  assert.strictEqual(A.inspectPlanText(ro).ok, true);
  assert.strictEqual(A.planReadOnlyOf(ro), true);
  loadTasks(ro);
  assert(A.readOnly(), "the plan's own flag closes every editing path");
  assert(A.serializeCSV(A.state.tasks).includes("#META|readOnly|yes\r\n#META|writer|gantora-app\r\n"), "and is written back");
  loadTasks(skillFile);
  assert(!A.readOnly());
});

/* A plan an assistant keeps carries `__ai` in its file name (skill-terv v1.1):
   the suffix is for programs, the person sees the name and a 🤖. The file's
   real name is the truth and stays wherever the file system is meant. */
console.log("\n-- a plan an assistant keeps: __ai in the file, 🤖 on the screen --");

t("showPlanName(): .gantora comes off, .csv stays on, and the 🤖 mark goes to an __ai name", () => {
  assert.strictEqual(A.AI_SUFFIX, "__ai");
  assert.strictEqual(A.showPlanName("Gantora_Roadmap__ai.gantora"), "Gantora_Roadmap 🤖");
  /* 🔴 .csv is kept on purpose: it is the ONE extension that says something —
     that this plan is still in the older format. .gantora is on every plan in
     the list and therefore distinguishes nothing. */
  assert.strictEqual(A.showPlanName("Board__ai.csv"), "Board 🤖.csv", "an old-extension plan keeps its extension");
  assert.strictEqual(A.showPlanName("Board__ai"), "Board 🤖", "with no extension at all");
  assert.strictEqual(A.showPlanName("Kitchen.gantora"), "Kitchen", "the default extension says nothing, so it is not shown");
  assert.strictEqual(A.showPlanName("Kitchen.csv"), "Kitchen.csv");
  assert.strictEqual(A.showPlanName("plan__Daniel__2026.gantora"), "plan__Daniel__2026", "the conflict-copy idiom is not the AI suffix");
  assert.strictEqual(A.showPlanName("ai.gantora"), "ai");
  assert.strictEqual(A.showPlanName("notes.txt"), "notes.txt", "and an extension we do not own is left alone");
  assert.strictEqual(A.showPlanName(""), "");
  assert(A.isAiPlan("x__AI.gantora"), "case does not matter to the suffix");
  assert(!A.isAiPlan("x__ai_v2.gantora"), "the suffix has to be at the end");
});

ta("the picker shows the mark but keeps the real name as its value; the title chip and the tab show the mark", async () => {
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.dbHandle = fakeRoot({ "Kitchen.gantora": FILE_A, "Gantora_Roadmap__ai.gantora": FILE_B });
  await A.refreshFileList("Gantora_Roadmap__ai.gantora");
  const opts = [...els.fileSelect.innerHTML.matchAll(/<option value="([^"]*)"[^>]*>([^<]*)<\/option>/g)].map(m => [m[1], m[2]]);
  assert.deepStrictEqual(opts, [["Gantora_Roadmap__ai.gantora", "Gantora_Roadmap 🤖"], ["Kitchen.gantora", "Kitchen"]],
    "value = the file on disk, text = what a person reads — and nobody needs to read .gantora twice");
  A.state.dbHandle = null;
  useRef({}); useName("Gantora_Roadmap__ai.gantora"); A.state.projectName = "";
  assert.strictEqual(A.projectTitle(), "Gantora_Roadmap 🤖", "the title chip drops the extension and wears the mark");
  A.syncPlanName();
  assert.strictEqual(els.planName.title, "Gantora_Roadmap__ai.gantora", "and its tooltip names the real file — the truth is one hover away");
  useRef(null); useName(""); resetApp();
  /* a shared copy of such a plan names the tab the same way */
  const text = fs.readFileSync(__dirname + "/DB/_fixture-claude-skill.gantora", "utf8");
  A.openSnapshot({ files: [{ name: "Board__ai.gantora", csv: text }] });
  assert.strictEqual(ctx.document.title, "Board 🤖 · read-only", "the tab reads like the picker does");
  A.state.snapshot = false; resetApp();
});

/* ======================= The MCP server: mcp/gantora-mcp.mjs =======================
   The skill's rules, enforced in code, over stdio. These drive the module
   directly (a Store over a scratch folder) and once through the real process
   on stdio — and put every file it writes through the APP's parser: the one
   judge of whether a write was a Gantora write. */
/* The switcher's three sizes (felso-savok-brief §1): wide · icon-only · the
   phone's bottom bar. The decision is a measurement of the LAYOUT (has the
   bar wrapped?), not a breakpoint — labels are a different width in every
   language. The stub cannot lay text out, so what is held here is the whole
   mechanism around the measurement: the wrap-predicate, the class, the words
   moving to title/aria, the re-measure on resize and on language change; the
   real widths are the 3rd slice's device pass. */
console.log("\n-- the view switcher folds to icons before it would wrap --");

t("wrapped bar → icon-only, with every button's words kept as title and aria-label", () => {
  const vt = els.viewToggle;
  ctx.window.mediaMatchMap = { "(pointer: coarse)": true };     // a tablet: a finger is driving
  els.btnDbMenu.offsetTop = 10; els.status.offsetTop = 10;
  ctx.window.innerWidth = 1200;
  A.syncViewToggleMode();
  assert(!vt.classList.contains("icons"), "one row: the wide buttons stay");
  els.status.offsetTop = 60;                       // the dot fell to a second row
  const btns = [
    { textContent: "List", _attr: {}, title: "", setAttribute(k, v){ this._attr[k] = v; } },
    { textContent: "Design", _attr: {}, title: "Design view — the timeline", setAttribute(k, v){ this._attr[k] = v; } },
  ];
  vt.querySelectorAll = () => btns;
  A.syncViewToggleMode();
  assert(vt.classList.contains("icons"), "wrapped: the labels fold into the icons");
  assert.strictEqual(btns[0].title, "List", "a button with no tooltip gets its label as one");
  assert.strictEqual(btns[1].title, "Design view — the timeline", "an existing tooltip is not overwritten");
  assert.strictEqual(btns[0]._attr["aria-label"], "List");
  els.status.offsetTop = 10;
  A.syncViewToggleMode();
  assert(!vt.classList.contains("icons"), "fitting again, the words come back");
  vt.querySelectorAll = () => []; delete ctx.window.mediaMatchMap;
});

/* 🔴 A stage only counts as fitting if what is on the bar is still legible.
   Daniel's three desktop screenshots, 2026-08-23: the switcher read "Ganto…",
   then an EMPTY stub, and then — one rung narrower — the whole name again.
   "Összeesik." The cause was `min-width:0` on the switcher: it could shrink to
   nothing, so it did, silently absorbing the very pressure the ladder exists to
   answer. The bar went on "fitting" while the one thing that says WHICH PLAN
   you are editing was crushed to a caret, and the words on buttons that also
   carry icons and tooltips outlived it by seventy pixels.

   Neither the ladder nor its tests could see it, because both read the RUNG and
   not what the rung cost. The floor is what makes the measurement honest: below
   ten characters and the caret the bar genuinely overflows, barOverflows() sees
   it, and the next rung arrives — at about 1000px instead of 920. */
t("the plan's name has a floor, so the ladder cannot buy a rung with it", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const base = (css.match(/\n\s*#btnPlanMenu\{([^}]*)\}/) || [])[1];   // the base rule, not the compact one
  assert(base, "the switcher has a rule of its own");
  assert(!/min-width:0/.test(base), "🔴 min-width:0 is what let it be crushed to nothing");
  const floor = base.match(/min-width:calc\((\d+)ch/);
  assert(floor && Number(floor[1]) >= 8,
    "the floor is some of the NAME, counted in characters: " + base);
  /* The phone keeps its own, smaller floor on purpose — there the name may fold
     to a glyph, and that is its rule rather than a failure of this one. */
  const { block } = mobileCss();
  assert(/#toolbar > #planWrap > #btnPlanMenu\{min-width:var\(--ctl-h\)/.test(block),
    "…and the phone's floor is one icon, which is the phone's own answer");
});

/* 🔴 SUPERSEDED, and by the man who set it. On 2026-08-20 Daniel's rule was
   that a wrapping bar is not a fault on a desktop — it had always taken a
   second row there. On 2026-08-23 he sent a screenshot of exactly that and
   wrote "itt már feltörik a dizájn", with the remedy: "vissza előre, save stb
   menjenek a menübe". So the desktop is not left alone any more; what survives
   of the exception is the last stage only — a mouse never gets a bottom
   navigation bar, because that is a phone's shape and a narrow window on a desk
   is still a window.

   This test is rewritten rather than deleted: the old assertion was true of a
   rule that no longer holds, and the new one has to say which rule replaced it. */
t("a mouse gives up the editing buttons before its words, and never gets the bottom bar", () => {
  const vt = els.viewToggle, tb = els.toolbar, root = ctx.document.documentElement;
  vt.querySelectorAll = () => [];
  els.btnDbMenu.offsetTop = 10; els.status.offsetTop = 90;      // two rows, plainly
  ctx.window.innerWidth = 1440;
  ctx.window.mediaMatchMap = { "(pointer: coarse)": false };
  const stage = A.syncViewToggleMode();
  assert(!A.touchBar(), "no finger on this screen");
  assert(tb.classList.contains("tight"), "Save, Undo and Redo went to the ⋯ first — stage 2");
  assert.strictEqual(stage, 4, "…then the chrome, and only then the words: " + stage);
  assert(!root.classList.contains("navBottom"),
    "🔴 but never to the bottom bar: that is the desktop exception, and it is the whole of what is left of it");

  /* A phone is the one place the last stage is offered, and even there only
     when the icon row does not fit at the top. */
  ctx.window.mediaMatchMap = { "(pointer: coarse)": true };
  ctx.window.innerWidth = 390;
  assert.strictEqual(A.syncViewToggleMode(), 5, "a wrapped phone bar ends at the bottom bar");
  assert(root.classList.contains("navBottom"));

  /* …and an iPad upright is NOT a phone, whatever its width says: the icon row
     fits at the top there, so the plan keeps the bottom of the screen. */
  els.status.offsetTop = 10;                                   // one row again: it fits
  ctx.window.innerWidth = 820;
  assert.strictEqual(A.syncViewToggleMode(), 1, "a bar that fits is left whole");
  assert(!root.classList.contains("navBottom"), "and the bottom bar is gone with the need for it");

  delete ctx.window.mediaMatchMap; vt.querySelectorAll = () => [];
  ctx.window.innerWidth = 1200; A.syncViewToggleMode();
});

/* The bug that put the tablet's compact bar on a 2000px desktop (2026-08-20,
   Daniel's screenshot): #btnDbMenu lives inside .menuWrap, which is
   position:relative for its dropdown, so its offsetTop is ~0 measured from
   THAT — while #status, with no positioned ancestor, reports the toolbar's own
   9px of padding. Two origins, one comparison, and the answer was "wrapped"
   at every width. Both must be resolved to the page before they are compared. */
t("the wrap test resolves both controls to the page, not to whatever box they sit in", () => {
  const vt = els.viewToggle, tb = els.toolbar;
  ctx.window.innerWidth = 1800;
  ctx.window.mediaMatchMap = { "(pointer: coarse)": true };     // measuring the wrap test, not the pointer one
  vt.querySelectorAll = () => [];
  // the real shape: the DB button reads 0 inside its positioned .menuWrap,
  // which itself sits 9px down the toolbar — the same 9px the dot reports
  els.btnDbMenu.offsetTop = 0; els.btnDbMenu.offsetParent = { offsetTop: 9 };
  els.status.offsetTop = 9;
  assert.strictEqual(A.pageTop(els.btnDbMenu), 9, "the climb adds the positioned wrapper's own offset");
  A.syncViewToggleMode();
  assert(!vt.classList.contains("icons") && !tb.classList.contains("compact"),
    "one row on a wide desktop: the words and the two rows stay");
  els.status.offsetTop = 59;                       // now it really did fall to a second row
  A.syncViewToggleMode();
  assert(vt.classList.contains("icons") && tb.classList.contains("compact"),
    "and a genuine wrap is still caught");
  delete els.btnDbMenu.offsetParent;
  els.btnDbMenu.offsetTop = 10; els.status.offsetTop = 10;
  A.syncViewToggleMode(); vt.querySelectorAll = () => []; ctx.window.innerWidth = 1200;
  delete ctx.window.mediaMatchMap;
});

/* §2b: the icon fold only pays if the two chrome rows fold into one with it —
   on the tablet screenshots the bar came back on two rows even in icon mode. */
t("icon mode folds the whole chrome into one row, and lets go of it again", () => {
  const vt = els.viewToggle, tb = els.toolbar;
  ctx.window.mediaMatchMap = { "(pointer: coarse)": true };     // a tablet, per the rule above
  els.btnDbMenu.offsetTop = 10; els.status.offsetTop = 60;
  ctx.window.innerWidth = 1024;
  vt.querySelectorAll = () => [];
  A.syncViewToggleMode();
  assert(vt.classList.contains("icons") && tb.classList.contains("compact"),
    "the two classes travel together — labels folded, rows merged");
  els.status.offsetTop = 10;
  A.syncViewToggleMode();
  assert(!vt.classList.contains("icons") && !tb.classList.contains("compact"), "and both come off when it fits again");
  ctx.window.innerWidth = 390;
  els.status.offsetTop = 60;
  A.syncViewToggleMode();
  assert(!tb.classList.contains("compact"),
    "never on a phone: these rules outweigh the phone block by that extra class");
  ctx.window.innerWidth = 1200; els.status.offsetTop = 10; vt.querySelectorAll = () => [];
  delete ctx.window.mediaMatchMap;
});

t("the compact row: one elastic, no dead gaps, the chips down to their glyph, the rest in the ⋯ menu", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const rule = re => { const m = css.match(re); assert(m, "missing rule: " + re); return m[1]; };
  assert(/flex-wrap:nowrap/.test(rule(/#toolbar\.compact\{([^}]*)\}/)), "the bar cannot wrap in compact");
  assert(/--ctl-h:44px/.test(rule(/#toolbar\.compact\{([^}]*)\}/)), "and keeps the touch token — a tablet is a touch screen");
  /* Measured, not guessed: on the 08-20 screenshot the bar ran at 44 / 44 / 50
     everywhere and 20 / 19 at these two, because a span is not a button and a
     menulist select refuses to be sized. The chip fix is still needed; the
     select's is not — the picker became a menu of our own on 2026-08-22, and a
     button was always sizeable. */
  assert(/height:var\(--ctl-h\); min-height:var\(--ctl-h\)/.test(
    rule(/#toolbar\.compact #roChip, #toolbar\.compact #planRoChip, #toolbar\.compact #combinedChip\{([^}]*)\}/)),
    "and the chips are spans: the button rule never reached them");
  assert(/flex:0 0 auto/.test(rule(/#toolbar\.compact > \*\{([^}]*)\}/)), "everything is fixed…");
  const fs = rule(/#toolbar\.compact > #planWrap\{([^}]*)\}/);
  assert(/flex:0 1 auto/.test(fs) && /min-width:0/.test(fs),
    "…except the plan switcher, which shrinks and never grows: " + fs);
  assert(/#toolbar\.compact > \.sep\{display:none;\}/.test(css), "no separator holds dead space");
  assert(/#toolbar\.compact #status\{[^}]*margin-left:0/.test(css), "and no auto-margin does either — the switcher gives the gap");
  /* 🔴 The mark ends the bar since 2026-08-23 — the same decision as the
     phone's, and the same bar at another width (menu brief §6/2). */
  assert(/#toolbar\.compact > #tbRight\{display:contents;\}/.test(css) &&
         /#toolbar\.compact #btnAbout\{order:100/.test(css) && /#toolbar\.compact #status\{order:99/.test(css),
    "the dot then the mark close the bar");
  assert(/#toolbar\.compact > #dbWrap\{order:1;\}/.test(css) &&
         /#toolbar\.compact > #planWrap\{order:3;/.test(css) &&
         /#toolbar\.compact > #roChip[^{]*\{order:2;\}/.test(css),
    "and it opens with where the plans live, then this plan's lock and its name, together");
  assert(/#toolbar\.compact #brandMark\{--wordmark:var\(--mark-sm\); width:40px; height:40px;\}/.test(css),
    "the mark is the square one — 84px of lettering does not fit a folded bar — and it fills its button the way the other icons fill theirs");
  /* The right-hand half, in the order Daniel asked for on 2026-08-20: after the
     view buttons the ⋯, then light/dark, and the dot last of all. #tbRight is
     display:contents, so its children need their own rule or they land at 0 —
     which is where the moon turned up, in front of the mark. */
  assert(/#toolbar\.compact > #viewToggle\{order:5;\}/.test(css) &&
         /#toolbar\.compact > #moreWrap\{order:6;\}/.test(css) &&
         /#toolbar\.compact #btnTheme\{order:7;\}/.test(css),
    "views, then ⋯, then the theme — and the dot at 99");
  /* 🔴 Save/Undo/Redo leave at STAGE 2 now (`.tight`), one notch before the
     labels fold — Daniel, 2026-08-23: "vissza előre, save stb menjenek a
     menübe". `.compact` implies `.tight`, so they are still gone here; what
     changed is that they can go without the rest of the bar going with them.
     The Lists pages still leave with the compact bar, which is stage 3. */
  assert(/#toolbar\.tight > #btnSave, #toolbar\.tight > #btnUndo, #toolbar\.tight > #btnRedo,\s*#toolbar\.tight > \.sep\{display:none;\}/.test(css),
    "Save, Undo and Redo leave the bar as soon as it is tight");
  assert(/#toolbar\.compact #tbRight > \.menuWrap\{display:none;\}/.test(css), "and the Lists pages with the compact bar");
  assert(/#toolbar\.tight #mMore\{display:inline-flex/.test(css), "…for the ⋯ menu, which appears to hold them");
  for (const id of ["btnSave", "btnUndo", "btnRedo", "btnViewTeams", "btnViewSystems", "btnViewPeople"])
    assert(html.includes('data-mirrors="' + id + '"'), id + " has to be reachable from the ⋯ menu: no mirror");
  assert(/#toolbar\.compact > #btnNewTask\{/.test(css) && /#toolbar\.compact > #btnNewTask \.btnWord\{display:none;\}/.test(css),
    "New task stays on the bar, icon-only — the one action this app exists to make easy");
  const chip = rule(/#toolbar\.compact #roChip, #toolbar\.compact #planRoChip, #toolbar\.compact #combinedChip\{([^}]*)\}/);
  assert(/font-size:0/.test(chip), "the chips fold their words away…");
  assert(/#toolbar\.compact #roChip::before, #toolbar\.compact #planRoChip::before\{content:"🔒"/.test(css),
    "…and keep the padlock as a glyph of its own");
  /* the sentence is not lost: both chips carry a title, and popTip reads titles */
  for (const id of ["roChip", "planRoChip", "combinedChip"])
    assert(new RegExp('id="' + id + '"[^>]*title="').test(html), id + " would be a mute badge: no title to tap");
  /* …and the one badge that is not a button reads its sentence out on a tap,
     like the save dot: its neighbours have a press of their own to keep. */
  /* Since 2026-08-21 the same chip carries TWO padlocks: a plan marked
     read-only by whoever wrote it (reads its sentence out, as before) and one
     this app put on while the plan is out with an assistant — that one is
     lifted by the tap. The order matters: the lift is asked about first. */
  assert(/\$\("planRoChip"\)\.addEventListener\("click", e => \{ e\.stopPropagation\(\); popTip\(\$\("planRoChip"\)\); \}\)/.test(html),
    "the plan's read-only badge says what it means on a tap");
  els.planRoChip.getAttribute = k => (k === "title" ? "This plan is read-only." : null);
  els.planRoChip.getBoundingClientRect = () => ({ left: 8, top: 6, bottom: 50 });
  A.popTip(els.planRoChip);
  assert.strictEqual(els.popTip.textContent, "This plan is read-only.");
  A.closePopTip();
});

/* 🔴 At phone width the POINTER decides the last step, not the width — Daniel's
   desktop finding 5, 2026-08-23: a browser window pulled narrow got a phone's
   bottom navigation bar, and "a nézet gombok még elférnek, a kisebb változatban
   felülre – ez már volt, hogy jó volt". A window on a desk is a window however
   narrow it is dragged; only a finger gets the bar at the bottom. */
t("at phone width a finger gets the bottom bar and a mouse gets the icons on top", () => {
  const vt = els.viewToggle, root = ctx.document.documentElement;
  vt.querySelectorAll = () => [];
  els.btnDbMenu.offsetTop = 10; els.status.offsetTop = 90;      // nothing fits, at any stage
  ctx.window.innerWidth = 390;

  ctx.window.mediaMatchMap = { "(pointer: coarse)": true };
  assert.strictEqual(A.syncViewToggleMode(), 5, "a finger: the switcher becomes the navigation");
  assert(root.classList.contains("navBottom"));
  assert(!vt.classList.contains("icons"), "…and stage 4's class comes off, or it outweighs the phone block");

  ctx.window.mediaMatchMap = { "(pointer: coarse)": false };
  assert.strictEqual(A.syncViewToggleMode(), 4, "a mouse: the icons stay on top, where they fit");
  assert(vt.classList.contains("icons") && !root.classList.contains("navBottom"),
    "🔴 a narrow window is still a window — this is the one Daniel photographed");

  delete ctx.window.mediaMatchMap;
  ctx.window.innerWidth = 1200; els.status.offsetTop = 10; A.syncViewToggleMode();
});

t("the mechanism is wired where the widths change: boot, resize, language — and styled to the touch token", () => {
  const script = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
  assert(/window\.addEventListener\("resize", syncViewToggleMode\)/.test(script), "a resize re-measures");
  const setLang = script.slice(script.indexOf("function setLang("), script.indexOf("function setLang(") + 900);
  assert(/syncViewToggleMode\(\)/.test(setLang), "a language change re-measures — German wraps where Hungarian fits");
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const rule = css.match(/\.viewToggle\.icons > button, \.viewToggle\.icons \.viewMoreWrap > button\{([^}]*)\}/);
  assert(rule && /font-size:0/.test(rule[1]) && /min-width:44px; min-height:44px/.test(rule[1]),
    "icon-only hides the words and keeps the 44px targets: " + (rule && rule[1]));
  assert(/\.viewToggle\.icons > button::before[^{]*\{[^}]*margin-right:0; font-size:17px;/.test(css),
    "and the glyph loses its label-gap and grows legible");
  /* every button in the switcher has words for the tooltip to carry: a label
     in its text, or a title already in the markup (the ⠿ grip) */
  const toggle = html.match(/<div class="viewToggle" id="viewToggle">[\s\S]*?<div id="tbRight">/)[0];
  for (const m of toggle.matchAll(/<button id="(btnView\w+|btnMoreViews)"([^>]*)>([^<]*)</g))
    assert(m[3].trim() || /title="/.test(m[2]), m[1] + " would be a mute icon: no label and no title");
});

/* The chart header's one-row phone form (felso-savok-brief §2): the month
   moves into the day strip, the upper band goes dark, the stylesheet folds
   the height — 28px back to the plan on every screenful. */
console.log("\n-- the chart header folds to one row on a phone --");

t("phone: the month rides the day strip; wide zooms keep the month band as THE row; desktop is untouched", () => {
  loadSample();
  A.state.view = "gantt"; useRef({});
  ctx.window.innerWidth = 390;
  A.state.pxPerDay = 24;
  A.renderGantt();
  assert(els.ganttHead.classList.contains("oneRow"), "the header wears the one-row class");
  assert.strictEqual(els.ganttHeadUpper.innerHTML, "", "the month band is dark");
  const months = [...els.ganttHeadLower.innerHTML.matchAll(/<span class="hMon">([^<]+)<\/span>/g)].map(m => m[1]);
  assert(months.length >= 2, "the strip carries the month at each month's first day (and the first cell): " + months.join(", "));
  assert(/hMon">[^<]*<\/span>1</.test(els.ganttHeadLower.innerHTML), "the mark sits on the 1st");
  A.state.pxPerDay = 2;                     // the widest zoom: months are the unit
  A.renderGantt();
  assert(els.ganttHeadUpper.innerHTML === "" && /hCell/.test(els.ganttHeadLower.innerHTML),
    "months ARE the single row — moved into the lower band, nothing above it");
  assert(!/hMon/.test(els.ganttHeadLower.innerHTML), "and there is no mark to double them");
  ctx.window.innerWidth = 1200;
  A.state.pxPerDay = 24;
  A.renderGantt();
  assert(!els.ganttHead.classList.contains("oneRow"), "desktop: the two-band form, exactly as before");
  assert(/hCell/.test(els.ganttHeadUpper.innerHTML), "months on top");
  assert(!/hMon/.test(els.ganttHeadLower.innerHTML), "no mark in the day strip");
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/#ganttHead\.oneRow\{height:28px;\}/.test(css) && /#ganttHead\.oneRow #ganttHeadUpper\{display:none;\}/.test(css),
    "the stylesheet folds the height with the same class");
  useRef(null);
});

console.log("\n-- the MCP server --");

const mcpPath = __dirname + "/mcp/gantora-mcp.mjs";
const mcpScratch = () => {
  const os = require("os");
  const dir = fs.mkdtempSync(os.tmpdir() + "/gantora-mcp-");
  fs.writeFileSync(dir + "/Kitchen.gantora", skillFile);
  fs.writeFileSync(dir + "/_lists.csv", "Team|PMO\r\n");            // the app's own: never a plan
  fs.writeFileSync(dir + "/notes.txt", "not a plan\r\n");
  return dir;
};

t("the server is one file, node builtins only — no network, no package", () => {
  const src = fs.readFileSync(mcpPath, "utf8");
  for (const m of src.matchAll(/^import .* from "([^"]+)";/gm)) assert(m[1].startsWith("node:"), "a bare import: " + m[1]);
  /* One URL literal is allowed through: SPEC_URL, the #META|spec TEXT every
     write stamps into the file — never a request. Everything else stays banned. */
  const bare = src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "").split("https://gantora.net/format.md").join("");
  assert(!/\bfetch\(|https?:\/\/|require\(/.test(bare), "nothing fetched, nothing required");
  assert.strictEqual(src.split("https://gantora.net/format.md").length - 1 >= 1, true, "the spec text is there to stamp");
  assert(/#META\|readOnly|readOnly/.test(src) && /renameSync/.test(src), "honours the flag and writes by rename");
});

ta("list_plans and read_plan: the folder's plans, a plan as structure, and the skill's derived facts", async () => {
  const M = await import("file://" + mcpPath);
  const dir = mcpScratch();
  const store = new M.Store([dir]);
  const plans = store.list();
  eqArr(plans.map(p => p.name), ["Kitchen.gantora"], "plan files only — _lists.csv and notes.txt are not plans");
  assert.strictEqual(plans[0].rows, 5); assert.strictEqual(plans[0].writer, "claude-skill"); assert.strictEqual(plans[0].readOnly, false);
  /* compact is the default since v1.1 — the full roadmap read came to ~100 KB
     of JSON on the first real client, which eats a chat context. */
  const rc = store.read("Kitchen");
  assert.strictEqual(rc.detail, "compact");
  const c = Object.fromEntries(rc.tasks.map(t => [t.name, t]));
  assert.strictEqual(Object.keys(rc.tasks[0])[0], "uid", "uid first: it is the handle to remember a row by");
  assert.strictEqual(c["Workshop"].parent, 1);
  assert(!("history" in c["Workshop"]) && !("notes" in c["Workshop"]) && !("drift" in c["Workshop"]),
    "compact drops the heavy and the empty — history, notes, a zero drift");
  assert.deepStrictEqual(c["Design"].members, [2, 3]);
  assert.strictEqual(c["Christmas freeze"].event, true);
  assert(JSON.stringify(rc.tasks).length < JSON.stringify(store.read("Kitchen", "full").tasks).length / 2,
    "and it is less than half the size of full even on a small plan");
  const r = store.read("Kitchen", "full");                          // the extension is optional
  assert.strictEqual(r.name, "Kitchen.gantora");
  assert.strictEqual(r.detail, "full");
  assert.strictEqual(r.tasks.length, 5);
  const by = Object.fromEntries(r.tasks.map(t => [t.name, t]));
  assert.strictEqual(by["Workshop"].parent, 1, "a member knows its group");
  assert.strictEqual(by["Workshop"].level, 1);
  assert.strictEqual(by["Design"].isGroup, true);
  assert.strictEqual(by["Design"].rolledUpStatus, "In Progress", "one member Done, one Planned → In Progress");
  assert.strictEqual(by["Workshop"].notes, "Room booked\nAgenda: scope| risks", "cells decoded");
  assert.strictEqual(by["Design"].history[1].by, "Anna Kovacs", "History By read in step");
  assert.strictEqual(by["Christmas freeze"].event, true); assert.strictEqual(by["Christmas freeze"].bucket, "event");
  assert.strictEqual(by["Spec approved"].milestone, true);
  assert.strictEqual(r.summary.tasks, 2, "leaf tasks: Workshop and Write the spec — not the group, not the milestone, not the event");
  assert.strictEqual(by["Write the spec"].drift, null, "Not Finalized: no drift to report");
  assert.strictEqual(by["Workshop"].drift, 0);
  assert.strictEqual(r.summary.milestones[0].name, "Spec approved");
  fs.rmSync(dir, { recursive: true, force: true });
});

ta("add_task and set_status: the two writes change exactly their rows, and the app opens the result clean", async () => {
  const M = await import("file://" + mcpPath);
  const dir = mcpScratch();
  const store = new M.Store([dir]);
  const before = fs.readFileSync(dir + "/Kitchen.gantora", "utf8").split("\r\n");
  const added = M.addTask(store, { plan: "Kitchen", name: "Buy tiles", start: "2026-09-05", end: "2026-09-06", group: 1, labels: ["shopping"], notes: "A|B\nC", by: "Daniel" });
  assert(/^mcp-[a-z0-9]+-[a-z0-9]+-[0-9a-f]{4}$/.test(added.uid), added.uid);
  assert.strictEqual(Object.keys(added)[0], "uid", "uid before id in the answer — the id renumbers, the uid is the handle");
  /* Buy tiles starts 09-05: inside group 1, between Write the spec (09-04) and
     nothing — so it lands at position 4, and the milestone and the event after
     it move up one. This is the insert-in-time-order the read-only plans need:
     the app repairs a mis-numbered file on open but cannot save the repair
     into a flagged file, so the writer has to get the order right itself. */
  assert.strictEqual(added.id, 4, "inserted at its place in time order, not appended: id " + added.id);
  const moved = M.setStatus(store, { plan: "Kitchen", id: 3, status: "in progress", by: "Daniel" });
  assert.deepStrictEqual(moved, { uid: "clde-mfa3k9x2-2-z0p1", id: 3, from: "Planned", to: "In Progress", changed: true });
  assert.strictEqual(Object.keys(moved)[0], "uid");
  const again = M.setStatus(store, { plan: "Kitchen", id: 3, status: "In Progress" });
  assert.strictEqual(again.changed, false, "already there: nothing written, and it says so");
  const text = fs.readFileSync(dir + "/Kitchen.gantora", "utf8");
  assert(text.endsWith("\r\n") && !/[^\r]\n/.test(text), "CRLF kept, final break kept");
  const after = text.split("\r\n");
  /* Format v2: joining a group touches ONE row — the child's. The parent row
     (Design, line 2) is byte-identical too now, which is the direction flip's
     whole dividend: no more parent-side list to edit, race over, or misread. */
  for (const i of [0, 1, 2]) assert.strictEqual(after[i], before[i], "line " + (i + 1) + " carried through byte for byte");
  assert.strictEqual(after[4].split("|")[12], "clde-mfa3k9x2-0-7hq4",
    "the new member names its parent by UID in its own Parent cell: " + after[4]);
  assert(/\|In Progress\|2026-08-19 Planned;\d{4}-\d{2}-\d{2} In Progress\|/.test(after[3]), "History appended, never rewritten: " + after[3]);
  assert(/\|2026-08-19 Claude;\d{4}-\d{2}-\d{2} Daniel\|/.test(after[3]), "History By in step, with the name given");
  assert(/^4\|Buy tiles\|2\|2026-09-05\|\|2026-09-06\|.*\|A~pB~nC\|Planned\|/.test(after[4]), "the new row, in its slot, cells escaped: " + after[4]);
  assert(/\|no\|shopping$/.test(after[4]), "inside a group: Reported no; labels as given");
  assert(/^5\|Spec approved\|/.test(after[5]) && /^6\|Christmas freeze\|/.test(after[6]),
    "the rows after the insert moved up one, id and place agreeing");
  assert.strictEqual(before[4].replace(/^4\|/, "5|"), after[5], "a shifted row changes in its id cell and nowhere else");
  assert.strictEqual(after[7], "#META|format|2", "the v2 stamp leads the #META block");
  assert.strictEqual(after[8], "#META|writer|gantora-mcp 0.2.0", "the writer stamp is the server's");
  assert.strictEqual(after[9], "#META|spec|https://gantora.net/format.md", "and the spec stamp beside it");
  assert.strictEqual(after.length, 11, "header + 6 rows + format + writer + spec + the empty tail of the final break");
  // the one judge: the app's own parser — and the order the app would choose is the order on disk
  assert.strictEqual(A.inspectPlanText(text).ok, true);
  const tasks = A.parseCSV(text);
  eqArr(A.sanitizeTasks(tasks), [], "nothing for the app to repair");
  A.state.tasks = tasks;
  assert.strictEqual(A.renumberByTime(), false, "the app finds nothing to renumber — the writer's order IS the app's order");
  assert.strictEqual(A.state.tasks.find(t => t.name === "Buy tiles").notes, "A|B\nC");
  eqArr(A.state.tasks.find(t => t.name === "Design").group, [2, 3, 4]);
  fs.rmSync(dir, { recursive: true, force: true });
});

ta("the writes refuse what the rules refuse — and write nothing when they do", async () => {
  const M = await import("file://" + mcpPath);
  const dir = mcpScratch();
  const store = new M.Store([dir]);
  const snap = () => fs.readFileSync(dir + "/Kitchen.gantora", "utf8");
  const before = snap();
  const refuse = (fn, re) => { let msg = ""; try { fn(); } catch (e) { msg = e.message; } assert(re.test(msg), "expected " + re + ", got: " + JSON.stringify(msg)); assert.strictEqual(snap(), before, "and the file is untouched"); };
  refuse(() => M.setStatus(store, { plan: "Kitchen", id: 3, status: "Finished" }), /one of: Planned/);
  refuse(() => M.setStatus(store, { plan: "Kitchen", id: 1, status: "Done" }), /group's status is its members/);
  refuse(() => M.setStatus(store, { plan: "Kitchen", id: 5, status: "Done" }), /event has no status/);
  refuse(() => M.setStatus(store, { plan: "Kitchen", id: 99, status: "Done" }), /no such task/);
  refuse(() => M.addTask(store, { plan: "Kitchen", name: "x", start: "09/05/2026" }), /YYYY-MM-DD/);
  refuse(() => M.addTask(store, { plan: "Kitchen", name: "x", start: "2026-09-05", end: "2026-09-01" }), /before start/);
  refuse(() => M.addTask(store, { plan: "Kitchen", name: "x", start: "2026-09-05", predecessors: [42] }), /predecessor 42/);
  refuse(() => M.addTask(store, { plan: "Kitchen", name: "x", start: "2026-09-05", group: 4 }), /milestone cannot hold/);
  refuse(() => M.addTask(store, { plan: "../Kitchen", name: "x", start: "2026-09-05" }), /file name alone/);
  refuse(() => M.addTask(store, { plan: "_lists.csv", name: "x", start: "2026-09-05" }), /no such plan/);
  refuse(() => M.addTask(store, { plan: "Nope", name: "x", start: "2026-09-05" }), /no such plan/);
  // the read-only flag: read freely, write never
  fs.writeFileSync(dir + "/Board__ai.gantora", skillFile.replace("#META|writer|claude-skill", "#META|readOnly|yes\r\n#META|writer|claude-skill"));
  assert.strictEqual(store.read("Board__ai").readOnly, true);
  let msg = ""; try { M.addTask(store, { plan: "Board__ai", name: "x", start: "2026-09-05" }); } catch (e) { msg = e.message; }
  assert(/read-only/.test(msg), msg);
  // a --read-only server lists only the readers and refuses the writers
  const ro = new M.Store([dir], { readOnly: true });
  const listed = M.handle(ro, { jsonrpc: "2.0", id: 1, method: "tools/list" }).result.tools.map(t => t.name);
  eqArr(listed, ["list_plans", "read_plan"]);
  const res = M.handle(ro, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "set_status", arguments: { plan: "Kitchen", id: 3, status: "Done" } } });
  assert(res.result.isError && /read-only/.test(res.result.content[0].text));
  assert.strictEqual(snap(), before);
  fs.rmSync(dir, { recursive: true, force: true });
});

/* create_plan (v1.1): a new plan from nothing — the "make me a plan" opening
   the smoke could not walk. Every first task goes through addTask itself, so
   the validation is the one validation; a taken name is an error, never an
   overwrite; and the app opens the result clean. */
ta("create_plan makes a plan the app opens clean, and never lands on a taken name", async () => {
  const M = await import("file://" + mcpPath);
  const dir = mcpScratch();
  const store = new M.Store([dir]);
  const made = M.createPlan(store, { name: "Kitchen renovation", by: "Daniel", tasks: [
    { name: "Demolition", start: "2026-09-01", end: "2026-09-03", team: "Builder" },
    { name: "Drying time", start: "2026-09-04", end: "2026-09-10", notes: "screed cures — nobody works", notFinalized: true },
    { name: "Painting", start: "2026-09-11", end: "2026-09-13", predecessors: [2] },
  ] });
  assert.strictEqual(made.name, "Kitchen renovation.gantora");
  assert.strictEqual(made.tasks.length, 3);
  assert.strictEqual(Object.keys(made.tasks[0])[0], "uid");
  const text = fs.readFileSync(dir + "/Kitchen renovation.gantora", "utf8");
  assert(text.startsWith("Seq|Activity Name|"), "the v2 header first");
  assert(text.includes("#META|format|2\r\n"), "the v2 stamp is in the head");
  assert(text.endsWith("#META|writer|gantora-mcp 0.2.0\r\n#META|spec|https://gantora.net/format.md\r\n"), "stamped — writer and spec — final break kept");
  assert.strictEqual(A.inspectPlanText(text).ok, true);
  const tasks = A.parseCSV(text);
  eqArr(A.sanitizeTasks(tasks), [], "nothing for the app to repair");
  eqArr(tasks.map(t => t.name), ["Demolition", "Drying time", "Painting"]);
  eqArr(tasks[2].preds, [2], "a first task may wait on an earlier one — named by its position in the list");
  assert.strictEqual(tasks[1].notFinal, true, "the waiting-time row is honest about its dates");
  // a taken name is an error — under either extension, and nothing is touched
  const before = text;
  for (const nm of ["Kitchen renovation", "Kitchen renovation.gantora", "Kitchen"]) {
    let msg = ""; try { M.createPlan(store, { name: nm }); } catch (e) { msg = e.message; }
    assert(/exists|file name/.test(msg), nm + ": " + msg);
  }
  assert.strictEqual(fs.readFileSync(dir + "/Kitchen renovation.gantora", "utf8"), before);
  // a refused first task names itself, and the plan holds what came before it
  let msg = ""; try { M.createPlan(store, { name: "Half", tasks: [{ name: "ok", start: "2026-09-01" }, { name: "bad", start: "nope" }] }); } catch (e) { msg = e.message; }
  assert(/task 2 \("bad"\)/.test(msg) && /holds the 1 task/.test(msg), msg);
  assert.strictEqual(A.parseCSV(fs.readFileSync(dir + "/Half.gantora", "utf8")).length, 1);
  // path tricks and the app's own names are refused before anything is made
  for (const nm of ["../evil", "_secret", ".hidden"]) {
    let m2 = ""; try { M.createPlan(store, { name: nm }); } catch (e) { m2 = e.message; }
    assert(/file name|app's own/.test(m2), nm + ": " + m2);
  }
  // a --read-only server refuses it wholesale
  const ro = new M.Store([dir], { readOnly: true });
  let m3 = ""; try { M.createPlan(ro, { name: "Nope" }); } catch (e) { m3 = e.message; }
  assert(/read-only/.test(m3), m3);
  const listed = M.handle(ro, { jsonrpc: "2.0", id: 1, method: "tools/list" }).result.tools.map(t => t.name);
  assert(!listed.includes("create_plan"), "and does not offer it");
  fs.rmSync(dir, { recursive: true, force: true });
});

ta("over stdio, as a client sees it: initialize, tools/list, a call, a notification, an unknown method", async () => {
  const { spawn } = require("child_process");
  const dir = mcpScratch();
  const child = spawn(process.execPath, [mcpPath, dir], { stdio: ["pipe", "pipe", "pipe"] });
  const out = []; let buf = "";
  child.stdout.on("data", d => { buf += d; let i; while ((i = buf.indexOf("\n")) >= 0) { out.push(JSON.parse(buf.slice(0, i))); buf = buf.slice(i + 1); } });
  const send = m => child.stdin.write(JSON.stringify(m) + "\n");
  send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "suite", version: "0" } } });
  send({ jsonrpc: "2.0", method: "notifications/initialized" });
  send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
  send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "read_plan", arguments: { plan: "Kitchen.gantora" } } });
  send({ jsonrpc: "2.0", id: 4, method: "resources/list" });
  send({ jsonrpc: "2.0", id: 5, method: "ping" });
  await new Promise((res, rej) => { const t0 = Date.now(); (function wait() { if (out.length >= 5) return res(); if (Date.now() - t0 > 8000) return rej(new Error("server did not answer in time: " + JSON.stringify(out))); setTimeout(wait, 20); })(); });
  child.stdin.end(); child.kill();
  const byId = Object.fromEntries(out.map(m => [m.id, m]));
  assert.strictEqual(byId[1].result.protocolVersion, "2025-03-26", "the client's version is echoed when it is one we speak");
  assert.strictEqual(byId[1].result.serverInfo.name, "gantora-mcp");
  assert.deepStrictEqual(byId[1].result.capabilities, { tools: {} });
  eqArr(byId[2].result.tools.map(t => t.name), ["list_plans", "read_plan", "create_plan", "add_task", "set_status"]);
  const descs = Object.fromEntries(byId[2].result.tools.map(t => [t.name, t.description]));
  for (const n of ["read_plan", "add_task", "set_status"])
    assert(/uid/i.test(descs[n]) && /renumber/.test(descs[n]), n + " teaches UID-first — the id renumbers");
  assert(/compact/i.test(descs.read_plan) && /full/.test(descs.read_plan), "read_plan says which detail when");
  for (const tool of byId[2].result.tools) assert(tool.inputSchema && tool.inputSchema.type === "object" && tool.description, tool.name + " has a schema and a description");
  const plan = JSON.parse(byId[3].result.content[0].text);
  assert.strictEqual(plan.tasks.length, 5);
  assert.strictEqual(byId[4].error.code, -32601, "an unknown method is a JSON-RPC error, not a crash");
  assert.deepStrictEqual(byId[5].result, {});
  assert(!out.some(m => m.id === undefined), "the notification got no answer");
  fs.rmSync(dir, { recursive: true, force: true });
});

console.log("\n-- Two editions, one file --");

/* The free build is this file with one line flipped. These check the flip
   itself, because a packager that quietly matches nothing would produce a
   "free" build that is the full app — and nobody would notice until somebody
   downloaded it. Running the free edition end to end needs a second vm
   context and is a browser check for now. */
const EDITION_FROM = 'const EDITION = "pro";';
const EDITION_TO = 'const EDITION = "free";';

t("the edition is one line, and the packager matches it exactly", () => {
  assert.strictEqual(html.split(EDITION_FROM).length - 1, 1,
    "exactly one edition flag, or make-free.js cannot know which to flip");
  const packer = fs.readFileSync(__dirname + "/license/make-free.js", "utf8");
  assert(packer.includes("const FROM = '" + EDITION_FROM + "'"),
    "the packager looks for the very string the app declares");
  assert(packer.includes("const TO   = '" + EDITION_TO + "'"));
});

t("flipping it changes that line and nothing else", () => {
  const free = html.replace(EDITION_FROM, EDITION_TO);
  assert.strictEqual(free.length, html.length + (EDITION_TO.length - EDITION_FROM.length));
  assert.strictEqual(free.split(EDITION_TO).length - 1, 1);
  assert(!free.includes(EDITION_FROM), "no pro flag survives in the free build");
});

t("the five views the free edition does without are named once, as data", () => {
  eqArr(A.PRO_VIEWS, ["res", "team", "report", "cockpit", "pres"]);
  // in a pro build every view is allowed, which is the other half of the claim
  for (const v of ["dash", "table", "gantt", "zoom", ...A.PRO_VIEWS])
    assert.strictEqual(A.viewAllowed(v), true, v);
});

/* Hiding the buttons is not enough: a view can be reached by a saved view, a
   launch parameter or a keyboard shortcut, none of which press anything. */
t("setView asks whether the edition has the view, before anything else", () => {
  const body = html.slice(html.indexOf("function setView(v) {"));
  const guard = body.indexOf("viewAllowed(v)");
  const snapshotCheck = body.indexOf('v === "cockpit" && state.snapshot');
  assert(guard > 0 && guard < snapshotCheck, "the edition gate comes first");
});

t("a free copy opens one fixed file, and makes it if it is not there", () => {
  assert.strictEqual(A.freeFile(), "project.gantora");
  const body = html.slice(html.indexOf("async function openFirstProject()"));
  const cut = body.indexOf("listProjectFiles");
  assert(body.indexOf("isFree()") > 0 && body.indexOf("isFree()") < cut,
    "the fixed-file path is taken before the folder is ever listed");
  /* The evidence moved with the storage seam and the behaviour did not: creating
     is what writeText() does — it opens the name with `create: true` and there is
     a test on the adapter for that. This assertion follows the call rather than
     the literal, which is the only reason it was edited during a refactor whose
     rule is not to edit tests. */
  assert(body.slice(0, cut).includes("storage.writeText(want"),
    "and the file is created on a first run");
  const adapter = html.slice(html.indexOf("const FsaAdapter = {"));
  assert(/async writeText\(name, text\) \{[\s\S]{0,200}create: true/.test(adapter),
    "…which depends on writeText() creating what is not there");
});

/* A licence is a receipt for the full edition. Offering a free copy somewhere
   to paste one is an offer that leads nowhere, and showing a name a free copy
   is not for would be worse. */
t("the free edition neither offers a licence nor reads one", () => {
  const body = html.slice(html.indexOf("async function checkLicence("));
  const guard = body.indexOf("isFree()");
  assert(guard > 0 && guard < body.indexOf("storedLicence()"),
    "a licence in this browser is ignored before it is ever read");

  const ui = html.slice(html.indexOf("function applyEdition()"), html.indexOf("/* ============================== The licence"));
  assert(ui.includes('$("licRow")') && ui.includes('$("btnLicClear")'),
    "and the box and its remove button go with the rest");

  const sync = html.slice(html.indexOf("function syncLicenceUI()"));
  assert(/isFree\(\) \|\| licence\.status === "licensed"/.test(sync),
    "syncLicenceUI must not put the box back on a free copy");
});

/* The one answer a one-file edition cannot honestly offer. */
t("the free edition does not offer a copy it could never open again", () => {
  const ui = html.slice(html.indexOf("function applyEdition()"), html.indexOf("/* ============================== The licence"));
  assert(ui.includes('"btnConflictCopy"'), "the copy answer goes with the several-files features");
  assert(!ui.includes('"btnConflictOver"') && !ui.includes('"btnConflictMerge"'),
    "and the two that resolve one plan against its own newer self stay");
});

t("a shared page says where it came from", () => {
  const body = html.slice(html.indexOf("function buildSnapshot("));
  assert(/isFree\(\)\s*\?\s*SHARE_CREDIT_FREE/.test(body), "free pages carry the product line");
  assert(body.includes("licensedTo()"), "licensed ones carry the licensee instead");
  assert(A.SHARE_CREDIT_FREE.includes("Gantora"), A.SHARE_CREDIT_FREE);
});

console.log("\n-- The licence --");

/* A licence signed by license/make-license.js, checked by the app's own code.
   This is the compatibility gate the CLI spec asks for: the CLI signs through
   WebCrypto and so does this, so a signature that passes here passes in Edge.
   Written out by hand rather than read off disk, because the private key is
   not in the repository and this has to run on any clone. */
const LIC_REAL = [
  "GANTORA LICENSE v1",
  "Licensed to|Cofidis Magyarországi Fióktelepe",
  "Issuer|Online Factory Kft.",
  "Seats|25",
  "Type|Partner",
  "Updates until|forever",
  "Issued|2026-08-13",
  "Order|NFR-0001",
  "",
  "---",
  "HABC8C01n/PJ5Yfr95xAaxiqkEZriGUfaYhdKEBmt/FpOQix4+11zb7zpKWPDz/jChPpLj/HVHATLXGx5tay4w==",
  "",
].join("\n");

ta("a licence signed by the CLI verifies through the app's own check", async () => {
  const out = await A.checkLicence(LIC_REAL);
  assert.strictEqual(out.status, "licensed",
    "the real signature must verify, or the CLI and the app disagree");
  assert.strictEqual(out.fields["Licensed to"], "Cofidis Magyarországi Fióktelepe");
  assert.strictEqual(out.fields["Issuer"], "Online Factory Kft.");
  assert.strictEqual(out.fields["Type"], "Partner");
  assert.strictEqual(A.licensedTo(), "Cofidis Magyarországi Fióktelepe");
});

ta("one changed character and the licence stops being one", async () => {
  const tampered = LIC_REAL.replace("Seats|25", "Seats|99");
  const out = await A.checkLicence(tampered);
  assert.strictEqual(out.status, "invalid", "the name cannot be re-badged without the private key");
  assert.strictEqual(A.licensedTo(), "", "and nothing outgoing claims a licence it does not have");
});

ta("no licence at all is Free, which is a product rather than a fault", async () => {
  const out = await A.checkLicence("");
  assert.strictEqual(out.status, "free");
  A.setLang("en");
  assert.strictEqual(A.licenceText(), "Free");
});

/* 🔴 What this copy costs, said ONCE, in the one place somebody goes to look it
   up (licence plan §1). The line between saying it and nagging is where it is
   said and to whom: About only, never in the way of anything, and never at
   somebody who has already paid. */
ta("About says what a copy costs — and stops saying it the moment a licence is in", async () => {
  A.setLang("en");
  try {
    await A.checkLicence("");
    A.syncLicenceUI();
    assert.strictEqual(els.licNote.style.display, "", "an unlicensed copy is told the terms");
    const said = html.slice(html.indexOf('id="licNote"'), html.indexOf('id="licNote"') + 260);
    assert(/free for personal use/i.test(said) && /Business licence/i.test(said),
      "…and the sentence is the approved one: " + said);
    assert(/gantora\.net/.test(said), "with somewhere to go");

    await A.checkLicence(LIC_REAL);
    A.syncLicenceUI();
    assert.strictEqual(els.licNote.style.display, "none",
      "🔴 a licensed copy has answered the question — repeating the terms at it would be a nag");

    /* 🔴 And the other half of the same principle, which the app has always
       kept: the ABSENCE of a licence is marked nowhere a reader can see. The
       word appears in three comments explaining exactly that, and nowhere in
       anything drawn — so the search is of the markup, not of the source. */
    const shown = html.replace(/\/\*[\s\S]*?\*\//g, "").replace(/<!--[\s\S]*?-->/g, "");
    assert(!/unlicensed/i.test(shown), "no copy is ever stamped as unlicensed");
  } finally { await A.checkLicence(""); A.syncLicenceUI(); }
});

ta("a licence that is not one at all is refused before any crypto runs", async () => {
  for (const junk of ["hello", "GANTORA LICENSE v9\nx|1\n\n---\nzzz\n", "GANTORA LICENSE v1\nno separator"]) {
    const out = await A.checkLicence(junk);
    assert.strictEqual(out.status, "invalid", JSON.stringify(junk.slice(0, 20)));
  }
});

/* The same forward-compatibility the CLI has: a field this build has never
   heard of must not make an otherwise good licence unreadable. */
t("the reader keys on the field name and skips what it does not know", () => {
  const p = A.parseLicence("GANTORA LICENSE v1\nLicensed to|X\nX-Future|1\n\n---\nAA==\n");
  assert.strictEqual(p.fields["Licensed to"], "X");
  assert.strictEqual(p.fields["X-Future"], "1");
  assert.strictEqual(A.parseLicence("nonsense"), null);
});

ta("what the About line says, in each of the three states", async () => {
  A.setLang("en");
  await A.checkLicence(LIC_REAL);
  const line = A.licenceText();
  assert(line.includes("Cofidis Magyarországi Fióktelepe"), line);
  assert(line.includes("Partner"), "a partner copy says so: " + line);
  /* Not the seat count. Nothing in the app counts against it — no server, no
     telemetry — so printing it would claim a limit that is not kept. */
  assert(!line.includes("25"), "the seat count is not advertised: " + line);
  assert.strictEqual(A.licence.fields["Seats"], "25", "though the licence still carries it");

  await A.checkLicence(LIC_REAL.replace("Seats|25", "Seats|26"));
  assert.strictEqual(A.licenceText(), "Licence file not valid",
    "a broken signature is not quietly downgraded to Free");

  await A.checkLicence("");
  assert.strictEqual(A.licenceText(), "Free");
});

/* The licensee's name has to reach the things a copy produces, or the whole
   arrangement is a line in a dialog nobody opens. */
/* The licence reaches its owner as text in an email and arrives through the
   clipboard. A Windows round trip turns every LF into CRLF, which changes the
   bytes the signature was made over — so the reader normalises before it
   checks, and this is the test that says so. */
ta("a licence pasted with Windows line endings still checks out", async () => {
  const crlf = LIC_REAL.replace(/\n/g, "\r\n");
  assert(crlf.includes("\r\n"), "the fixture really is CRLF");
  const out = await A.checkLicence(crlf);
  assert.strictEqual(out.status, "licensed", "a clipboard round trip must not break it");
});

ta("pasting a licence keeps it, and only when it verifies", async () => {
  ctx.localStorage.clear();
  const wasToast = els.toast.textContent;
  // reached through getElementById: the stub only exists once something asks for it
  const box = ctx.document.getElementById("licText");

  box.value = "not a licence at all";
  assert.strictEqual(await A.applyLicenceText(box.value), false);
  assert.strictEqual(ctx.localStorage.getItem("ppp.lic"), null,
    "a licence that does not verify is never stored, or every later start would report it");

  box.value = LIC_REAL;
  assert.strictEqual(await A.applyLicenceText(box.value), true);
  // stored trimmed and LF-normalised, which is what the reader checks against
  assert.strictEqual(ctx.localStorage.getItem("ppp.lic"), LIC_REAL.trim(), "kept beside the config");
  assert.strictEqual(box.value, "", "and the box is emptied once it is in");
  assert(/Cofidis/.test(els.toast.textContent), els.toast.textContent);

  /* The point of storing it: the next start reads it back and the copy is
     still licensed without anybody pasting anything. */
  const back = await A.checkLicence();
  assert.strictEqual(back.status, "licensed", "it survives a restart");
  assert.strictEqual(A.licensedTo(), "Cofidis Magyarországi Fióktelepe");

  els.toast.textContent = wasToast;
});

ta("removing the licence goes back to Free rather than to broken", async () => {
  ctx.localStorage.clear();
  await A.applyLicenceText(LIC_REAL);
  const wasConfirm = ctx.confirm;
  ctx.confirm = () => true;
  try {
    assert.strictEqual(await A.clearLicenceEntry(), true);
    assert.strictEqual(ctx.localStorage.getItem("ppp.lic"), null);
    assert.strictEqual(A.licence.status, "free");
    assert.strictEqual(A.licensedTo(), "");
  } finally { ctx.confirm = wasConfirm; }
});

/* The delivery model the plan settled on: one generic build for everybody,
   the licence pasted in. Nothing may read a licence out of the page itself —
   that route was considered and dropped, and a leftover reader would quietly
   reintroduce per-recipient packaging. */
t("the app takes its licence from storage, not from the page", () => {
  assert(!/GANTORA_LICENSE/.test(html), "no baked-in licence reader survives");
  assert(html.includes('localStorage.getItem(LIC_KEY)'), "it reads the one beside the config");
  assert(html.includes('id="licText"'), "and there is a box to paste one into");
});

/* Everything About offers that assumes you are *running* Gantora goes, in a
   copy that is a document. The name is written beside an edit and nothing here
   can be edited; File access reaches a folder that will never exist; the
   settings file belongs to the copy the reader runs themselves. Language and
   the theme stay: those are about the screen in front of them. */
t("a shared copy stops offering the settings of an installation", () => {
  const was = A.state.snapshot;
  A.state.snapshot = true;
  A.syncSnapshotUI();
  for (const id of ["aboutMe", "cfgRowMode", "cfgRowFile"])
    assert.strictEqual(ctx.document.getElementById(id).style.display, "none",
      id + " should be gone from a shared copy");
  /* Not the language: somebody sent this to a reader who may not share their
     own. Asserted so a later sweep does not take it with the rest. */
  assert.notStrictEqual(ctx.document.getElementById("cfgLang").style.display, "none",
    "the language picker stays");
  A.state.snapshot = was;
});

/* Whoever opens a shared page was sent a plan to read. They are not running
   Gantora, so there is nothing for a licence to be about — and the storage a
   check would read is theirs, not the sender's. */
ta("a shared copy neither asks for a licence nor reads the reader's", async () => {
  const was = A.state.snapshot;
  ctx.localStorage.clear();
  await A.applyLicenceText(LIC_REAL);                 // the *reader* is a licensee too
  assert.strictEqual(A.licence.status, "licensed", "set up: they have their own licence");

  A.state.snapshot = true;
  const out = await A.checkLicence();
  assert.strictEqual(out.status, "free",
    "a plan a colleague sent must not be stamped with the reader's own company");
  assert.strictEqual(A.licensedTo(), "", "and nothing it exports claims that name");

  A.syncLicenceUI();
  for (const id of ["aboutLicenceLabel", "aboutLicence", "licRow", "btnLicClear"])
    assert.strictEqual(ctx.document.getElementById(id).style.display, "none",
      id + " should be gone from a shared copy");

  A.state.snapshot = was;
  ctx.localStorage.clear();
  await A.checkLicence("");
});

ta("the licensee travels out with the Excel export", async () => {
  loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-06" }]);
  const rows = A.xlRows();
  await A.checkLicence(LIC_REAL);
  const withLic = A.spreadsheetML(rows, "Plan");
  assert(withLic.includes("<Company>Cofidis Magyarországi Fióktelepe</Company>"), "named in the workbook");
  /* In DocumentProperties rather than as a row above the header: a row would
     be read back as the header by our own importer and break the round trip. */
  assert(withLic.indexOf("<Company>") < withLic.indexOf("<Table"), "and above the table, not inside it");

  await A.checkLicence("");
  assert(!A.spreadsheetML(rows, "Plan").includes("<Company>"),
    "a free copy claims nobody");
});

console.log("\n-- Cockpit: the plan watched while it happens --");

/* A cutover at 21:00, on **today**. The hour is fixed so the window maths does
   not drift with the clock; the *day* cannot be, because healthOf() reads the
   real date and nothing here passes it one. Pinned to a literal day, this whole
   block passed on the day it was written and then began failing the next
   morning: a Planned row dated yesterday is Overdue, which is an alert, and
   "one alert" became two. Today, that same row is Late Start — which is what
   the fixture meant by a task that has not begun.
   Times are given as hours because that is what the view is about; every task
   carries a uid, since the lane rows are remembered by it. */
const CK_NOW = new Date();
const CK_DAY = `${CK_NOW.getFullYear()}-${String(CK_NOW.getMonth() + 1).padStart(2, "0")}-${String(CK_NOW.getDate()).padStart(2, "0")}`;
const ckAt = (h, m) => new Date(CK_NOW.getFullYear(), CK_NOW.getMonth(), CK_NOW.getDate(), h, m || 0, 0, 0).getTime();
let ckUid = 0;
const ckTask = (o) => Object.assign({
  id: ++ckUid, uid: "u" + ckUid, name: "T" + ckUid,
  start: CK_DAY, end: CK_DAY, hourStart: "", hourEnd: "",
  group: [], preds: [], status: "Planned", history: [], notes: "",
  team: "", system: "", modified: "",
}, o || {});

/* 🔴 The fixture starts from a PLAIN board: Focus off, so what a test sees is
   every task it wrote. Focus ships ON (Daniel, 2026-08-23) and that is asserted
   from the source below — a default and a fixture are different questions, and
   leaving the first to leak into the second made a lane-counting test fail for
   a reason that had nothing to do with lanes. */
const ckPlan = (tasks) => { ckUid = 0; A.state.tasks = tasks; A.state.cockRows.clear(); A.state.cockFocus = false; };

t("two tasks share a lane row when they do not overlap, and open a new one when they do", () => {
  const apart = [{ uid: "a", from: ckAt(20), to: ckAt(20, 30) },
                 { uid: "b", from: ckAt(21), to: ckAt(21, 30) }];
  assert.strictEqual(A.cockPack(apart, null), 1, "one row holds both");
  assert.strictEqual(apart[0].row, 0);
  assert.strictEqual(apart[1].row, 0);

  const over = [{ uid: "a", from: ckAt(20), to: ckAt(21) },
                { uid: "b", from: ckAt(20, 30), to: ckAt(21, 30) }];
  assert.strictEqual(A.cockPack(over, null), 2, "an overlap opens a second row");
  assert.strictEqual(over[0].row, 0);
  assert.strictEqual(over[1].row, 1);
});

/* The difference between a chart and an instrument: a bar that is running must
   not jump to another row while somebody is watching it. */
t("a task keeps the row it was given, even when it would now pack higher", () => {
  const remembered = new Map([["b", 3]]);
  const items = [{ uid: "a", from: ckAt(20), to: ckAt(21) },
                 { uid: "b", from: ckAt(22), to: ckAt(23) }];
  assert.strictEqual(A.cockPack(items, remembered), 4, "row 3 is honoured, so the lane is four deep");
  assert.strictEqual(items[1].row, 3, "and b stayed where it was");

  /* Every remembered row is honoured before any newcomer is placed, so a bar
     already on screen never moves to make room for one arriving. Two tasks
     remembering the *same* row is the one case that cannot be honoured twice:
     the earlier keeps it and the later is packed afresh rather than drawn on
     top of it. */
  const taken = new Map([["a", 0], ["b", 0]]);
  const clash = [{ uid: "a", from: ckAt(20), to: ckAt(22) },
                 { uid: "b", from: ckAt(21), to: ckAt(23) }];
  A.cockPack(clash, taken);
  assert.strictEqual(clash[0].row, 0, "the earlier one keeps the row");
  assert.strictEqual(clash[1].row, 1, "and the later gives it up rather than overlap");
});

t("the seven health buckets map onto the five states the cockpit draws", () => {
  const of = (o) => A.cockStateOf(ckTask(o));
  assert.strictEqual(of({ status: "Done" }), "done");
  assert.strictEqual(of({ status: "In Progress", start: CK_DAY, end: "2099-01-01" }), "run");
  assert.strictEqual(of({ status: "Blocked" }), "late", "blocked is red");
  assert.strictEqual(of({ status: "Planned", start: "2020-01-01", end: "2020-01-02" }), "late", "and so is overdue");
  assert.strictEqual(of({ status: "On Hold" }), "risk", "paused on purpose is amber, not red");
  assert.strictEqual(of({ status: "Planned", start: "2099-01-01", end: "2099-01-02" }), "wait");
  /* Every bucket the app has must be named in the map. An unmapped one falls
     through to "waiting", which would draw a problem as a task that has not
     started yet — the quietest way to under-report one. */
  for (const key of A.HEALTH.map(h => h.key))
    assert(["done", "run", "risk", "late", "wait"].includes(A.COCK_STATES[key]),
      'the "' + key + '" bucket is not mapped to a cockpit state');
});

t("a task with no hour fills its whole day, so a day-scale plan still draws", () => {
  const win = { from: ckAt(0), to: ckAt(23, 59), len: ckAt(23, 59) - ckAt(0) };
  const bar = A.cockBar(ckTask({ start: CK_DAY, end: CK_DAY }), win);
  assert(bar, "it is in the window");
  const hours = (bar.to - bar.from) / 3600000;
  assert(hours > 23, "it spans the day rather than a point: " + hours.toFixed(1) + "h");

  const timed = A.cockBar(ckTask({ start: CK_DAY, end: CK_DAY, hourStart: "21:00", hourEnd: "21:30" }), win);
  assert.strictEqual((timed.to - timed.from) / 60000, 30, "and an hour-carrying task is half an hour long");
});

t("the window hangs off now, with the now-line a third of the way in", () => {
  A.state.cockSpan = "2h";
  const win = A.cockWindow(ckAt(21));
  assert.strictEqual(win.len, 120 * 60000, "two hours wide");
  const before = (win.now - win.from) / win.len;
  assert(Math.abs(before - A.COCK_NOW_AT) < 1e-9, "the past takes the left third");
  assert.strictEqual(A.cockTicks(win).length, 5, "five marks under the lanes");
});

/* 🔴 Cockpit brief §8/3 — how the board OPENS. Daniel: the window is three
   days, and the format is the team view when the plan is written by team.
   Decided rather than fixed, and decided by READING: `cockBy` is empty until
   somebody presses a button, and while it is empty the plan answers. */
t("the cockpit opens on three days, and grouped the way the plan is written", () => {
  /* The state as it STARTS, read from the source: by the time this runs, other
     tests have pressed the span buttons, and what they left behind is not what
     the app opens with. */
  assert(/\n  cockSpan: "3d",/.test(html),
    "two hours is a cutover's window, not a week's work");
  /* 🔴 And Focus is ON (Daniel, 2026-08-23: "focus legyen default szinten").
     What is already finished is not part of "what is happening around now" — it
     is the reassuring half of the screen, and it pushes the half that needs
     attention off the bottom. Nothing is hidden silently: the count stays on
     the button and one press brings it back. */
  assert(/\n  cockFocus: true,/.test(html), "the board opens on what still needs doing");

  const was = A.state.cockBy;
  try {
    A.state.cockBy = "";
    ckPlan([ckTask({ name: "Alone" })]);
    assert.strictEqual(A.cockDefaultBy(), "group", "no team on any row: the lanes are the main tasks");
    assert.strictEqual(A.cockAxis(), "group");

    ckPlan([ckTask({ name: "Alone" }), ckTask({ name: "Wired", team: "Build" })]);
    assert.strictEqual(A.cockAxis(), "team", "one team anywhere is a plan organised by team");
    assert.strictEqual(A.state.cockBy, "", "🔴 …and reading the answer must not WRITE it: the plan may still change");

    /* The reader's own press is what sticks — and from then on the plan stops
       answering, which is the whole difference between a default and a rule. */
    A.setCockBy("group");
    assert.strictEqual(A.state.cockBy, "group");
    assert.strictEqual(A.cockAxis(), "group", "a plan full of teams no longer overrides the choice");
  } finally { A.state.cockBy = was; }
});

/* 🔴 §8/2 — the third axis. One lane per name: whoever is accountable, or the
   first responsible when nobody is, and the unnamed work last. */
t("the person axis puts a lane under whoever is carrying the work", () => {
  const was = A.state.cockBy;
  try {
    const acc = ckTask({ name: "Owned", accountable: "Anna", responsible: "Bela" });
    const resp = ckTask({ name: "Helped", accountable: "", responsible: "Cili:4" });
    const none = ckTask({ name: "Nobody's" });
    assert.strictEqual(A.cockPersonOf(acc), "Anna", "accountable first — it is the question being asked");
    assert.strictEqual(A.cockPersonOf(resp), "Cili", "…and the first responsible when nobody is accountable");
    assert.strictEqual(A.cockPersonOf(none), "", "neither: the lane at the bottom");

    ckPlan([acc, resp, none]);
    A.state.cockBy = "person";
    const lanes = A.cockpitModel(ckAt(21)).lanes.map(l => l.name);
    assert(lanes.length >= 2, "a lane each: " + lanes.join(" | "));
    assert.strictEqual(lanes[lanes.length - 1], A.COCK_NO_TEAM,
      "the unnamed work is last, not first — it is the leftover, not the headline");
  } finally { A.state.cockBy = was; }
});

/* 🔴 §7 — the Accountable filter. Daniel: "mivel Cockpitben nem lehet emberre
   szűrni, ezért ezt nem látom". It matches Accountable OR Responsible: the
   question is who carries the item, but dropping a responsible-only match
   would lie about the load. */
t("the cockpit's Accountable filter narrows every axis, and every count with it", () => {
  const wasBy = A.state.cockBy, wasAcc = A.state.cockAcc;
  try {
    const hers = ckTask({ name: "Hers", accountable: "Anna" });
    const helps = ckTask({ name: "Helps", accountable: "Bela", responsible: "Anna:2" });
    const his = ckTask({ name: "His", accountable: "Bela" });
    ckPlan([hers, helps, his]);
    A.state.cockBy = "group"; A.state.cockAcc = "";

    const all = A.cockpitModel(ckAt(21));
    const namesOf = m => m.lanes.flatMap(l => l.bars.map(b => b.task.name)).sort();
    eqArr(namesOf(all), ["Helps", "Hers", "His"], "unfiltered: everything in the window");

    A.state.cockAcc = "Anna";
    const mine = A.cockpitModel(ckAt(21));
    eqArr(namesOf(mine), ["Helps", "Hers"],
      "🔴 accountable OR responsible — dropping the second would lie about the load");

    /* The filter is a filter, not an axis: it narrows whatever the board is
       grouped by (the §6.2 lesson, which cost a round when it was merged). */
    A.state.cockBy = "person";
    eqArr(namesOf(A.cockpitModel(ckAt(21))), ["Helps", "Hers"], "…and it holds on the person axis too");

    A.state.cockAcc = "Nobody At All";
    assert.strictEqual(namesOf(A.cockpitModel(ckAt(21))).length, 0, "a name nobody carries empties the board");
  } finally { A.state.cockBy = wasBy; A.state.cockAcc = wasAcc; }
});

t("lanes are the top-level groups, and nothing is left off the screen", () => {
  ckPlan([]);
  const a = ckTask({ name: "Backup", status: "Done" });
  const b = ckTask({ name: "Migration", status: "Blocked", notes: "schema timeout" });
  const db = ckTask({ name: "Database", group: [a.id, b.id] });
  // inside the two-hour window the cockpit is showing at 21:00
  const loose = ckTask({ name: "DNS", status: "Planned", hourStart: "21:30", hourEnd: "21:40" });
  A.state.tasks = [db, a, b, loose];

  const m = A.cockpitModel(ckAt(21));
  assert.strictEqual(m.lanes.length, 2, "the group, and a lane for what is in no group");
  assert.strictEqual(m.lanes[0].name, "Database");
  assert.strictEqual(m.lanes[0].count, 2);
  assert.strictEqual(m.lanes[1].name, "", "the loose lane is named at render time, not here");
  assert.strictEqual(m.worst, "red", "a blocked task makes the lamp critical");
  assert.strictEqual(m.alerts.length, 1);
  assert.strictEqual(m.alerts[0].name, "Migration");
  assert.strictEqual(m.alerts[0].why, "schema timeout", "the first line of Notes is the reason");
  assert(m.alerts[0].blocked, "and it says BLOCKED rather than a number of minutes");
  assert.strictEqual(m.done.length, 1);
  assert.strictEqual(m.done[0].name, "Backup");
});

/* The span picker is the scope, not just the scale: it decides which tasks the
   whole board is about. */
t("only what is running in the chosen period is counted, groups excluded", () => {
  A.state.cockSpan = "2h";                        // 20:19 to 22:19 around 21:00
  ckPlan([]);
  const now = ckTask({ name: "Now", hourStart: "20:50", hourEnd: "21:20" });
  const soon = ckTask({ name: "Soon", hourStart: "22:00", hourEnd: "22:10" });
  const gone = ckTask({ name: "Gone", hourStart: "06:00", hourEnd: "07:00" });
  const later = ckTask({ name: "Later", hourStart: "23:30", hourEnd: "23:45" });
  const across = ckTask({ name: "Across", hourStart: "18:00", hourEnd: "23:00" });
  const dayLong = ckTask({ name: "AllDay" });      // no hours at all: 00:00 to 23:59
  const grp = ckTask({ name: "Stream", group: [now.id, soon.id] });
  A.state.tasks = [grp, now, soon, gone, later, across, dayLong];

  const m = A.cockpitModel(ckAt(21));
  const names = m.lanes.flatMap(l => l.bars.map(b => b.task.name)).sort();
  /* "Across" and "AllDay" straddle the window rather than starting or ending
     in it, and both are plainly running during it — the strict reading would
     empty the board on any plan whose Hour columns are blank. */
  eqArr(names, ["Across", "AllDay", "Now", "Soon"]);
  assert(!names.includes("Gone") && !names.includes("Later"), "outside the period, so not counted");
  assert(!names.includes("Stream"), "a group is a lane here, never a bar — its dates are its members'");

  /* Widening the span widens what is considered, because they are the same
     question asked once. Note the window is not centred: the now-line stands a
     third of the way in, so a day-wide span reaches about eight hours back and
     sixteen forward — 06:00 is still behind it. */
  A.state.cockSpan = "24h";
  A.state.cockRows.clear();
  const wide = A.cockpitModel(ckAt(21));
  const wideNames = wide.lanes.flatMap(l => l.bars.map(b => b.task.name));
  assert(wideNames.includes("Later"), "the rest of the night comes into view");
  assert(!wideNames.includes("Gone"), "but this morning is still behind the window");
  A.state.cockSpan = "2h";
});

/* ---- The cockpit's second half (Daniel's brief, 2026-08-21) ---------------
   A calendar window, a second axis, and a switch that puts finished work away.
   The three are independent and have to compose: the brief asks for the time
   filter and Focus to be an AND, and for both to hold on either axis. */
/* 🔴 This asserted the opposite until 2026-08-22: 1w was Monday to Sunday and
   1m was the 1st to the 1st, anchored to the calendar. Daniel, looking at it on
   the phone — "1w és 1m cockpit nézet nem jól van osztva, mai napnak középen
   kell lennie": on Saturday the 22nd the now-line sat at 86% of the week and
   70% of the month, so both boards were nearly all past and the work ahead was
   off the right-hand edge. Every span but "all" divides the same way now. */
t("every span in the picker divides the same way — today is not at the edge", () => {
  ckPlan([ckTask({})]);
  const was = A.state.cockSpan;
  try {
    for (const key of ["2h", "6h", "24h", "3d", "1w", "1m"]) {
      A.state.cockSpan = key;
      const w = A.cockWindow(ckAt(21));
      assert.strictEqual(w.at, A.COCK_NOW_AT, key + ": the now-line moved — " + w.at);
      assert(w.from <= ckAt(21) && ckAt(21) < w.to, key + ": the window has to contain now");
      /* Room ahead is the point of the instrument: whatever the span, most of
         the board is what has not happened yet. */
      assert(w.to - ckAt(21) > ckAt(21) - w.from, key + ": more past than future on screen");
    }
    A.state.cockSpan = "1w";
    assert.strictEqual(Math.round(A.cockWindow(ckAt(21)).len / 86400000), 7, "a week is seven days");
    A.state.cockSpan = "1m";
    assert.strictEqual(Math.round(A.cockWindow(ckAt(21)).len / 86400000), 30, "a month is thirty");

    /* "All" is the one exception, and it has to be: it is the plan's own
       extent, so the line lands wherever today falls inside it. */
    A.state.cockSpan = "all";
    assert(A.cockWindow(ckAt(21)).at !== A.COCK_NOW_AT, "'all' is measured, not hung off now");
  } finally { A.state.cockSpan = was; }
});

t("\"All\" is the plan's own extent, and an undated plan still draws", () => {
  const was = A.state.cockSpan;
  try {
    A.state.cockSpan = "all";
    ckPlan([ckTask({ start: "2026-03-02", end: "2026-03-04" }),
            ckTask({ start: "2026-05-10", end: "2026-05-12" })]);
    const w = A.cockWindow(ckAt(21));
    assert(w.from <= new Date("2026-03-02T00:00:00").getTime(), "it reaches the first task");
    assert(w.to >= new Date("2026-05-12T00:00:00").getTime(), "…and the last");

    ckPlan([]);
    const empty = A.cockWindow(ckAt(21));
    assert(empty.len > 0, "an empty plan is a month, not a division by zero: " + empty.len);
    assert(empty.at >= 0 && empty.at <= 1, "and the now-line stays on the board");
  } finally { A.state.cockSpan = was; }
});

t("the ticks stop being clock times once the window is longer than a day and a half", () => {
  const was = A.state.cockSpan;
  try {
    A.state.cockSpan = "2h";
    ckPlan([ckTask({})]);
    assert(/^\d\d:\d\d$/.test(A.cockTicks(A.cockWindow(ckAt(21)))[0]), "hours on a short window");
    A.state.cockSpan = "1m";
    /* Five "00:00"s in a row is what this replaces — a clock on a month-long
       window says nothing at all. */
    assert(/^\d\d-\d\d$/.test(A.cockTicks(A.cockWindow(ckAt(21)))[0]), "dates on a long one");
  } finally { A.state.cockSpan = was; }
});

t("the team axis changes what a lane stands for, and nothing else", () => {
  const was = A.state.cockSpan, wasBy = A.state.cockBy;
  try {
    A.state.cockSpan = "24h";
    const a1 = ckTask({ name: "Wiring", team: "Sparks" });
    const a2 = ckTask({ name: "Sockets", team: "Sparks" });
    const b1 = ckTask({ name: "Screed", team: "Floors" });
    const none = ckTask({ name: "Snagging", team: "" });
    const grp = ckTask({ name: "Fit-out", group: [a1.id, a2.id, b1.id] });
    ckPlan([grp, a1, a2, b1, none]);

    A.state.cockBy = "team";
    A.state.cockRows.clear();
    const m = A.cockpitModel(ckAt(21));
    const lanes = m.lanes.map(l => l.name);
    assert.strictEqual(lanes.length, 3, "one lane per team, plus the nameless one: " + lanes.join(", "));
    assert.strictEqual(lanes[0], "Sparks", "named teams in the order the plan mentions them");
    assert.strictEqual(lanes[1], "Floors");
    assert.strictEqual(lanes[2], A.COCK_NO_TEAM, "🔴 and work with nobody's team on it goes LAST");
    /* 🔴 The group summary is a concept of the other axis. Here the members
       speak, each in the lane of its own team — a "Fit-out" lane would put the
       same work on the board twice. */
    const names = m.lanes.flatMap(l => l.bars.map(b => b.task.name));
    assert(!names.includes("Fit-out"), "the group is not a lane here");
    assert(names.includes("Wiring") && names.includes("Screed") && names.includes("Snagging"),
      "every leaf is somewhere: " + names.join(", "));
  } finally { A.state.cockSpan = was; A.state.cockBy = wasBy; A.state.cockRows.clear(); }
});

t("Focus puts finished work away, says how much, and holds on both axes", () => {
  const was = A.state.cockSpan, wasBy = A.state.cockBy, wasF = A.state.cockFocus;
  try {
    A.state.cockSpan = "24h";
    ckPlan([ckTask({ name: "Done1", status: "Done", team: "Sparks" }),
            ckTask({ name: "Done2", status: "Done", team: "Floors" }),
            ckTask({ name: "Live", status: "In Progress", team: "Sparks", end: "2099-01-01" })]);

    A.state.cockFocus = false;
    assert.strictEqual(A.cockpitModel(ckAt(21)).total, 3);
    assert.strictEqual(A.cockpitModel(ckAt(21)).hidden, 0, "nothing hidden, nothing to say");

    A.state.cockFocus = true;
    A.state.cockRows.clear();
    const m = A.cockpitModel(ckAt(21));
    assert.strictEqual(m.total, 1, "only what is still going");
    /* 🔴 The count is not decoration: rows disappearing with no sign of it
       reads as work having been lost. */
    assert.strictEqual(m.hidden, 2, "…and the switch can say what it is holding back");
    /* And it is one filter over the WHOLE model, not just the lanes — a corner
       count that still included finished work would be the board disagreeing
       with itself. */
    assert.strictEqual(m.done.length, 0, "the Done list follows it too");
    const names = m.lanes.flatMap(l => l.bars.map(b => b.task.name));
    eqArr(names, ["Live"]);

    A.state.cockBy = "team";
    A.state.cockRows.clear();
    const byTeam = A.cockpitModel(ckAt(21));
    assert.strictEqual(byTeam.lanes.length, 1, "a team whose work is all done has no lane left");
    assert.strictEqual(byTeam.lanes[0].name, "Sparks");
  } finally {
    A.state.cockSpan = was; A.state.cockBy = wasBy; A.state.cockFocus = wasF;
    A.state.cockRows.clear();
  }
});

/* 🔴 A 403 on a real phone (Daniel, 2026-08-21), where the OFFICIAL Dropbox app
   failed too and the browser worked. The lesson from the Keychain evening
   applies: when a failure is indistinguishable from the outside, make the app
   carry the evidence instead of asking somebody to guess. */
t("a refusal from Dropbox keeps its evidence, and 403 says what it usually means", async () => {
  const was = ctx.fetch;
  try {
    /* A blocking proxy answers with ITS OWN html — which is the decisive clue,
       and looks nothing like Dropbox's own JSON error. */
    ctx.fetch = async () => ({
      status: 403, ok: false, headers: { get: () => null },
      text: async () => "<html><head><title>403 Forbidden</title></head><body>nginx</body></html>",
    });
    A.dbxNet.lastFail = null;
    await A.dbxFetch("https://api.dropboxapi.com/2/users/get_current_account", {});
    const f = A.dbxNet.lastFail;
    assert(f, "the refusal was kept");
    assert.strictEqual(f.status, 403);
    assert.strictEqual(f.host, "api.dropboxapi.com", "…with WHERE it happened");
    assert(/nginx/.test(f.body), "…and enough of the body to tell a proxy from Dropbox: " + f.body);
    assert.strictEqual(f.json, false, "html, not Dropbox's own error shape");

    const said = A.dbxFailText(f);
    assert(/403/.test(said) && /not Dropbox that answered/i.test(said),
      "🔴 a 403 with no error_summary is not Dropbox talking: " + said);
    assert(/mobile data/i.test(said), "…and it says the thing that saved an evening: " + said);

    /* 🔴 The other 403, and telling them apart is the point (agreed with the
       design agent): Dropbox itself refusing, with a reason. That one will not
       heal by waiting, so it must NOT read as a network state — sending
       somebody to check their router while a missing scope sits in the code is
       the worst of both. */
    ctx.fetch = async () => ({
      status: 403, ok: false, headers: { get: () => null },
      text: async () => JSON.stringify({ error_summary: "missing_scope/.tag/files.content.write" }),
    });
    A.dbxNet.blocked = false; A.dbxNet.lastFail = null;
    await A.dbxFetch("https://api.dropboxapi.com/2/files/upload", {});
    assert.strictEqual(A.dbxNet.blocked, false, "Dropbox said why, so this is ours to fix");
    const real = A.dbxFailText(A.dbxNet.lastFail);
    assert(/missing_scope/.test(real), "…and its own words are shown: " + real);
    assert(!/mobile data/i.test(real), "no network hint beside a real error — it would mislead");

    /* …and a success clears nothing but also records nothing. */
    ctx.fetch = async () => ({ status: 200, ok: true, headers: { get: () => null }, text: async () => "{}" });
    const before = A.dbxNet.lastFail;
    await A.dbxFetch("https://api.dropboxapi.com/2/x", {});
    assert.strictEqual(A.dbxNet.lastFail, before, "a call that worked adds no evidence");
  } finally { ctx.fetch = was; A.dbxNet.lastFail = null; }
});

/* 🔴 Measured, not guessed (Daniel, 2026-08-21): Dropbox works on Wi-Fi and is
   refused with a 403 on mobile data — and the official Dropbox app does the same
   thing on that carrier, with two different accounts. For the person holding the
   phone that is indistinguishable from having no connection, so it has to behave
   the same way: hold the work, say it once, keep trying. */
t("a network that refuses Dropbox is a network state, not a fault", () => {
  try {
    A.dbxNet.offline = false; A.dbxNet.blocked = false;
    /* Asked of the adapter itself: `storage` is the app's own binding and does
       not travel out as a live one. */
    assert.strictEqual(A.DropboxAdapter.offline(), false);

    A.dbxNet.blocked = true;
    /* One question for the callers, because they all care about the same
       thing: the write is not going to happen. */
    assert.strictEqual(A.DropboxAdapter.offline(), true,
      "🔴 refused and unreachable are the same answer to 'can we write'");

    /* …and the sentence is different, because what to DO is different. */
    const src = html.slice(html.indexOf("function noteOffline("), html.indexOf("function retryOffline("));
    assert(/dbxNet\.blocked/.test(src), "noteOffline knows which of the two it is");
    assert(/Try Wi-Fi instead of mobile data/.test(src),
      "…and says the thing that actually helps on a carrier that blocks it");
    /* The work is never the casualty: both roads end in the plan being held. */
    assert(/nothing was lost/.test(src), "both sentences say the plan is safe");
  } finally { A.dbxNet.blocked = false; }
});

/* 🔴 "The paste button does not paste anything" (Daniel, on Megatron). His
   screenshot showed iOS's own Paste confirmation appearing, so the request DID
   reach the system — but every road out of this function was `return false`,
   silently, and a button that answers nothing is indistinguishable from one
   that is not wired up. */
ta("a paste that does not arrive leaves you able to finish it by hand", async () => {
  const was = ctx.navigator;
  try {
    els.dbxCode.value = "";
    ctx.navigator = { clipboard: { readText: async () => "ABC123" } };
    assert.strictEqual(await A.dropboxPasteCode(), true);
    assert.strictEqual(els.dbxCode.value, "ABC123", "the happy road is unchanged");

    for (const [what, nav] of [
      ["no clipboard API at all", {}],
      ["the read was refused", { clipboard: { readText: async () => { throw new Error("no"); } } }],
      ["nothing on it", { clipboard: { readText: async () => "  " } }],
    ]) {
      els.dbxCode._focused = false;
      els.toast.textContent = "";
      ctx.navigator = nav;
      assert.strictEqual(await A.dropboxPasteCode(), false, what);
      /* Two things, and both matter: it SAYS something, and it leaves the
         cursor where a hand can finish the job. */
      assert(els.toast.textContent.length > 10, what + ": said nothing — " + els.toast.textContent);
      assert.strictEqual(els.dbxCode._focused, true, what + ": the field was not put in reach");
    }
  } finally { ctx.navigator = was; }
});

/* 🔴 The ✨ became a menu on 2026-08-22, and the two paste items became one.
   The finding behind it is the sharpest kind: the paste items sat in the DB
   menu and their own commissioner did not recognise them — "what is 'paste a
   plan'?". Not a naming fault, a PLACE fault. */
t("the ✨ menu answers one question, and says which assistant is chosen", () => {
  A.setLang("en");
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.dbHandle = fakeRoot({});
  useRef(null); useName("");
  try {
    /* 🔴 Four buttons, not a box to type a name into. It WAS a box until
       2026-08-22 — Daniel found it on a phone: "ezeknek fix gomboknak kéne
       lenniük… nem látszik, hogy mi a default." A chooser you have to spell
       into is not a chooser, and a default you cannot see is not a default. */
    A.state.cfg.ai = "";
    A.syncAiMenu();
    const chips = [...els.aiWho.innerHTML.matchAll(/data-ai="([\w]+)"/g)].map(m => m[1]);
    eqArr(chips, A.AI_KEYS.concat("custom"), "all four, plus the way to give an address by hand");
    assert(!/class="on"/.test(els.aiWho.innerHTML), "nothing chosen: nothing is filled in");

    A.state.cfg.ai = "claude";
    A.syncAiMenu();
    assert(/data-ai="claude" class="on"/.test(els.aiWho.innerHTML),
      "…and the one in use is filled in, so the default is a fact you can SEE");
    assert(/🔗/.test(els.aiWho.innerHTML), "the custom address is an icon, not a fifth name");

    /* 🔴 Disabled rather than hidden: a menu whose items move around is a menu
       nobody learns, and the tooltip says why it is off. */
    A.state.dbHandle = null;
    A.syncAiMenu();
    assert.strictEqual(els.mAiPaste.disabled, true, "no folder: there is nowhere for a plan to land");
    assert(els.mAiPaste.title.length > 10, "…and it says why: " + els.mAiPaste.title);

    // the two old menu items are off the DB menu, and the way back is here
    const db = html.slice(html.indexOf('id="dbMenu"'), html.indexOf('id="fileSelect"'));
    for (const id of ["btnPastePlan", "btnPasteInto"])
      assert(/style="display:none"/.test(db.slice(db.indexOf('id="' + id + '"') - 60, db.indexOf('id="' + id + '"') + 40)),
        id + " is still on the DB menu");
  } finally { A.state.cfg.ai = ""; resetApp(); }
});

ta("the way back asks only where there is a real choice to make", async () => {
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.dbHandle = fakeRoot({});
  const was = ctx.navigator;
  const asked = [];
  const wasPaste = A.pastePlan;
  try {
    ctx.navigator = { clipboard: { readText: async () => "" } };

    /* Nothing open → it can only be a new plan, and asking would be theatre. */
    useRef(null); useName("");
    await A.pasteFromAi();
    assert.strictEqual(els.askOverlay.style.display, "none", "nothing open: no question");

    /* An EMPTY open plan → fill it. Nothing to lose, so nothing to ask. */
    useRef({}); useName("Terasz.gantora");
    A.state.tasks = [];
    await A.pasteFromAi();
    assert.strictEqual(els.askOverlay.style.display, "none", "an empty plan: no question either");

    /* 🔴 A plan WITH tasks is the one place there is a real choice, and it is
       the one place this app must never act quietly. */
    loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03" }]);
    useRef({}); useName("Terasz.gantora");
    const p = A.pasteFromAi();
    await new Promise(r => setTimeout(r, 0));
    assert.strictEqual(els.askOverlay.style.display, "flex", "a plan with work in it IS asked about");
    assert(/update/i.test(els.askLabel.textContent), "and the question names both answers: " + els.askLabel.textContent);
    A.askCancel();
    await p;
  } finally { ctx.navigator = was; useRef(null); useName(""); resetApp(); }
});

/* 🔴 The DB menu had grown to fourteen items answering four different
   questions, and the organising rule is one question per menu (menu-order
   brief, 2026-08-22). This is the half that moved: everything you can do TO a
   plan now hangs off the plan. */
t("the DB menu answers where plans live, and nothing else", () => {
  const db = html.slice(html.indexOf('id="dbMenu"'), html.indexOf('id="fileSelect"'));
  const shown = [...db.matchAll(/<button id="(\w+)"([^>]*)>/g)]
    .filter(([, , attrs]) => !/style="display:none"/.test(attrs))
    .map(([, id]) => id);
  /* Four: the folder, the two stores, a new file, and re-reading from disk.
     Everything else in there is hidden and pressed from somewhere better. */
  assert(shown.length <= 8, "the DB menu is on a diet: " + shown.join(", "));
  for (const gone of ["btnSaveAs", "btnShare", "btnImport", "btnExport", "btnCombine",
                      "btnMerge", "btnDeletePlan", "btnResetDue", "btnReadOnly",
                      "btnPastePlan", "btnPasteInto"])
    assert(!shown.includes(gone), gone + " is still offered from the DB menu");
  /* …but every one of them is still IN the page: the ⋮ and the ✨ press these
     very buttons, so there is one implementation of each. */
  for (const kept of ["btnSaveAs", "btnDeletePlan", "btnResetDue", "btnReadOnly"])
    assert(html.includes('id="' + kept + '"'), kept + " was deleted, not moved");
});

/* 🔴 The rule the live round wrote (menu brief §1b): a plan's NAME is the
   content of this menu, and content never gets abbreviated to an initial. The
   shipped switcher showed "P." and "T." with 90% of the panel empty, and the
   chosen row showed no name at all — just the tick. */
/* 🔴 Daniel's 08-23 round: "a látható menük elég hiányosak". The plan's ⋮ had
   lost Save as, Share, Import, Export, Combined view, Compare & merge and the
   repair report — all seven at once — and Delete could only appear there by
   ALSO appearing in the DB menu, where the menu brief had just removed it from.

   One cause for both. Those buttons were moved off the DB menu on 2026-08-22 by
   giving them a permanent inline `display:none`; syncMirrors() reads a hidden
   source as "not offered", so every mirror of them went with it. The display is
   a live signal — "is this offered" — and it was being used as a second thing,
   "do not draw this here". Now the drawing is the stylesheet's job (.parked)
   and the signal is left alone. */
/* 🔴 Daniel's 08-23 screenshot has the DB menu, the plan switcher and the ✨
   menu ALL open at once, overlapping each other. Every opener stops the click
   from reaching the document — it must, or the document handler would close the
   menu it has just opened — and the side effect was that it never reached the
   other menus either. */
t("opening one menu on the bar closes the rest", () => {
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.dbHandle = fakeDb({});
  A.state.snapshot = null;
  els.fileSelect.options = [{ value: "" }, { value: "Terasz.gantora" }];
  useName("Terasz.gantora"); useRef({});

  const open = id => els[id].classList.add("open");
  const isOpen = id => els[id].classList.contains("open");

  open("dbMenu"); open("listsMenu");
  A.openPlanMenu();
  assert(isOpen("planMenu"), "the switcher opened");
  assert(!isOpen("dbMenu") && !isOpen("listsMenu"),
    "🔴 two menus stayed open behind it — that is Daniel's screenshot");

  open("dbMenu");
  A.openAiMenu();
  assert(!isOpen("dbMenu"), "…and the ✨ menu closes the DB menu too");

  /* Every menu on the bar is in the list, or the next one added will be the
     one that stays open behind the others. */
  for (const id of A.BAR_MENUS) assert(els[id], "BAR_MENUS names a menu that is not in the page: " + id);
  A.closeBarMenus();
  for (const id of A.BAR_MENUS) assert(!isOpen(id), id + " would not close");
  useRef(null); useName(""); resetApp();
});

/* 🔴 The completeness audit the menu brief asks for by name (§7/4): "E nélkül
   az assert nélkül a menü-brief egyik szelete sem tekinthető késznek." The
   08-22 move lost half the inventory and nobody noticed for a day, because
   every individual test still passed — what was missing was a test that
   COUNTS. Every operation the old menus offered is enumerated here and has to
   be reachable somewhere, on a writable plan AND on a read-only one; the two
   deliberate deletions are named, so a third disappearance cannot hide among
   them. */
const MENU_INVENTORY = [
  "btnSaveAs", "btnShare", "btnImport", "btnExport", "btnCombine", "btnMerge",
  "btnDeletePlan", "btnTrash", "btnPickFolder", "btnReload", "btnDropbox",
  "btnViewTeams", "btnViewSystems", "btnViewPeople",
];
/* Off the menus on purpose (menu brief §1), and a test elsewhere asserts that
   neither is offered anywhere. They are named so that this count cannot be
   satisfied by quietly dropping a third one. */
const MENU_DELIBERATELY_GONE = ["btnResetDue", "btnReadOnly"];

t("every operation the old menus offered is still reachable — writable and read-only alike", () => {
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.dbHandle = fakeDb({});
  A.state.snapshot = null;
  A.applyEdition();
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03" }]);
  useName("Terasz.gantora"); useRef({});

  /* Where an operation can be reached from: its own button on the bar, or any
     menu that mirrors it. "Reachable" means the element is on screen — being
     DISABLED is a different statement and read-only is allowed to make it. */
  const menus = ["planOpsMenu", "mMoreMenu", "listsMenu", "dbMenu"];
  const mirrored = new Set();
  for (const id of menus) {
    const at = html.indexOf('id="' + id + '"');
    if (at < 0) continue;
    for (const m of html.slice(at, at + 3000).matchAll(/data-mirrors="([a-zA-Z]+)"/g)) mirrored.add(m[1]);
  }

  const check = (what) => {
    /* A fresh bar: syncSnapshotUI() only ever hides, so a suite that has drawn
       a shared copy would poison this. See the note in the ⋮ test. */
    for (const id of MENU_INVENTORY.concat([...mirrored])) if (els[id]) els[id].style.display = "";
    /* syncCombinedUI() is the app's own chain for these — it runs
       syncReadOnlyUI() and syncModeUI() under it, which is where every one of
       these buttons learns whether it is available. render() alone would leave
       whatever an earlier test had set. */
    A.syncCombinedUI(); A.syncRepairReport(); A.syncDeleteBtn(); A.render();
    const gone = MENU_INVENTORY.filter(id => !els[id] || els[id].style.display === "none");
    assert.strictEqual(gone.length, 0, "🔴 " + what + ": unreachable — " + gone.join(", "));
  };

  check("on a writable plan");

  /* 🔴 And on a read-only one. Read-only forbids changing the plan's CONTENTS,
     not looking at it or taking a copy away — Save as is the official way OUT
     of read-only, so it of all things may not vanish here (§7/2). */
  A.state.planReadOnly = true;
  check("on a read-only plan");
  assert.strictEqual(els.btnSaveAs.disabled, false, "🔴 Save as is the way out of read-only — it may not be disabled" +
    " [restricted=" + A.restricted() + " free=" + A.isFree() + " folderOpen=" + A.folderOpen() + " tasks=" + A.state.tasks.length + "]");
  assert.strictEqual(els.btnExport.disabled, false, "exporting reads the plan, it does not change it");
  assert.strictEqual(els.btnShare.disabled, false, "…and so does sharing it as a page");
  assert.strictEqual(els.btnImport.disabled, true, "but importing INTO it is a content change, and stays off");
  A.state.planReadOnly = false;

  /* The two that went on purpose are still gone, so the count above cannot be
     met by dropping a third one quietly. */
  for (const id of MENU_DELIBERATELY_GONE)
    assert(!mirrored.has(id), id + " came back into a menu — it was removed on purpose (brief §1)");

  useRef(null); useName(""); resetApp();
});

t("the plan's ⋮ offers every operation the brief moved into it", () => {
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.dbHandle = fakeDb({});
  A.state.snapshot = null;
  A.applyEdition();                      // an ordinary paid, editing copy
  useName("Terasz.gantora"); useRef({});
  A.state.lastRepairs = { list: ["something was put right"] };

  const at0 = html.indexOf('id="planOpsMenu"');
  const menu0 = html.slice(at0, html.indexOf("</div>", html.indexOf("btnTrash", at0)));
  /* A page that has ever drawn a SHARED copy has hidden four of these for good
     — syncSnapshotUI() only ever hides, because a shared copy has no way back
     to a folder and so nothing to restore them for. In the app that is one page
     load; in this suite it is whichever test ran first, so the fixture starts
     from a clean bar rather than inheriting one. */
  for (const m of menu0.matchAll(/data-mirrors="([a-zA-Z]+)"/g))
    if (els[m[1]]) els[m[1]].style.display = "";
  A.syncRepairReport();
  A.syncDeleteBtn();
  A.syncMirrors("planOpsMenu");

  const at = html.indexOf('id="planOpsMenu"');
  const menu = html.slice(at, html.indexOf("</div>", html.indexOf("btnTrash", at)));
  const ids = [...menu.matchAll(/data-mirrors="([a-zA-Z]+)"/g)].map(m => m[1]);
  assert(ids.length >= 8, "the ⋮ has lost items from its markup: " + ids.join(", "));
  const missing = ids.filter(id => els[id] && els[id].style.display === "none");
  assert.strictEqual(missing.length, 0,
    "🔴 the ⋮ is missing: " + missing.join(", ") + " — a parked button's display is a signal, not a way to hide it");

  /* 🔴 …and being IN the menu is not the same as the menu being able to open.
     That distinction cost Daniel a blocker on 2026-08-23: every assertion above
     passed while the ⋮ did nothing at all on every platform, because
     `#planOpsWrap` carries an inline display:none in the markup — it has no
     button on the bar — and nothing ever turned it back on. A menu inside a
     hidden parent is hidden however open it says it is. So the test opens it
     for real and looks. */
  A.closePlanOps();
  assert.strictEqual(els.planOpsWrap.style.display, "none", "shut, the wrapper is out of the way");
  A.openPlanOpsFor(A.planName(), { x: 0, y: 0 });
  assert(A.planOpsOpen(), "the menu says it is open");
  assert.notStrictEqual(els.planOpsWrap.style.display, "none",
    "🔴 …and its wrapper lets it be seen — this is the one that was false while everything else was true");
  A.closePlanOps();

  /* 🔴 …and it opens WHERE IT WAS ASKED FOR. Daniel, 2026-08-23, on the phone
     and the desktop both: "a fájl másodlagos menü balra fent jelenik meg, nem a
     menüsor alatt." The panel was being placed perfectly — at the origin of the
     window, because the anchor handed to it was a literal {x:0, y:0}. */
  A.openPlanOpsFor(A.planName(), { x: 120, y: 200 });
  assert.strictEqual(els.planOpsMenu.style.left, "120px", "the panel is put where the press was");
  assert.strictEqual(els.planOpsMenu.style.top, "200px");
  A.closePlanOps();
  assert(!/openPlanOpsFor\([^)]*\{ x: 0, y: 0 \}/.test(html),
    "🔴 nobody hands it the origin of the window as an anchor any more");
  /* The rect is read while the plan menu is still OPEN: closing it first takes
     the row out of the layout and hands back zeros — a second road to the same
     corner. */
  const handler = html.slice(html.indexOf('$("planMenu").addEventListener'));
  const readAt = handler.indexOf("getBoundingClientRect");
  const closeAt = handler.indexOf("closePlanMenu()");
  assert(readAt > 0 && readAt < closeAt, "the press is measured before the menu it sits in closes");
  /* And the placer is given the panel it is about to move. It placed #rowMenu
     and nothing else, so the plan panel was positioned by copying that one's
     left/top afterwards — with the flip-up branch measuring the wrong panel's
     height (Winston's reading, and right). */
  assert(/function placeMenuAt\(m, x, y\)/.test(html), "one placer, told which panel");
  assert(/function placeRowMenu\(x, y\) \{ placeMenuAt\(\$\("rowMenu"\), x, y\); \}/.test(html),
    "…and the row menu goes through it unchanged");

  /* …and the other half: none of them may be DRAWN in the DB menu, which is
     the menu that answers "where do my plans live" in four items. */
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/#dbMenu > \.parked\{display:none !important;\}/.test(css),
    "🔴 nothing keeps the parked buttons off the DB menu");
  const db = html.slice(html.indexOf('id="dbMenu"'), html.indexOf('id="planWrap"'));
  for (const id of ids)
    assert(new RegExp('class="parked" id="' + id + '"').test(db) || !db.includes('id="' + id + '"'),
      "🔴 " + id + " sits in the DB menu without being parked — it will show there");

  /* 🔴 And the regression itself, stated as plainly as it can be: a parked
     button may not carry a permanent inline display:none in the markup. That
     is what took the seven items away — the markup said "not offered" for
     ever, and syncMirrors() believed it. */
  for (const id of ids) {
    const tag = (db.match(new RegExp('<button[^>]*id="' + id + '"[^>]*>')) || [""])[0];
    if (!tag) continue;
    const restored = ["btnCopyRepairs", "btnDeletePlan", "btnTrash"].includes(id);
    assert(restored || !/display:none/.test(tag),
      "🔴 " + id + " is hidden in the markup and nothing ever un-hides it: " + tag);
  }
  useRef(null); useName(""); A.state.lastRepairs = null; resetApp();
});

t("a plan's name is a name: never an initial, never replaced by the tick", () => {
  A.setLang("en");
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.snapshot = null;
  const names = ["Primelords.gantora", "Teraszfelujitas.gantora", "Gantora_Roadmap__ai.gantora"];
  els.fileSelect.options = [{ value: "" }].concat(names.map(value => ({ value })));
  useRef({}); useName(names[0]);
  const out = A.planMenuHtml();
  for (const n of names) {
    const shown = out.match(new RegExp('data-plan="' + n.replace(/[.]/g, "\\.") + '">([^<]*)<'));
    assert(shown, n + " is not in the switcher at all");
    /* Exactly the file's name without its extension — the app's own
       showPlanName(), not a shortened version of it. */
    assert.strictEqual(shown[1], A.showPlanName(n), n + " reads as: " + shown[1]);
    assert(shown[1].length > 2, "🔴 an initial is not a name: " + shown[1]);
  }
  /* And the chosen row still carries its name; the ✓ is a mark beside it, in a
     column of its own, not a replacement for it. */
  const on = out.match(/planRow on">([\s\S]*?)<\/div>/)[1];
  assert(/✓/.test(on) && /Primelords</.test(on), "the open plan keeps its name: " + on);
  useRef(null); useName(""); resetApp();
});

/* 🔴 dbx-dialog-mobil §3c, from the live ChatGPT round of 2026-08-22: the
   assistant wrote a plan into the right folder through the Dropbox connector,
   and Gantora would not show it — the folder was listed once at startup and
   never again, so the only ways to see the file were restarting the app or
   switching the storage away and back. Nothing here opens anything by itself;
   the whole promise is that the list is current when somebody looks at it. */
ta("a plan written into the folder by somebody else turns up without a restart", async () => {
  resetApp();
  const files = { "switchover.csv": FILE_A };
  const root = fakeRoot(files);
  idbStore.set("root", root);
  await A.restoreSavedFolder(true);
  assert(els.fileSelect.innerHTML.includes('value="switchover.csv"'), "one plan to begin with");
  assert(!els.fileSelect.innerHTML.includes("KoltozesChatGPt"), "…and only the one");

  files["KoltozesChatGPt.gantora"] = FILE_B;          // the AI writes, while we are elsewhere
  await A.refreshPlanList();
  assert(els.fileSelect.innerHTML.includes('value="KoltozesChatGPt.gantora"'),
    "🔴 the list never looked again: " + els.fileSelect.innerHTML);
  assert.strictEqual(A.planName(), "switchover.csv", "and the plan we were in is still the open one");
  assert.strictEqual(els.fileSelect.value, "switchover.csv", "…and still the selected one");

  /* Opening the switcher is the desktop's version of the same moment — there is
     no coming-back-to-the-app event there. Emptying the box first makes the
     re-listing visible: what comes back came from the folder, not from a cache. */
  files["second-one.gantora"] = FILE_B;
  els.fileSelect.innerHTML = "";
  A.openPlanMenu();
  await new Promise(r => setTimeout(r, 0));
  assert(els.fileSelect.innerHTML.includes('value="second-one.gantora"'),
    "🔴 the ▾ served a cached list: " + els.fileSelect.innerHTML);
  A.closePlanMenu();
  resetApp();
});

/* The other two moments cannot be pressed in a stub DOM — one is a Capacitor
   event, the other a click listener — so what is asserted is that they are
   wired at all. Without this the rule survives as a comment. */
t("coming back to the app, and Reload from disk, both ask the folder again", () => {
  const near = s => html.slice(html.indexOf(s), html.indexOf(s) + 600);
  assert(/appStateChange/.test(html), "🔴 nothing listens for the app coming back");
  assert(/refreshPlanList\(\)/.test(near("appStateChange")),
    "🔴 the app came back and the folder was not looked at");
  assert(/refreshPlanList\(\)/.test(near('$("btnReload").addEventListener')),
    "🔴 Reload from disk still means one file — the item's name promises the list too");
});

t("the plan switcher, and its rows' ⋮, disappear where there is nothing to switch", () => {
  const was = A.state.snapshot;
  try {
    A.state.snapshot = null;
    A.syncPlanName();
    assert.strictEqual(els.planWrap.style.display, "", "an ordinary copy switches plans");
    assert.strictEqual(A.planOpsAllowed(), true, "…and can do things to them");
    /* A shared copy is a document: one fixed plan, nothing to switch to and
       nothing to save-as, delete or merge. A menu of greyed items would say
       "you are missing something" about a build that is not missing anything. */
    A.state.snapshot = { plans: [] };
    A.syncPlanName();
    assert.strictEqual(els.planWrap.style.display, "none", "a shared copy has neither");
    assert.strictEqual(A.planOpsAllowed(), false);
  } finally { A.state.snapshot = was; A.syncPlanName(); }
});

/* 🔴 The switcher is a menu of our own now, and the ⋮ lives on the ROW rather
   than on the bar — "what do I do with THIS plan" is a question about a plan.
   The <select> stays behind it as the list and the value, the way the status
   <select> stayed behind the status chips. */
t("the switcher is built from the select, and every row carries its own ⋮", () => {
  A.setLang("en");
  resetApp();
  A.setStorage(A.FsaAdapter);
  A.state.snapshot = null;
  els.fileSelect.innerHTML =
    '<option value=""></option><option value="Terasz.gantora">Terasz</option>' +
    '<option value="Primelords.gantora">Primelords</option>';
  els.fileSelect.options = [{ value: "" }, { value: "Terasz.gantora" }, { value: "Primelords.gantora" }];
  useRef({}); useName("Terasz.gantora");

  const out = A.planMenuHtml();
  assert(/data-plan-new/.test(out), "＋ New plan leads the list");
  assert(out.indexOf("data-plan-new") < out.indexOf('data-plan="'), "…and it leads it, not follows");
  assert((out.match(/data-plan-ops="/g) || []).length === 2, "every plan row has its own ⋮");
  /* The tick is a column of its own, so the names line up under each other. */
  assert(/planRow on"><span class="planTick">✓<\/span><button class="planPick" data-plan="Terasz/.test(out),
    "the open one is ticked, in its own column: " + out.slice(0, 220));
  assert(/<button class="planPick" data-plan="Primelords\.gantora">Primelords</.test(out),
    "🔴 and a name is a NAME — it was being squeezed to one letter by a flex row");
  assert(!/\.gantora</.test(out), "the rows show the plan's name, not the file's extension");
  useRef(null); useName(""); resetApp();
});

/* 🔴 Three controls for something opened once a week, side by side on a bar
   that has room for none: a list, a save and a delete (view-controls brief §1).
   They fold into ONE ☆, and the three stay in the page as the implementation —
   the menu presses them, the way every other menu in this app does. */
t("the saved views fold into one ☆ that still says how many there are", () => {
  A.setLang("en");
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03" }]);
  A.state.savedViews = [];
  A.state.activeView = "";
  A.refreshSavedViews();
  assert(/Views/.test(els.btnSavedViews.innerHTML), "with none saved it says what it is for");
  assert(/No saved view yet/.test(els.btnSavedViews.title), "…and the tooltip says so: " + els.btnSavedViews.title);

  A.state.savedViews = [{ name: "Kickoff", view: "gantt" }, { name: "Go-live", view: "gantt" }];
  A.state.activeView = "Go-live";
  A.refreshSavedViews();
  /* 🔴 The one thing the old row did well and the fold must not lose: a plan
     with saved views says so without being opened. */
  assert(/svN">2</.test(els.btnSavedViews.innerHTML), "the count is on the button: " + els.btnSavedViews.innerHTML);

  const out = A.savedViewMenuHtml();
  assert(out.indexOf("data-sv-save") < out.indexOf('data-sv="'), "saving leads — it is why the menu is opened");
  assert(/data-sv="Kickoff"/.test(out) && /data-sv="Go-live"/.test(out), "both are listed");
  assert((out.match(/data-sv-del="/g) || []).length === 2, "each one can be deleted from its own row");
  assert(/planRow on"><span class="planTick">✓<\/span><button class="planPick" data-sv="Go-live"/.test(out),
    "and the one in use is ticked: " + out.slice(0, 200));

  /* One implementation: the menu presses the buttons that were always there. */
  const wiring = html.slice(html.indexOf('$("savedViewMenu").addEventListener'));
  assert(/\$\("btnSaveView"\)\.click\(\)/.test(wiring.slice(0, 700)), "saving presses the save button");
  assert(/\$\("btnDelView"\)\.click\(\)/.test(wiring.slice(0, 700)), "and deleting presses the delete button");
  A.state.savedViews = []; A.state.activeView = "";
});

/* The scale in words is a row of its own on a narrow bar and answers a question
   nobody asks twice. It leaves; the slider's tooltip carries it, and popTip()
   reads a tooltip on a tap. */
t("the zoom scale follows its label onto the slider, wherever the label is written", () => {
  const code = html.replace(/\/\*[\s\S]*?\*\//g, "");
  /* Eleven places set the label — none of them should have to remember the
     slider as well, which is why an observer does it rather than eleven lines. */
  const setters = (code.match(/\$\("zoomLbl"\)\.textContent/g) || []).length;
  assert(setters > 8, "the label is written from many places: " + setters);
  assert(/new MutationObserver\(syncZoomTip\)/.test(code), "one observer keeps the tooltip true by construction");
  els.zoomLbl.textContent = "28 px/month";
  A.syncZoomTip();
  assert.strictEqual(els.zoomRange.title, "28 px/month");
  const { block } = mobileCss();
  assert(/#zoomBox #zoomLbl\{display:none;\}/.test(block), "and the words leave the narrow bar");
});

/* 🔴 Daniel's 08-23 round, finding 1: the icon chip AND the open field were
   both on screen at rest. Not a specificity fight — syncViewUI() was setting
   an INLINE `display:flex` on #searchBox, and an inline style beats any
   stylesheet rule however specific. The division that fixes it for good:
   whether the search belongs on this bar is the VIEW's business (set in JS, on
   the wrapper); whether it is folded to its icon is the CHIP's own state (left
   to the CSS). */
t("the view decides whether the search is on the bar; the chip decides whether it is open", () => {
  const code = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>")).replace(/\/\*[\s\S]*?\*\//g, "");
  assert(!/\$\("searchBox"\)\.style\.display/.test(code),
    "🔴 an inline display on #searchBox takes the fold away from the stylesheet again");
  assert(/\$\("fSearchWrap"\)\.style\.display/.test(code), "the wrapper is what the view shows and hides");
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/#fSearchWrap:not\(\.open\) > #searchBox\{display:none;\}/.test(css), "…and the fold is the CSS's");
  assert(/#fSearchWrap\.open > #fSearchChip\{display:none;\}/.test(css), "one element, two states: never both at once");
});

/* A keyboard shortcut printed at a thumb is noise in a box with none to spare. */
t("the search placeholder drops (Ctrl+F) where there is no Ctrl", () => {
  const was = ctx.window.matchMedia;
  try {
    ctx.window.matchMedia = q => ({ matches: /pointer: fine/.test(q) });
    A.syncSearchChip(true);
    assert(/Ctrl\+F/.test(els.searchInput.placeholder), "a mouse gets the shortcut: " + els.searchInput.placeholder);
    ctx.window.matchMedia = () => ({ matches: false });
    A.syncSearchChip(true);
    assert(!/Ctrl\+F/.test(els.searchInput.placeholder), "🔴 a touch screen does not: " + els.searchInput.placeholder);
    assert(/🔍/.test(els.searchInput.placeholder), "…but it is still a search box");
  } finally { ctx.window.matchMedia = was; }
});

/* 🔴 The 44px token lives on `#toolbar.compact`, and `#ctxBar` is not inside
   the toolbar — so a chip asking for `var(--ctl-h)` was silently taking its
   34px fallback on the one bar built for fingers. Caught by reading the
   cascade, not by looking at it; a 34px chip looks fine in a screenshot and
   fails under a thumb. */
t("a filter chip is a touch target on the phone, not a 34px fallback", () => {
  const { block } = mobileCss();
  assert(/#ctxBar\{--ctl-h:44px/.test(block), "🔴 the chips have no 44px token where they live: " + block.slice(0, 200));
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  assert(/\.fChip\{height:var\(--ctl-h/.test(css), "…and the chip asks for it");
});

/* 🔴 Eight cards with a sentence each broke to 6+2 on a tablet, and the
   sentences were CUT OFF where they stood — a card that says half of something
   reads as broken (view-controls brief §2). Label and number there; the
   sentence one tap away. */
t("on a tablet a card is a label and a number, and the sentence is a tap away", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  /* 🔴 The band starts at 700, not 821. Daniel's tablet is an iPad Air 11" —
     1180×820 — and in PORTRAIT it is 820px wide, which is PHONE_W: the app and
     the stylesheet both call it a phone, nothing sets .kpiRow there, and the
     base minmax(150px) rule gives five columns with three cards below them.
     The first fix was aimed at a band his tablet was never in while portrait.
     Measure the device's width; do not trust the name of the breakpoint the
     symptom seems to belong to. */
  const bandAt = css.indexOf("@media (min-width:700px) and (max-width:1400px)");
  assert(bandAt > 0, "🔴 the card band no longer reaches a portrait tablet");
  assert(700 <= A.PHONE_W, "the band has to start at or below PHONE_W (" + A.PHONE_W + ") or a portrait tablet misses it");
  const band = css.slice(bandAt, css.indexOf("---------- Cards that are not just reporting"));
  assert(/\.kpi \.kNote\{display:none;\}/.test(band), "the sentence leaves the card");

  /* 🔴 And the row is no longer ALLOWED to break. `auto-fit` with a pixel
     minimum was the first answer and it broke anyway on Daniel's own tablet
     (2026-08-23, "a következők doboz új sorba csúszik"): 8×96 + 7×8 = 824px
     wanted against 806px offered, eighteen short, so the eighth card dropped
     to a line of its own. A smaller minimum only moves the gap to the next
     language or the next device — asking for as many columns as there are
     cards removes the failure mode. */
  assert(!/auto-fit/.test(band), "🔴 auto-fit is what let the row break: " + band.slice(0, 200));
  assert(/repeat\(var\(--kpiN/.test(band), "the column count comes from the cards: " + band.slice(0, 200));
  assert(/minmax\(0,1fr\)/.test(band), "…and they may shrink below their content rather than wrap");
  /* The label wraps rather than losing letters: it was already being cut here
     at 96px, and a cut word says less than a wrapped one. */
  assert(!/text-overflow:ellipsis/.test(band), "no ellipsis on the label any more: " + band);

  /* One source of truth for the count, so a ninth card cannot quietly put the
     row back to two lines. Asked of what is DRAWN, not of the source: the
     source only says DASH_CARDS.length, which is the point. */
  loadRowsNow([{ id: 9, name: "Z", start: "2026-03-02", end: "2026-03-03" }]);
  const wasView = A.state.view;
  A.state.view = "dash";
  A.renderDash();
  const drawn = els.dashView.innerHTML;
  assert(new RegExp("--kpiN:" + A.DASH_CARDS.length).test(drawn),
    "🔴 the row is told a number the cards do not agree with: " + drawn.slice(0, 160));
  assert.strictEqual((drawn.match(/class="kpi[ "]/g) || []).length, A.DASH_CARDS.length,
    "and there are as many cards as columns");
  A.state.view = wasView;
  /* Below the band the phone keeps its two-column cards WITH the sentences —
     Daniel confirmed that is right there — and above it there was never a
     problem to solve. */
  const { block } = mobileCss();
  assert(!/\.kpi \.kNote/.test(block), "the phone is untouched");

  /* The sentence is not lost: it leads the card's tooltip, which popTip()
     reads. It has to come FIRST — on a tablet it is the only place it exists. */
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-03" }]);
  const h = A.dashKpiHtml(A.healthCounts(), A.dashTasks().length);
  const card = h.slice(h.indexOf('data-health="upcoming"'));
  assert(/title="Start in future/.test(card), "the note leads the tooltip: " + card.slice(0, 160));

  /* And a press says it before it navigates — but only where a finger is
     driving. With a mouse the hover tooltip already answers. */
  assert(/\$\("dashView"\)\.addEventListener\("click"[\s\S]{0,240}?touchBar\(\)[\s\S]{0,200}?popTip\(el\)/.test(html),
    "a tap on a card says what it means first");
  assert(/dashTipFor === el/.test(html), "…and a second tap goes through to the list");
});

t("the cockpit's Focus takes cancelled work away with the finished work", () => {
  const was = A.state.cockSpan, wasF = A.state.cockFocus;
  try {
    A.state.cockSpan = "24h";
    ckPlan([ckTask({ name: "Live", status: "In Progress", end: "2099-01-01" }),
            ckTask({ name: "Fin", status: "Done" }),
            ckTask({ name: "Gone", status: "Cancelled" })]);
    A.state.cockFocus = false;
    assert.strictEqual(A.cockpitModel(ckAt(21)).total, 3);
    A.state.cockFocus = true;
    A.state.cockRows.clear();
    const m = A.cockpitModel(ckAt(21));
    eqArr(m.lanes.flatMap(l => l.bars.map(b => b.task.name)), ["Live"]);
    assert.strictEqual(m.hidden, 2, "and both are counted as put away, not one");
  } finally { A.state.cockSpan = was; A.state.cockFocus = wasF; A.state.cockRows.clear(); }
});

t("the lamp is the worst thing on the plan, and green only when nothing is wrong", () => {
  ckPlan([ckTask({ status: "In Progress", start: CK_DAY, end: "2099-01-01" })]);
  assert.strictEqual(A.cockpitModel(ckAt(21)).worst, "green");
  A.state.tasks.push(ckTask({ status: "On Hold" }));
  assert.strictEqual(A.cockpitModel(ckAt(21)).worst, "yellow");
  A.state.tasks.push(ckTask({ status: "Blocked" }));
  assert.strictEqual(A.cockpitModel(ckAt(21)).worst, "red");
});

/* The answer to "why can I not edit" has to name the cockpit, not the two
   older reasons — and it has to give editing back when the view is left. */
t("the cockpit stops editing while it is open, and only while it is open", () => {
  const was = A.state.view;
  A.state.combined = null; A.state.readOnly = false;
  A.state.view = "table";
  assert.strictEqual(A.readOnly(), false);
  A.state.view = "cockpit";
  assert.strictEqual(A.readOnly(), true, "nothing can be changed from the instrument");
  assert(/cockpit/i.test(A.roReason()), "and it says which of the three reasons it is: " + A.roReason());
  A.state.view = "table";
  assert.strictEqual(A.readOnly(), false, "leaving gives it back with nothing to restore");
  A.state.view = was;
});

t("a shared copy has no file to watch, and is told so rather than shown an empty cockpit", () => {
  const was = { view: A.state.view, snap: A.state.snapshot };
  A.state.snapshot = { files: [] };
  A.state.view = "table";
  A.setView("cockpit");
  assert.strictEqual(A.state.view, "table", "the view did not change");
  assert(/shared copy/.test(els.toast.textContent), "and it says why: " + els.toast.textContent);
  A.state.snapshot = was.snap; A.state.view = was.view;
});

t("the beat stops when the cockpit is left, so a hidden view is not still polling", () => {
  const was = A.state.view;
  A.state.view = "table";
  A.cockLoop();                       // the guard is the first thing it does
  A.state.view = "cockpit";
  assert.strictEqual(A.cockWatching(), false, "with no folder there is nothing to watch");
  assert(/not watching/.test(A.cockWatchLabel()), A.cockWatchLabel());
  A.state.view = was;
  A.cockStop();
});

/* You can walk into the cockpit with a change already made and not written
   down. Re-reading the file then would throw it away with no way back. */
ta("the watch never re-reads the file over unsaved work", async () => {
  const was = { view: A.state.view, dirty: A.state.dirty, fh: A.planRef(),
                db: A.state.dbHandle, name: A.planName() };
  let looked = 0;
  A.state.view = "cockpit";
  useRef({});
  useName("plan.gantora");
  A.state.dbHandle = { getFileHandle: async () => { looked++; return { getFile: async () => ({ text: async () => "x" }) }; } };

  A.state.dirty = true;
  await A.cockPoll();
  assert.strictEqual(looked, 0, "with a change unsaved the file is not even opened");

  A.state.dirty = false;
  await A.cockPoll();
  assert.strictEqual(looked, 1, "and with nothing pending it is read again");

  A.state.view = was.view; A.state.dirty = was.dirty; useRef(was.fh);
  A.state.dbHandle = was.db; useName(was.name);
  A.cockStop();
});

t("the cockpit draws its header, its lanes and a blocked panel that is always there", () => {
  ckPlan([]);
  const a = ckTask({ name: "Backup", status: "Done" });
  const db = ckTask({ name: "Database", group: [a.id] });
  A.state.tasks = [db, a];
  useName("switchover.gantora");
  useRef({});            // in normal mode a plan is open when there is a handle
  const was = A.state.view;
  A.state.view = "cockpit";
  A.renderCockpit();
  const out = els.cockpitView.innerHTML;
  /* 🔴 The title does NOT name the plan any more (Daniel, tablet round
     2026-08-21): the name was on this one screen three times — here, in the
     watch chip, and in the picker at the top of the app. Only the picker keeps
     it, and this asserts the two copies stay gone. */
  assert(/<h1>COCKPIT<\/h1>/.test(out), "the title is the word alone: " + out.slice(out.indexOf("<h1>"), out.indexOf("</h1>") + 5));
  assert(!out.includes("switchover.gantora"), "and the plan's name is not repeated anywhere in the cockpit");
  assert(out.includes("ckLamp green"), "the lamp is green with nothing wrong");
  assert(out.includes("ckNowTag"), "the now-line is tagged");
  assert(out.includes("Database"), "the group is a lane");
  assert(out.includes("ckAlert"), "the blocked panel is drawn even when it is empty");
  assert(out.includes("ckB0_0"), "and each bar carries an id, which is how the slide moves it");
  A.state.view = was;
  useRef(null);
  A.cockStop();
});

/* An empty state rather than a broken one: the cockpit is reachable from the
   switcher before a plan has been opened. */
t("with no plan open the cockpit says so instead of drawing an empty instrument", () => {
  const was = { view: A.state.view, fh: A.planRef(), name: A.planName() };
  useRef(null); useName(""); A.state.view = "cockpit";
  A.renderCockpit();
  assert(els.cockpitView.innerHTML.includes("ckEmpty"), "the empty state is drawn");
  A.state.view = was.view; useRef(was.fh); useName(was.name);
  A.cockStop();
});

console.log("\n-- Labels: the plan's own words for a row --");

t("a cell is split on ';', tidied, and repeats are one label", () => {
  eqArr(A.parseLabels("risk;wave-2"), ["risk", "wave-2"]);
  eqArr(A.parseLabels(" risk ; ; wave-2 "), ["risk", "wave-2"], "blanks and padding drop out");
  eqArr(A.parseLabels("risk;Risk"), ["risk"], "the same word twice is one label, first spelling wins");
  eqArr(A.parseLabels("two  words"), ["two words"], "runs of space collapse");
  eqArr(A.parseLabels(""), [], "nothing is no labels, not one empty one");
  eqArr(A.parseLabels(null), []);
});

/* "|" is the file's delimiter and a row is one line: a label carrying either
   would cut the row in half when it is written. A comma is what somebody types
   when they mean "and another one". */
t("the delimiter can never survive inside a label", () => {
  eqArr(A.parseLabels("a|b"), ["a b"]);
  eqArr(A.parseLabels("risk,wave-2"), ["risk wave-2"], "a comma is not a second delimiter");
  assert(!A.normLabels("a|b").includes("|"), "and nothing pipe-shaped reaches the file");
});

t("hasLabel ignores case, and no filter matches everything", () => {
  const t1 = { labels: "Risk;wave-2" };
  assert(A.hasLabel(t1, "risk"), "the filter is not case-sensitive");
  assert(A.hasLabel(t1, "WAVE-2"));
  assert(!A.hasLabel(t1, "wave"), "and it is the whole label, not part of one");
  assert(A.hasLabel(t1, ""), "no label asked for is every row");
  assert(A.hasLabel({}, ""), "including a row that carries none");
});

t("labels survive the file, and an older plan reads as one with none", () => {
  loadRowsNow([{ id: 1, name: "Cutover", start: "2026-03-02", end: "2026-03-03", labels: "risk;wave-2" },
               { id: 2, name: "Backup",  start: "2026-03-02", end: "2026-03-02" }]);
  assert.strictEqual(row("Cutover").labels, "risk;wave-2");
  assert.strictEqual(row("Backup").labels, "", "a row with no labels has none, not undefined");

  const text = A.serializeCSV(A.state.tasks);
  assert(text.split("\n")[0].trim().endsWith("Labels"), "the column is appended at the end of the header");
  const back = A.parseCSV(text);
  assert.strictEqual(back.find(x => x.name === "Cutover").labels, "risk;wave-2", "round trip");

  // the layout before labels existed: every row reads back with none
  const old = CSV_HDR + "\n" + csvRow({ id: 1, name: "Old", start: "2026-03-02", end: "2026-03-02" }) + "\n";
  assert.strictEqual(A.parseCSV(old)[0].labels, "", "a file without the column is a plan without labels");
});

t("a hand-edited cell is tidied rather than reported", () => {
  const tasks = A.parseCSV(CSV_HDR_NOW + "\n" +
    csvRowNow({ id: 1, name: "A", start: "2026-03-02", end: "2026-03-02", labels: " risk ;risk; " }) + "\n");
  const warn = A.sanitizeTasks(tasks);
  assert.strictEqual(tasks[0].labels, "risk");
  assert(!warn.some(w => /label/i.test(w)), "free text has nothing to repair, so nothing to say");
});

t("the picker offers what the plan uses, each spelling once", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-02", labels: "wave-2;risk" },
               { id: 2, name: "B", start: "2026-03-02", end: "2026-03-02", labels: "Risk;external" }]);
  eqArr(A.knownValues("label"), ["external", "risk", "wave-2"],
    "sorted, and 'Risk' is the same label as 'risk'");
  A.refreshDatalists();
  assert(els.labelList.innerHTML.includes('value="wave-2"'), "and the box offers them");
});

t("the filter keeps the rows carrying the label — milestones included", () => {
  loadRowsNow([{ id: 1, name: "Cutover", start: "2026-03-02", end: "2026-03-02", labels: "wave-2" },
               { id: 2, name: "Backup",  start: "2026-03-02", end: "2026-03-02", labels: "wave-1" },
               { id: 3, name: "Go live", start: "2026-03-03", end: "2026-03-03", ms: true, labels: "wave-2" },
               { id: 4, name: "Freeze",  start: "2026-03-04", end: "2026-03-04", ms: true }]);
  A.state.filterLabel = "wave-2";
  const names = A.displayTasks().map(r => r.t.name).sort();
  eqArr(names, ["Cutover", "Go live"],
    "a milestone is not exempt from this one: it can carry a label, so it can miss one");
  assert(A.anyFilterOn(), "and the list knows a filter is on");
  A.state.filterLabel = "";
});

t("a group is kept for a member that carries the label", () => {
  loadRowsNow([{ id: 1, name: "Stream", start: "2026-03-02", end: "2026-03-04", group: [2] },
               { id: 2, name: "Inside", start: "2026-03-02", end: "2026-03-02", labels: "risk" },
               { id: 3, name: "Other",  start: "2026-03-04", end: "2026-03-04" }]);
  A.state.filterLabel = "risk";
  eqArr(A.displayTasks().map(r => r.t.name), ["Stream", "Inside"],
    "filtering never orphans a row from the group it sits in");
  A.state.filterLabel = "";
});

t("a copy carries the labels, and a merge can see they differ", () => {
  loadRowsNow([{ id: 1, name: "Cutover", start: "2026-03-02", end: "2026-03-02", labels: "risk" }]);
  A.duplicateTask(idOfName("Cutover"));
  assert.strictEqual(row("Cutover - duplicate").labels, "risk",
    "a copy of a wave-2 risk is a wave-2 risk");
  A.clearSelection();

  const uidOf = new Map();
  const a = { name: "A", preds: [], group: [], history: [], labels: "risk" };
  const b = { name: "A", preds: [], group: [], history: [], labels: "risk;wave-2" };
  assert.notStrictEqual(A.rowSig(a, uidOf), A.rowSig(b, uidOf),
    "two copies differing only in a label are two different rows to the merge");
  assert.strictEqual(A.rowSig(a, uidOf), A.rowSig({ ...a, labels: " risk " }, uidOf),
    "…but spacing is not a difference");
});

t("a label is searchable", () => {
  loadRowsNow([{ id: 1, name: "Cutover", start: "2026-03-02", end: "2026-03-02", labels: "firewall-change" }]);
  assert(A.searchHay(row("Cutover")).includes("firewall-change"),
    "searching for a label finds the row that carries it");
});

t("the dialog and the inline row both carry labels", () => {
  loadRowsNow([{ id: 1, name: "Cutover", start: "2026-03-02", end: "2026-03-02", labels: "risk" }]);
  A.openModal({ type: "edit", id: row("Cutover").id });
  assert.strictEqual(els.fLabels.value, "risk", "the dialog opens on what the row carries");
  els.fLabels.value = "risk; wave-2 ;risk";
  assert.strictEqual(A.readModal().labels, "risk;wave-2", "and reads back tidied");
  A.closeModal();

  els.iLabels.value = "cutover;;risk";
  assert.strictEqual(A.readInline().labels, "cutover;risk", "the inline row too");
});

t("saving the dialog writes the labels onto the task", () => {
  loadRowsNow([{ id: 1, name: "Cutover", start: "2026-03-02", end: "2026-03-02" }]);
  const id = row("Cutover").id;
  A.openModal({ type: "edit", id });
  els.fName.value = "Cutover"; els.fLabels.value = "wave-2";
  A.saveModal();
  assert.strictEqual(row("Cutover").labels, "wave-2");
});

/* Every other box in the mass dialog overwrites. This one cannot: a row holds
   any number of labels, and tagging twenty rows must not untag them. */
t("a mass change adds labels rather than replacing them", () => {
  loadRowsNow([{ id: 1, name: "A", start: "2026-03-02", end: "2026-03-02", labels: "risk" },
               { id: 2, name: "B", start: "2026-03-02", end: "2026-03-02" }]);
  const ids = [row("A").id, row("B").id];
  A.openMassChange(ids);
  els.mLabels.value = "wave-2;risk";
  A.applyMassChange();
  assert.strictEqual(row("A").labels, "risk;wave-2", "what it had, plus what was asked for, once each");
  assert.strictEqual(row("B").labels, "wave-2;risk", "and a row with none simply gets both");
});

t("labels go out to Excel and come back in", () => {
  loadRowsNow([{ id: 1, name: "Cutover", start: "2026-03-02", end: "2026-03-02", labels: "risk;wave-2" }]);
  assert(A.xlCols().some(c => c.head === "Labels"), "the sheet has a column for them");

  const { map } = A.mapHeader(["ID", "Level 1", "Start", "End", "Labels"]);
  assert.strictEqual(map.labels, 4, "and reads it back by name");
  for (const head of ["Tags", "Címkék", "Category"]) {
    const m = A.mapHeader(["ID", "Level 1", head]).map;
    assert.strictEqual(m.labels, 2, head + " is a labels column too");
  }
});

t("one person's board is drawn as their tasks, with the way back in the heading", () => {
  loadRowsNow([{ id: 1, name: "Firewall", start: "2026-03-02", end: "2026-03-03", resp: "Anna:2" },
               { id: 2, name: "Handover", start: "2026-03-04", end: "2026-03-04", resp: "Anna:2" },
               { id: 3, name: "Other",    start: "2026-03-02", end: "2026-03-02", resp: "Bela:8" }]);
  useRef({}); A.state.view = "res";
  A.state.filterPerson = "Anna";
  A.renderResources();

  const left = els.resLeftBody.innerHTML;
  assert(left.includes("Firewall") && left.includes("Handover"), "the rows are her tasks");
  assert(!left.includes("Other"), "and only hers");
  assert(!/data-person=/.test(left), "a task row sets no person filter — it is already set");
  assert(els.resHeadTitle.innerHTML.includes("Anna"), "the heading says whose board this is");
  assert(els.resHeadTitle.innerHTML.includes("resBackAll"), "and carries the way back to everybody");

  fireEl(els.resBackAll, "click", {});
  assert.strictEqual(A.state.filterPerson, "", "which lets the person go");
  useRef(null); A.state.view = "table";
});

console.log("\n-- One level of the plan, on its own --");

/* Three levels deep, so "level 2 only" has something above it and something
   below it — the two things this filter has to leave out.

   **Drawn as a chart, because the list view does not take this filter**: it has
   carets of its own, and a filter with no control on the bar narrowing the view
   you are reading is a plan with rows missing and nothing saying why. */
const lvlPlan = () => { loadRowsNow(lvlRows); A.state.view = "gantt"; };
const lvlDone = () => { A.state.filterLevel = 0; A.state.view = "table"; };
const lvlRows = [
  { id: 1, name: "Stream",  start: "2026-03-02", end: "2026-03-06", group: [2, 5] },
  { id: 2, name: "Phase",   start: "2026-03-02", end: "2026-03-04", group: [3, 4] },
  { id: 3, name: "Step A",  start: "2026-03-02", end: "2026-03-03" },
  { id: 4, name: "Step B",  start: "2026-03-04", end: "2026-03-04" },
  { id: 5, name: "Loose",   start: "2026-03-05", end: "2026-03-06" },
  { id: 6, name: "Top task", start: "2026-03-02", end: "2026-03-02" },
];

t("the levels offered follow the depth the plan allows", () => {
  assert.strictEqual(A.ROW_LEVELS, A.MAX_GROUP_DEPTH + 1,
    "a group nesting N deep holds an N+1th row of tasks");
  assert.strictEqual(A.clampRowLevel(0), 0, "0 is off");
  assert.strictEqual(A.clampRowLevel(A.ROW_LEVELS + 1), 0, "and so is a level the plan cannot have");
  assert.strictEqual(A.clampRowLevel("2"), 2, "the select hands over a string");
});

t("one level is a flat list: no parents above it, no members under it", () => {
  lvlPlan();
  A.state.filterLevel = 1;
  eqArr(A.displayTasks().map(r => r.t.name).sort(), ["Stream", "Top task"],
    "level 1 is what the list starts with");

  A.state.filterLevel = 2;
  const rows = A.displayTasks();
  eqArr(rows.map(r => r.t.name).sort(), ["Loose", "Phase"], "level 2 alone");
  assert(rows.every(r => r.level === 0), "drawn flat — the indent would stand for parents that are gone");

  A.state.filterLevel = 3;
  eqArr(A.displayTasks().map(r => r.t.name).sort(), ["Step A", "Step B"]);

  A.state.filterLevel = A.ROW_LEVELS;
  eqArr(A.displayTasks().map(r => r.t.name), [], "a depth this plan does not reach shows nothing");
  lvlDone();
});

/* The reason "Tasks only" cannot be said as a level: the work in this plan sits
   at three different depths, and no single number reaches all of it. */
t("Tasks only takes the containers out, at whatever depth the work sits", () => {
  loadRowsNow([
    { id: 1, name: "Stream",   start: "2026-03-02", end: "2026-03-06", group: [2, 5] },
    { id: 2, name: "Phase",    start: "2026-03-02", end: "2026-03-04", group: [3, 4] },
    { id: 3, name: "Step A",   start: "2026-03-02", end: "2026-03-03" },
    { id: 4, name: "Step B",   start: "2026-03-04", end: "2026-03-04" },
    { id: 5, name: "Loose",    start: "2026-03-05", end: "2026-03-06" },
    { id: 6, name: "Top task", start: "2026-03-02", end: "2026-03-02" },
    { id: 7, name: "Go live",  start: "2026-03-09", end: "2026-03-09", ms: true },
  ]);
  A.state.view = "gantt";
  A.state.filterLevel = A.LEVEL_LEAF;
  const rows = A.displayTasks();
  eqArr(rows.map(r => r.t.name).sort(), ["Go live", "Loose", "Step A", "Step B", "Top task"],
    "every row that is not a group — level 3, level 2 and level 1 work in one list");
  assert(!rows.some(r => A.isGroup(r.t)), "and not one container among them");
  assert(rows.every(r => r.level === 0), "drawn flat, like the numbered levels");

  // a milestone is a moment, not a container: it comes with the work it dates
  assert(rows.some(r => r.t.name === "Go live"), "the milestone is one of the rows");

  lvlDone();
  assert(!A.anyFilterOn(), "and letting it go is letting the filter go");
});

t("Tasks only still answers to the other filters", () => {
  loadRowsNow([
    { id: 1, name: "Stream", start: "2026-03-02", end: "2026-03-04", group: [2, 3] },
    { id: 2, name: "Dev bit", start: "2026-03-02", end: "2026-03-02", team: "Dev" },
    { id: 3, name: "QA bit",  start: "2026-03-04", end: "2026-03-04", team: "QA" },
  ]);
  A.state.view = "gantt";
  A.state.filterLevel = A.LEVEL_LEAF;
  A.state.filterTeam = "Dev";
  eqArr(A.displayTasks().map(r => r.t.name), ["Dev bit"]);
  A.state.filterTeam = ""; lvlDone();
});

t("it counts the plan's own depth, not what the other filters leave", () => {
  lvlPlan();
  A.state.filterLevel = 3;
  A.state.filterLabel = "";
  A.state.search = "Step A";
  eqArr(A.displayTasks(true).map(r => r.t.name), ["Step A"],
    "the search still applies inside the level");
  A.state.search = "";
  lvlDone();
});

/* The list has carets and shows the hierarchy, so it does not take this filter
   — and it must *ignore* it rather than only hiding the control: a view still
   being narrowed by a control that is no longer on the bar is a plan with rows
   missing and nothing on screen saying why. */
t("the list view neither offers the level filter nor obeys one", () => {
  lvlPlan();
  A.state.filterLevel = A.LEVEL_LEAF;
  assert.strictEqual(A.levelOnly(), A.LEVEL_LEAF, "the Design view has it");

  A.state.view = "table";
  assert.strictEqual(A.levelOnly(), 0, "the list does not");
  assert(A.displayTasks().some(r => A.isGroup(r.t)), "so the list still shows its groups");
  assert(!A.anyFilterOn(), "and does not claim a filter is on");

  // the setting itself survives, so the chart is as it was left
  A.state.view = "gantt";
  assert.strictEqual(A.levelOnly(), A.LEVEL_LEAF, "coming back finds it where it was");
  lvlDone();
});

t("the picker offers every level even when nothing sits at it", () => {
  lvlPlan();
  A.refreshFilterSelects();
  const opts = (els.filterLevel.innerHTML.match(/<option/g) || []).length;
  assert.strictEqual(opts, A.ROW_LEVELS + 2, "one per level, plus 'all' and 'tasks only'");
  assert(els.filterLevel.innerHTML.includes('value="0"'), "and 'all' is 0, not a level");
  assert(els.filterLevel.innerHTML.includes('value="' + A.LEVEL_LEAF + '"'),
    "'tasks only' is not a level either — it is every depth at once");
});

console.log("\n-- Asking for the folder on the way in --");

ta("a first run is asked for the folder rather than left on the empty page", async () => {
  const was = { db: A.state.dbHandle, snap: A.state.snapshot };
  A.state.dbHandle = null; A.state.snapshot = null;
  ctx.window.showDirectoryPicker = async () => ({ name: "DB", kind: "directory" });
  const savedIdb = ctx.indexedDB;
  ctx.indexedDB = { open(){ throw new Error("nothing remembered"); } };   // idb() rejects: a first run

  assert(A.openPromptApplies(), "there is a folder to ask for");
  assert.strictEqual(await A.offerOpenDb(), true);
  assert.strictEqual(els.openOverlay.style.display, "flex", "the prompt is on screen");
  assert.strictEqual(els.openFirst.style.display, "", "and it is the first-run question");
  assert.strictEqual(els.openAgain.style.display, "none");

  A.closeOpenPrompt();
  assert.strictEqual(els.openOverlay.style.display, "none");
  ctx.indexedDB = savedIdb;
  delete ctx.window.showDirectoryPicker;
  A.state.dbHandle = was.db; A.state.snapshot = was.snap;
});

/* The three copies that must never see it: a shared page carries its plan, a
   free copy keeps one fixed file, and restricted mode exists precisely so that
   no folder is ever asked for. */
t("it is never asked of a shared page, and never while a folder is open", () => {
  const was = { db: A.state.dbHandle, snap: A.state.snapshot, mode: A.state.cfg.mode };
  ctx.window.showDirectoryPicker = async () => ({ name: "DB", kind: "directory" });

  A.state.dbHandle = null; A.state.snapshot = null; A.state.cfg.mode = "normal";
  assert(A.openPromptApplies(), "the ordinary case");

  A.state.snapshot = { made: "now", files: [] };
  assert(!A.openPromptApplies(), "a shared copy has its plan inside the page");
  A.state.snapshot = null;

  A.state.cfg.mode = "restricted";
  assert(!A.openPromptApplies(), "restricted mode asks for no folder at all");
  A.state.cfg.mode = "normal";

  A.state.dbHandle = { name: "DB" };
  assert(!A.openPromptApplies(), "and a folder already open is not a question");
  A.state.dbHandle = null;

  delete ctx.window.showDirectoryPicker;
  assert(!A.openPromptApplies(), "a browser with no picker gets the toast, not a dialog");

  A.state.dbHandle = was.db; A.state.snapshot = was.snap; A.state.cfg.mode = was.mode;
});

t("the prompt's button is the same one the menu has", () => {
  assert(html.includes('id="btnOpenNow"') && html.includes('id="btnOpenLater"'),
    "both answers are in the page");
  assert(A.I18N_HOSTS.includes("openOverlay"),
    "and the dialog is translated like every other one");
});

console.log("\n-- Enter saves the task dialog --");

t("Enter in a field saves; in the notes box it does not", () => {
  loadRowsNow([{ id: 1, name: "Cutover", start: "2026-03-02", end: "2026-03-02" }]);
  const id = row("Cutover").id;

  A.openModal({ type: "edit", id });
  els.fName.value = "Renamed by Enter";
  fireEl(els.modal, "keydown", { key: "Enter", target: els.fName, preventDefault(){}, stopPropagation(){} });
  assert(A.taskById(id).name === "Renamed by Enter", "Enter is the Save button");

  A.openModal({ type: "edit", id });
  els.fName.value = "Not this one";
  fireEl(els.modal, "keydown", { key: "Enter", target: els.fNotes, preventDefault(){}, stopPropagation(){} });
  assert.strictEqual(A.taskById(id).name, "Renamed by Enter", "in a notes box Enter is a new line");

  // …and a modifier means somebody meant something else by it
  fireEl(els.modal, "keydown", { key: "Enter", shiftKey: true, target: els.fName, preventDefault(){}, stopPropagation(){} });
  assert.strictEqual(A.taskById(id).name, "Renamed by Enter", "Shift+Enter is not Save");
  A.closeModal();
});

console.log("\n-- The phone bar --");

/* Two facts about the stylesheet, which is all this harness can say about
   layout — and both of these were bugs that a green suite sat through. */
t("the phone's brand mark is sized before the media query, not after it", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const base = css.indexOf("#brandMark{display:inline-block");
  const phone = css.indexOf("#brandMark{width:40px");
  assert(base >= 0 && phone >= 0, "both rules are there");
  assert(base < phone,
    "the desktop rule ties with the phone's on specificity, so written last it wins everywhere " +
    "— the small mark would sit in the wide button's hole");
});

/* Six filters, a sort, two fold buttons and a search box do not fit across a
   phone, and the sideways scroll they used to sit in was a gesture nobody
   found: everything past the Team filter was simply unreachable. */
t("on a phone the filter bar stacks instead of scrolling sideways", () => {
  /* The *first* block at that width is the toolbar's — there are several in the
     stylesheet, and the last one is the wordmark's. */
  const { css, at } = mobileCss();
  const phone = css.slice(at, css.indexOf("@media", at + 10));
  const bar = phone.match(/#ctxBar\{[^}]*\}/);
  assert(bar, "the phone block sets the bar");
  assert(/flex-wrap:wrap/.test(bar[0]), "it wraps: " + bar[0]);
  assert(!/overflow-x:auto/.test(bar[0]),
    "a sideways scroll inside a one-line bar is what could not be used");

  /* **Two to a line, not one.** A line each was the first answer and it was too
     much: a filter the full width of the screen to hold the word "Alpha" made
     the bar half the page, and the reader had to scroll past the controls to
     reach the plan. */
  const flat = phone.replace(/\s+/g, " ");
  assert(/#listCtrls > select, #ctxRight > select\{flex:1 1 160px/.test(flat),
    "the filters go two to a line: " + (flat.match(/#listCtrls > select[^}]*\}/) || [""])[0]);
  assert(/#teamCtrls select\{flex:1 1 160px/.test(flat),
    "and so does the team board's pair of pickers, which are still selects");
  /* 🔴 The dashboard's scope left that company on 2026-08-23: it is a chip now,
     in the same flow as the search, because a whole row for one picker was
     what Daniel saw on his tablet. */
  assert(!/#dashCtrls select\{flex:1 1/.test(flat), "the scope picker is not a half-width select any more");
  /* 🔴 …and content-width was not enough either (§6/1): a group is still a box,
     and a box that fills the line pushes the next one whole onto a row of its
     own. The groups are out of the box tree now — see the test below. */
  assert(/#listCtrls, #teamCtrls, #dashCtrls, #ctxRight, #listCtrls > \.fChips, #dashCtrls > \.fChips\{display:contents;\}/.test(flat),
    "the four groups and their chip strips hand their controls straight to the bar");
  /* The one that keeps a line: text being typed, not a value being picked —
     and since 2026-08-23 only once it is OPEN, because at rest it is an icon.
     🔴 The zoom and the ☆ no longer take a line each (view brief §5.2): with
     the Drift and the Lvl picker beside them the Design view opened with five
     rows of controls above the plan. */
  assert(/#searchBox\{flex:1 1 100%/.test(flat), "the search box is still a line of its own once open");
  assert(/#zoomBox\{flex:1 1 auto/.test(flat), "the zoom shares its line: " + (flat.match(/#zoomBox\{[^}]*\}/) || [""])[0]);
  assert(/#savedViewWrap\{flex:0 0 auto/.test(flat), "…and the ☆ asks for nothing, being a square");
  assert(!/#groupLvlBox\{flex:1 1 100%/.test(flat), "🔴 the Lvl picker is a chip now, not a full-width row");
  /* 🔴 A width, not a share of one. `calc(50% - 4px)` was doing two jobs and
     only one of them well: two to a line on a 390px phone, and the same two on
     an 820px iPad, each blown up to 400px to hold the word "Team" (Daniel,
     08-23: "minden együtt bőven elférne egy sorban"). A pixel basis says the
     one thing meant — a picker needs about this much — and the wrap does the
     arithmetic at every width. The number is checked against what it has to
     come out as, not merely read back. */
  const gap = Number((flat.match(/#ctxBar\{[^}]*gap:(\d+)px/) || [])[1]);
  const basis = Number((flat.match(/#listCtrls > select[^{]*\{flex:1 1 (\d+)px/) || [])[1]);
  const room = 390 - 16;                                     // a phone, less the bar's padding
  assert(basis > 0 && basis * 2 + gap <= room && basis * 3 + gap * 2 > room,
    "two pickers to a line on a phone and not three, at a basis of " + basis + "px");
  assert(basis * 4 + gap * 3 <= 820 - 16, "…and four of them on an iPad upright, which is the point of a width");
  assert(!/id="listCtrls"[^>]*style=/.test(html),
    "and the group carries no inline layout at all — an inline display would outrank the phone block");
});

/* Ordering by time and by ID were the same list — IDs follow the dates — so
   the picker offering both was a control whose two settings looked identical. */
t("there is one display order, and no picker pretending otherwise", () => {
  assert(!html.includes('id="sortSelect"'), "the Sort picker is gone from the bar");
  assert(!/state\.sort\b/.test(html), "and nothing reads a sort setting any more");

  loadRowsNow([{ id: 1, name: "Second", start: "2026-03-04", end: "2026-03-04" },
               { id: 2, name: "First",  start: "2026-03-02", end: "2026-03-02" }]);
  eqArr(A.displayTasks().map(r => r.t.name), ["First", "Second"], "still the time order");
});

/* …which makes the bar half a screen tall, so it starts shut there. */
t("the filter bar comes up shut on a phone, and open on a screen with room", () => {
  const wasW = ctx.window.innerWidth;
  ctx.localStorage.removeItem("ppp.ctxBar");

  ctx.window.innerWidth = 390;
  A.loadCtxBar();
  assert.strictEqual(A.state.ctxBar, false, "nothing stored, and no room: shut");

  ctx.window.innerWidth = 1200;
  A.loadCtxBar();
  assert.strictEqual(A.state.ctxBar, true, "nothing stored, and room for it: open");

  /* **A phone starts shut whatever is stored**, and this is the case that was
     reported: the answer is one value for the whole browser, so a tab pressed
     open on a desktop — where the bar is one line and always wanted — arrived
     on the phone as half a screen of filters, on every reload. */
  ctx.localStorage.setItem("ppp.ctxBar", "1");
  ctx.window.innerWidth = 390;
  A.loadCtxBar();
  assert.strictEqual(A.state.ctxBar, false, "a desktop's answer does not open the phone's bar");

  // …and the wide screen still keeps the reader's answer, in both directions
  ctx.localStorage.setItem("ppp.ctxBar", "0");
  ctx.window.innerWidth = 1200;
  A.loadCtxBar();
  assert.strictEqual(A.state.ctxBar, false, "shut on a desktop stays shut");

  /* Nothing is written from a phone either: opening it there is for the moment
     it is needed, and storing that would change what the desktop opens with. */
  ctx.localStorage.setItem("ppp.ctxBar", "0");
  ctx.window.innerWidth = 390;
  A.state.ctxBar = false;
  fireEl(els.btnCtxBar, "click", {});
  assert.strictEqual(A.state.ctxBar, true, "the tap still opens it for now");
  assert.strictEqual(ctx.localStorage.getItem("ppp.ctxBar"), "0",
    "but the stored answer is the wide screen's and is left alone");

  ctx.localStorage.removeItem("ppp.ctxBar");
  ctx.window.innerWidth = wasW;
  A.loadCtxBar();
});

/* Escaping the clip costs the panel its anchor: fixed means the viewport, not
   the button. It opened in the middle of the screen until this put it back. */
t("a bar menu opens under the button that opened it, not wherever it lands", () => {
  const wasW = ctx.window.innerWidth, wasH = ctx.window.innerHeight;
  ctx.window.innerWidth = 390; ctx.window.innerHeight = 780;      // a phone

  const btn = document.getElementById("btnDbMenu");
  const menu = document.getElementById("dbMenu");
  btn.getBoundingClientRect = () => ({ left: 8, top: 6, right: 78, bottom: 42 });
  menu.offsetWidth = 215;

  A.placeBarMenu("btnDbMenu", "dbMenu");
  assert.strictEqual(menu.style.top, "46px", "just under the button's own bottom");
  assert.strictEqual(menu.style.left, "8px", "and lined up with its left edge");
  assert(parseInt(menu.style.maxHeight, 10) > 200,
    "with the rest of the screen to be as tall as: " + menu.style.maxHeight);

  /* A menu hanging off the last button on the bar would run off the right edge,
     so it slides back on rather than being cut in half. */
  btn.getBoundingClientRect = () => ({ left: 330, top: 6, right: 380, bottom: 42 });
  A.placeBarMenu("btnDbMenu", "dbMenu");
  assert.strictEqual(menu.style.left, "167px", "390 wide, 215 of menu, 8 of margin");

  // …and a window that grows back hands the placing to the stylesheet again
  ctx.window.innerWidth = 1200;
  A.placeBarMenu("btnDbMenu", "dbMenu");
  assert.strictEqual(menu.style.top, "", "an inline top would beat the desktop rule for good");
  assert.strictEqual(menu.style.left, "");

  ctx.window.innerWidth = wasW; ctx.window.innerHeight = wasH;
  delete btn.getBoundingClientRect;
});

/* The compact bar is `overflow:hidden` too, and it clipped its dropdowns the
   same way the phone bar once did — on Daniel's screenshot (2026-08-20) the DB
   button "did nothing": the menu was opening inside a box that cut it off. */
t("the compact bar's menus escape its clip, and are placed like the phone's", () => {
  const wasW = ctx.window.innerWidth, wasH = ctx.window.innerHeight;
  ctx.window.innerWidth = 1024; ctx.window.innerHeight = 768;      // a tablet, not a phone
  const btn = document.getElementById("btnDbMenu"), menu = document.getElementById("dbMenu");
  btn.getBoundingClientRect = () => ({ left: 60, top: 5, right: 130, bottom: 49 });
  menu.offsetWidth = 215;

  els.toolbar.classList.remove("compact");
  A.placeBarMenu("btnDbMenu", "dbMenu");
  assert(!A.menuEscapes(), "a wide bar does not clip: the stylesheet places it");
  assert.strictEqual(menu.style.top, "", "and no inline top is left behind");

  els.toolbar.classList.add("compact");
  assert(A.menuEscapes(), "the compact bar clips, so the menu goes to the viewport");
  A.placeBarMenu("btnDbMenu", "dbMenu");
  assert.strictEqual(menu.style.top, "53px", "under the button that opened it");
  assert.strictEqual(menu.style.left, "60px", "and lined up with it");
  assert(parseInt(menu.style.maxHeight, 10) > 600,
    "no bottom bar under a compact bar, so it keeps the room: " + menu.style.maxHeight);

  els.toolbar.classList.remove("compact");
  A.placeBarMenu("btnDbMenu", "dbMenu");
  ctx.window.innerWidth = wasW; ctx.window.innerHeight = wasH;
  delete btn.getBoundingClientRect;
});

t("the stylesheet lets those menus out of both clipped bars — and the compact one lets ALL of them out", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  /* 🔴 The two differ by one exclusion, and the difference is the whole point:
     on a phone .viewToggle is fixed to the bottom of the screen, so its
     overflow menu was never in the clipped bar. In compact mode the switcher
     IS in the bar, and excluding it there cut the menu off (Daniel, tablet,
     2026-08-20: the ⋯ button "did nothing", more views unreachable). */
  for (const sel of ["#toolbar.compact .menuWrap > .menu",
                     "#toolbar .menuWrap:not(.viewMoreWrap) > .menu"]) {
    const i = css.indexOf(sel);
    assert(i > -1, "missing rule: " + sel);
    assert(/position:fixed/.test(css.slice(i, i + 200)), sel + " must leave the clipped row");
  }
  assert(!/#toolbar\.compact \.menuWrap:not\(\.viewMoreWrap\)/.test(css),
    "the compact bar must not exclude the switcher's own menu: it is inside the clip there");
  // and the button that opens it has to put it back under itself
  const wired = html.slice(html.indexOf('$("btnMoreViews").addEventListener'), html.indexOf('$("btnMoreViews").addEventListener') + 1200);
  assert(wired.includes("placeMoreViews()"),
    "fixed means the viewport, so the menu needs placing under its button");
});

/* 🔴 …but only in the compact bar. On a phone the switcher is fixed to the
   bottom of the screen and its menu opens UPWARDS from the stylesheet; an
   inline top written by the placer puts it below the bar, which is below the
   edge of the phone. Same button, two bars, two right answers. */
t("the switcher's menu is placed from script in the compact bar and never on a phone", () => {
  const tb = els.toolbar, m = els.moreViewsMenu, btn = els.btnMoreViews;
  const wasW = ctx.window.innerWidth;
  btn.getBoundingClientRect = () => ({ left: 300, top: 700, right: 360, bottom: 744 });
  try {
    ctx.window.innerWidth = 390;                       // a phone: the stylesheet owns it
    tb.classList.remove("compact");
    m.style.top = "leftover";
    assert.strictEqual(A.placeMoreViews(), false, "the placer stands down");
    assert.strictEqual(m.style.top, "", "and clears anything a wider window left behind");

    ctx.window.innerWidth = 1024;                       // a tablet: inside the clipped bar
    tb.classList.add("compact");
    assert.strictEqual(A.placeMoreViews(), true);
    assert.strictEqual(m.style.top, "748px", "under its own button, in viewport coordinates");
  } finally {
    tb.classList.remove("compact"); delete btn.getBoundingClientRect;
    m.style.top = ""; m.style.left = ""; m.style.maxHeight = "";
    ctx.window.innerWidth = wasW;
  }
});

t("opening one places it — all three of them", () => {
  for (const [btn, menu] of [["btnDbMenu", "dbMenu"], ["btnListsMenu", "listsMenu"], ["mMore", "mMoreMenu"]]) {
    const src = html.slice(html.indexOf(`$("${btn}").addEventListener("click"`));
    const wired = src.slice(0, src.indexOf("});"));
    assert(wired.includes(`placeBarMenu("${btn}", "${menu}")`),
      `#${btn} opens #${menu} without placing it`);
  }
});

t("the bar's menus escape the bar that clips them", () => {
  const css = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
  const rule = css.match(/#toolbar \.menuWrap:not\(\.viewMoreWrap\) > \.menu\{[^}]*\}/);
  assert(rule, "the phone rule exists");
  assert(/position:fixed/.test(rule[0]),
    "absolute keeps it inside #toolbar{overflow:hidden}, which is why the DB menu opened into nothing");
  const media = css.lastIndexOf("@media (max-width: " + A.PHONE_W + "px)", css.indexOf(rule[0]));
  assert(media >= 0 && media < css.indexOf(rule[0]), "and it is inside the phone block");
});

console.log("\n-- the presentation view: the plan as a picture --");

/* Two main groups, one of them with a bare task sitting beside its subgroups,
   a top-level task with nothing inside it at all, and a milestone:

   Delivery (group)
     Prepare (group)   Analysis, Design   <- these two overlap in time
     Build (group)     Code
     Handover                             <- a task straight under the band
   Support (group)
     Watch (group)     Monitor
   Audit                                  <- a top-level task, nothing inside
   Go live (milestone)                                                        */
const presPlan = () => loadRowsNow([
  { id: 1,  name: "Delivery", group: [2, 5, 7] },
  { id: 2,  name: "Prepare",  group: [3, 4] },
  { id: 3,  name: "Analysis", start: "2026-03-02", end: "2026-03-06" },
  { id: 4,  name: "Design",   start: "2026-03-04", end: "2026-03-10" },
  { id: 5,  name: "Build",    group: [6] },
  { id: 6,  name: "Code",     start: "2026-03-11", end: "2026-03-20" },
  { id: 7,  name: "Handover", start: "2026-03-23", end: "2026-03-25" },
  { id: 8,  name: "Support",  group: [9] },
  { id: 9,  name: "Watch",    group: [10] },
  { id: 10, name: "Monitor",  start: "2026-04-01", end: "2026-04-08" },
  { id: 11, name: "Audit",    start: "2026-04-10", end: "2026-04-14" },
  { id: 12, name: "Go live",  start: "2026-03-21", end: "2026-03-21", ms: true },
]);
// what the board is made of, named rather than numbered
const presShape = m => m.bands.map(b => [b.t.name, b.lanes.map(l => l.t.name)]);
const presBarNames = (m, band, lane) =>
  m.bands.find(b => b.t.name === band).lanes.find(l => l.t.name === lane).bars.map(x => x.t.name);

const inPres = () => {
  presPlan();
  A.state.view = "pres"; useRef({});
  A.state.presStart = 1; A.state.presFocus = null;
};

t("a band is a main group, a lane one of its subgroups, and the bars what is inside", () => {
  inPres();
  const m = A.presModel();
  eqArr(presShape(m).map(x => x[0]), ["Delivery", "Support", "Audit"], "one band per top-level row");
  eqArr(m.bands[0].lanes.map(l => l.t.name), ["Prepare", "Build", "Handover"]);
  eqArr(presBarNames(m, "Delivery", "Prepare"), ["Analysis", "Design"], "the subgroup's own children");
  eqArr(presBarNames(m, "Delivery", "Build"), ["Code"]);
});

/* The mockup's Group 4 carries two bars on one line; two tasks that actually
   run at the same time cannot, or one of them is drawn over the other. */
t("tasks of one subgroup that overlap in time get a line each, and the lane grows", () => {
  inPres();
  const m = A.presModel();
  const prepare = m.bands[0].lanes[0], build = m.bands[0].lanes[1];
  assert.strictEqual(prepare.rows, 2, "Analysis and Design overlap");
  eqArr(prepare.bars.map(b => b.row), [0, 1], "so they are on different lines");
  assert.strictEqual(build.rows, 1, "Code is alone on its lane");
  assert.strictEqual(prepare.h, build.h + A.PRES_LINE_H, "and the taller lane is taller by exactly one line");
});

t("two tasks that do not overlap share one line", () => {
  loadRowsNow([{ id: 1, name: "Stream", group: [2] },
               { id: 2, name: "Wave",   group: [3, 4] },
               { id: 3, name: "First",  start: "2026-03-02", end: "2026-03-06" },
               { id: 4, name: "Second", start: "2026-03-09", end: "2026-03-12" }]);
  A.state.view = "pres"; A.state.presStart = 1; A.state.presFocus = null;
  const lane = A.presModel().bands[0].lanes[0];
  assert.strictEqual(lane.rows, 1);
  eqArr(lane.bars.map(b => b.row), [0, 0]);
});

/* Their answer to the third question: nothing in the plan may go missing from
   the picture because it does not fit the three-storey shape. */
t("a row with nothing inside it still gets a lane of its own", () => {
  inPres();
  const m = A.presModel();
  const handover = m.bands[0].lanes.find(l => l.t.name === "Handover");
  assert(handover && handover.leaf, "a task straight under the band is a lane beside the subgroups");
  eqArr(handover.bars.map(b => b.t.name), ["Handover"], "and it is its own bar");

  const audit = m.bands.find(b => b.t.name === "Audit");
  eqArr(audit.lanes.map(l => l.t.name), ["Audit"], "a top-level task is a band of one lane");
  assert(audit.lanes[0].leaf);
});

t("the start level drops the main groups and makes each subgroup a band", () => {
  inPres();
  A.state.presStart = 2;
  const m = A.presModel();
  eqArr(m.bands.map(b => b.t.name), ["Prepare", "Build", "Handover", "Watch", "Audit"],
    "a branch too shallow to reach that level stands in for itself rather than vanishing");
  eqArr(m.bands[0].lanes.map(l => l.t.name), ["Analysis", "Design"], "its members are the lanes now");
});

t("clicking a lane's name opens that group on its own, and the picker follows", () => {
  inPres();
  assert.strictEqual(A.presDrillTo(idOfName("Prepare")), true);
  assert.strictEqual(A.state.presFocus, idOfName("Prepare"));
  assert.strictEqual(A.state.presStart, 2, "the board really is showing that level now");
  const m = A.presModel();
  eqArr(m.bands.map(b => b.t.name), ["Prepare"], "and nothing else is drawn");
  eqArr(m.bands[0].lanes.map(l => l.t.name), ["Analysis", "Design"]);

  // a lane standing for a task has nothing to open
  assert.strictEqual(A.presDrillTo(idOfName("Handover")), false);
});

t("changing the start level lets go of the group it was narrowed to", () => {
  inPres();
  A.presDrillTo(idOfName("Prepare"));
  fireEl(els.presStart, "change", { target: { value: "1" } });
  assert.strictEqual(A.state.presFocus, null);
  assert.strictEqual(A.state.presStart, 1);
});

t("a focus on a row that has since gone falls back to the whole plan", () => {
  inPres();
  A.state.presFocus = 9999;
  eqArr(A.presModel().bands.map(b => b.t.name), ["Delivery", "Support", "Audit"]);
});

t("the panel and the chart place a lane at the same height", () => {
  inPres();
  A.renderPres();
  const m = A.presModel();
  const lane = m.bands[0].lanes[1];                       // Build, under the two-line Prepare
  assert.strictEqual(lane.top, m.bands[0].lanes[0].h, "it starts where the lane above it ends");
  // the chart draws the rule that closes a band exactly at the band's foot
  const b0 = m.bands[0];
  assert(els.presBody.innerHTML.includes(`class="presBandLine" style="top:${b0.top + b0.h}px`),
    "the band's own rule is drawn at its foot");
  assert(els.presBody.innerHTML.includes(`class="presBandBg" style="top:${b0.top}px;height:${b0.h}px`));
  // …and the next band starts a gap below it
  assert.strictEqual(m.bands[1].top, b0.top + b0.h + A.PRES_BAND_GAP);
});

t("the names panel draws a spine per band and a lane per subgroup", () => {
  inPres();
  A.renderPres();
  const left = els.presLeftBody.innerHTML;
  assert.strictEqual((left.match(/class="presSpine"/g) || []).length, 3, "three bands");
  assert(left.includes(">Delivery</span>") && left.includes(">Support</span>"), "the spine carries the group's name");
  assert(/class="presLane"[^>]*data-pres="\d+"/.test(left), "a group lane can be clicked");
  assert(/class="presLane leaf"(?![^>]*data-pres)/.test(left), "a task lane cannot");
  assert.strictEqual((left.match(/class="presGap"/g) || []).length, 2,
    "a trough between the bands, and none after the last one");
});

t("the bars are drawn with the health colour and the name inside when it fits", () => {
  inPres();
  A.state.pxPerPresDay = 30;                              // wide enough for a label
  A.renderPres();
  const body = els.presBody.innerHTML;
  const code = body.match(/<div class="bar[^"]*"[^>]*data-id="(\d+)"[\s\S]*?<\/div>/g)
    .find(b => /Code/.test(b));
  assert(code, "the bar is there");
  assert(/class="bar h\w+/.test(code), "wearing a health class: " + code.slice(0, 60));
  assert(/class="lblIn">Code/.test(code), "with its name inside it");
  assert(!/data-grip=/.test(body), "and no grips anywhere — nothing here can be dragged");
  A.state.pxPerPresDay = 14;
});

/* A rule a day is right for a chart you edit and wrong for one you show: at the
   width this board opens at, a year of them is a hatch pattern behind the work. */
t("the board rules the months always and the days only when they are wide", () => {
  inPres();
  const march = A.parseDate("2026-03-01"), days = 40;
  const wide = A.presGridHtml(march, days, A.PRES_DAY_GRID);
  const narrow = A.presGridHtml(march, days, 14);
  assert.strictEqual((narrow.match(/class="gridLine major"/g) || []).length, 2, "1 March and 1 April");
  assert.strictEqual((narrow.match(/class="gridLine"/g) || []).length, 0, "and no day rules at all");
  assert((wide.match(/class="gridLine"/g) || []).length > 30, "wide enough to count days, so they are ruled");
  assert(!/weekend/.test(narrow) && /weekend/.test(wide), "the weekend shading follows the same line");
});

t("a milestone rules the board rather than taking a lane of its own", () => {
  inPres();
  A.renderPres();
  assert(!/Go live/.test(els.presLeftBody.innerHTML), "no lane for a moment in time");
  assert(els.presBody.innerHTML.includes("msRule"), "it is a rule down the whole board");
  assert(/title="★ Go live/.test(els.presBody.innerHTML));
});

t("the filters are asked of the bars, and a lane with nothing left goes with them", () => {
  inPres();
  A.taskById(idOfName("Code")).team = "Dev";
  A.state.filterTeam = "Dev";
  const m = A.presModel();
  eqArr(m.bands.map(b => b.t.name), ["Delivery"], "only the band with work left in it");
  eqArr(m.bands[0].lanes.map(l => l.t.name), ["Build"]);
  A.state.filterTeam = "";
});

/* The Level filter has a control of its own on the bar and the presentation
   view has another; two of them in force at once, one out of sight, is a board
   nobody can account for — so this view does not take it, exactly as the list
   does not. */
t("the level filter does not narrow this view", () => {
  inPres();
  A.state.filterLevel = 3;
  assert.strictEqual(A.levelOnly(), 0, "off here, like it is off in the list");
  eqArr(A.presModel().bands.map(b => b.t.name), ["Delivery", "Support", "Audit"]);
  A.state.filterLevel = 0;
});

t("the picker names one level of grouping per setting, from the app's own constant", () => {
  assert.strictEqual(A.PRES_STARTS.length, A.GROUP_LEVELS,
    "a fifth level of grouping would need a fifth label");
  assert.strictEqual(A.presStartLabel(1), "Start: main groups");
  assert.strictEqual(A.clampPresStart(99), A.GROUP_LEVELS);
  assert.strictEqual(A.clampPresStart(0), 1);
  A.buildPresStartOptions();
  assert.strictEqual((els.presStart.innerHTML.match(/<option/g) || []).length, A.GROUP_LEVELS);
});

t("the view has a day width of its own, so zooming it leaves the Design view alone", () => {
  inPres();
  const was = A.state.pxPerDay;
  A.state.view = "pres";
  A.zoomUnit().set(40);
  assert.strictEqual(A.state.pxPerPresDay, 40);
  assert.strictEqual(A.state.pxPerDay, was, "the Design view's scale is untouched");
  assert.strictEqual(A.zoomUnit().pane, "presRight");
  A.state.pxPerPresDay = 14;
});

t("an empty board says which of the two reasons it is empty", () => {
  loadRowsNow([{ id: 1, name: "Only", start: "2026-03-02", end: "2026-03-04", team: "Dev" }]);
  A.state.view = "pres"; A.state.presStart = 1; A.state.presFocus = null;
  A.state.filterTeam = "Ops";
  A.renderPres();
  assert(/filters/.test(els.presBody.innerHTML), els.presBody.innerHTML);
  A.state.filterTeam = "";
  A.state.tasks = [];
  A.renderPres();
  assert(/presentation view/.test(els.presBody.innerHTML), els.presBody.innerHTML);
  useRef(null);
});

console.log("\n-- M2: the native filesystem adapter (Capacitor) --");

/* A fake `window.Capacitor.Plugins.Filesystem`, in-memory, throwing the same
   plain `Error("... does not exist.")` the real plugin does on a missing path
   — the one thing NativeAdapter's own exists()/readHandle() have to tell apart
   from every other failure. `seed` keys are paths relative to Directory.Documents
   ("DB/plan.gantora"), and the DB folder itself always starts out already there,
   the same as it is by the time any of these calls actually runs in the app —
   attach() is what creates it, and that has its own test below. */
function fakeCapFs(seed = {}) {
  const files = new Map(Object.entries(seed).map(([k, v]) =>
    [k, typeof v === "string" ? { text: v, mtime: 1 } : v]));
  const dirs = new Set(["DB"]);
  const log = [];
  const notFound = () => { throw new Error("File does not exist."); };
  const plugin = {
    async stat({ path }) {
      log.push("stat " + path);
      if (dirs.has(path)) return { name: path.split("/").pop(), type: "directory", size: 0, mtime: 0, ctime: 0, uri: path };
      const f = files.get(path);
      if (!f) return notFound();
      return { name: path.split("/").pop(), type: "file", size: f.text.length, mtime: f.mtime, ctime: f.mtime, uri: path };
    },
    async readFile({ path }) {
      log.push("readFile " + path);
      const f = files.get(path);
      if (!f) return notFound();
      return { data: f.text };
    },
    async writeFile({ path, data }) {
      log.push("writeFile " + path);
      files.set(path, { text: data, mtime: (files.get(path) ? files.get(path).mtime : 0) + 1 });
      return { uri: path };
    },
    async readdir({ path }) {
      log.push("readdir " + path);
      if (!dirs.has(path)) throw new Error("Folder does not exist.");
      const prefix = path + "/";
      return { files: [...files.keys()].filter(k => k.startsWith(prefix) && !k.slice(prefix.length).includes("/"))
        .map(k => ({ name: k.slice(prefix.length), type: "file", size: files.get(k).text.length })) };
    },
    async mkdir({ path }) {
      log.push("mkdir " + path);
      if (dirs.has(path)) throw new Error("Current directory does already exist.");
      dirs.add(path);
    },
    _files: files, _dirs: dirs, log,
  };
  ctx.window.Capacitor = { Plugins: { Filesystem: plugin }, isNativePlatform: () => true, getPlatform: () => "ios" };
  return plugin;
}
const noCapacitor = () => { delete ctx.window.Capacitor; };

ta("list() sees only plan files at the top of DB, sorted, fixtures and _lists.csv kept out", async () => {
  fakeCapFs({ "DB/b.gantora": "x", "DB/a.csv": "x", "DB/_lists.csv": "x", "DB/notes.txt": "x" });
  try { eqArr(await A.NativeAdapter.list(), ["a.csv", "b.gantora"]); }
  finally { noCapacitor(); }
});

ta("list() before the folder exists is empty, not a thrown error", async () => {
  const fs = fakeCapFs();
  fs._dirs.delete("DB");
  try { eqArr(await A.NativeAdapter.list(), []); }
  finally { noCapacitor(); }
});

ta("readHandle: a file at rest is read and handed over, a missing one says so, a half-written one is partial", async () => {
  const text = A.serializeCSV([]);
  fakeCapFs({ "DB/plan.gantora": text, "DB/broken.gantora": "not a plan" });
  try {
    const ok = await A.NativeAdapter.readHandle({ path: "DB/plan.gantora" });
    assert.strictEqual(ok.ok, true);
    assert.strictEqual(ok.text, text);
    assert.strictEqual((await A.NativeAdapter.readHandle({ path: "DB/nope.gantora" })).why, "unreadable");
    assert.strictEqual((await A.NativeAdapter.readHandle({ path: "DB/broken.gantora" })).why, "partial");
  } finally { noCapacitor(); }
});

ta("writeText creates the file and can be read straight back; exists() answers honestly either way", async () => {
  const fs = fakeCapFs();
  try {
    assert.strictEqual(await A.NativeAdapter.exists("plan.gantora"), false);
    const ref = await A.NativeAdapter.writeText("plan.gantora", "A|B\r\n");
    eqJson(ref, { path: "DB/plan.gantora" });
    assert.strictEqual(await A.NativeAdapter.exists("plan.gantora"), true);
    assert.strictEqual(await A.NativeAdapter.readText("plan.gantora"), "A|B\r\n");
    assert(fs.log.some(op => op === "writeFile DB/plan.gantora"));
  } finally { noCapacitor(); }
});

ta("attach() creates DB once, and a second attach on an already-there folder is not a failure", async () => {
  const fs = fakeCapFs();
  fs._dirs.delete("DB");
  try {
    await A.NativeAdapter.attach();
    assert(fs._dirs.has("DB"), "the folder was not created");
    assert.strictEqual(A.NativeAdapter.open(), true);
    await A.NativeAdapter.attach();               // the folder is there now — must not throw
  } finally { noCapacitor(); A.NativeAdapter.disconnect(); }
});

ta("connect() has no picker to open — it just says yes", async () => {
  fakeCapFs();
  try { eqJson(await A.NativeAdapter.connect(), { ok: true, root: { native: true } }); }
  finally { noCapacitor(); }
});

ta("available(), remembered() and permitted() all follow the platform, not a remembered choice", async () => {
  noCapacitor();
  assert.strictEqual(A.NativeAdapter.available(), false, "no Capacitor bridge — not a native platform");
  assert.strictEqual(await A.NativeAdapter.remembered(), null);
  fakeCapFs();
  try {
    assert.strictEqual(A.NativeAdapter.available(), true);
    assert(await A.NativeAdapter.remembered(), "the sandbox is always 'remembered' — there is nothing else it could be");
    assert.strictEqual(await A.NativeAdapter.permitted(), true, "no permission system on a sandboxed app");
  } finally { noCapacitor(); }
});

ta("the stamp: our own write landing late is not a change, somebody else's is", async () => {
  fakeCapFs({ "DB/plan.gantora": { text: "old", mtime: 5 } });
  try {
    const ref = { path: "DB/plan.gantora" };
    const stamp = await A.NativeAdapter.stampFor(ref, "old");
    eqJson(stamp, { at: 5, text: "old" });
    assert.strictEqual(await A.NativeAdapter.changed(ref, stamp), null, "nothing moved");

    // our own write: the mtime moves on, but the text we hold is the text now on disk
    await A.NativeAdapter.writeHandle(ref, "old");
    assert.strictEqual(await A.NativeAdapter.changed(ref, stamp), null, "our own write landing late is not somebody else's change");

    // a text that actually differs is reported, with a fresh stamp of its own
    ctx.window.Capacitor.Plugins.Filesystem._files.set("DB/plan.gantora", { text: "new", mtime: 99 });
    const diff = await A.NativeAdapter.changed(ref, stamp);
    assert.strictEqual(diff.text, "new");
    eqJson(diff.stamp, { at: 99, text: "new" });
  } finally { noCapacitor(); }
});

ta("in the app: creating, saving and reopening a plan through the native adapter, byte-identical either way", async () => {
  resetApp();
  const fs = fakeCapFs();
  fs._dirs.delete("DB");
  try {
    A.setStorage(A.NativeAdapter);
    await A.NativeAdapter.attach();
    A.state.dbHandle = {}; A.state.rootHandle = {};   // the seam only asks storage — these just have to be non-null
    await A.createFile("switchover");
    assert.strictEqual(A.planName(), "switchover" + A.PLAN_EXT);
    // loadRowsNow() clears planRef() on purpose ("keep saveFile() a no-op" for
    // ordinary fixtures) — restored here, the same way the Dropbox save tests do,
    // since this test means to exercise a real save through the adapter.
    loadRowsNow([{ id: 1, name: "Build", start: "2026-03-02", end: "2026-03-04" }]);
    useName("switchover.gantora"); useRef({ path: "DB/switchover.gantora" });
    A.markDirty();
    await A.saveFile(true);
    const onDisk = fs._files.get("DB/switchover.gantora").text;
    assert(onDisk.includes("Build"), onDisk);

    await A.openFile("switchover.gantora");
    assert.strictEqual(A.state.tasks.length, 1);
    assert.strictEqual(A.state.tasks[0].name, "Build");
    assert.strictEqual(A.serializeCSV(A.state.tasks), onDisk, "byte-identical with what is on disk");
  } finally { noCapacitor(); A.setStorage(A.FsaAdapter); A.NativeAdapter.disconnect(); resetApp(); }
});

console.log("\n-- the storage seam --");

/* The seam is only worth having if the operations really do live behind it. As
   each one moves in, it is named here — and the assertion is not "the adapter has
   a method called list" but "**nothing outside the adapter performs that
   operation any more**". That is what stops the seam eroding: the next feature
   that reaches for a handle directly fails a test instead of quietly working. */
const SCRIPT = html.slice(html.indexOf("<script>"), html.lastIndexOf("<" + "/script>"));
const adapterBlock = () => {
  const at = SCRIPT.indexOf("const FsaAdapter = {");
  assert(at > 0, "the adapter is gone");
  const end = SCRIPT.indexOf("\n};", at);
  assert(end > at, "the adapter block is not closed where expected");
  return SCRIPT.slice(at, end);
};
/* Since P3 there are two adapters. What lies "outside" the seam is the script
   minus both blocks — the folder's and Dropbox's — since each may legitimately
   look inside its own stamp and hold its own store. Everything else may not. */
const dbxAdapterSpan = code => {
  const at = code.indexOf("const DropboxAdapter = (() => {");
  assert(at > 0, "the Dropbox adapter is gone");
  const end = code.indexOf("\n})();", at);
  assert(end > at, "the Dropbox adapter block is not closed where expected");
  return [at, end + 6];
};
const nativeAdapterSpan = code => {
  const at = code.indexOf("const NativeAdapter = (() => {");
  assert(at > 0, "the native filesystem adapter is gone");
  const end = code.indexOf("\n})();", at);
  assert(end > at, "the native adapter block is not closed where expected");
  return [at, end + 6];
};
const outsideAdapters = (code, at, end) => {
  const [dAt, dEnd] = dbxAdapterSpan(code);
  const [nAt, nEnd] = nativeAdapterSpan(code);
  return code.slice(0, at) + code.slice(end, dAt) + code.slice(dEnd, nAt) + code.slice(nEnd);
};
const onlyInsideAdapter = (op, what) => {
  const inside = adapterBlock();
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");        // prose may discuss it
  const all = (code.match(op) || []).length;
  const mine = (inside.replace(/\/\*[\s\S]*?\*\//g, "").match(op) || []).length;
  assert(mine > 0, what + ": the adapter does not do it");
  assert.strictEqual(all, mine, what + ": " + (all - mine) + " place(s) outside the adapter still do it directly");
};

t("listing the plans happens in one place", () => {
  onlyInsideAdapter(/dbHandle\.entries\(\)/g, "listing /DB");
  assert(/const listProjectFiles = \(\) => storage\.list\(\)/.test(SCRIPT),
    "listProjectFiles should be the adapter's list(), under its old name");
});

t("reading a plan happens in one place", () => {
  assert(/const readPlanFile = fh => storage\.readHandle\(fh\)/.test(SCRIPT),
    "readPlanFile should be the adapter's readHandle(), under its old name");
  /* The three that never used the guard now go through readText(), which is the
     same raw read under a name that says so. */
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");
  assert.strictEqual((code.match(/storage\.readText\(/g) || []).length, 4,
    "the registry, the combined view, the shared-page export and the cockpit's poll read through readText()");
  assert(!/getFileHandle\(LISTS_FILE\)/.test(code), "the registry still opens its own handle");
});

t("writing a plan happens in one place", () => {
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");
  const at = code.indexOf("const FsaAdapter = {"), end = code.indexOf("\n};", at);
  const outside = outsideAdapters(code, at, end);   // both adapters are inside the seam
  const fnAt = i => {
    const m = [...outside.slice(0, i).matchAll(/(?:async )?function ([A-Za-z_]\w*)/g)];
    return m.length ? m[m.length - 1][1] : "(top)";
  };
  const who = new Set();
  let m; const re = /createWritable\(/g;
  while ((m = re.exec(outside))) who.add(fnAt(m.index));

  /* The three that stay for ever: each writes to wherever the *reader* chose,
     through a save picker, and has nothing to do with the plan store. A Dropbox
     session still exports its spreadsheet to the local disk. */
  eqArr([...who].sort(), ["exportExcel", "exportSnapshot", "saveRestricted"],
    "only the three local-disk exports may open a writable of their own");

  /* …and the five plan-store writers go through the adapter: the registry, a
     new file, the free edition's fixed file, a saved copy, and (2026-08-21) a
     plan pasted back from an assistant. */
  assert.strictEqual((code.match(/storage\.writeText\(/g) || []).length, 5,
    "every plan-store write goes through writeText(), including the one that comes back from a chat");
  // …handing over the stamp, which is the version a compare-and-swap store writes over (P3)
  assert(/storage\.writeHandle\(planRef\(\), text, state\.stamp\)/.test(code),
    "an ordinary save writes to the plan it already has open, over the version it agreed with");
  assert(!/keepExistingData/.test(code), "the writable must stay the atomic kind");
});

t("asking whether a file is there happens in one place", () => {
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");
  assert(/const fileExists = name => storage\.exists\(name\)/.test(code),
    "fileExists should be the adapter's exists(), under its old name");
  /* Five askers: the plan we had open last (twice — startup, and leaving the
     combined view), a name in the address, the free edition's fixed file, and
     (2026-08-20) the plan a Dropbox folder was left in, which may have been
     deleted from another machine since. */
  /* …and since 2026-08-21 a sixth: restorePlan(), asking whether the name a
     plan is coming back to has been taken while it sat in the wastebasket. */
  // six askers plus fileExists's own delegation
  assert.strictEqual((code.match(/storage\.exists\(/g) || []).length, 7,
    "every existence check goes through the adapter");
  /* Probing by catching an exception is fine — inside the adapter, which is the
     one place that knows a missing file throws. Outside it, an answer of "no"
     should arrive as a value. */
  const at = code.indexOf("const FsaAdapter = {"), end = code.indexOf("\n};", at);
  const outside = outsideAdapters(code, at, end);   // both adapters are inside the seam
  assert(!/try \{ await state\.dbHandle\.getFileHandle/.test(outside),
    "something outside the adapter still probes for a file by catching an exception");
  assert(/try \{ await state\.dbHandle\.getFileHandle\(name\); return true; \}/.test(code.slice(at, end)),
    "…and the adapter is where that lives");
});

/* `openFile` and `compareWith` were the last two taking a handle of their own —
   both wanting the thing that *identifies* a plan rather than its contents. They
   ask the adapter for a ref by name now, so nothing outside opens a handle at
   all, and the fence below says so far more strictly than counting could. */
t("no function outside the adapter takes a handle", () => {
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");
  const at = code.indexOf("const FsaAdapter = {"), end = code.indexOf("\n};", at);
  const outside = outsideAdapters(code, at, end);   // both adapters are inside the seam
  assert.strictEqual((outside.match(/getFileHandle\(/g) || []).length, 0,
    "something outside the adapter opens a handle");
  assert(/storage\.refFor\(name\)/.test(outside), "…and a name is turned into a ref by asking");
});

/* Connecting is the deepest difference between the two sources — a folder picker
   and a permission asked afresh on every page load here, an authorisation code
   pasted from another tab there. What must not differ is anything above it. */
t("getting a store at all happens in one place", () => {
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");
  const at = code.indexOf("const FsaAdapter = {"), end = code.indexOf("\n};", at);
  const inside = code.slice(at, end), outside = code.slice(0, at) + code.slice(end);

  for (const [op, what] of [
    [/showDirectoryPicker/g, "opening the picker"],
    [/getDirectoryHandle\(/g, "finding the DB folder"],
    [/queryPermission|requestPermission/g, "asking for permission"],
    [/idb\("(get|put|del)", "root"/g, "remembering the folder"],
  ]) {
    assert((inside.match(op) || []).length > 0, what + ": the adapter does not do it");
    assert.strictEqual((outside.match(op) || []).length, 0,
      what + " still happens outside the adapter");
  }

  /* …and the policy around it stays with the app, where it belongs: which
     failure is worth a toast, and what to open once there is a store. */
  const pick = code.slice(code.indexOf("async function pickFolder"));
  assert(/got\.why === "cancelled"/.test(pick), "a cancel says nothing, and that is the app's call");
  assert(/storage\.connect\(fsMode\(\)\)/.test(pick), "the picker itself is the adapter's");
  assert(/storage\.remember\(got\.root\)/.test(pick), "and so is remembering it");
});

/* The stamp is the one piece of state the two adapters can never share: here it
   is a modification time and the text, on Dropbox a `rev` the server compares.
   It only works if nobody outside looks inside it. */
t("the stamp is opaque outside the adapter", () => {
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");
  const at = code.indexOf("const FsaAdapter = {"), end = code.indexOf("\n};", at);
  const outside = outsideAdapters(code, at, end);   // both adapters are inside the seam

  const peeks = [...outside.matchAll(/state\.stamp\.\w+|stamp\.(at|text)\b/g)].map(m => m[0]);
  assert.deepStrictEqual(peeks, [],
    "something outside the adapter reads inside the stamp: " + peeks.join(", "));
  assert(!/state\.fileStamp|state\.fileText/.test(code),
    "the two-field stamp is gone; there is one opaque one");

  /* The single fact the app legitimately needs from a stamp — when it was taken,
     so a conflict can name who has touched a row since — comes through a named
     accessor rather than by reaching into the object. */
  assert(/whoChanged\(text, storage\.stampTime\(state\.stamp\)\)/.test(code),
    "the conflict message should ask the adapter when the stamp was taken");
  assert(/stampTime: stamp =>/.test(code.slice(at, end)), "…and the adapter should answer that");
});

t("the guard keeps the policy and gives away the comparison", () => {
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");
  const fn = code.slice(code.indexOf("async function fileChangedUnderUs"));
  const body = fn.slice(0, fn.indexOf("\n}") + 2);
  // what stays here: the two questions that are the app's, not the store's
  assert(/state\.stampUnknown/.test(body), "‘could it be asked at all’ stays with the app");
  assert(/storage\.changed\(planRef\(\), state\.stamp\)/.test(body), "the comparison is the adapter's");
  assert(!/lastModified|\.text\(\)/.test(body), "and no comparing is left behind in it");
});

/* ================= The fence the whole seam rests on =================
   Two halves, because closing only the first would move the leak rather than
   stop it: no handle may be reached for outside the adapter, **and** the ref
   inside `state.openPlan` may not be opened there either. A ref that everybody
   dereferences is a handle wearing a different name. */
t("nothing outside the adapter holds a handle, or opens a ref", () => {
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");
  const at = code.indexOf("const FsaAdapter = {"), end = code.indexOf("\n};", at);
  const outside = outsideAdapters(code, at, end);   // both adapters are inside the seam

  // half one: the handles themselves
  for (const f of ["state.fileHandle", "state.dbHandle"]) {
    const n = outside.split(f).length - 1;
    assert.strictEqual(n, 0, n + " reference(s) to " + f + " outside the adapter");
  }

  /* Half two: the ref is passed, never opened. Exactly one place may look
     inside — planRef(), the named accessor the rest of the app asks — and it is
     excluded by name rather than by silence. */
  const accessor = "const planRef  = () => (state.openPlan && state.openPlan.ref) || null;";
  assert(outside.includes(accessor), "the one accessor allowed to look inside the ref is gone");
  const rest = outside.split(accessor).join("");
  const peeks = [...rest.matchAll(/\.ref\s*\.\w+|\.ref\s*\(|openPlan\.ref/g)].map(m => m[0]);
  assert.deepStrictEqual(peeks, [],
    "something outside the adapter opens the ref instead of passing it: " + peeks.join(", "));

  // and the app asks its three questions by name
  for (const q of ["planName()", "planRef()", "planOpen()"])
    assert(outside.includes(q), "the app should ask " + q);
});

/* **An exception, stated rather than silent — and an open item for P3.**

   `lastPlan` in IndexedDB holds a `FileSystemFileHandle`: it is how the app
   remembers which plan was open across sessions, and it is a *persisted* form of
   the ref. On Dropbox the same thing would be `{namespace_id, path}` — so the ref
   has a serialised shape as well as a runtime one, and that is a second place its
   nature can leak.

   Deliberately not solved in P1, which changes nothing. When the Dropbox adapter
   lands, decide there whether it grows `serializeRef()` / `restoreRef()` or simply
   owns the whole "remember and restore the last plan" concern. Until then this
   test pins the exception so it cannot quietly become the way things are. */
t("the one place a ref is persisted, and it is FSA-shaped for now", () => {
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");
  assert(/const LAST_PLAN_KEY = "lastPlan"/.test(code), "the key is gone or renamed");
  const keeps = [...code.matchAll(/idb\("(get|put)", LAST_PLAN_KEY[^)]*\)/g)].map(m => m[0]);
  assert.strictEqual(keeps.length, 2, "exactly one place writes it and one reads it: " + keeps.join(", "));
  /* It stores a handle, not a name — which is the whole point of the exception,
     and what a second adapter will have to answer for. */
  assert(/rememberPlanHandle = async fh =>/.test(code), "…and what it stores is a handle");
});

/* **A budget, not a rule.** Some reading is legitimately outside the adapter and
   always will be — the Excel import reads a file the reader picked, from anywhere.
   The rest is what later slices take in. Naming the remainder and counting it
   means a new leak fails immediately instead of hiding among the known ones, and
   the number coming down is the refactor's visible progress. */
t("what still reaches for a file outside the adapter, and why", () => {
  const code = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, "");
  const at = code.indexOf("const FsaAdapter = {"), end = code.indexOf("\n};", at);
  const outside = outsideAdapters(code, at, end);   // both adapters are inside the seam
  const fnAt = i => {
    const m = [...outside.slice(0, i).matchAll(/(?:async )?function ([A-Za-z_]\w*)/g)];
    return m.length ? m[m.length - 1][1] : "(top)";
  };
  const who = new Set();
  let m; const re = /\.getFile\(\)/g;
  while ((m = re.exec(outside))) who.add(fnAt(m.index));

  /* **The floor.** One function still reads a file of its own, and always will:
     the Excel import reads whatever the reader picked, from anywhere on the disk.
     That is not the plan store and no adapter should pretend it is. Everything
     else is behind the seam, so this list going up again means a leak. */
  eqArr([...who].sort(), ["pickImportFile"],
    "the set of functions still reading a file directly has changed — if that was " +
    "intended, move the name; if not, something leaked");
});

ta("the adapter is what the app actually asks", async () => {
  const keep = A.state.dbHandle;
  try {
    A.state.dbHandle = tracedDb({ "b.gantora": "", "a.gantora": "", "_fixture.gantora": "", "notes.txt": "" });
    const direct = await A.FsaAdapter.list();
    const viaApp = await A.listProjectFiles();
    eqArr(direct, ["a.gantora", "b.gantora"], "name order, and the _ prefix is not a plan");
    eqArr(viaApp, direct, "the app's answer is the adapter's answer");
  } finally { A.state.dbHandle = keep; }
});

console.log("\n-- the I/O trace: what the app asks the disk, and in what order --");

/* **Why this exists, and why it is not just another test.**

   The suite is the project's one objective success criterion, and for a refactor
   of the file handling it is not enough on its own: 1142 checks would still pass
   if two reads swapped places, or if a `getFile()` that *looks* redundant went
   away. In a folder somebody else is syncing, those are exactly the changes that
   cannot be traced back afterwards — a plan is simply shorter one morning.

   So this records the **ordered sequence of operations** each flow performs and
   freezes it. The `StorageAdapter` seam is about to move every one of these calls
   behind an interface; the claim that goes with it is "nothing changed", and this
   is the only thing that can hold that claim to account. When a slice of the
   refactor alters a trace, one of two things is true — the refactor changed
   behaviour, or the change was intended and the expectation below is what has to
   be edited, deliberately and in the same commit.

   Written before any of the moving starts, against the code as it stands. */

let ioLog = null;
const rec = op => { if (ioLog) ioLog.push(op); };

/* A /DB that answers like the real thing and writes everything down. Sizes and
   times are fixed: a trace has to be the same on every run, so nothing here may
   read the clock. */
const tracedDb = files => {
  const stamps = {};
  for (const n of Object.keys(files)) stamps[n] = 1000;
  const handleFor = name => ({
    name,
    getFile: async () => {
      rec("getFile " + name);
      const text = files[name];
      return {
        lastModified: stamps[name], size: text.length,
        text: async () => { rec("text " + name); return text; },
      };
    },
    createWritable: async () => {
      rec("createWritable " + name);
      return {
        write: async t => { rec("write " + name + " " + t.length); files[name] = t; },
        close: async () => { rec("close " + name); stamps[name] = (stamps[name] || 1000) + 1; },
      };
    },
  });
  return {
    files, stamps,
    getFileHandle: async (name, opts) => {
      rec("getFileHandle " + name + (opts && opts.create ? " create" : ""));
      if (!(name in files)) {
        if (!(opts && opts.create)) throw Object.assign(new Error("no such file"), { name: "NotFoundError" });
        files[name] = "";
      }
      return handleFor(name);
    },
    entries: async function* () {
      rec("entries");
      for (const n of Object.keys(files)) yield [n, { kind: "file" }];
    },
    removeEntry: async name => { rec("removeEntry " + name); delete files[name]; },
  };
};

/* One flow, traced. Everything the app might have been left holding is put back
   afterwards — the stub DOM and A.state both keep whatever the last test wrote. */
const traceOf = async (setup, run) => {
  const keep = { db: A.state.dbHandle, fh: A.planRef(), name: A.planName(),
                 tasks: A.state.tasks, dirty: A.state.dirty, stamp: A.state.stamp,
                 unk: A.state.stampUnknown, ro: A.state.readOnly,
                 comb: A.state.combined, view: A.state.view, want: A.state.wantFile,
                 prompt: ctx.prompt, confirm: ctx.confirm, alert: ctx.alert };
  try {
    A.state.readOnly = false; A.state.combined = null; A.state.view = "table";
    A.state.stampUnknown = false; A.state.wantFile = "";
    ctx.alert = () => {}; ctx.confirm = () => true;
    /* Getting *into* the state a flow starts from is itself file work — opening
       the plan you are about to save. Recording begins after it, or every trace
       would carry the previous one in front of it. */
    ioLog = null;
    const db = await setup();
    ioLog = [];
    await run(db);
    return ioLog.slice();
  } finally {
    ioLog = null;
    A.closeConflict();
    A.state.dbHandle = keep.db; useRef(keep.fh); useName(keep.name);
    A.state.tasks = keep.tasks; A.state.dirty = keep.dirty; A.state.stamp = keep.stamp;
    A.state.stampUnknown = keep.unk; A.state.readOnly = keep.ro;
    A.state.combined = keep.comb; A.state.view = keep.view; A.state.wantFile = keep.want;
    ctx.prompt = keep.prompt; ctx.confirm = keep.confirm; ctx.alert = keep.alert;
  }
};

// a plan on disk, and a second one to compare against
const PLAN_A = () => wholePlan(3);
const PLAN_B = () => stampedPlan("Their work", "Anna Reed", "2026-08-12T10:00:00Z");

const openedDb = async (extra) => {
  const db = tracedDb(Object.assign({ "plan.gantora": PLAN_A() }, extra || {}));
  A.state.dbHandle = db;
  await A.openFile("plan.gantora", true);
  return db;
};

/* The trace answers "in what order", and deliberately not "with what bytes": a
   write's length moves whenever the sample plan grows a column, and a trace that
   has to be re-frozen for an unrelated reason stops being believed. What was
   written is asserted separately, where it matters. */
const maskLen = ops => ops.map(o => o.replace(/^(write \S+) \d+$/, "$1"));

const eqTrace = (got, want, what) =>
  assert.strictEqual(maskLen(got).join("\n"), maskLen(want).join("\n"),
    what + "\n--- the disk was asked ---\n" + got.join("\n") +
    "\n--- and the frozen trace is ---\n" + want.join("\n") + "\n");

/* ---- the eight flows, frozen -------------------------------------------- */

ta("opening a plan: the exact reads, in order", async () => {
  const t = await traceOf(async () => {
    const db = tracedDb({ "plan.gantora": PLAN_A() });
    A.state.dbHandle = db;
    return db;
  }, async () => { await A.openFile("plan.gantora", true); });

  /* Three getFile() in a row is not a mistake: two of them are readPlanFile's
     settle check either side of the read — same size, same time, so the file was
     at rest — and the third is rememberFileStamp recording what we agreed with.
     This is the trio the seam is most likely to "tidy up", and the reason the
     order is frozen rather than the outcome. */
  eqTrace(t, [
    "getFileHandle plan.gantora",
    "getFile plan.gantora",
    "text plan.gantora",
    "getFile plan.gantora",
    "getFile plan.gantora",
  ], "opening a plan");
});

ta("saving: the guard looks before the write, every time", async () => {
  let wanted = "", landed = "";
  const t = await traceOf(() => openedDb(), async db => {
    A.state.tasks[0].name = "Edited";
    wanted = A.serializeCSV(A.state.tasks);
    await A.saveFile(true);
    landed = db.files["plan.gantora"];
  });
  eqTrace(t, [
    "getFile plan.gantora",          // fileChangedUnderUs: has anybody else been here
    "createWritable plan.gantora",
    "write plan.gantora",
    "close plan.gantora",
    "getFile plan.gantora",          // rememberFileStamp, with the text we just wrote
  ], "an ordinary save");
  // and what landed is the whole plan as it stood, not a fragment of it
  assert.strictEqual(landed, wanted, "the file on disk is not what was on screen");
  assert(landed.includes("Edited"), "the edit did not reach the file");
});

ta("a save that finds somebody else's work writes nothing", async () => {
  const t = await traceOf(() => openedDb(), async db => {
    db.files["plan.gantora"] = PLAN_B();      // somebody else's version, landed
    db.stamps["plan.gantora"] = 9000;
    A.state.tasks[0].name = "Mine";
    await A.saveFile(true);
  });
  /* **This trace changed in the stamp slice, and it is the one change the seam
     could not avoid.** It used to read the text *twice*: once in the guard, to
     compare against ours, and again in handleConflict — off the same `File`
     object, which CLAUDE.md says never to hold on to, because a snapshot whose
     file has moved on throws NotReadableError on the next read. On a synced
     folder that meant the conflict dialog could fail to open at the moment it
     was most needed.

     The second read is gone because a `File` is a File System Access object and
     cannot travel across an interface Dropbox has to satisfy too: the guard now
     hands the dialog the text it already had. Re-frozen here, in the commit that
     did it, rather than quietly relaxed — which is the whole reason the traces
     are written down. */
  eqTrace(t, [
    "getFile plan.gantora",
    "text plan.gantora",              // the guard reads it once, and passes it on
  ], "a conflicting save");
  assert(!t.some(op => /createWritable|write /.test(op)), "nothing may be written on this path");
});

ta("creating a file: written once with a header, then opened", async () => {
  // createFile takes the name; only a taken or invalid one reaches a prompt
  const t = await traceOf(() => openedDb(), async () => { await A.createFile("switchover"); });
  assert(t.includes("getFileHandle switchover.gantora create"), t.join(" · "));
  assert.strictEqual(t.filter(op => op.startsWith("write ")).length, 1, "written exactly once: " + t.join(" · "));
  /* Creating ends by opening what it made, so the open's own trace follows —
     the header write and then the ordinary read. Worth freezing whole: the seam
     could easily leave a created file unopened and nothing else would notice. */
  assert(t.indexOf("close switchover.gantora") < t.indexOf("text switchover.gantora"),
    "the file is read after it is written, not before: " + t.join(" · "));
  assert(t.filter(op => op === "entries").length >= 1, "the file list is refreshed: " + t.join(" · "));
});

ta("save as: the copy is written, the original is not touched again", async () => {
  const t = await traceOf(() => openedDb(), async () => {
    const p = A.saveAsFile();
    await new Promise(r => setTimeout(r, 0));
    els.askInput.value = "copy-of-plan";
    A.askSubmit();
    await p;
  });
  assert(t.includes("getFileHandle copy-of-plan.gantora create"), t.join(" · "));
  assert(!t.some(op => /^(createWritable|write) plan\.gantora/.test(op)),
    "the file we saved *from* must not be written: " + t.join(" · "));
});

ta("compare & merge reads the other file, and only reads it", async () => {
  const t = await traceOf(() => openedDb({ "other.gantora": PLAN_B() }),
    async () => { await A.compareWith("other.gantora"); });
  eqTrace(t, [
    "getFileHandle other.gantora",
    "getFile other.gantora",
    "text other.gantora",
    "getFile other.gantora",
  ], "reading the other side of a merge");
  assert(!t.some(op => /createWritable|write |removeEntry/.test(op)), "a merge source is never written");
});

ta("the combined view reads each file once", async () => {
  const t = await traceOf(async () => {
    const db = tracedDb({ "a.gantora": PLAN_A(), "b.gantora": PLAN_B() });
    A.state.dbHandle = db;
    return db;
  }, async () => { await A.openCombined(["a.gantora", "b.gantora"]); });
  for (const n of ["a.gantora", "b.gantora"]) {
    assert.strictEqual(t.filter(op => op === "text " + n).length, 1, n + " read more than once: " + t.join(" · "));
    assert(t.includes("getFileHandle " + n), n + " not opened");
  }
  assert(!t.some(op => /createWritable|write /.test(op)), "the combined view is read-only");
});

ta("the cockpit's poll costs one look, and re-reads only on a change", async () => {
  /* cockStart() is what resets the cockpit's memory of the file, and it is
     module-level — left over from an earlier test it makes the first poll think
     the plan has changed and re-read the whole thing. cockStop() takes the timer
     back down again; neither does any file work, so the trace is unaffected. */
  const freshCockpit = async () => { const db = await openedDb(); A.cockStart(); A.cockStop(); return db; };

  const quiet = await traceOf(freshCockpit, async () => {
    A.state.view = "cockpit"; A.state.dirty = false;
    await A.cockPoll();                       // first look: nothing to compare against yet
    await A.cockPoll();                       // unchanged
  });
  assert.strictEqual(quiet.filter(op => op.startsWith("getFileHandle")).length, 2,
    "two polls, two looks: " + quiet.join(" · "));
  assert(!quiet.some(op => op.startsWith("createWritable")), "a poll never writes");

  const moved = await traceOf(freshCockpit, async db => {
    A.state.view = "cockpit"; A.state.dirty = false;
    await A.cockPoll();
    db.files["plan.gantora"] = PLAN_B();
    db.stamps["plan.gantora"] = 9000;
    await A.cockPoll();                       // changed: the whole plan is re-read
  });
  assert(moved.length > quiet.length, "a changed file costs more than an unchanged one");
});

/* The trace is worth nothing if the recorder cannot see the calls. */
ta("the recorder is wired to the operations it claims to record", async () => {
  const t = await traceOf(async () => {
    const db = tracedDb({ "plan.gantora": PLAN_A() });
    A.state.dbHandle = db;
    return db;
  }, async db => {
    const fh = await db.getFileHandle("plan.gantora");
    const f = await fh.getFile();
    await f.text();
    const w = await fh.createWritable();
    await w.write("x");
    await w.close();
    await db.removeEntry("plan.gantora");
    for await (const e of db.entries()) void e;
  });
  eqTrace(t, [
    "getFileHandle plan.gantora", "getFile plan.gantora", "text plan.gantora",
    "createWritable plan.gantora", "write plan.gantora 1", "close plan.gantora",
    "removeEntry plan.gantora", "entries",
  ], "every operation the recorder knows about");
});

/* =====================================================================
   scripts/plan-lock.js — how three writers share one plan file
   =====================================================================
   🔴 Not the app, and here anyway. On 2026-08-23 two CLI writers collided on
   Gantora_Roadmap__ai.gantora and one of them flattened the other's work; the
   lock and the compare-and-swap are what came out of that afternoon. A thing
   that exists to protect Daniel's data is covered by the thing that runs before
   every commit — a mechanism nobody exercises is a mechanism nobody trusts.

   Real files in a real temp directory, because the whole point is what the
   filesystem does: `wx`, EEXIST, and bytes changing under a reader. `settleMs:0`
   throughout — the settle is for a sync client, and there is none in /tmp. */
console.log("\n-- the plan lock: three writers, one file --");
{
  const planLock = require(__dirname + "/scripts/plan-lock");   // absolute: the roadmap tool runs this file from scripts/
  const osmod = require("os"), pathmod = require("path");
  const dir = fs.mkdtempSync(pathmod.join(osmod.tmpdir(), "gantora-lock-"));
  const plan = pathmod.join(dir, "Plan.gantora");
  const fresh = (body) => { fs.writeFileSync(plan, body || "one|two\n1|A\n"); return plan; };
  const opts = { settleMs: 0 };

  t("one writer holds it, the second is told who has it", () => {
    fresh();
    const a = planLock.acquire(plan, "Malcolm (CLI, Mac)", opts);
    assert(fs.existsSync(plan + ".lock"), "the lock is a sibling of the plan");
    let e = null;
    try { planLock.acquire(plan, "Winston (CLI, Windows)", opts); } catch (x) { e = x; }
    assert(e && e.name === "LockHeld", "the second writer is refused: " + e);
    assert(/Malcolm/.test(e.message), "…and told whose it is, not just that it failed: " + e.message);
    assert(planLock.release(a), "released by the one who took it");
    assert(!fs.existsSync(plan + ".lock"), "and the file is gone");
    const b = planLock.acquire(plan, "Winston (CLI, Windows)", opts);
    assert(b && b.id, "so the next writer can take it");
    planLock.release(b);
  });

  /* A run that died — a crash, a closed laptop — must not hold the plan hostage
     until a human notices. It is broken, and loudly: whoever comes back to that
     desk should be able to find out what happened. */
  t("a lock older than the limit is stale, and breaking it is said out loud", () => {
    fresh();
    const a = planLock.acquire(plan, "Malcolm (CLI, Mac)", opts);
    const old = JSON.parse(fs.readFileSync(a.path, "utf8"));
    old.at = new Date(Date.now() - 60 * 60 * 1000).toISOString();     // an hour ago
    fs.writeFileSync(a.path, JSON.stringify(old));
    let told = null;
    const b = planLock.acquire(plan, "Winston (CLI, Windows)",
      { settleMs: 0, onStale: (info, secs) => { told = { who: info.who, secs }; } });
    assert(told && /Malcolm/.test(told.who), "the break names who left it: " + JSON.stringify(told));
    assert(told.secs >= 3500, "…and how long ago: " + told.secs + "s");
    /* And the writer whose lock was broken must not delete the new holder's:
       that would pull the floor from under a run that is mid-write. */
    assert.strictEqual(planLock.release(a), false, "the old holder cannot release a lock that is no longer his");
    assert(fs.existsSync(b.path), "the new holder still has it");
    planLock.release(b);
  });

  /* A lock nobody can read is a lock nobody can release. Treat it as stale
     rather than as a wall — the alternative is a file that stays locked for
     ever because one process wrote half a line. */
  t("an unreadable lock is stale, not a wall", () => {
    fresh();
    fs.writeFileSync(plan + ".lock", "{ this is not json");
    const a = planLock.acquire(plan, "Malcolm (CLI, Mac)", opts);
    assert(a && a.id, "it is taken rather than refused for ever");
    planLock.release(a);
  });

  /* 🔴 The one that matters. The lock is advisory — a sync client can hand two
     machines the same "exclusive" create — so the guarantee has to be the bytes
     themselves: what we read is what we overwrite, or we do not overwrite. */
  t("the bytes are the guarantee: a file that moved under us is not written", () => {
    fresh("one|two\n1|A\n");
    const snap = planLock.snapshot(plan);
    assert(planLock.unchanged(snap), "nothing has happened yet");
    fs.writeFileSync(plan, "one|two\n1|A\n2|B\n");                    // somebody else writes
    assert(!planLock.unchanged(snap), "and now it has");
    let e = null;
    try { planLock.assertUnchanged(snap); } catch (x) { e = x; }
    assert(e && /changed under us/.test(e.message), "it says what happened: " + e);
    assert(/NOTHING was written/.test(e.message), "…and what it did about it: " + e.message);
  });

  t("withLock writes when the file held still, and never when it did not", () => {
    fresh("one|two\n1|A\n");
    const out = planLock.withLock(plan, "Malcolm (CLI, Mac)", () => "one|two\n1|EDITED\n", opts);
    assert.strictEqual(fs.readFileSync(plan, "utf8"), out, "the edit landed");
    assert(!fs.existsSync(plan + ".lock"), "and the lock was given back");

    let e = null;
    try {
      planLock.withLock(plan, "Malcolm (CLI, Mac)", () => {
        fs.writeFileSync(plan, "one|two\n1|SOMEBODY ELSE\n");         // moved mid-edit
        return "one|two\n1|MINE\n";
      }, opts);
    } catch (x) { e = x; }
    assert(e && /changed under us/.test(e.message), "the write was refused: " + e);
    assert.strictEqual(fs.readFileSync(plan, "utf8"), "one|two\n1|SOMEBODY ELSE\n",
      "🔴 and the other writer's bytes are still there — ours were dropped, not theirs");
    assert(!fs.existsSync(plan + ".lock"), "the lock is released even when the write is refused");
  });

  /* What a sync client does when two machines create the same file at the same
     moment. Ours by the local filesystem, contested by Dropbox — and two
     writers who both believe they won is the state this whole file prevents. */
  t("a conflicted copy of the lock means we did not win it", () => {
    fresh();
    const stem = pathmod.basename(plan);
    let e = null;
    /* The sibling has to exist at the moment of the read-back, which is what a
       settle wait is for; created up front, it is the same picture. */
    fs.writeFileSync(pathmod.join(dir, stem + " (Winston's conflicted copy 2026-08-23).lock"), "{}");
    try { planLock.acquire(plan, "Malcolm (CLI, Mac)", opts); } catch (x) { e = x; }
    assert(e && /same moment/.test(e.message), "we stand down: " + e);
    assert(!fs.existsSync(plan + ".lock"), "…and leave the field rather than holding a lock we did not win");
    fs.rmSync(pathmod.join(dir, stem + " (Winston's conflicted copy 2026-08-23).lock"), { force: true });
  });

  t("the lock is invisible to the app, like .crswap and .trash before it", () => {
    /* isPlanFile() is a positive whitelist on the plan extensions, so a sidecar
       ending .lock is never listed as a plan. Asserted against the app's own
       function rather than against a rule written twice. */
    assert(!A.isPlanFile("Plan.gantora.lock"), "the lock file is not a plan");
    assert(A.isPlanFile("Plan.gantora"), "…and the plan still is");
  });

  fs.rmSync(dir, { recursive: true, force: true });
}

(async () => {
  for (const [name, fn] of asyncTests) { await fn(); console.log("  ok  " + name); pass++; }
  console.log("\n" + pass + " checks passed\n");
})().catch(e => { console.error(e); process.exit(1); });


