diff --git a/scripts/adapters/generic-adapter.mjs b/scripts/adapters/generic-adapter.mjs index 96cf7b0..4d6df50 100644 --- a/scripts/adapters/generic-adapter.mjs +++ b/scripts/adapters/generic-adapter.mjs @@ -7,11 +7,24 @@ * without a hand-written adapter, as long as the system manifest includes * foundry_path annotations on its character fields. * - * Field definitions specify: + * Field definitions specify either a SCALAR mapping or a COLLECTION mapping: + * + * Scalar (a single value on actor.system): * - key: Chronicle field key (e.g. "hp_current") * - foundry_path: dot-notation path on actor.system (e.g. "system.attributes.hp.value") * - foundry_writable: whether Chronicle may write back to this Foundry path (default true) * - type: field type ("number", "string", etc.) for casting + * + * Collection (embedded documents — abilities, inventory, features — that live in + * actor.items[] etc., which a dot-path cannot reach): + * - key: Chronicle field key (e.g. "abilities_json") + * - foundry_collection: the actor collection to read ("items", "effects") + * - foundry_item_type: optional Foundry item type(s) to keep (string or string[]) + * - foundry_item_fields: projection { outKey: "dot.path.on.item" }; omit for a + * default {id,name,type} projection + * - type: "json"/"string" → serialized JSON string; else a raw array + * Collection fields are READ-ONLY today (pull only); write-back is a future tier, + * so they are never included in the Foundry update path. */ /** @@ -35,14 +48,17 @@ export async function createGenericAdapter(api, chronicleSystemId) { return null; } - // Only include fields that have a foundry_path annotation. - const mappedFields = fieldDefs.fields.filter((f) => f.foundry_path); + // A field is pullable if it maps a scalar (foundry_path) OR a collection + // (foundry_collection). Both are read on toChronicleFields. + const mappedFields = fieldDefs.fields.filter((f) => f.foundry_path || f.foundry_collection); if (mappedFields.length === 0) { - console.warn(`Chronicle: Generic adapter — no fields with foundry_path for "${chronicleSystemId}"`); + console.warn(`Chronicle: Generic adapter — no fields with foundry_path/foundry_collection for "${chronicleSystemId}"`); return null; } - const writableFields = mappedFields.filter((f) => f.foundry_writable !== false); + // Only scalar (foundry_path) fields are writable back to Foundry — collection + // write-back is a future tier, so it is excluded from the update path here. + const writableFields = mappedFields.filter((f) => f.foundry_path && f.foundry_writable !== false); console.debug( `Chronicle: Generic adapter loaded for "${chronicleSystemId}" — ` + @@ -72,12 +88,7 @@ export async function createGenericAdapter(api, chronicleSystemId) { * @returns {object} Chronicle fields_data object. */ toChronicleFields(actor) { - const result = {}; - for (const field of mappedFields) { - const value = _getNestedValue(actor, field.foundry_path); - result[field.key] = value ?? null; - } - return result; + return buildChronicleFields(actor, mappedFields); }, /** @@ -117,13 +128,14 @@ export async function createGenericAdapter(api, chronicleSystemId) { /** * Read a nested value from an object using dot-notation path. * Supports both nested objects and Foundry's system data. - * e.g., _getNestedValue(actor, "system.abilities.str.value") + * e.g., getNestedValue(actor, "system.abilities.str.value") * * @param {object} obj * @param {string} path * @returns {*} */ -function _getNestedValue(obj, path) { +export function getNestedValue(obj, path) { + if (!path) return undefined; const keys = path.split('.'); let current = obj; for (const key of keys) { @@ -132,3 +144,72 @@ function _getNestedValue(obj, path) { } return current; } + +/** + * Extract a collection-mapped field (e.g. abilities/inventory from actor.items[]). + * Reads field.foundry_collection off the actor, optionally filters by + * foundry_item_type, projects each entry per foundry_item_fields (or a default + * {id,name,type}), and returns a JSON string (type json/string) or a raw array. + * Defensive — a malformed actor/collection yields an empty result, never throws. + * + * @param {object} actor + * @param {object} field - field def with foundry_collection + * @returns {string|Array} + */ +export function extractCollectionField(actor, field) { + const wantJson = field.type === 'json' || field.type === 'string'; + const empty = wantJson ? '[]' : []; + try { + const coll = actor?.[field.foundry_collection]; + if (!coll) return empty; + let contents = coll.contents + || (typeof coll[Symbol.iterator] === 'function' ? Array.from(coll) : []); + + if (field.foundry_item_type) { + const types = Array.isArray(field.foundry_item_type) + ? field.foundry_item_type + : [field.foundry_item_type]; + contents = contents.filter((it) => it && types.includes(it.type)); + } + + const proj = field.foundry_item_fields && typeof field.foundry_item_fields === 'object' + ? field.foundry_item_fields + : null; + + const items = contents.map((it) => { + if (!proj) return { id: it.id ?? null, name: it.name ?? null, type: it.type ?? null }; + const out = {}; + for (const [outKey, path] of Object.entries(proj)) { + out[outKey] = getNestedValue(it, path) ?? null; + } + return out; + }); + + return wantJson ? JSON.stringify(items) : items; + } catch (err) { + console.warn(`Chronicle: generic adapter — collection extract failed for "${field.key}"`, err); + return empty; + } +} + +/** + * Build a Chronicle fields_data object from a Foundry actor and the mapped field + * defs. Scalar fields read their foundry_path; collection fields extract from the + * named actor collection. PURE (no Foundry globals) → unit-testable. + * + * @param {object} actor + * @param {Array} mappedFields + * @returns {object} + */ +export function buildChronicleFields(actor, mappedFields) { + const result = {}; + for (const field of mappedFields) { + if (field.foundry_collection) { + result[field.key] = extractCollectionField(actor, field); + } else { + const value = getNestedValue(actor, field.foundry_path); + result[field.key] = value ?? null; + } + } + return result; +} diff --git a/scripts/capability-inspector.mjs b/scripts/capability-inspector.mjs new file mode 100644 index 0000000..a7ae9b7 --- /dev/null +++ b/scripts/capability-inspector.mjs @@ -0,0 +1,329 @@ +/** + * Chronicle Sync — Sync Capability Inspector + * + * Answers the operator question: "what CAN we pull from Foundry vs what DO we + * currently sync?" — per source (the character actor today; extensible to items, + * effects, calendar). Drives the data-pull-completeness work and is the permanent + * in-UI diagnostic that replaces grepping the manifest. + * + * Two layers (mirrors the calendar diagnostics/validation pattern): + * - captureActorSnapshot(actor) → DEFENSIVE: touches the live Foundry actor, + * wrapped so a malformed actor can't blank the panel. + * - buildCapabilityReport(...) → PURE: diffs the snapshot against the system's + * declared character-field manifest. No Foundry globals. + * - renderCapabilityMarkdown/Json → PURE: copy-to-clipboard payloads. + * + * The pure layers are unit-testable without Foundry (see tools/). + */ + +// Cap recursion so a deep/cyclic system object can't run away. Draw Steel's +// hero.system is shallow; 4 covers it with headroom. +const DEFAULT_MAX_DEPTH = 4; + +/** + * walkSchema flattens a plain object tree into [{ path, type, sample }] rows. + * Generalized from sync-dashboard's _extractSchemaSnapshot so it's standalone and + * testable. Arrays are summarized (not expanded); objects recurse; primitives + * carry a truncated sample so the operator sees a real value. + * + * @param {*} obj + * @param {string} prefix - path prefix (e.g. "system") + * @param {number} maxDepth + * @returns {Array<{path: string, type: string, sample: *}>} + */ +export function walkSchema(obj, prefix = '', maxDepth = DEFAULT_MAX_DEPTH) { + const out = []; + if (maxDepth <= 0 || obj == null || typeof obj !== 'object') return out; + + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value == null) { + out.push({ path, type: 'null', sample: null }); + } else if (typeof value === 'number') { + out.push({ path, type: 'number', sample: value }); + } else if (typeof value === 'string') { + out.push({ path, type: 'string', sample: value.length > 60 ? `${value.slice(0, 60)}…` : value }); + } else if (typeof value === 'boolean') { + out.push({ path, type: 'boolean', sample: value }); + } else if (Array.isArray(value)) { + out.push({ path, type: 'array', sample: `[${value.length}]` }); + } else if (typeof value === 'object') { + const nested = walkSchema(value, path, maxDepth - 1); + if (nested.length === 0) { + // empty object / opaque — still record its existence + out.push({ path, type: 'object', sample: '{}' }); + } else { + out.push(...nested); + } + } + } + return out; +} + +/** + * captureActorSnapshot reads everything pullable off a live Foundry actor. + * DEFENSIVE — every access is guarded; returns { ok:false, error } on failure so + * the inspector panel degrades gracefully instead of throwing. + * + * @param {object} actor - a Foundry Actor (or test double exposing the same shape) + * @returns {object} snapshot + */ +export function captureActorSnapshot(actor) { + try { + if (!actor) return { ok: false, error: 'no actor available to sample' }; + + // actor.system may be a DataModel — toObject() yields a plain tree to walk. + let system = {}; + try { + const raw = actor.system; + system = raw && typeof raw.toObject === 'function' ? raw.toObject() : (raw || {}); + } catch (err) { + system = {}; + } + + // items / effects are Foundry collections (Map-like) — summarize by type, with + // a sample item's system schema. The generic dot-path adapter cannot reach + // these today; that's the gap the report flags for WS-3. + const items = _summarizeCollection(actor.items, true); + const effects = _summarizeCollection(actor.effects, false); + + let flags = []; + try { + flags = walkSchema(actor.flags || {}, 'flags', 2); + } catch (err) { + flags = []; + } + + return { + ok: true, + actorId: actor.id ?? null, + actorName: actor.name ?? '(unnamed)', + actorType: actor.type ?? null, + system: walkSchema(system, 'system'), + items, + effects, + flags, + }; + } catch (err) { + return { ok: false, error: (err && err.message) || String(err) }; + } +} + +/** + * _summarizeCollection summarizes a Foundry embedded collection (items/effects). + * Returns counts, the distinct sub-types, and (for items) one sample's system + * schema so the operator can see what a future collection-pull would expose. + */ +function _summarizeCollection(coll, withSampleSchema) { + const result = { available: false, count: 0, types: [], sample: null }; + if (!coll) return result; + let contents = []; + try { + // Foundry Collections expose .contents; fall back to Array.from for Map-likes. + contents = coll.contents || (typeof coll[Symbol.iterator] === 'function' ? Array.from(coll) : []); + } catch (err) { + contents = []; + } + result.count = contents.length; + result.available = contents.length > 0; + const typeSet = new Set(); + for (const c of contents) { + if (c && c.type) typeSet.add(c.type); + } + result.types = [...typeSet]; + if (withSampleSchema && contents.length > 0) { + const first = contents[0]; + let sys = {}; + try { + const raw = first.system; + sys = raw && typeof raw.toObject === 'function' ? raw.toObject() : (raw || {}); + } catch (err) { + sys = {}; + } + result.sample = { + name: first.name ?? '(unnamed)', + type: first.type ?? null, + schema: walkSchema(sys, 'system', 3), + }; + } + return result; +} + +/** + * Status codes for a capability row. + * - synced: manifest field has a foundry_path AND the actor has a value there + * - mapped-missing: manifest field has a foundry_path but the actor lacks that path + * - declared-unmapped: manifest field exists but has NO foundry_path (e.g. abilities_json) + * - available-unmapped: actor exposes a path no manifest field maps (candidate to add) + * - collection: items/effects available but the dot-path adapter can't pull them yet + */ +export const CAP_STATUS = Object.freeze({ + SYNCED: 'synced', + MAPPED_MISSING: 'mapped-missing', + DECLARED_UNMAPPED: 'declared-unmapped', + AVAILABLE_UNMAPPED: 'available-unmapped', + COLLECTION: 'collection', +}); + +/** + * buildCapabilityReport diffs a captured actor snapshot against the system's + * declared character-field manifest. PURE — feed it the snapshot + the fieldDefs + * fetched from /systems/{id}/character-fields. + * + * @param {object} snapshot - from captureActorSnapshot + * @param {object} fieldDefs - { fields: [{key, foundry_path, foundry_writable, type}], preset_slug, foundry_actor_type } + * @returns {object} report { ok, source, summary, fields, collections } + */ +export function buildCapabilityReport(snapshot, fieldDefs) { + if (!snapshot || !snapshot.ok) { + return { ok: false, error: (snapshot && snapshot.error) || 'no snapshot' }; + } + const defs = (fieldDefs && Array.isArray(fieldDefs.fields)) ? fieldDefs.fields : []; + + // Index the actor's available system paths for O(1) lookup. + const availByPath = new Map(); + for (const row of snapshot.system) availByPath.set(row.path, row); + + const mappedPaths = new Set(); + const rows = []; + + // 1. Walk the manifest: classify each declared field. + for (const f of defs) { + if (!f.foundry_path) { + rows.push({ + status: CAP_STATUS.DECLARED_UNMAPPED, + key: f.key, + foundry_path: null, + type: f.type || null, + writable: f.foundry_writable !== false, + sample: undefined, + }); + continue; + } + mappedPaths.add(f.foundry_path); + const avail = availByPath.get(f.foundry_path); + rows.push({ + status: avail ? CAP_STATUS.SYNCED : CAP_STATUS.MAPPED_MISSING, + key: f.key, + foundry_path: f.foundry_path, + type: f.type || (avail ? avail.type : null), + writable: f.foundry_writable !== false, + sample: avail ? avail.sample : undefined, + }); + } + + // 2. Walk the actor: any available path not mapped is a candidate to add. + for (const row of snapshot.system) { + if (mappedPaths.has(row.path)) continue; + rows.push({ + status: CAP_STATUS.AVAILABLE_UNMAPPED, + key: null, + foundry_path: row.path, + type: row.type, + writable: null, + sample: row.sample, + }); + } + + // 3. Collections (items/effects): available but unreachable by the dot-path adapter. + const collections = []; + if (snapshot.items && snapshot.items.available) { + collections.push({ + status: CAP_STATUS.COLLECTION, + source: 'actor.items', + count: snapshot.items.count, + types: snapshot.items.types, + note: 'Embedded items (abilities/inventory) — not pullable by the dot-path adapter yet (WS-3).', + sample: snapshot.items.sample, + }); + } + if (snapshot.effects && snapshot.effects.available) { + collections.push({ + status: CAP_STATUS.COLLECTION, + source: 'actor.effects', + count: snapshot.effects.count, + types: snapshot.effects.types, + note: 'Active effects (conditions) — live combat-state tier (WS-3 later).', + sample: null, + }); + } + + const summary = _summarize(rows, collections); + return { + ok: true, + source: { + actorName: snapshot.actorName, + actorType: snapshot.actorType, + presetSlug: fieldDefs && fieldDefs.preset_slug ? fieldDefs.preset_slug : null, + }, + summary, + fields: rows, + collections, + }; +} + +function _summarize(rows, collections) { + const c = { + synced: 0, + mappedMissing: 0, + declaredUnmapped: 0, + availableUnmapped: 0, + collectionsAvailable: collections.length, + totalDeclared: 0, + }; + for (const r of rows) { + if (r.status === CAP_STATUS.SYNCED) c.synced++; + else if (r.status === CAP_STATUS.MAPPED_MISSING) c.mappedMissing++; + else if (r.status === CAP_STATUS.DECLARED_UNMAPPED) c.declaredUnmapped++; + else if (r.status === CAP_STATUS.AVAILABLE_UNMAPPED) c.availableUnmapped++; + } + c.totalDeclared = c.synced + c.mappedMissing + c.declaredUnmapped; + return c; +} + +/** + * renderCapabilityMarkdown — copy-to-clipboard report (human + AI friendly). + * PURE. + */ +export function renderCapabilityMarkdown(report) { + if (!report || !report.ok) { + return `# Sync Capability\n\n_Could not build report: ${(report && report.error) || 'unknown'}_\n`; + } + const s = report.summary; + const lines = []; + lines.push(`# Sync Capability — ${report.source.presetSlug || report.source.actorType || 'character'}`); + lines.push(''); + lines.push(`Sampled actor: **${report.source.actorName}** (type \`${report.source.actorType}\`)`); + lines.push(''); + lines.push(`- Synced: **${s.synced}** / ${s.totalDeclared} declared`); + lines.push(`- Declared but unmapped (no foundry_path): **${s.declaredUnmapped}**`); + lines.push(`- Mapped but missing on this actor: **${s.mappedMissing}**`); + lines.push(`- Available but not mapped (candidates): **${s.availableUnmapped}**`); + lines.push(`- Collections available (items/effects): **${s.collectionsAvailable}**`); + lines.push(''); + lines.push('| status | chronicle key | foundry_path | type | writable | sample |'); + lines.push('|--------|---------------|--------------|------|----------|--------|'); + for (const r of report.fields) { + lines.push(`| ${r.status} | ${r.key ?? ''} | ${r.foundry_path ?? ''} | ${r.type ?? ''} | ${r.writable == null ? '' : r.writable} | ${_fmtSample(r.sample)} |`); + } + if (report.collections.length) { + lines.push(''); + lines.push('## Collections (not yet pullable)'); + for (const c of report.collections) { + lines.push(`- \`${c.source}\` — ${c.count} item(s), types: ${c.types.join(', ') || '—'} — ${c.note}`); + } + } + return lines.join('\n') + '\n'; +} + +/** renderCapabilityJSON — machine-readable copy payload. PURE. */ +export function renderCapabilityJSON(report) { + return JSON.stringify(report, null, 2); +} + +function _fmtSample(v) { + if (v === undefined) return ''; + if (v === null) return 'null'; + if (typeof v === 'string') return v.replace(/\|/g, '\\|'); + return String(v); +} diff --git a/scripts/logger.mjs b/scripts/logger.mjs new file mode 100644 index 0000000..ff1596d --- /dev/null +++ b/scripts/logger.mjs @@ -0,0 +1,89 @@ +/** + * Chronicle Sync — leveled logger with an in-memory ring buffer. + * + * Two jobs: + * 1. Gate console noise by a current level (error < warn < info < debug < trace). + * 2. ALWAYS capture recent messages into a ring buffer, so the dashboard can + * export them (Diagnostic Bundle) even when the console wasn't open. + * + * Adoption is incremental: modules migrate `console.*` → `log.*` over time. The + * level control is wired into the UI only once enough call sites route through + * here for it to actually govern output (so we never ship a no-op control). + * + * PURE except for `console` and the clock — unit-tested in tools/test-logger.mjs. + */ + +/** Numeric severities; lower = more severe. `silent` suppresses all console output. */ +export const LOG_LEVELS = Object.freeze({ silent: -1, error: 0, warn: 1, info: 2, debug: 3, trace: 4 }); + +const RING_MAX = 500; +let _level = LOG_LEVELS.info; +const _ring = []; + +/** + * Set the active console level by name ("error".."trace"/"silent") or number. + * Unknown names are ignored (keeps the prior level). + * @param {string|number} level + */ +export function setLogLevel(level) { + if (typeof level === 'number' && Number.isFinite(level)) { + _level = level; + return; + } + if (typeof level === 'string' && Object.prototype.hasOwnProperty.call(LOG_LEVELS, level)) { + _level = LOG_LEVELS[level]; + } +} + +/** @returns {string} the active level name. */ +export function getLogLevelName() { + return Object.keys(LOG_LEVELS).find((k) => LOG_LEVELS[k] === _level) ?? 'info'; +} + +function _stringify(v) { + if (typeof v === 'string') return v; + if (v instanceof Error) return v.message || String(v); + try { return JSON.stringify(v); } catch (err) { return String(v); } +} + +function _record(levelName, args) { + _ring.push({ t: Date.now(), level: levelName, msg: args.map(_stringify).join(' ') }); + if (_ring.length > RING_MAX) _ring.shift(); +} + +function _emit(levelName, args) { + _record(levelName, args); + const sev = LOG_LEVELS[levelName]; + if (sev < 0 || sev > _level) return; // below the active threshold → captured but not printed + const fn = levelName === 'error' ? console.error + : levelName === 'warn' ? console.warn + : levelName === 'info' ? (console.info || console.log) + : (console.debug || console.log); + try { fn.call(console, `Chronicle [${levelName}]:`, ...args); } catch (err) { /* console unavailable */ } +} + +/** Leveled log methods. Each records to the ring AND prints if the level allows. */ +export const log = { + error: (...a) => _emit('error', a), + warn: (...a) => _emit('warn', a), + info: (...a) => _emit('info', a), + debug: (...a) => _emit('debug', a), + trace: (...a) => _emit('trace', a), +}; + +/** @returns {Array<{t:number, level:string, msg:string}>} a copy of the ring buffer. */ +export function getLogBuffer() { + return _ring.slice(); +} + +/** Clear the captured ring buffer. */ +export function clearLogBuffer() { + _ring.length = 0; +} + +/** Render the ring buffer as plain text (for the Diagnostic Bundle / export). */ +export function exportLogText() { + return _ring + .map((e) => `[${new Date(e.t).toISOString()}] ${e.level.toUpperCase()} ${e.msg}`) + .join('\n'); +} diff --git a/scripts/sync-dashboard.mjs b/scripts/sync-dashboard.mjs index 9daa114..41915e4 100644 --- a/scripts/sync-dashboard.mjs +++ b/scripts/sync-dashboard.mjs @@ -15,6 +15,15 @@ import { openSyncCalendar } from './sync-calendar.mjs'; import { buildCalendarDiagnostics } from './sync-calendar-diagnostics.mjs'; import { memberKey } from './sync-manager.mjs'; import { buildMemberRows } from './_member-mapping.mjs'; +import { + captureActorSnapshot, + buildCapabilityReport, + renderCapabilityMarkdown, + renderCapabilityJSON, + CAP_STATUS, +} from './capability-inspector.mjs'; +import { buildDiagnosticBundle } from './sync-diagnostic-bundle.mjs'; +import { log, getLogBuffer } from './logger.mjs'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; /** @@ -70,6 +79,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { 'test-connection': SyncDashboard.#onTestConnectionAction, 'save-config': SyncDashboard.#onSaveConfigAction, 'copy-debug': SyncDashboard.#onCopyDebugAction, + 'copy-diagnostic-bundle': SyncDashboard.#onCopyDiagnosticBundleAction, + 'copy-capability': SyncDashboard.#onCopyCapabilityAction, + 'copy-capability-json': SyncDashboard.#onCopyCapabilityJsonAction, 'copy-calendar-diagnostics': SyncDashboard.#onCopyCalendarDiagnosticsAction, 'open-wizard': SyncDashboard.#onOpenWizardAction, 'bulk-set-public': SyncDashboard.#onBulkSetPublicAction, @@ -216,6 +228,15 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { // Build status tab data. const statusData = this._buildStatusData(); + // Build sync-capability data (what CAN pull vs what DOES) — WS-4. + let capabilityData = null; + try { + capabilityData = await this._buildCapabilityData(); + } catch (err) { + log.error('Dashboard: Failed to build sync capability', err); + loadErrors.push({ tab: 'status', message: err.message || 'Failed to build sync capability' }); + } + // Build characters tab data. const characterData = this._buildCharacterData(); @@ -284,6 +305,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { // Status tab. ...statusData, + + // Sync Capability (status tab panel) — WS-4. + capability: capabilityData, }; } @@ -986,6 +1010,85 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { }; } + /** + * Build the Sync Capability report (WS-4): what a representative Foundry actor + * EXPOSES vs what Chronicle currently SYNCS. Samples one actor of the synced + * type and diffs it against the system's declared character-field manifest. + * Stashes the full report on `this._capabilityReport` for the copy actions. + * Returns null when no system is matched. @private + */ + async _buildCapabilityData() { + const matchedSystem = this._syncManager?.getMatchedSystem?.(); + if (!matchedSystem) return null; + + const actorSync = this._syncManager?._modules?.find( + (m) => m.constructor?.name === 'ActorSync' + ); + const actorType = actorSync?._actorType || actorSync?._adapter?.actorType || 'character'; + + // Sample a representative actor: prefer a linked one, else the first of the type. + const ofType = (game.actors?.contents || []).filter((a) => a?.type === actorType); + const sample = ofType.find((a) => a.getFlag?.(FLAG_SCOPE, 'entityId')) || ofType[0] || null; + if (!sample) { + this._capabilityReport = null; + return { available: false, actorType, error: `no "${actorType}" actor to sample` }; + } + + // Fetch the system's declared character fields (same source the adapter uses). + let fieldDefs = { fields: [] }; + try { + const resp = await this.api?.get(`/systems/${matchedSystem}/character-fields`); + if (resp && Array.isArray(resp.fields)) fieldDefs = resp; + } catch (err) { + log.warn('Dashboard: capability — failed to load character-fields', err); + } + + const snapshot = captureActorSnapshot(sample); + const report = buildCapabilityReport(snapshot, fieldDefs); + this._capabilityReport = report; // stashed for the copy actions + + return { + available: !!report.ok, + actorType, + summary: report.summary, + source: report.source, + gaps: this._capabilityGaps(report), + error: report.ok ? null : report.error, + }; + } + + /** + * Flatten the non-synced capability rows into {status,label,detail} for the + * template (the actionable "what we're missing" list). Caps the available-but- + * unmapped list so a large system can't flood the panel. @private + */ + _capabilityGaps(report) { + if (!report?.ok) return []; + const AVAIL_CAP = 50; + const gaps = []; + let availCount = 0; + for (const r of report.fields) { + if (r.status === CAP_STATUS.SYNCED) continue; + if (r.status === CAP_STATUS.DECLARED_UNMAPPED) { + gaps.push({ status: r.status, label: r.key, detail: 'declared field, no foundry_path' }); + } else if (r.status === CAP_STATUS.MAPPED_MISSING) { + gaps.push({ status: r.status, label: r.key, detail: `${r.foundry_path} — absent on this actor` }); + } else if (r.status === CAP_STATUS.AVAILABLE_UNMAPPED) { + if (availCount < AVAIL_CAP) { + gaps.push({ status: r.status, label: r.foundry_path, detail: r.type }); + } + availCount++; + } + } + if (availCount > AVAIL_CAP) { + gaps.push({ status: 'available-unmapped', label: `…and ${availCount - AVAIL_CAP} more available paths`, detail: '' }); + } + for (const c of report.collections) { + gaps.push({ status: c.status, label: c.source, detail: `${c.count} item(s) [${c.types.join(', ')}] — ${c.note}` }); + } + return gaps; + } + /** * @param {string} state * @returns {string} @@ -1405,6 +1508,103 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { this._onCopyDebug(); } + /** Copy the troubleshooting Diagnostic Bundle to the clipboard. */ + static #onCopyDiagnosticBundleAction() { + this._onCopyDiagnosticBundle(); + } + + /** + * Gather the live data for a Diagnostic Bundle from the same accessors the + * status tab uses (versions, health, logs, field mapping, capability). @private + */ + _buildDiagnosticInput() { + const health = this.api?.health ?? {}; + const matchedSystem = this._syncManager?.getMatchedSystem?.() || null; + const cap = this._capabilityReport; + return { + generatedAt: new Date().toISOString(), + versions: { + module: game.modules.get('chronicle-sync')?.version ?? null, + chronicle: this._syncManager?.getChronicleVersion?.() ?? null, + foundry: game.version ?? null, + system: game.system?.title ?? null, + systemId: game.system?.id ?? null, + }, + connection: { + state: this.api?.state ?? 'disconnected', + uptimePercent: this.api?.getUptimePercent?.() ?? null, + restSuccess: health.restSuccessCount ?? null, + restError: health.restErrorCount ?? null, + reconnectAttempts: health.reconnectAttempts ?? null, + retryQueue: this.api?.getRetryQueueSize?.() ?? null, + }, + fieldMapping: this._buildFieldMappingInfo(matchedSystem), + capability: cap && cap.ok ? { source: cap.source, summary: cap.summary } : null, + activityLog: (this._syncManager?.getActivityLog?.() ?? []).slice(0, 100), + errorLog: (this.api?.getErrorLog?.() ?? []).slice(0, 100), + logBuffer: getLogBuffer().slice(-200), + }; + } + + /** Build + copy the troubleshooting Diagnostic Bundle. Mirrors _onCopyDebug. @private */ + async _onCopyDiagnosticBundle() { + const el = this.element; + const resultEl = el?.querySelector('[data-bundle-result]'); + try { + const text = buildDiagnosticBundle(this._buildDiagnosticInput()); + await game.clipboard.copyPlainText(text); + if (resultEl) { + resultEl.textContent = 'Copied to clipboard!'; + resultEl.className = 'debug-copy-result test-success'; + setTimeout(() => { resultEl.textContent = ''; }, 3000); + } + ui.notifications.info('Chronicle: Diagnostic bundle copied to clipboard.'); + } catch (err) { + log.error('Dashboard: diagnostic bundle copy failed', err); + if (resultEl) { + resultEl.textContent = 'Copy failed — check console.'; + resultEl.className = 'debug-copy-result test-error'; + } + } + } + + /** Copy the Sync Capability report (markdown) to clipboard. */ + static #onCopyCapabilityAction() { + this._onCopyCapability(false); + } + + /** Copy the Sync Capability report (JSON) to clipboard. */ + static #onCopyCapabilityJsonAction() { + this._onCopyCapability(true); + } + + /** + * Copy the stashed Sync Capability report to the clipboard — markdown (human + + * AI friendly) or JSON. Mirrors _onCopyDebug. @private + */ + async _onCopyCapability(asJson = false) { + const el = this.element; + const resultEl = el?.querySelector('[data-capability-result]'); + try { + const report = this._capabilityReport; + if (!report) throw new Error('no capability report available'); + const text = asJson ? renderCapabilityJSON(report) : renderCapabilityMarkdown(report); + await game.clipboard.copyPlainText(text); + if (resultEl) { + resultEl.textContent = 'Copied to clipboard!'; + resultEl.className = 'debug-copy-result test-success'; + setTimeout(() => { resultEl.textContent = ''; }, 3000); + } + ui.notifications.info('Chronicle: Sync capability report copied to clipboard.'); + } catch (err) { + log.error('Dashboard: capability copy failed', err); + if (resultEl) { + resultEl.textContent = 'Copy failed — check console.'; + resultEl.className = 'debug-copy-result test-error'; + } + } + } + /** Copy calendar diagnostics to clipboard. */ static #onCopyCalendarDiagnosticsAction() { this._onCopyCalendarDiagnostics(); diff --git a/scripts/sync-diagnostic-bundle.mjs b/scripts/sync-diagnostic-bundle.mjs new file mode 100644 index 0000000..a912153 --- /dev/null +++ b/scripts/sync-diagnostic-bundle.mjs @@ -0,0 +1,146 @@ +/** + * Chronicle Sync — Diagnostic Bundle builder + * + * Assembles a single, copy-paste troubleshooting report from the data the + * dashboard already gathers: versions, connection/health status, per-resource + * sync state, field-mapping, the Sync Capability summary, and the recent + * activity/error logs. This is the manual precursor to the admin AI assist — it + * is exactly what a human (or an AI) needs to diagnose a sync problem in one + * paste, instead of the multi-screenshot hunting this project lived through. + * + * PURE — no Foundry globals, no DOM. The dashboard supplies a plain input object; + * this returns a Markdown string. Unit-tested in tools/test-sync-diagnostic-bundle.mjs. + * + * Security: callers must pass already-redacted data. This builder never invents + * values, but it also does not fetch secrets — keep API keys / tokens out of the + * input. (The dashboard sources from activity/error logs + health metrics, none + * of which carry secrets.) + */ + +/** + * @typedef {object} DiagnosticInput + * @property {object} [versions] - { module, chronicle, foundry, system, systemId } + * @property {object} [connection] - { state, uptimePercent, restSuccess, restError, reconnectAttempts, retryQueue } + * @property {Array} [syncStatus] - [{ resource, direction, lastSync, count, errors }] + * @property {object} [fieldMapping]- { systemId, characterTypeSlug, adapterType } + * @property {object} [capability] - { source:{actorName,actorType}, summary:{...} } + * @property {Array} [activityLog] - [{ timeFormatted, type, message }] + * @property {Array} [errorLog] - [{ timeFormatted, method, path, status, message }] + * @property {string} [generatedAt] - ISO timestamp (caller stamps it; pure builder won't read the clock) + */ + +/** + * Build a Markdown diagnostic bundle. Tolerant of missing/partial input — every + * section degrades to a clear placeholder rather than throwing. + * @param {DiagnosticInput} [input] + * @returns {string} + */ +export function buildDiagnosticBundle(input) { + const d = input && typeof input === 'object' ? input : {}; + const L = []; + + L.push('# Chronicle Sync — Diagnostic Bundle'); + if (d.generatedAt) L.push(`_Generated: ${d.generatedAt}_`); + L.push(''); + + // --- Versions --- + L.push('## Versions'); + const v = d.versions || {}; + L.push(`- Module: **${_s(v.module)}**`); + L.push(`- Chronicle: **${_s(v.chronicle)}**`); + L.push(`- Foundry: **${_s(v.foundry)}**`); + L.push(`- Game system: **${_s(v.system)}** (\`${_s(v.systemId)}\`)`); + L.push(''); + + // --- Connection / health --- + L.push('## Connection'); + const c = d.connection || {}; + L.push(`- State: **${_s(c.state)}**`); + if (c.uptimePercent != null) L.push(`- Uptime: ${c.uptimePercent}%`); + if (c.restSuccess != null || c.restError != null) { + L.push(`- REST: ${_n(c.restSuccess)} ok / ${_n(c.restError)} error`); + } + if (c.reconnectAttempts != null) L.push(`- Reconnect attempts: ${_n(c.reconnectAttempts)}`); + if (c.retryQueue != null) L.push(`- Retry queue: ${_n(c.retryQueue)}`); + L.push(''); + + // --- Per-resource sync status --- + if (Array.isArray(d.syncStatus) && d.syncStatus.length) { + L.push('## Sync status'); + L.push('| resource | direction | last sync | count | errors |'); + L.push('|----------|-----------|-----------|-------|--------|'); + for (const r of d.syncStatus) { + L.push(`| ${_s(r.resource)} | ${_s(r.direction)} | ${_s(r.lastSync)} | ${_n(r.count)} | ${_n(r.errors)} |`); + } + L.push(''); + } + + // --- Field mapping --- + if (d.fieldMapping) { + const f = d.fieldMapping; + L.push('## Field mapping'); + L.push(`- Adapter: **${_s(f.adapterType)}** (\`${_s(f.systemId)}\`)`); + L.push(`- Character type: \`${_s(f.characterTypeSlug)}\``); + L.push(''); + } + + // --- Capability summary --- + if (d.capability && d.capability.summary) { + const s = d.capability.summary; + const src = d.capability.source || {}; + L.push('## Sync capability'); + if (src.actorName) L.push(`Sampled: **${_s(src.actorName)}** (\`${_s(src.actorType)}\`)`); + L.push(`- Synced: **${_n(s.synced)}** / ${_n(s.totalDeclared)} declared`); + L.push(`- Declared, unmapped: ${_n(s.declaredUnmapped)}`); + L.push(`- Mapped, missing on actor: ${_n(s.mappedMissing)}`); + L.push(`- Available, not mapped: ${_n(s.availableUnmapped)}`); + L.push(`- Collections available: ${_n(s.collectionsAvailable)}`); + L.push(''); + } + + // --- Error log (most useful first) --- + L.push(`## Error log (${_count(d.errorLog)})`); + if (Array.isArray(d.errorLog) && d.errorLog.length) { + for (const e of d.errorLog) { + const status = e.status ? ` (${e.status})` : ''; + L.push(`- \`${_s(e.timeFormatted)}\` ${_s(e.method)} ${_s(e.path)}${status}: ${_s(e.message)}`); + } + } else { + L.push('_none_'); + } + L.push(''); + + // --- Recent activity --- + L.push(`## Recent activity (${_count(d.activityLog)})`); + if (Array.isArray(d.activityLog) && d.activityLog.length) { + for (const a of d.activityLog) { + L.push(`- \`${_s(a.timeFormatted)}\` [${_s(a.type)}] ${_s(a.message)}`); + } + } else { + L.push('_none_'); + } + L.push(''); + + // --- Captured log buffer (leveled logger ring) --- + if (Array.isArray(d.logBuffer) && d.logBuffer.length) { + L.push(`## Log buffer (${d.logBuffer.length})`); + for (const e of d.logBuffer) { + const ts = e && e.t ? new Date(e.t).toISOString() : ''; + L.push(`- \`${ts}\` ${String((e && e.level) || '').toUpperCase()} ${_s(e && e.msg)}`); + } + L.push(''); + } + + return L.join('\n'); +} + +function _s(v) { + if (v == null || v === '') return '—'; + return String(v); +} +function _n(v) { + return v == null ? 0 : v; +} +function _count(arr) { + return Array.isArray(arr) ? arr.length : 0; +} diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs index f7289c9..2b47967 100644 --- a/templates/sync-dashboard.hbs +++ b/templates/sync-dashboard.hbs @@ -1000,6 +1000,42 @@ {{/if}} + {{!-- Sync Capability — what CAN pull vs what DOES (WS-4) --}} + {{#if capability}} +

Sync Capability

+ {{#if capability.available}} +

What a Foundry actor exposes vs what Chronicle currently syncs — sampled from {{capability.source.actorName}} (type {{capability.source.actorType}}).

+
+
Synced:{{capability.summary.synced}} / {{capability.summary.totalDeclared}} declared
+
Declared, unmapped:{{capability.summary.declaredUnmapped}}
+
Mapped, missing on actor:{{capability.summary.mappedMissing}}
+
Available, not mapped:{{capability.summary.availableUnmapped}}
+
Collections (items/effects):{{capability.summary.collectionsAvailable}}
+
+ {{#if capability.gaps.length}} +
+ {{#each capability.gaps}} +
+ {{status}} + {{label}}{{#if detail}} — {{detail}}{{/if}} +
+ {{/each}} +
+ {{/if}} +
+ + + +
+ {{else}} +

No representative actor available to sample{{#if capability.error}} ({{capability.error}}){{/if}}.

+ {{/if}} + {{/if}} + {{!-- Error log --}} {{#if hasErrors}}

Error Log

@@ -1049,6 +1085,16 @@ {{/if}} + {{!-- Diagnostic Bundle (troubleshooting — versions, health, logs, capability) --}} +

Diagnostic Bundle

+

One copy-paste troubleshooting report — versions, connection health, sync status, capability, and recent activity/errors. Paste into a bug report or the admin AI.

+
+ + +
+ {{!-- System Debug Export --}}

System Debug Export

Copy a snapshot of your Foundry system data to the clipboard. Paste this into Claude AI to help build a Chronicle system adapter for your game system.

diff --git a/tools/test-capability-inspector.mjs b/tools/test-capability-inspector.mjs new file mode 100644 index 0000000..6a79336 --- /dev/null +++ b/tools/test-capability-inspector.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * Unit tests for the Sync Capability Inspector + * (`scripts/capability-inspector.mjs`). + * + * Run: `node --test tools/test-capability-inspector.mjs` + * Uses Node's built-in `node:test`; the inspected layers are pure (and the + * "live actor" layer takes a plain test double), so no Foundry globals needed. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const { + walkSchema, + captureActorSnapshot, + buildCapabilityReport, + renderCapabilityMarkdown, + renderCapabilityJSON, + CAP_STATUS, +} = await import('../scripts/capability-inspector.mjs'); + +// A Foundry-ish actor test double: system is a DataModel (has toObject), items +// and effects are collections (have .contents), flags is plain. +function makeActor() { + return { + id: 'a1', + name: 'Orrin Goldwind', + type: 'hero', + system: { + toObject() { + return { + characteristics: { might: { value: 2 }, agility: { value: 0 } }, + stamina: { value: 33, max: 33 }, + biography: 'Born beneath the broken peak of Cinderhold…', + }; + }, + }, + items: { + contents: [ + { type: 'ability', name: 'Ashfall Strike', system: { keywords: ['melee'] } }, + { type: 'kit', name: 'Mountain', system: {} }, + ], + }, + effects: { contents: [{ type: 'condition' }, { type: 'condition' }] }, + flags: { 'chronicle-sync': { entityId: 'ent-1' } }, + }; +} + +const FIELD_DEFS = { + preset_slug: 'drawsteel-character', + foundry_actor_type: 'hero', + fields: [ + { key: 'might', foundry_path: 'system.characteristics.might.value', type: 'number', foundry_writable: false }, + { key: 'stamina_current', foundry_path: 'system.stamina.value', type: 'number' }, + { key: 'abilities_json', type: 'string' }, // declared but no foundry_path + { key: 'speed', foundry_path: 'system.movement.speed', type: 'number' }, // absent on actor + ], +}; + +test('walkSchema flattens nested objects, summarizes arrays, caps depth', () => { + const rows = walkSchema({ a: { b: { c: 1 } }, list: [1, 2, 3], s: 'hi', n: null }, 'system'); + const byPath = Object.fromEntries(rows.map((r) => [r.path, r])); + assert.equal(byPath['system.a.b.c'].type, 'number'); + assert.equal(byPath['system.list'].type, 'array'); + assert.equal(byPath['system.list'].sample, '[3]'); + assert.equal(byPath['system.s'].sample, 'hi'); + assert.equal(byPath['system.n'].type, 'null'); +}); + +test('captureActorSnapshot reads system (via toObject), items, effects, flags', () => { + const snap = captureActorSnapshot(makeActor()); + assert.equal(snap.ok, true); + assert.equal(snap.actorType, 'hero'); + const paths = snap.system.map((r) => r.path); + assert.ok(paths.includes('system.characteristics.might.value')); + assert.ok(paths.includes('system.stamina.value')); + assert.equal(snap.items.available, true); + assert.equal(snap.items.count, 2); + assert.deepEqual(snap.items.types.sort(), ['ability', 'kit']); + assert.equal(snap.items.sample.name, 'Ashfall Strike'); + assert.equal(snap.effects.count, 2); + assert.ok(snap.flags.some((r) => r.path === 'flags.chronicle-sync.entityId')); +}); + +test('captureActorSnapshot is defensive (null / throwing actor never throws)', () => { + assert.equal(captureActorSnapshot(null).ok, false); + const bad = { get system() { throw new Error('boom'); }, name: 'x' }; + assert.doesNotThrow(() => captureActorSnapshot(bad)); +}); + +test('buildCapabilityReport classifies synced / mapped-missing / declared-unmapped / available-unmapped', () => { + const report = buildCapabilityReport(captureActorSnapshot(makeActor()), FIELD_DEFS); + assert.equal(report.ok, true); + const byKey = Object.fromEntries(report.fields.filter((r) => r.key).map((r) => [r.key, r])); + assert.equal(byKey.might.status, CAP_STATUS.SYNCED); + assert.equal(byKey.might.sample, 2); + assert.equal(byKey.might.writable, false); + assert.equal(byKey.stamina_current.status, CAP_STATUS.SYNCED); + assert.equal(byKey.abilities_json.status, CAP_STATUS.DECLARED_UNMAPPED); + assert.equal(byKey.speed.status, CAP_STATUS.MAPPED_MISSING); + + // biography + stamina.max etc. are available but unmapped → candidates + const unmapped = report.fields.filter((r) => r.status === CAP_STATUS.AVAILABLE_UNMAPPED).map((r) => r.foundry_path); + assert.ok(unmapped.includes('system.biography')); + assert.ok(unmapped.includes('system.stamina.max')); + + // collections surfaced (not pullable yet) + assert.equal(report.collections.length, 2); + assert.ok(report.collections.some((c) => c.source === 'actor.items' && c.count === 2)); + + // summary + assert.equal(report.summary.synced, 2); + assert.equal(report.summary.declaredUnmapped, 1); + assert.equal(report.summary.mappedMissing, 1); + assert.ok(report.summary.availableUnmapped >= 2); +}); + +test('renderers produce a titled markdown report and valid JSON; bad input tolerated', () => { + const report = buildCapabilityReport(captureActorSnapshot(makeActor()), FIELD_DEFS); + const md = renderCapabilityMarkdown(report); + assert.match(md, /# Sync Capability — drawsteel-character/); + assert.match(md, /Synced: \*\*2\*\*/); + assert.match(md, /actor\.items/); + assert.doesNotThrow(() => JSON.parse(renderCapabilityJSON(report))); + // bad input never throws + assert.match(renderCapabilityMarkdown({ ok: false, error: 'x' }), /Could not build report/); + assert.equal(buildCapabilityReport({ ok: false }, FIELD_DEFS).ok, false); +}); diff --git a/tools/test-generic-adapter.mjs b/tools/test-generic-adapter.mjs new file mode 100644 index 0000000..5342ab3 --- /dev/null +++ b/tools/test-generic-adapter.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Unit tests for the generic adapter's pure extraction core + * (`scripts/adapters/generic-adapter.mjs`): scalar dot-path reads AND the new + * collection extraction (abilities/inventory from actor.items[]). + * + * Run: `node --test tools/test-generic-adapter.mjs` + * The extraction layer is pure (createGenericAdapter's async API fetch is not + * exercised here), so no Foundry globals are needed. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const { getNestedValue, extractCollectionField, buildChronicleFields } = + await import('../scripts/adapters/generic-adapter.mjs'); + +// A Foundry-ish actor with system data + an items collection (Map-like w/ .contents). +function makeActor() { + return { + name: 'Tyne, the Ashbound', + type: 'hero', + system: { + characteristics: { might: { value: 2 } }, + stamina: { value: 21, max: 30 }, + biography: 'Born beneath the broken peak of Cinderhold…', + }, + items: { + contents: [ + { id: 'i1', name: 'Ashfall Strike', type: 'ability', system: { keywords: ['melee'], heroic: 3 } }, + { id: 'i2', name: 'Mountain Stance', type: 'ability', system: { keywords: ['stance'] } }, + { id: 'i3', name: 'Ashbrand', type: 'equipment', system: { equipped: true } }, + ], + }, + }; +} + +test('getNestedValue reads dot-paths and tolerates missing/garbage', () => { + const a = makeActor(); + assert.equal(getNestedValue(a, 'system.characteristics.might.value'), 2); + assert.equal(getNestedValue(a, 'system.stamina.max'), 30); + assert.equal(getNestedValue(a, 'system.nope.deep'), undefined); + assert.equal(getNestedValue(a, ''), undefined); + assert.equal(getNestedValue(null, 'system.x'), undefined); +}); + +test('extractCollectionField filters by item type and projects fields → JSON', () => { + const json = extractCollectionField(makeActor(), { + key: 'abilities_json', + type: 'string', + foundry_collection: 'items', + foundry_item_type: 'ability', + foundry_item_fields: { name: 'name', keywords: 'system.keywords', heroic: 'system.heroic' }, + }); + const abilities = JSON.parse(json); + assert.equal(abilities.length, 2); // only the two 'ability' items + assert.deepEqual(abilities[0], { name: 'Ashfall Strike', keywords: ['melee'], heroic: 3 }); + assert.equal(abilities[1].heroic, null); // missing path → null, not undefined +}); + +test('extractCollectionField supports multiple types + default projection + raw array', () => { + const arr = extractCollectionField(makeActor(), { + key: 'inventory', + type: 'array', + foundry_collection: 'items', + foundry_item_type: ['equipment', 'consumable'], + // no projection → default {id,name,type} + }); + assert.ok(Array.isArray(arr)); // type !== json/string → raw array + assert.equal(arr.length, 1); + assert.deepEqual(arr[0], { id: 'i3', name: 'Ashbrand', type: 'equipment' }); +}); + +test('extractCollectionField is defensive (missing collection / bad actor → empty)', () => { + assert.equal(extractCollectionField({}, { key: 'x', type: 'string', foundry_collection: 'items' }), '[]'); + assert.deepEqual(extractCollectionField({}, { key: 'x', type: 'array', foundry_collection: 'items' }), []); + assert.doesNotThrow(() => extractCollectionField(null, { key: 'x', type: 'json', foundry_collection: 'items' })); +}); + +test('buildChronicleFields mixes scalar + collection fields into one fields_data', () => { + const mapped = [ + { key: 'might', foundry_path: 'system.characteristics.might.value', type: 'number' }, + { key: 'stamina_current', foundry_path: 'system.stamina.value', type: 'number' }, + { key: 'backstory', foundry_path: 'system.biography', type: 'string' }, + { + key: 'abilities_json', + type: 'string', + foundry_collection: 'items', + foundry_item_type: 'ability', + foundry_item_fields: { name: 'name' }, + }, + ]; + const out = buildChronicleFields(makeActor(), mapped); + assert.equal(out.might, 2); + assert.equal(out.stamina_current, 21); + assert.match(out.backstory, /Cinderhold/); + assert.deepEqual(JSON.parse(out.abilities_json), [{ name: 'Ashfall Strike' }, { name: 'Mountain Stance' }]); +}); + +test('buildChronicleFields sets null for a missing scalar path', () => { + const out = buildChronicleFields(makeActor(), [{ key: 'speed', foundry_path: 'system.movement.speed', type: 'number' }]); + assert.equal(out.speed, null); +}); diff --git a/tools/test-logger.mjs b/tools/test-logger.mjs new file mode 100644 index 0000000..389ff3c --- /dev/null +++ b/tools/test-logger.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Unit tests for the leveled logger (`scripts/logger.mjs`). + * Run: `node --test tools/test-logger.mjs` + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const { log, setLogLevel, getLogLevelName, getLogBuffer, clearLogBuffer, exportLogText, LOG_LEVELS } = + await import('../scripts/logger.mjs'); + +function withConsoleSpy(fn) { + const calls = { error: 0, warn: 0, info: 0, debug: 0, log: 0 }; + const orig = { error: console.error, warn: console.warn, info: console.info, debug: console.debug, log: console.log }; + console.error = () => { calls.error++; }; + console.warn = () => { calls.warn++; }; + console.info = () => { calls.info++; }; + console.debug = () => { calls.debug++; }; + console.log = () => { calls.log++; }; + try { fn(calls); } finally { Object.assign(console, orig); } +} + +test('level set/get by name and number; unknown name ignored', () => { + setLogLevel('warn'); + assert.equal(getLogLevelName(), 'warn'); + setLogLevel(LOG_LEVELS.debug); + assert.equal(getLogLevelName(), 'debug'); + setLogLevel('bogus'); + assert.equal(getLogLevelName(), 'debug'); // unchanged +}); + +test('ring buffer ALWAYS captures, regardless of console level', () => { + clearLogBuffer(); + setLogLevel('error'); // only errors print… + withConsoleSpy(() => { + log.debug('a debug line'); + log.error('an error line'); + }); + const buf = getLogBuffer(); + assert.equal(buf.length, 2); // both captured + assert.deepEqual(buf.map((e) => e.level), ['debug', 'error']); + assert.match(buf[0].msg, /a debug line/); +}); + +test('console output is gated by the active level', () => { + clearLogBuffer(); + setLogLevel('warn'); + withConsoleSpy((calls) => { + log.error('e'); + log.warn('w'); + log.info('i'); + log.debug('d'); + assert.equal(calls.error, 1); + assert.equal(calls.warn, 1); + assert.equal(calls.info, 0); // below threshold → not printed + assert.equal(calls.debug, 0); + }); +}); + +test('silent suppresses all console output but still captures', () => { + clearLogBuffer(); + setLogLevel('silent'); + withConsoleSpy((calls) => { + log.error('still recorded'); + assert.equal(calls.error, 0); + }); + assert.equal(getLogBuffer().length, 1); +}); + +test('objects/errors are stringified; exportLogText renders lines', () => { + clearLogBuffer(); + setLogLevel('trace'); + withConsoleSpy(() => { + log.info('obj', { a: 1 }); + log.error(new Error('boom')); + }); + const txt = exportLogText(); + assert.match(txt, /INFO obj \{"a":1\}/); + assert.match(txt, /ERROR boom/); +}); + +test('ring buffer is capped (does not grow unbounded)', () => { + clearLogBuffer(); + setLogLevel('silent'); + for (let i = 0; i < 600; i++) log.info('x' + i); + assert.ok(getLogBuffer().length <= 500); +}); diff --git a/tools/test-sync-diagnostic-bundle.mjs b/tools/test-sync-diagnostic-bundle.mjs new file mode 100644 index 0000000..6281b9d --- /dev/null +++ b/tools/test-sync-diagnostic-bundle.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * Unit tests for the pure Diagnostic Bundle builder + * (`scripts/sync-diagnostic-bundle.mjs`). + * + * Run: `node --test tools/test-sync-diagnostic-bundle.mjs` + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const { buildDiagnosticBundle } = await import('../scripts/sync-diagnostic-bundle.mjs'); + +test('empty / garbage input never throws and produces a titled report', () => { + for (const bad of [undefined, null, 'nope', 42, {}]) { + assert.doesNotThrow(() => buildDiagnosticBundle(bad)); + } + const out = buildDiagnosticBundle(); + assert.match(out, /# Chronicle Sync — Diagnostic Bundle/); + assert.match(out, /## Versions/); + assert.match(out, /## Connection/); + assert.match(out, /## Error log \(0\)/); + assert.match(out, /## Recent activity \(0\)/); +}); + +test('versions, connection and capability surface', () => { + const out = buildDiagnosticBundle({ + generatedAt: '2026-06-26T12:00:00Z', + versions: { module: '1.2.3', chronicle: '0.13.0', foundry: '13.300', system: 'Draw Steel', systemId: 'drawsteel' }, + connection: { state: 'connected', uptimePercent: 99, restSuccess: 120, restError: 3, reconnectAttempts: 1, retryQueue: 0 }, + fieldMapping: { systemId: 'drawsteel', characterTypeSlug: 'drawsteel-character', adapterType: 'generic' }, + capability: { source: { actorName: 'Tyne', actorType: 'hero' }, summary: { synced: 19, totalDeclared: 29, declaredUnmapped: 4, mappedMissing: 1, availableUnmapped: 12, collectionsAvailable: 2 } }, + }); + assert.match(out, /Generated: 2026-06-26T12:00:00Z/); + assert.match(out, /Module: \*\*1\.2\.3\*\*/); + assert.match(out, /State: \*\*connected\*\*/); + assert.match(out, /REST: 120 ok \/ 3 error/); + assert.match(out, /drawsteel-character/); + assert.match(out, /Sampled: \*\*Tyne\*\*/); + assert.match(out, /Synced: \*\*19\*\* \/ 29 declared/); +}); + +test('logs render with counts; sync status table when present', () => { + const out = buildDiagnosticBundle({ + syncStatus: [{ resource: 'characters', direction: 'both', lastSync: '12:01', count: 3, errors: 0 }], + errorLog: [{ timeFormatted: '12:00', method: 'PUT', path: '/entities/x/fields', status: 500, message: 'boom' }], + activityLog: [{ timeFormatted: '12:01', type: 'pull', message: 'pulled Tyne' }], + }); + assert.match(out, /## Sync status/); + assert.match(out, /\| characters \| both \| 12:01 \| 3 \| 0 \|/); + assert.match(out, /## Error log \(1\)/); + assert.match(out, /PUT \/entities\/x\/fields \(500\): boom/); + assert.match(out, /## Recent activity \(1\)/); + assert.match(out, /\[pull\] pulled Tyne/); +}); + +test('missing values degrade to placeholders, not crashes', () => { + const out = buildDiagnosticBundle({ versions: {}, connection: {} }); + assert.match(out, /Module: \*\*—\*\*/); + assert.match(out, /State: \*\*—\*\*/); +}); + +test('leveled log buffer renders when present', () => { + const out = buildDiagnosticBundle({ + logBuffer: [ + { t: Date.parse('2026-06-26T12:00:00Z'), level: 'error', msg: 'PUT failed' }, + { t: Date.parse('2026-06-26T12:00:01Z'), level: 'debug', msg: 'pull start' }, + ], + }); + assert.match(out, /## Log buffer \(2\)/); + assert.match(out, /ERROR PUT failed/); + assert.match(out, /2026-06-26T12:00:01\.000Z` DEBUG pull start/); +});