update the workspace and skills flow
This commit is contained in:
@@ -798,6 +798,591 @@ for (const skill of reg.SKILLS) {
|
||||
`${rehydrated.id} / ${rehydrated.name}`);
|
||||
}
|
||||
|
||||
/* ── 8. Markdown ↔ manual equivalence ─────────────────────────────────────
|
||||
The two authoring paths, asserted to produce one artefact.
|
||||
|
||||
A form that cannot express what the format can is not a shortcut to it: the
|
||||
manual Owliver editor carried a single `source` shared by every selected
|
||||
capability, so a capability chosen before a source was picked composed no
|
||||
`responses:` block at all — and a definition whose capabilities all drop
|
||||
registers, shows as active on its page, and contributes no chip. These check
|
||||
that both doors compose the same definition, and that the one that cannot be
|
||||
configured is refused rather than saved empty. */
|
||||
console.log('\n── Markdown ↔ manual equivalence ──');
|
||||
|
||||
const dataResolver = await server.ssrLoadModule('/src/lib/skills/dataResolver.js');
|
||||
const positionsContext = Object.entries(placement.PLACEMENT_ROUTES)
|
||||
.find(([route]) => reg.pageKeyForRoute(route) === 'positions')?.[1];
|
||||
|
||||
/* The canonical model is what `parseSkill` makes of a definition. Comparing the
|
||||
whole record would compare its Markdown too, which is the one thing the two
|
||||
paths are allowed to differ on — so the runtime configuration is compared,
|
||||
which is exactly what every consumer reads. */
|
||||
const canonical = (skill) => JSON.parse(JSON.stringify({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
status: skill.status,
|
||||
pages: skill.pages,
|
||||
kind: skill.kind,
|
||||
triggers: skill.triggers,
|
||||
ui: skill.ui,
|
||||
owliver: skill.owliver,
|
||||
}));
|
||||
|
||||
/* TEST A / B — the Board card, written both ways. */
|
||||
const BOARD_MD = `---
|
||||
id: board
|
||||
name: Board
|
||||
description: Helps Owliver understand, analyze, and act on the current task board.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: after-position-list-summary
|
||||
title: Board
|
||||
source: candidates.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Board
|
||||
`;
|
||||
|
||||
const boardManual = templates.uiSkillTemplate({
|
||||
id: 'board',
|
||||
name: 'Board',
|
||||
description: 'Helps Owliver understand, analyze, and act on the current task board.',
|
||||
pages: ['positions'],
|
||||
type: 'card',
|
||||
placement: 'after-position-list-summary',
|
||||
title: 'Board',
|
||||
source: 'candidates.activity',
|
||||
periods: ['today', 'last-7-days', 'previous-month'],
|
||||
});
|
||||
|
||||
record('TEST A: Markdown Board saves', reg.validateSkillSource(BOARD_MD) === null,
|
||||
reg.validateSkillSource(BOARD_MD) || 'accepted');
|
||||
record('TEST B: manual Board saves', reg.validateSkillSource(boardManual) === null,
|
||||
reg.validateSkillSource(boardManual) || 'accepted');
|
||||
|
||||
const boardFromMd = reg.parseSkill(BOARD_MD, { custom: true });
|
||||
const boardFromForm = reg.parseSkill(boardManual, { custom: true });
|
||||
const boardSection = (skill) => Object.values(skill.ui || {}).flatMap((pg) => pg.sections || [])[0];
|
||||
|
||||
record('TEST A: one section, above the position list',
|
||||
Object.values(boardFromMd.ui).flatMap((pg) => pg.sections).length === 1
|
||||
&& boardSection(boardFromMd).placement === 'after-position-list-summary',
|
||||
`${boardSection(boardFromMd).type}@${boardSection(boardFromMd).placement}`);
|
||||
record('TEST A: the Board card contributes no Owliver capability',
|
||||
boardFromMd.owliver.enabled === false && boardFromMd.owliver.capabilities.length === 0);
|
||||
record('TEST A: no unresolvable section', reg.unresolvableSections(boardFromMd).length === 0,
|
||||
reg.unresolvableSections(boardFromMd)[0]?.message || 'none');
|
||||
record('TEST B: manual Board renders the identical section',
|
||||
JSON.stringify(boardSection(boardFromForm)) === JSON.stringify(boardSection(boardFromMd)),
|
||||
JSON.stringify(boardSection(boardFromForm)));
|
||||
record('TEST B: manual Board keeps its title', boardSection(boardFromForm).title === 'Board',
|
||||
String(boardSection(boardFromForm).title));
|
||||
|
||||
/* TEST C / D — the conversational skill, written both ways. */
|
||||
const OWLIVER_MD = `---
|
||||
id: owliver-conversation-test
|
||||
name: Hiring Activity Test
|
||||
description: Shows hiring activity from the Positions page.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
triggers:
|
||||
- hiring activity test
|
||||
owliver:
|
||||
enabled: true
|
||||
suggestions:
|
||||
- Show hiring activity
|
||||
- Summarize hiring activity
|
||||
capabilities:
|
||||
- summary
|
||||
responses:
|
||||
summary:
|
||||
source: candidates.activity
|
||||
periods:
|
||||
- today
|
||||
- last-7-days
|
||||
- previous-month
|
||||
---
|
||||
|
||||
# Hiring Activity Test
|
||||
`;
|
||||
|
||||
const owliverManual = templates.owliverSkillTemplate({
|
||||
id: 'owliver-conversation-test',
|
||||
name: 'Hiring Activity Test',
|
||||
description: 'Shows hiring activity from the Positions page.',
|
||||
pages: ['positions'],
|
||||
suggestions: ['Show hiring activity', 'Summarize hiring activity'],
|
||||
capabilities: ['summary'],
|
||||
responses: {
|
||||
summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
|
||||
},
|
||||
});
|
||||
|
||||
record('TEST C: Markdown Owliver skill saves', reg.validateSkillSource(OWLIVER_MD) === null,
|
||||
reg.validateSkillSource(OWLIVER_MD) || 'accepted');
|
||||
record('TEST D: manual Owliver skill saves', reg.validateSkillSource(owliverManual) === null,
|
||||
reg.validateSkillSource(owliverManual) || 'accepted');
|
||||
|
||||
for (const [label, source] of [['TEST C', OWLIVER_MD], ['TEST D', owliverManual]]) {
|
||||
const custom = [{ path: 'custom/owliver-conversation-test.md', raw: source }];
|
||||
const skill = reg.parseSkill(source, { custom: true });
|
||||
|
||||
record(`${label}: the capability keeps its source`,
|
||||
skill.owliver.responses.summary?.source === 'candidates.activity',
|
||||
skill.owliver.responses.summary?.source || 'dropped');
|
||||
record(`${label}: the capability keeps its periods`,
|
||||
JSON.stringify(skill.owliver.responses.summary?.periods)
|
||||
=== JSON.stringify(['today', 'last-7-days', 'previous-month']),
|
||||
JSON.stringify(skill.owliver.responses.summary?.periods));
|
||||
record(`${label}: it draws no page card`,
|
||||
Object.keys(skill.ui).length === 0, `${Object.keys(skill.ui).length} pages`);
|
||||
|
||||
const chips = resolver.owliverSuggestions(positionsContext, [], custom, {});
|
||||
const mine = chips.filter((c) => c.skillId === 'owliver-conversation-test');
|
||||
record(`${label}: its suggestion reaches the panel`,
|
||||
mine.some((c) => c.label === 'Show hiring activity'),
|
||||
JSON.stringify(chips.map((c) => c.label)));
|
||||
record(`${label}: the suggestion answers without asking for a record`,
|
||||
mine.every((c) => !c.deferred));
|
||||
|
||||
const owlSkills = resolver.owliverSkillsForContext(positionsContext, [], custom);
|
||||
const matched = resolver.matchOwliverSkill('Show hiring activity', owlSkills);
|
||||
record(`${label}: clicking it routes to this skill's summary`,
|
||||
matched?.skill.id === 'owliver-conversation-test' && matched?.capability === 'summary' && matched?.exact,
|
||||
`${matched?.skill.id}:${matched?.capability} exact=${matched?.exact}`);
|
||||
|
||||
const answered = resolver.resolveOwliverResponse({
|
||||
skill: matched.skill,
|
||||
capability: matched.capability,
|
||||
question: 'Show hiring activity',
|
||||
context: {
|
||||
positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
|
||||
applications: [{ id: 'a1', job_posting_id: 'p1', created_date: new Date().toISOString() }],
|
||||
},
|
||||
now: new Date(),
|
||||
});
|
||||
record(`${label}: it answers with the configured reading`,
|
||||
answered?.missing === null && resolver.summaryLines(answered.data).length === 3,
|
||||
JSON.stringify(resolver.summaryLines(answered?.data || {})));
|
||||
}
|
||||
|
||||
/* TEST H — the same skill through both doors, normalized, compared. */
|
||||
record('TEST H: Markdown and manual normalize identically (Board)',
|
||||
JSON.stringify(canonical(boardFromMd)) === JSON.stringify(canonical(boardFromForm)),
|
||||
JSON.stringify(canonical(boardFromForm)).slice(0, 120));
|
||||
record('TEST H: Markdown and manual normalize identically (Owliver)',
|
||||
JSON.stringify(canonical(reg.parseSkill(OWLIVER_MD, { custom: true })))
|
||||
=== JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))),
|
||||
JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))).slice(0, 120));
|
||||
|
||||
/* TEST E — a capability with no source is refused, never saved empty. */
|
||||
const noSourceFields = {
|
||||
id: 'no-source-test',
|
||||
name: 'No Source Test',
|
||||
description: 'A capability with nothing to read.',
|
||||
pages: ['positions'],
|
||||
suggestions: ['Show hiring activity'],
|
||||
capabilities: ['summary'],
|
||||
responses: {},
|
||||
};
|
||||
const noSource = templates.owliverSkillTemplate(noSourceFields);
|
||||
record('TEST E: an unconfigured capability is named by the form',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)) === '["summary"]',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)));
|
||||
record('TEST E: and the save is refused',
|
||||
/owliver\.responses\.summary/.test(reg.validateSkillSource(noSource) || ''),
|
||||
reg.validateSkillSource(noSource) || 'ACCEPTED — a skill with no capability would register');
|
||||
record('TEST E: the refused definition would have had zero capabilities',
|
||||
reg.parseSkill(noSource, { custom: true }).owliver.capabilities.length === 0,
|
||||
'which is why it is refused rather than stored');
|
||||
|
||||
/* TEST F — two capabilities, two different sources, neither inheriting. */
|
||||
const twoSources = templates.owliverSkillTemplate({
|
||||
id: 'two-source-test',
|
||||
name: 'Two Source Test',
|
||||
description: 'Two capabilities reading two sources.',
|
||||
pages: ['positions'],
|
||||
suggestions: ['Summarize workspace applications', 'List the open roles'],
|
||||
capabilities: ['summary', 'list'],
|
||||
responses: {
|
||||
summary: { source: 'candidates.activity', periods: ['today'] },
|
||||
list: { source: 'positions.demand', limit: 5 },
|
||||
},
|
||||
});
|
||||
const twoSkill = reg.parseSkill(twoSources, { custom: true });
|
||||
record('TEST F: manual save accepted', reg.validateSkillSource(twoSources) === null,
|
||||
reg.validateSkillSource(twoSources) || 'accepted');
|
||||
record('TEST F: each capability keeps its own source',
|
||||
twoSkill.owliver.responses.summary?.source === 'candidates.activity'
|
||||
&& twoSkill.owliver.responses.list?.source === 'positions.demand',
|
||||
`summary→${twoSkill.owliver.responses.summary?.source}, list→${twoSkill.owliver.responses.list?.source}`);
|
||||
record('TEST F: neither inherits the other\'s options',
|
||||
JSON.stringify(twoSkill.owliver.responses.summary.periods) === '["today"]'
|
||||
&& twoSkill.owliver.responses.list.limit === 5
|
||||
&& twoSkill.owliver.responses.list.periods.length === 0,
|
||||
`summary periods=${JSON.stringify(twoSkill.owliver.responses.summary.periods)}, list limit=${twoSkill.owliver.responses.list.limit}`);
|
||||
record('TEST F: both are offered as suggestions',
|
||||
resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/two.md', raw: twoSources }], {})
|
||||
.filter((c) => c.skillId === 'two-source-test').length === 2);
|
||||
|
||||
/* TEST G — editing one field leaves every other configuration alone. */
|
||||
const edited = fields.patchFrontmatter(
|
||||
twoSources,
|
||||
fields.owliverPatch(
|
||||
{ ...fields.owliverFieldsFromSource(twoSources), description: 'A new description.' },
|
||||
{ existing: twoSources }
|
||||
)
|
||||
);
|
||||
const editedSkill = reg.parseSkill(edited, { custom: true });
|
||||
record('TEST G: the edit lands', editedSkill.description === 'A new description.', editedSkill.description);
|
||||
record('TEST G: every capability source survives the edit',
|
||||
JSON.stringify(canonical(editedSkill).owliver.responses)
|
||||
=== JSON.stringify(canonical(twoSkill).owliver.responses),
|
||||
`summary→${editedSkill.owliver.responses.summary?.source}, list→${editedSkill.owliver.responses.list?.source}`);
|
||||
record('TEST G: suggestions survive the edit',
|
||||
JSON.stringify(editedSkill.owliver.suggestions) === JSON.stringify(twoSkill.owliver.suggestions));
|
||||
|
||||
const boardEdited = fields.patchFrontmatter(
|
||||
boardManual,
|
||||
fields.boardPatch(
|
||||
{ ...fields.boardFieldsFromSource(boardManual), description: 'A new description.' },
|
||||
{ existing: boardManual }
|
||||
)
|
||||
);
|
||||
record('TEST G: Board UI configuration survives the edit',
|
||||
JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true })))
|
||||
=== JSON.stringify(boardSection(boardFromForm)),
|
||||
JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true }))));
|
||||
|
||||
/* TEST 6 — one source/shape resolver, shared by the pickers and the validator. */
|
||||
record('shape compatibility: the resolver refuses what the normalizer refuses',
|
||||
surfaces.sourcesForShape('list').every((src) => surfaces.sourceSupportsShape(src.id, 'list'))
|
||||
&& !surfaces.sourcesForShape('list').some((src) => src.id === 'candidates.activity'),
|
||||
`list sources: ${surfaces.sourcesForShape('list').map((s) => s.id).join(', ')}`);
|
||||
record('shape compatibility: a list against candidates.activity is refused on save',
|
||||
/cannot be shown as/.test(reg.validateSkillSource(templates.owliverSkillTemplate({
|
||||
id: 'bad-shape-test',
|
||||
name: 'Bad Shape Test',
|
||||
description: 'A list of something with no list in it.',
|
||||
pages: ['positions'],
|
||||
capabilities: ['list'],
|
||||
responses: { list: { source: 'candidates.activity' } },
|
||||
})) || ''),
|
||||
'the picker cannot offer it, and the save refuses it');
|
||||
record('summary is prose, so every source suits it',
|
||||
surfaces.shapeForCapability('summary') === null
|
||||
&& surfaces.sourcesForShape(null).length === surfaces.DATA_SOURCES.length);
|
||||
|
||||
/* ── 9. The reported failure, reproduced end to end ───────────────────────── */
|
||||
console.log('\n── Regression: manual Owliver skill on Positions ──');
|
||||
|
||||
/* Exactly the configuration from the report: Pages = Positions, one suggestion,
|
||||
Summary, `candidates.activity`, three periods — composed the way the form
|
||||
composes it, then read the way the panel reads it. */
|
||||
const REGRESSION = templates.owliverSkillTemplate({
|
||||
id: 'owliver-conversation-test',
|
||||
name: 'Hiring Activity Test',
|
||||
description: 'Shows hiring activity from the Positions page.',
|
||||
pages: ['positions'],
|
||||
suggestions: ['Show hiring activity'],
|
||||
capabilities: ['summary'],
|
||||
responses: {
|
||||
summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
|
||||
},
|
||||
});
|
||||
const stored = [{ path: 'custom/owliver-conversation-test.md', raw: REGRESSION }];
|
||||
|
||||
record('the manual definition saves', reg.validateSkillSource(REGRESSION) === null,
|
||||
reg.validateSkillSource(REGRESSION) || 'accepted');
|
||||
record('it registers', reg.allSkills(stored).some((s) => s.id === 'owliver-conversation-test'));
|
||||
record('it is attached to Positions',
|
||||
reg.skillsForContext(positionsContext, [], stored).some((s) => s.id === 'owliver-conversation-test'));
|
||||
record('it keeps a capability — the step that used to empty',
|
||||
reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities.length === 1,
|
||||
JSON.stringify(reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities));
|
||||
record('it reaches owliverSkillsForContext',
|
||||
resolver.owliverSkillsForContext(positionsContext, [], stored)
|
||||
.some((s) => s.id === 'owliver-conversation-test'));
|
||||
|
||||
const regressionChips = resolver.owliverSuggestions(positionsContext, [], stored, {});
|
||||
record('"Show hiring activity" is offered as a chip',
|
||||
regressionChips.some((c) => c.label === 'Show hiring activity'),
|
||||
JSON.stringify(regressionChips.map((c) => c.label)));
|
||||
|
||||
const clicked = resolver.matchOwliverSkill(
|
||||
'Show hiring activity',
|
||||
resolver.owliverSkillsForContext(positionsContext, [], stored)
|
||||
);
|
||||
record('clicking it invokes this skill\'s summary',
|
||||
clicked?.skill.id === 'owliver-conversation-test' && clicked?.capability === 'summary',
|
||||
`${clicked?.skill.id}:${clicked?.capability}`);
|
||||
|
||||
const now = new Date();
|
||||
const answer = resolver.resolveOwliverResponse({
|
||||
skill: clicked.skill,
|
||||
capability: clicked.capability,
|
||||
question: 'Show hiring activity',
|
||||
context: {
|
||||
positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
|
||||
applications: [
|
||||
{ id: 'a1', job_posting_id: 'p1', created_date: now.toISOString() },
|
||||
{ id: 'a2', job_posting_id: 'p1', created_date: new Date(now - 3 * 86400000).toISOString() },
|
||||
],
|
||||
},
|
||||
now,
|
||||
});
|
||||
record('it answers with the configured source and periods',
|
||||
answer?.missing === null && answer.section.source === 'candidates.activity'
|
||||
&& answer.section.periods.length === 3,
|
||||
`${answer?.section.source} over ${JSON.stringify(answer?.section.periods)}`);
|
||||
record('the answer carries real figures',
|
||||
resolver.summaryLines(answer.data).length === 3,
|
||||
JSON.stringify(resolver.summaryLines(answer.data)));
|
||||
record('and no `position.activity needs a position` is possible',
|
||||
dataResolver.resolveSkillData(answer.section, { applications: [], positions: [] }, now).unavailable !== true,
|
||||
'the source declares no context requirement');
|
||||
|
||||
/* Reload: what is stored is the Markdown, so the round trip through storage is
|
||||
the round trip through the parser. */
|
||||
const reloaded = reg.parseSkill(
|
||||
fields.patchFrontmatter(REGRESSION, {}),
|
||||
{ custom: true }
|
||||
);
|
||||
record('the source survives a reload',
|
||||
reloaded.owliver.responses.summary?.source === 'candidates.activity',
|
||||
reloaded.owliver.responses.summary?.source || 'lost');
|
||||
record('the form reads it back unchanged',
|
||||
JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses)
|
||||
=== JSON.stringify({ summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'], limit: null } }),
|
||||
JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses));
|
||||
|
||||
/* ── 10. Storage round trip ───────────────────────────────────────────────
|
||||
The editors do not hand the registry a record; they write Markdown into
|
||||
`preferences.customSkills`, which `base44Client` persists to localStorage and
|
||||
reads back on the next load. "The source disappeared after reload" is a claim
|
||||
about *that* path, so it is exercised here rather than assumed: the same
|
||||
module the app runs, against a localStorage the same shape the browser's is. */
|
||||
console.log('\n── Storage round trip ──');
|
||||
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
|
||||
const { base44 } = await server.ssrLoadModule('/src/api/base44Client.js');
|
||||
const custom = await server.ssrLoadModule('/src/lib/skills/customSkills.js');
|
||||
|
||||
/* Saving is what the editor's Save button does: compose, validate, upsert,
|
||||
write the preference. */
|
||||
const saved = custom.upsertCustomSkill(base44.auth.preferences().customSkills || [], REGRESSION);
|
||||
const written = await base44.auth.updatePreferences({ customSkills: saved.next });
|
||||
record('the definition is persisted', written.persisted, written.error?.message || 'written to storage');
|
||||
|
||||
/* Reload: a fresh read of the same key, exactly as a page load does. */
|
||||
const rehydratedSkills = base44.auth.preferences().customSkills || [];
|
||||
record('it survives the reload', rehydratedSkills.length === 1
|
||||
&& String(rehydratedSkills[0].raw).includes('candidates.activity'),
|
||||
`${rehydratedSkills.length} stored`);
|
||||
|
||||
const afterReload = reg.parseSkill(rehydratedSkills[0].raw, { custom: true });
|
||||
record('its capability survives the reload',
|
||||
afterReload.owliver.responses.summary?.source === 'candidates.activity',
|
||||
afterReload.owliver.responses.summary?.source || 'lost');
|
||||
record('and it still offers its chip after the reload',
|
||||
resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {})
|
||||
.some((c) => c.label === 'Show hiring activity'),
|
||||
JSON.stringify(resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {}).map((c) => c.label)));
|
||||
record('the editor reopens it with its source intact',
|
||||
fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses.summary?.source === 'candidates.activity',
|
||||
JSON.stringify(fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses));
|
||||
|
||||
/* The Board skill through the same path, stored beside it — the two subsystems
|
||||
are independent, so both must survive one write. */
|
||||
const bothStored = custom.upsertCustomSkill(rehydratedSkills, boardManual).next;
|
||||
await base44.auth.updatePreferences({ customSkills: bothStored });
|
||||
const finalStored = base44.auth.preferences().customSkills || [];
|
||||
const finalRegistry = reg.readSkillRegistry(finalStored);
|
||||
record('Board and Owliver skills coexist in storage', finalStored.length === 2,
|
||||
`${finalStored.length} stored`);
|
||||
record('neither produces a registry diagnostic',
|
||||
!finalRegistry.diagnostics.some((d) => d.level === 'error'),
|
||||
finalRegistry.diagnostics.filter((d) => d.level === 'error').map((d) => d.message).join(' | ') || 'clean');
|
||||
record('the Board card draws once on Positions, and offers no chip',
|
||||
reg.getSkillsForPage('positions', { customSources: finalStored })
|
||||
.filter((sk) => sk.id === 'board')
|
||||
.every((sk) => Object.values(sk.ui).flatMap((pg) => pg.sections).length === 1
|
||||
&& sk.owliver.capabilities.length === 0));
|
||||
record('the Owliver skill offers its chip, and draws no card',
|
||||
resolver.owliverSuggestions(positionsContext, [], finalStored, {})
|
||||
.some((c) => c.skillId === 'owliver-conversation-test')
|
||||
&& Object.keys(reg.parseSkill(REGRESSION, { custom: true }).ui).length === 0);
|
||||
|
||||
/* ── 11. A fresh Add Owliver Skill screen ─────────────────────────────────
|
||||
What the form starts as, asserted at the state that drives the checkbox
|
||||
rather than at the checkbox. `checked` is
|
||||
`draft.capabilities.includes(capability.id)` and the per-capability
|
||||
configuration is rendered under `{checked && …}`, so the array below is the
|
||||
whole of "what is ticked" and "what is shown". */
|
||||
console.log('\n── Fresh Add Owliver Skill ──');
|
||||
|
||||
const CAPS = surfaces.OWLIVER_CAPABILITIES.map((c) => c.id);
|
||||
|
||||
/* 1. NEW skill. `emptyDraft` is `EMPTY_OWLIVER_FIELDS`, which is what the
|
||||
editor initializes to when there is no `:id` and nothing handed over. */
|
||||
record('new skill: initial capabilities are []',
|
||||
Array.isArray(fields.EMPTY_OWLIVER_FIELDS.capabilities)
|
||||
&& fields.EMPTY_OWLIVER_FIELDS.capabilities.length === 0,
|
||||
JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.capabilities));
|
||||
record('new skill: no response is pre-configured either',
|
||||
Object.keys(fields.EMPTY_OWLIVER_FIELDS.responses).length === 0,
|
||||
JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.responses));
|
||||
record(`new skill: all ${CAPS.length} capability checkboxes start unchecked`,
|
||||
CAPS.every((id) => !fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)),
|
||||
CAPS.filter((id) => fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)).join(', ') || 'none ticked');
|
||||
|
||||
/* The template the fresh screen composes must not declare capabilities either:
|
||||
an author who types a name before ticking anything patches *that* file, and a
|
||||
`capabilities:` key in it would tick boxes nobody chose. */
|
||||
const freshTemplate = templates.owliverSkillTemplate({});
|
||||
record('new skill: the composed template declares no capabilities',
|
||||
!/^\s*capabilities:/m.test(freshTemplate),
|
||||
freshTemplate.split('\n').filter((l) => /capabilit/i.test(l)).join(' / ') || 'no capabilities key');
|
||||
record('new skill: reading that template back still yields []',
|
||||
fields.owliverFieldsFromSource(freshTemplate).capabilities.length === 0,
|
||||
JSON.stringify(fields.owliverFieldsFromSource(freshTemplate).capabilities));
|
||||
|
||||
/* 2. One capability, chosen. The editor's `update({ capabilities })` is a plain
|
||||
state merge, so the assertion is on what that array then means. */
|
||||
const pickedSummary = { ...fields.EMPTY_OWLIVER_FIELDS, capabilities: ['summary'] };
|
||||
record('selecting Summary ticks exactly Summary',
|
||||
JSON.stringify(pickedSummary.capabilities) === '["summary"]');
|
||||
record('and only Summary renders a configuration block',
|
||||
CAPS.filter((id) => pickedSummary.capabilities.includes(id)).length === 1,
|
||||
CAPS.filter((id) => pickedSummary.capabilities.includes(id)).join(', '));
|
||||
record('an unsourced Summary is invalid, and only Summary is reported',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)) === '["summary"]',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)));
|
||||
|
||||
/* 3. Choosing its source clears the error, and the definition saves. */
|
||||
const sourcedSummary = {
|
||||
...pickedSummary,
|
||||
id: 'fresh-summary-test',
|
||||
name: 'Fresh Summary Test',
|
||||
description: 'One capability, configured.',
|
||||
pages: ['positions'],
|
||||
suggestions: ['Show hiring activity'],
|
||||
responses: { summary: { source: 'candidates.activity', periods: ['today'] } },
|
||||
};
|
||||
record('choosing a source clears the validation error',
|
||||
fields.unconfiguredCapabilities(sourcedSummary).length === 0,
|
||||
JSON.stringify(fields.unconfiguredCapabilities(sourcedSummary)));
|
||||
const freshSaved = templates.owliverSkillTemplate(sourcedSummary);
|
||||
record('and the save succeeds', reg.validateSkillSource(freshSaved) === null,
|
||||
reg.validateSkillSource(freshSaved) || 'accepted');
|
||||
record('the saved definition declares only the chosen capability',
|
||||
JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities) === '["summary"]',
|
||||
JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities));
|
||||
record('and its suggestion reaches the Positions panel',
|
||||
resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/fresh.md', raw: freshSaved }], {})
|
||||
.some((c) => c.skillId === 'fresh-summary-test'));
|
||||
|
||||
/* 4. Several, chosen by hand — only those, each validated on its own. */
|
||||
const pickedTwo = {
|
||||
...fields.EMPTY_OWLIVER_FIELDS,
|
||||
capabilities: ['summary', 'list'],
|
||||
responses: { summary: { source: 'candidates.activity' } },
|
||||
};
|
||||
record('selecting two capabilities renders exactly those two',
|
||||
CAPS.filter((id) => pickedTwo.capabilities.includes(id)).join(',') === 'summary,list');
|
||||
record('and only the unconfigured one is reported',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)) === '["list"]',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)));
|
||||
|
||||
/* 5. An EXISTING skill hydrates from its own definition, and from nothing else.
|
||||
Including the capability that did *not* resolve: it is the one the author
|
||||
opened the editor to fix, and dropping it from the form would delete it
|
||||
from the file on the next save. */
|
||||
const DECLARED_ONE = `---
|
||||
id: declared-one
|
||||
name: Declared One
|
||||
description: Declares a single capability.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
owliver:
|
||||
enabled: true
|
||||
suggestions:
|
||||
- Show hiring activity
|
||||
capabilities:
|
||||
- summary
|
||||
responses:
|
||||
summary:
|
||||
source: candidates.activity
|
||||
---
|
||||
|
||||
# Declared One
|
||||
`;
|
||||
record('existing skill: hydrates exactly its declared capability',
|
||||
JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities) === '["summary"]',
|
||||
JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities));
|
||||
record('existing skill: no new-skill default is applied on top',
|
||||
CAPS.filter((id) => fields.owliverFieldsFromSource(DECLARED_ONE).capabilities.includes(id)).length === 1);
|
||||
|
||||
const HALF_BROKEN = DECLARED_ONE
|
||||
.replace(' - summary\n', ' - summary\n - list\n')
|
||||
.replace('id: declared-one', 'id: half-broken');
|
||||
const halfFields = fields.owliverFieldsFromSource(HALF_BROKEN);
|
||||
record('existing skill: a declared capability that lost its source still shows',
|
||||
JSON.stringify(halfFields.capabilities) === '["summary","list"]',
|
||||
JSON.stringify(halfFields.capabilities));
|
||||
record('...unsourced, so it stays invalid rather than silently dropped',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(halfFields)) === '["list"]',
|
||||
JSON.stringify(fields.unconfiguredCapabilities(halfFields)));
|
||||
record('...and the configured one keeps its source through the round trip',
|
||||
halfFields.responses.summary.source === 'candidates.activity',
|
||||
halfFields.responses.summary.source);
|
||||
record('...while the runtime still registers only what can answer',
|
||||
JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities) === '["summary"]',
|
||||
JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities));
|
||||
|
||||
/* A definition inheriting its reading from a `ui:` section still hydrates the
|
||||
capability it actually offers — the union is declaration-first, not
|
||||
declaration-only. */
|
||||
const INHERITED = `---
|
||||
id: inherited-cap
|
||||
name: Inherited Cap
|
||||
description: A capability reading the page section.
|
||||
pages:
|
||||
- positions
|
||||
status: active
|
||||
ui:
|
||||
type: card
|
||||
placement: after-position-list-summary
|
||||
source: candidates.activity
|
||||
owliver:
|
||||
enabled: true
|
||||
capabilities:
|
||||
- card
|
||||
---
|
||||
|
||||
# Inherited Cap
|
||||
`;
|
||||
record('a capability inheriting from `ui:` still hydrates with its source',
|
||||
fields.owliverFieldsFromSource(INHERITED).responses.card?.source === 'candidates.activity',
|
||||
fields.owliverFieldsFromSource(INHERITED).responses.card?.source || 'lost');
|
||||
|
||||
await server.close();
|
||||
|
||||
/* ── 8. Production bundle ─────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user