Lands the view-preset / server-persisted personal-layer feature that has been running in production, plus the hotfix for "the same preset shows different filter configs to different users" (物流问题-已出轨迹 on tblgsJTXhqwiDpsjeL5 / viwyszJmLQNOLhA9sh5). Root cause of the divergence: - usePersonalViewSync implicitly flushed any overlay edit by a view|update user INTO the shared preset row (last-writer-wins). Overlays are stale snapshots seeded at apply time, so one user's seed silently erased colleagues' filter changes (2026-09-17 02:55 the preset lost the 流转角色=售后 condition), and active overlays never converged because preset updates do not propagate. Fix (semantics: presets are shared definitions, edits are personal): - fork-on-edit: the first user edit strips the seed markers from the overlay (toolbar switches to the personal config); edits only ever write the user's personal row - shared presets change ONLY through the new explicit, confirmed "Update to current config" row action (canManage users) - un-edited preset overlays auto-follow redefinitions: applyPreset stamps a presetVersion (preset.lastModifiedTime); when the preset list refetches with a newer row the overlay re-seeds. Edited overlays carry no markers and are never clobbered. Legacy rows without a version re-seed exactly once - exits strip both seed markers from the persisted personal row Also lands the pending WIP shipped with the deployed build: select-choice visibility constraint, personal-view server sync + proxy, grid async records scope/request tracking, CLI resource display, preset i18n (10 locales), view_preset migrations, docs/skill updates, .zcode gitignore. Verified end-to-end on the live instance (headless chromium, real session): apply -> edit flips toolbar to Personal with ZERO shared-row PUTs; explicit update PUTs the shared row; API-side redefinition auto-reseeds an un-edited overlay on the next list refetch.
195 lines
5.8 KiB
TypeScript
195 lines
5.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
summarizeFieldOptions,
|
|
describeChoiceCondition,
|
|
choiceRows,
|
|
formatFieldDetail,
|
|
} from './field-display';
|
|
|
|
describe('summarizeFieldOptions (field list --with-options column)', () => {
|
|
it('counts select choices without conditions', () => {
|
|
const field = {
|
|
type: 'singleSelect',
|
|
options: { choices: [{ name: 'A' }, { name: 'B' }, { name: 'C' }] },
|
|
};
|
|
expect(summarizeFieldOptions(field)).toBe('3 choices');
|
|
});
|
|
|
|
it('counts conditional choices (visibilityFilter)', () => {
|
|
const field = {
|
|
type: 'singleSelect',
|
|
options: {
|
|
choices: [
|
|
{ name: 'A' },
|
|
{ name: 'B', visibilityFilter: { conjunction: 'and', filterSet: [] } },
|
|
{ name: 'C', visibilityFilter: { conjunction: 'and', filterSet: [] } },
|
|
],
|
|
},
|
|
};
|
|
expect(summarizeFieldOptions(field)).toBe('3 choices · 2 conditional');
|
|
});
|
|
|
|
it('summarizes rating options', () => {
|
|
expect(summarizeFieldOptions({ type: 'rating', options: { max: 5, icon: 'star' } })).toBe(
|
|
'max=5 icon=star'
|
|
);
|
|
});
|
|
|
|
it('summarizes number precision', () => {
|
|
expect(
|
|
summarizeFieldOptions({
|
|
type: 'number',
|
|
options: { formatting: { precision: 2 } },
|
|
})
|
|
).toBe('precision=2');
|
|
});
|
|
|
|
it('summarizes date formatting', () => {
|
|
expect(
|
|
summarizeFieldOptions({
|
|
type: 'date',
|
|
options: { formatting: { date: 'YYYY-MM-DD', time: 'HH:mm' } },
|
|
})
|
|
).toBe('YYYY-MM-DD HH:mm');
|
|
});
|
|
|
|
it('returns dash when no recognizable options', () => {
|
|
expect(summarizeFieldOptions({ type: 'singleLineText' })).toBe('—');
|
|
expect(summarizeFieldOptions({ type: 'checkbox', options: {} })).toBe('—');
|
|
});
|
|
});
|
|
|
|
describe('describeChoiceCondition (visibilityFilter summary)', () => {
|
|
it('returns (always) when no filter', () => {
|
|
expect(describeChoiceCondition(undefined)).toBe('(always)');
|
|
expect(describeChoiceCondition(null)).toBe('(always)');
|
|
});
|
|
|
|
it('renders a single is rule with resolved field name', () => {
|
|
const vf = {
|
|
conjunction: 'and',
|
|
filterSet: [{ fieldId: 'fldA', operator: 'is', value: '物流问题-丢件' }],
|
|
};
|
|
expect(describeChoiceCondition(vf, { fldA: '问题类型' })).toBe('问题类型 is 物流问题-丢件');
|
|
});
|
|
|
|
it('falls back to fieldId when no name map', () => {
|
|
const vf = {
|
|
conjunction: 'and',
|
|
filterSet: [{ fieldId: 'fldA', operator: 'is', value: 'X' }],
|
|
};
|
|
expect(describeChoiceCondition(vf)).toBe('fldA is X');
|
|
});
|
|
|
|
it('joins rules with AND / OR and array values with |', () => {
|
|
const vfOr = {
|
|
conjunction: 'or',
|
|
filterSet: [
|
|
{ fieldId: 'fldA', operator: 'is', value: ['A1', 'A2'] },
|
|
{ fieldId: 'fldA', operator: 'is', value: 'B' },
|
|
],
|
|
};
|
|
expect(describeChoiceCondition(vfOr, { fldA: '问题类型' })).toBe(
|
|
'问题类型 is A1|A2 OR 问题类型 is B'
|
|
);
|
|
const vfAnd = {
|
|
conjunction: 'and',
|
|
filterSet: [
|
|
{ fieldId: 'fldA', operator: 'isNot', value: 'X' },
|
|
{ fieldId: 'fldB', operator: 'isNotEmpty', value: null },
|
|
],
|
|
};
|
|
expect(describeChoiceCondition(vfAnd, { fldA: '甲', fldB: '乙' })).toBe(
|
|
'甲 isNot X AND 乙 isNotEmpty'
|
|
);
|
|
});
|
|
|
|
it('truncates long rule lists with +N more', () => {
|
|
const vf = {
|
|
conjunction: 'or',
|
|
filterSet: Array.from({ length: 6 }, (_, i) => ({
|
|
fieldId: 'fldA',
|
|
operator: 'is',
|
|
value: `value-number-${i}`,
|
|
})),
|
|
};
|
|
const out = describeChoiceCondition(vf, { fldA: 'F' });
|
|
// 4 rules fit the budget → the marker must report exactly the 2 omitted.
|
|
expect(out).toMatch(/\+2 more$/);
|
|
expect(out).not.toContain('value-number-4');
|
|
expect(out).not.toContain('value-number-5');
|
|
});
|
|
});
|
|
|
|
describe('choiceRows (select field → display rows)', () => {
|
|
it('maps choices to name/color/when rows', () => {
|
|
const field = {
|
|
type: 'singleSelect',
|
|
options: {
|
|
choices: [
|
|
{ name: 'A', color: 'red' },
|
|
{
|
|
name: 'B',
|
|
visibilityFilter: {
|
|
conjunction: 'and',
|
|
filterSet: [{ fieldId: 'fldX', operator: 'is', value: 'V' }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const rows = choiceRows(field, { fldX: 'X' });
|
|
expect(rows).toEqual([
|
|
{ name: 'A', color: 'red', when: '(always)' },
|
|
{ name: 'B', color: '', when: 'X is V' },
|
|
]);
|
|
});
|
|
|
|
it('returns [] for non-select fields', () => {
|
|
expect(choiceRows({ type: 'number', options: {} })).toEqual([]);
|
|
expect(choiceRows({ type: 'singleSelect', options: {} })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('formatFieldDetail (field get human mode)', () => {
|
|
it('expands select choices into lines with conditions', () => {
|
|
const field = {
|
|
id: 'fld1',
|
|
name: '处理反馈',
|
|
type: 'singleSelect',
|
|
unique: false,
|
|
options: {
|
|
defaultValue: 'A',
|
|
choices: [
|
|
{ name: 'A', color: 'blue' },
|
|
{
|
|
name: 'B',
|
|
visibilityFilter: {
|
|
conjunction: 'and',
|
|
filterSet: [{ fieldId: 'fldT', operator: 'is', value: '丢件' }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const out = formatFieldDetail(field, { fldT: '问题类型' });
|
|
expect(out).toContain('处理反馈');
|
|
expect(out).toMatch(/choices \(2 · 1 conditional\)/);
|
|
expect(out).toContain('(always)');
|
|
expect(out).toContain('问题类型 is 丢件');
|
|
expect(out).toContain('defaultValue'); // non-choice options still visible
|
|
});
|
|
|
|
it('keeps non-select options as a JSON line', () => {
|
|
const field = {
|
|
id: 'fld2',
|
|
name: 'Score',
|
|
type: 'rating',
|
|
options: { max: 5, icon: 'star' },
|
|
};
|
|
const out = formatFieldDetail(field);
|
|
expect(out).toContain('max');
|
|
expect(out).toContain('"max":5');
|
|
});
|
|
});
|