mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
188ae72f8e
Generated-By: looper 0.0.0-dev (runner=worker, agent=codex)
521 lines
23 KiB
TypeScript
521 lines
23 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
||
|
||
import {
|
||
formatFormAnswers,
|
||
splitOnQuestionForms,
|
||
parsePartialQuestionForm,
|
||
} from '../../src/artifacts/question-form';
|
||
|
||
const VALID_BODY = `{
|
||
"questions": [
|
||
{ "id": "platform", "label": "Platform", "type": "radio",
|
||
"options": ["Mobile", "Desktop", "Responsive"], "required": true }
|
||
]
|
||
}`;
|
||
|
||
describe('form content language (lang)', () => {
|
||
it('parses a top-level lang tag from the complete form body', () => {
|
||
const input = [
|
||
'<question-form id="discovery" title="快速确认 · 30秒">',
|
||
'{ "lang": "zh-CN", "questions": [',
|
||
' { "id": "output", "label": "我们要做什么?", "type": "radio", "options": ["海报", "网页"] }',
|
||
'] }',
|
||
'</question-form>',
|
||
].join('\n');
|
||
|
||
const segments = splitOnQuestionForms(input);
|
||
expect(segments[0]?.kind).toBe('form');
|
||
if (segments[0]?.kind !== 'form') return;
|
||
expect(segments[0].form.lang).toBe('zh-CN');
|
||
});
|
||
|
||
it('never exposes a defaultValue on the still-streaming trailing question', () => {
|
||
// Red spec for the truncated-prefill freeze (production run beaf2da0):
|
||
// while a question object is still streaming, the partial-JSON repair can
|
||
// terminate mid-default — `"default": ["历史背景与经过", "抗战精` repairs
|
||
// to ["历史背景与经过", "抗战精"], and `"default": "单位教` to "单位教".
|
||
// The card adopts a streamed default only while the answer is still
|
||
// empty, so a truncated adoption freezes garbage that the completed
|
||
// default can never overwrite. The in-flight question must therefore
|
||
// expose NO defaultValue until its braces close.
|
||
const head =
|
||
'<question-form id="discovery" title="快速确认">\n' +
|
||
'{ "questions": [\n' +
|
||
' { "id": "focus", "label": "内容重点(最多选2项)", "type": "checkbox", "maxSelections": 2,\n' +
|
||
' "default": ["历史背景与经过", "抗战精';
|
||
|
||
const midDefault = parsePartialQuestionForm(head);
|
||
const focusMid = midDefault?.questions.find((q) => q.id === 'focus');
|
||
expect(focusMid).toBeTruthy();
|
||
expect(focusMid?.defaultValue).toBeUndefined();
|
||
|
||
const closed = parsePartialQuestionForm(
|
||
head +
|
||
'神与当代意义"],\n' +
|
||
' "options": ["历史背景与经过", "重要战役与事件", "抗战精神与当代意义"] },\n' +
|
||
' { "id": "tone", "label": "整体基调", "type": "radio", "default": "庄重',
|
||
);
|
||
// First question closed → its complete default surfaces; the trailing
|
||
// in-flight question ("tone", mid-default "庄重…") still exposes none.
|
||
const focusClosed = closed?.questions.find((q) => q.id === 'focus');
|
||
expect(focusClosed?.defaultValue).toEqual(['历史背景与经过', '抗战精神与当代意义']);
|
||
const toneInFlight = closed?.questions.find((q) => q.id === 'tone');
|
||
expect(toneInFlight).toBeTruthy();
|
||
expect(toneInFlight?.defaultValue).toBeUndefined();
|
||
});
|
||
|
||
it('adopts a streaming lang only once its string literal terminates', () => {
|
||
// A half-streamed tag like "zh-C" must not resolve to a dictionary; the
|
||
// field appears once the closing quote lands (same churn rule as `id`).
|
||
const partial = parsePartialQuestionForm(
|
||
'<question-form id="discovery" title="快速确认">\n{ "lang": "zh-C',
|
||
);
|
||
expect(partial?.lang).toBeUndefined();
|
||
|
||
const complete = parsePartialQuestionForm(
|
||
'<question-form id="discovery" title="快速确认">\n{ "lang": "zh-CN", "questions": [',
|
||
);
|
||
expect(complete?.lang).toBe('zh-CN');
|
||
});
|
||
});
|
||
|
||
describe('splitOnQuestionForms', () => {
|
||
it('normalizes string and object question options', () => {
|
||
const input = [
|
||
'<question-form id="discovery" title="Quick brief">',
|
||
'{',
|
||
' "questions": [',
|
||
' {',
|
||
' "id": "platform",',
|
||
' "label": "Primary surface",',
|
||
' "type": "radio",',
|
||
' "required": true,',
|
||
' "options": [',
|
||
' "Responsive",',
|
||
' { "label": "Mobile (iOS/Android)", "description": "Phone-first app prototype", "value": "mobile" },',
|
||
' { "label": "Desktop web", "description": "Browser-first prototype" },',
|
||
' { "description": "Missing label" }',
|
||
' ]',
|
||
' }',
|
||
' ]',
|
||
'}',
|
||
'</question-form>',
|
||
].join('\n');
|
||
|
||
const segments = splitOnQuestionForms(input);
|
||
expect(segments).toHaveLength(1);
|
||
expect(segments[0]).toMatchObject({ kind: 'form' });
|
||
if (segments[0]?.kind !== 'form') throw new Error('expected parsed form segment');
|
||
|
||
expect(segments[0].form.questions[0]?.options).toEqual([
|
||
{ label: 'Responsive', value: 'Responsive' },
|
||
{
|
||
label: 'Mobile (iOS/Android)',
|
||
value: 'mobile',
|
||
description: 'Phone-first app prototype',
|
||
},
|
||
{
|
||
label: 'Desktop web',
|
||
value: 'Desktop web',
|
||
description: 'Browser-first prototype',
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('parses richer web input controls and custom-choice metadata', () => {
|
||
const input = [
|
||
'<question-form id="advanced" title="Advanced brief">',
|
||
'{',
|
||
' "questions": [',
|
||
' { "id": "accent", "label": "Accent", "type": "color", "defaultValue": "#ff5500" },',
|
||
' { "id": "intensity", "label": "Intensity", "type": "range", "min": 1, "max": 10, "step": 1 },',
|
||
' { "id": "deadline", "label": "Deadline", "type": "date" },',
|
||
' { "id": "source", "label": "Reference URL", "type": "url" },',
|
||
' { "id": "brief", "label": "Upload brief", "type": "file", "multiple": true, "accept": "image/*,.pdf" },',
|
||
' { "id": "exact", "label": "Exact id", "type": "select", "allowCustom": false, "options": [{ "label": "A", "value": "a" }] },',
|
||
' { "id": "tone", "label": "Tone", "type": "radio", "customLabel": "Something else", "customPlaceholder": "Describe it", "options": ["Sharp"] }',
|
||
' ]',
|
||
'}',
|
||
'</question-form>',
|
||
].join('\n');
|
||
|
||
const segment = splitOnQuestionForms(input).find((s) => s.kind === 'form');
|
||
if (!segment || segment.kind !== 'form') throw new Error('expected parsed form');
|
||
|
||
expect(segment.form.questions.map((q) => q.type)).toEqual([
|
||
'color',
|
||
'range',
|
||
'date',
|
||
'url',
|
||
'file',
|
||
'select',
|
||
'radio',
|
||
]);
|
||
expect(segment.form.questions[1]).toMatchObject({ min: 1, max: 10, step: 1 });
|
||
expect(segment.form.questions[4]).toMatchObject({ multiple: true, accept: 'image/*,.pdf' });
|
||
expect(segment.form.questions[5]).toMatchObject({ allowCustom: false });
|
||
expect(segment.form.questions[6]).toMatchObject({
|
||
customLabel: 'Something else',
|
||
customPlaceholder: 'Describe it',
|
||
});
|
||
});
|
||
|
||
it('parses the agent-recommended `default` prefill the prompt contract ships', () => {
|
||
// The system prompt instructs every question to carry a brief-inferred
|
||
// recommended `default` (option value for radio/select, array for
|
||
// checkbox, text otherwise) so the user can submit the form unchanged.
|
||
const input = [
|
||
'<question-form id="discovery" title="Quick brief">',
|
||
'{',
|
||
' "questions": [',
|
||
' { "id": "brand", "label": "Brand context", "type": "radio", "default": "pick_direction",',
|
||
' "options": [{ "label": "Pick a direction for me", "value": "pick_direction" }] },',
|
||
' { "id": "tone", "label": "Tone", "type": "checkbox", "default": ["Modern minimal", "tech"],',
|
||
' "options": ["Modern minimal", { "label": "Tech / utility", "value": "tech" }] },',
|
||
' { "id": "scale", "label": "Roughly how much?", "type": "text", "default": "8 slides" }',
|
||
' ]',
|
||
'}',
|
||
'</question-form>',
|
||
].join('\n');
|
||
|
||
const segment = splitOnQuestionForms(input).find((s) => s.kind === 'form');
|
||
if (!segment || segment.kind !== 'form') throw new Error('expected parsed form');
|
||
|
||
expect(segment.form.questions[0]?.defaultValue).toBe('pick_direction');
|
||
// Label-form defaults canonicalize to stable option values.
|
||
expect(segment.form.questions[1]?.defaultValue).toEqual(['Modern minimal', 'tech']);
|
||
expect(segment.form.questions[2]?.defaultValue).toBe('8 slides');
|
||
});
|
||
|
||
it('preserves stable option values when formatting object-option answers', () => {
|
||
const text = formatFormAnswers(
|
||
{
|
||
id: 'discovery',
|
||
title: 'Quick brief',
|
||
questions: [
|
||
{
|
||
id: 'platform',
|
||
label: 'Primary surface',
|
||
type: 'radio',
|
||
options: [
|
||
{ label: 'Mobile (iOS/Android)', value: 'mobile' },
|
||
{ label: 'Desktop web', value: 'Desktop web' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{ platform: 'mobile' },
|
||
);
|
||
|
||
expect(text).toContain('- Primary surface: Mobile (iOS/Android) [value: mobile]');
|
||
});
|
||
|
||
it('parses the canonical <question-form> tag', () => {
|
||
const out = splitOnQuestionForms(`prose\n<question-form id="d" title="T">${VALID_BODY}</question-form>\nmore`);
|
||
expect(out.map((s) => s.kind)).toEqual(['text', 'form', 'text']);
|
||
if (out[1]?.kind === 'form') {
|
||
expect(out[1].form.id).toBe('d');
|
||
expect(out[1].form.questions).toHaveLength(1);
|
||
}
|
||
});
|
||
|
||
it('parses the deliveryFormat/container array payload from Feishu acceptance', () => {
|
||
const input = [
|
||
'要的话我可以直接按这张图出 motion overlay。先选一下输出偏好:',
|
||
'<question-form>',
|
||
JSON.stringify([
|
||
{
|
||
id: 'deliveryFormat',
|
||
prompt: '导出格式?',
|
||
type: 'radio',
|
||
options: [
|
||
{ id: 'mov', label: 'MOV(透明背景,可直接导入剪映 / PR / FCP 叠加)' },
|
||
{ id: 'webm', label: 'WebM(透明背景,适合网页 / 浏览器播放)' },
|
||
{ id: 'preview', label: '预览图 / 预览工程(适合先确认效果)' },
|
||
{ id: 'remotion', label: 'Remotion 工程(适合继续编辑和拼装)' },
|
||
],
|
||
},
|
||
{
|
||
id: 'container',
|
||
prompt: '对话外壳?',
|
||
type: 'radio',
|
||
options: [
|
||
{ id: 'none', label: '不要外壳,只要气泡' },
|
||
{ id: 'phone', label: '手机聊天界面' },
|
||
],
|
||
},
|
||
]),
|
||
'</question-form>',
|
||
].join('\n');
|
||
|
||
const out = splitOnQuestionForms(input);
|
||
expect(out.map((s) => s.kind)).toEqual(['text', 'form']);
|
||
const form = out[1]?.kind === 'form' ? out[1].form : null;
|
||
expect(form?.questions.map((q) => [q.id, q.label, q.type])).toEqual([
|
||
['deliveryFormat', '导出格式?', 'radio'],
|
||
['container', '对话外壳?', 'radio'],
|
||
]);
|
||
expect(form?.questions[0]?.options?.map((option) => option.value)).toEqual([
|
||
'mov',
|
||
'webm',
|
||
'preview',
|
||
'remotion',
|
||
]);
|
||
});
|
||
|
||
it('normalizes partially missing question fields instead of dropping the form', () => {
|
||
const out = splitOnQuestionForms(
|
||
`<question-form>${JSON.stringify([
|
||
{ prompt: 'Pick one', options: ['A', 'B'] },
|
||
{ id: 'notes', type: 'textarea' },
|
||
])}</question-form>`,
|
||
);
|
||
|
||
expect(out.map((s) => s.kind)).toEqual(['form']);
|
||
const form = out[0]?.kind === 'form' ? out[0].form : null;
|
||
expect(form?.questions).toMatchObject([
|
||
{ id: 'q1', label: 'Pick one', type: 'radio' },
|
||
{ id: 'notes', label: 'notes', type: 'textarea' },
|
||
]);
|
||
});
|
||
|
||
it('accepts <ask-question> as an alias for <question-form> (#1194)', () => {
|
||
const out = splitOnQuestionForms(`<ask-question id="brief" title="Quick brief">${VALID_BODY}</ask-question>`);
|
||
expect(out.map((s) => s.kind)).toEqual(['form']);
|
||
if (out[0]?.kind === 'form') {
|
||
expect(out[0].form.id).toBe('brief');
|
||
expect(out[0].form.title).toBe('Quick brief');
|
||
expect(out[0].form.questions[0]?.id).toBe('platform');
|
||
}
|
||
});
|
||
|
||
it('handles mixed casing on the alias (e.g. <Ask-Question>)', () => {
|
||
const out = splitOnQuestionForms(`<Ask-Question>${VALID_BODY}</Ask-Question>`);
|
||
expect(out.map((s) => s.kind)).toEqual(['form']);
|
||
});
|
||
|
||
it('does not close one tag with the other tag name', () => {
|
||
const out = splitOnQuestionForms(`<question-form>${VALID_BODY}</ask-question>`);
|
||
expect(out.map((s) => s.kind)).toEqual(['text']);
|
||
});
|
||
|
||
it('replaces malformed JSON bodies with a safe fallback and diagnostics', () => {
|
||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
const out = splitOnQuestionForms(`<ask-question>not json</ask-question>`);
|
||
expect(out.map((s) => s.kind)).toEqual(['text']);
|
||
expect(out[0]).toMatchObject({
|
||
kind: 'text',
|
||
text: 'The assistant sent a question form that could not be rendered. Please ask it to resend the questions.',
|
||
});
|
||
expect(out[0]?.kind === 'text' ? out[0].text : '').not.toContain('<ask-question>');
|
||
expect(out[0]?.kind === 'text' ? out[0].text : '').not.toContain('not json');
|
||
expect(warn).toHaveBeenCalledWith(
|
||
'[question-form] failed to render inline question form',
|
||
expect.objectContaining({ reason: 'invalid-json', tagName: 'ask-question' }),
|
||
);
|
||
warn.mockRestore();
|
||
});
|
||
|
||
it('keeps unterminated tags as prose without swallowing trailing text', () => {
|
||
const out = splitOnQuestionForms(`leading <ask-question>${VALID_BODY}`);
|
||
expect(out).toHaveLength(1);
|
||
expect(out[0]).toMatchObject({ kind: 'text' });
|
||
});
|
||
|
||
it('finds close tags without Unicode index desync (#1194)', () => {
|
||
const out = splitOnQuestionForms(`prefix İ suffix<ask-question id="x">${VALID_BODY}</ask-question>`);
|
||
expect(out.map((s) => s.kind)).toEqual(['text', 'form']);
|
||
if (out[1]?.kind === 'form') {
|
||
expect(out[1].form.id).toBe('x');
|
||
}
|
||
});
|
||
|
||
it('unwinds a false-positive open tag mentioned in prose and re-parses the real form', () => {
|
||
// Model mentioned the tag name inside backtick-quoted prose before
|
||
// emitting the real form — the first open match must not consume the real
|
||
// close tag, or the real form is lost.
|
||
const input =
|
||
`my first output should be \`<question-form id="discovery">\`.\n\n` +
|
||
`Let me write a custom form:\n\n` +
|
||
`<question-form id="discovery" title="Quick brief">${VALID_BODY}</question-form>\n\n` +
|
||
`Now I'll proceed.`;
|
||
const out = splitOnQuestionForms(input);
|
||
expect(out.map((s) => s.kind)).toEqual(['text', 'text', 'form', 'text']);
|
||
if (out[2]?.kind === 'form') {
|
||
expect(out[2].form.id).toBe('discovery');
|
||
expect(out[2].form.questions).toHaveLength(1);
|
||
}
|
||
// Segments must reconstruct the input without gaps or duplication.
|
||
const reconstructed = out
|
||
.map((s) => (s.kind === 'form' ? (s as { raw: string }).raw : (s as { text: string }).text))
|
||
.join('');
|
||
expect(reconstructed).toBe(input);
|
||
});
|
||
|
||
it('unwinds a tag-name mismatch — prose mentions <ask-question> but the real form is <question-form>', () => {
|
||
const input =
|
||
`In your output you'll see \`<ask-question>\` tags.\n\n` +
|
||
`<question-form id="real" title="Brief">${VALID_BODY}</question-form>\n\n` +
|
||
`Done.`;
|
||
const out = splitOnQuestionForms(input);
|
||
expect(out.map((s) => s.kind)).toEqual(['text', 'text', 'form', 'text']);
|
||
if (out[2]?.kind === 'form') {
|
||
expect(out[2].form.id).toBe('real');
|
||
expect(out[2].form.questions).toHaveLength(1);
|
||
}
|
||
const reconstructed = out
|
||
.map((s) => (s.kind === 'form' ? (s as { raw: string }).raw : (s as { text: string }).text))
|
||
.join('');
|
||
expect(reconstructed).toBe(input);
|
||
});
|
||
});
|
||
|
||
describe('parsePartialQuestionForm (true token-by-token streaming)', () => {
|
||
it('surfaces a question before its object closes, then grows its options', () => {
|
||
const midLabel = '<question-form id="discovery">{"questions":[{"id":"platform","label":"Platform"';
|
||
expect(parsePartialQuestionForm(midLabel)?.questions.map((q) => q.label)).toEqual(['Platform']);
|
||
|
||
const oneOption =
|
||
'<question-form id="discovery">{"questions":[{"id":"platform","label":"Platform","type":"radio","options":["Mobile"';
|
||
expect(parsePartialQuestionForm(oneOption)?.questions[0]?.options?.map((o) => o.label)).toEqual([
|
||
'Mobile',
|
||
]);
|
||
|
||
const twoOptions =
|
||
'<question-form id="discovery">{"questions":[{"id":"platform","label":"Platform","type":"radio","options":["Mobile","Desktop"';
|
||
expect(parsePartialQuestionForm(twoOptions)?.questions[0]?.options?.map((o) => o.label)).toEqual([
|
||
'Mobile',
|
||
'Desktop',
|
||
]);
|
||
});
|
||
|
||
it('holds back a trailing question until its label exists (no id placeholder flicker)', () => {
|
||
const buf =
|
||
'<question-form id="discovery">{"questions":[{"id":"a","label":"First","type":"text"},{"id":"b"';
|
||
expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.label)).toEqual(['First']);
|
||
});
|
||
|
||
it('reads title/id from the open tag before any question arrives', () => {
|
||
const f = parsePartialQuestionForm('<question-form id="discovery" title="Quick brief">{"questions":[');
|
||
expect(f?.id).toBe('discovery');
|
||
expect(f?.title).toBe('Quick brief');
|
||
expect(f?.questions).toEqual([]);
|
||
});
|
||
|
||
it('does not adopt a partial body id (no char-by-char churn)', () => {
|
||
// No tag attr; the body `id` is still arriving — the preview id must NOT
|
||
// follow the partial token (which would remount the editable panel).
|
||
expect(parsePartialQuestionForm('<question-form>{"id":"d')?.id).toBe('discovery');
|
||
expect(parsePartialQuestionForm('<question-form>{"id":"disc')?.id).toBe('discovery');
|
||
});
|
||
|
||
it('adopts the body id once complete so it matches the final parse (no swap remount)', () => {
|
||
// Once the id string literal is terminated, the preview adopts it — which
|
||
// is exactly what tryParseForm assigns after the close tag, so there's no
|
||
// preview→final identity change to remount the panel.
|
||
const buf = '<question-form>{"id":"discovery-form","questions":[{"id":"a","label":"Q"}]}';
|
||
expect(parsePartialQuestionForm(buf)?.id).toBe('discovery-form');
|
||
const seg = splitOnQuestionForms(`${buf}</question-form>`).find((s) => s.kind === 'form');
|
||
expect(seg && seg.kind === 'form' ? seg.form.id : null).toBe('discovery-form');
|
||
});
|
||
|
||
it('ignores a nested question id when deriving the form id', () => {
|
||
expect(
|
||
parsePartialQuestionForm('<question-form>{"questions":[{"id":"platform","label":"P"}')?.id,
|
||
).toBe('discovery');
|
||
});
|
||
|
||
it('does not let a nested question id/description masquerade as form metadata', () => {
|
||
// No form-level id on the tag or top-level body — only a question-level
|
||
// id. The form id must stay the stable fallback, not adopt "platform"
|
||
// (which would change the live panel's identity mid-stream).
|
||
const f = parsePartialQuestionForm(
|
||
'<question-form>{"questions":[{"id":"platform","label":"Platform","description":"nested"',
|
||
);
|
||
expect(f?.id).toBe('discovery');
|
||
expect(f?.description).toBeUndefined();
|
||
expect(f?.questions.map((q) => q.label)).toEqual(['Platform']);
|
||
});
|
||
|
||
it('keeps streaming through a closed ```json fence before the close tag arrives', () => {
|
||
// Fenced body fully written, trailing ``` present, but </question-form>
|
||
// not yet — the preview must stay populated, not drop to empty.
|
||
const buf =
|
||
'<question-form id="discovery">```json\n{"questions":[{"id":"a","label":"First","type":"text"}]}\n```';
|
||
expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.label)).toEqual(['First']);
|
||
});
|
||
|
||
it('keeps streaming while only a partial trailing fence has arrived', () => {
|
||
const buf =
|
||
'<question-form id="discovery">```json\n{"questions":[{"id":"a","label":"First","type":"text"}]}\n``';
|
||
expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.label)).toEqual(['First']);
|
||
});
|
||
|
||
it('does not strip backticks that are content of a label still being typed', () => {
|
||
// The trailing ``` here is inside an open string value, not a closing
|
||
// fence — it must survive into the preview, not be trimmed to "Use ".
|
||
const buf = '<question-form id="discovery">{"questions":[{"id":"a","label":"Use ```';
|
||
expect(parsePartialQuestionForm(buf)?.questions[0]?.label).toContain('```');
|
||
});
|
||
|
||
it('carries a custom submitLabel through the streaming preview', () => {
|
||
const buf =
|
||
'<question-form id="discovery">{"submitLabel":"Generate brief","questions":[{"id":"a","label":"First","type":"text"}';
|
||
expect(parsePartialQuestionForm(buf)?.submitLabel).toBe('Generate brief');
|
||
});
|
||
|
||
it('keeps already-visible questions when a boolean value is split across deltas', () => {
|
||
// `required` cut mid-`true` must not drop the question already shown.
|
||
const buf = '<question-form id="discovery">{"questions":[{"id":"a","label":"Q","required":t';
|
||
expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.label)).toEqual(['Q']);
|
||
});
|
||
|
||
it('streams a single closed no-id question before the close tag arrives', () => {
|
||
// The object is complete (its `}` streamed) but has no explicit id and no
|
||
// `]`/close tag yet. Its fallback id (q1) is already final, so it should
|
||
// show now rather than waiting for the close tag.
|
||
const buf = '<question-form id="discovery">{"questions":[{"label":"First","type":"text"}';
|
||
expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.label)).toEqual(['First']);
|
||
expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.id)).toEqual(['q1']);
|
||
});
|
||
|
||
it('still holds a no-id question whose object is not yet closed', () => {
|
||
// Same content but the object is still open (no `}`): it might still gain
|
||
// an id, so surfacing it now risks an id swap that orphans an answer.
|
||
const buf = '<question-form id="discovery">{"questions":[{"label":"First","type":"text"';
|
||
expect(parsePartialQuestionForm(buf)?.questions).toEqual([]);
|
||
});
|
||
|
||
it('holds a label-first question until its canonical id is determinable', () => {
|
||
// label streamed, the in-flight object has no id yet — surfacing it now
|
||
// would force a later id swap that orphans an in-progress answer, so it
|
||
// waits. Once the id streams it appears with the canonical id.
|
||
const labelFirst = parsePartialQuestionForm(
|
||
'<question-form id="discovery">{"questions":[{"label":"Platform"',
|
||
);
|
||
expect(labelFirst?.questions).toEqual([]);
|
||
const idLater = parsePartialQuestionForm(
|
||
'<question-form id="discovery">{"questions":[{"label":"Platform","id":"platform"',
|
||
);
|
||
expect(idLater?.questions[0]?.id).toBe('platform');
|
||
});
|
||
|
||
it('gives preview question ids identical to the final parse (no orphaned answers on swap)', () => {
|
||
const finalIds = (input: string) => {
|
||
const seg = splitOnQuestionForms(input).find((s) => s.kind === 'form');
|
||
return seg && seg.kind === 'form' ? seg.form.questions.map((q) => q.id) : [];
|
||
};
|
||
// id-bearing question: preview id == final canonical id.
|
||
const withId =
|
||
'<question-form id="discovery">{"questions":[{"id":"platform","label":"Platform","type":"radio","options":["A"]}';
|
||
expect(parsePartialQuestionForm(withId)?.questions.map((q) => q.id)).toEqual(['platform']);
|
||
expect(finalIds(`${withId}]}</question-form>`)).toEqual(['platform']);
|
||
// no-id question that has closed (not in-flight): preview falls back to the
|
||
// same index id the final parse will assign.
|
||
const noId =
|
||
'<question-form id="discovery">{"questions":[{"label":"First","type":"text"},{"id":"b"';
|
||
expect(parsePartialQuestionForm(noId)?.questions.map((q) => q.id)).toEqual(['q1']);
|
||
expect(finalIds('<question-form id="discovery">{"questions":[{"label":"First","type":"text"}]}</question-form>')).toEqual(['q1']);
|
||
});
|
||
});
|