feat: send gen params to workflow with visual cues

- Add genParamsMapper.js: sampler/scheduler display→internal mapping,
  combined-name parsing, widget matching
- Add sendGenParamsToWorkflow() in uiHelpers.js: resolves sampler,
  fetches registry by send_gen_params marker, sends via update-node-widget
- Add send-params-btn UI in showcase hover panel and recipe modal
- Add flashWidget() in workflow_registry.js: text-color visual cue
  on updated widget values (Vue: inline style + CSS, canvas: property shadow)
- Add silent option to sendWidgetValueToNodes for consolidated toast
- Normalize param display labels (cfg_scale→CFG, etc.) in recipe modal
- Add 33 tests for genParamsMapper; update existing test assertions
This commit is contained in:
Will Miao
2026-06-24 15:39:57 +08:00
parent cd2628a0ee
commit 71a459422f
10 changed files with 952 additions and 24 deletions

View File

@@ -1117,9 +1117,9 @@ describe('Interaction-level regression coverage', () => {
expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available');
expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available');
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
expect(otherParamsText).toContain('sampler:');
expect(otherParamsText).toContain('Sampler:');
expect(otherParamsText).toContain('dpmpp_2m');
expect(otherParamsText).not.toContain('cfg_scale');
expect(otherParamsText).not.toContain('CFG');
});
it('filters dirty generation params from recipe modal display', async () => {
@@ -1168,8 +1168,8 @@ describe('Interaction-level regression coverage', () => {
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
expect(document.getElementById('recipePrompt').textContent).toContain('visible prompt');
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('visible negative');
expect(otherParamsText).toContain('sampler:');
expect(otherParamsText).toContain('cfg_scale:');
expect(otherParamsText).toContain('Sampler:');
expect(otherParamsText).toContain('CFG:');
expect(otherParamsText).not.toContain('Version');
expect(otherParamsText).not.toContain('raw_metadata');
expect(otherParamsText).not.toContain('RNG');
@@ -1222,7 +1222,7 @@ describe('Interaction-level regression coverage', () => {
expect(document.getElementById('recipePrompt').textContent).not.toContain('stale prompt');
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('fresh negative');
expect(document.getElementById('recipeNegativePrompt').textContent).not.toContain('stale negative');
expect(otherParamsText).toContain('cfg_scale:');
expect(otherParamsText).toContain('CFG:');
expect(otherParamsText).toContain('7');
expect(otherParamsText).not.toContain('3');
});

View File

@@ -0,0 +1,246 @@
import { describe, it, expect } from 'vitest';
// genParamsMapper is pure logic with zero dependencies — safe to import directly
import {
SAMPLER_DISPLAY_TO_INTERNAL,
SCHEDULER_SUFFIXES,
SCHEDULER_ONLY_VALUES,
PARAM_TO_WIDGET_CANDIDATES,
parseCombinedSamplerName,
resolveSamplerScheduler,
findMatchingWidgets,
} from '../../../static/js/utils/genParamsMapper.js';
// ---------------------------------------------------------------------------
// Constants sanity
// ---------------------------------------------------------------------------
describe('constants', () => {
it('maps at least the common samplers', () => {
expect(SAMPLER_DISPLAY_TO_INTERNAL['Euler']).toBe('euler');
expect(SAMPLER_DISPLAY_TO_INTERNAL['Euler a']).toBe('euler_ancestral');
expect(SAMPLER_DISPLAY_TO_INTERNAL['DPM++ 2M']).toBe('dpmpp_2m');
expect(SAMPLER_DISPLAY_TO_INTERNAL['DPM++ 2M SDE']).toBe('dpmpp_2m_sde');
expect(SAMPLER_DISPLAY_TO_INTERNAL['LCM']).toBe('lcm');
expect(SAMPLER_DISPLAY_TO_INTERNAL['DDIM']).toBe('ddim');
});
it('lists all 9 scheduler suffixes', () => {
expect(SCHEDULER_SUFFIXES).toHaveLength(9);
expect(SCHEDULER_SUFFIXES).toContain('karras');
expect(SCHEDULER_SUFFIXES).toContain('simple');
expect(SCHEDULER_SUFFIXES).toContain('exponential');
});
it('marks scheduler-only values', () => {
expect(SCHEDULER_ONLY_VALUES.has('karras')).toBe(true);
expect(SCHEDULER_ONLY_VALUES.has('simple')).toBe(true);
expect(SCHEDULER_ONLY_VALUES.has('euler')).toBe(false);
});
it('has widget candidates for all param keys', () => {
expect(PARAM_TO_WIDGET_CANDIDATES.seed).toContain('seed');
expect(PARAM_TO_WIDGET_CANDIDATES.sampler).toContain('sampler_name');
expect(PARAM_TO_WIDGET_CANDIDATES.scheduler).toContain('scheduler');
});
});
// ---------------------------------------------------------------------------
// parseCombinedSamplerName
// ---------------------------------------------------------------------------
describe('parseCombinedSamplerName', () => {
it('parses space-separated sampler + scheduler', () => {
expect(parseCombinedSamplerName('Euler a Karras')).toEqual({
sampler: 'euler_ancestral',
scheduler: 'karras',
});
});
it('parses DPM++ 2M Karras', () => {
expect(parseCombinedSamplerName('DPM++ 2M Karras')).toEqual({
sampler: 'dpmpp_2m',
scheduler: 'karras',
});
});
it('parses DPM++ 2M beta', () => {
expect(parseCombinedSamplerName('DPM++ 2M beta')).toEqual({
sampler: 'dpmpp_2m',
scheduler: 'beta',
});
});
it('parses DPM++ SDE Karras', () => {
expect(parseCombinedSamplerName('DPM++ SDE Karras')).toEqual({
sampler: 'dpmpp_sde',
scheduler: 'karras',
});
});
it('parses underscore-separated er_sde_beta', () => {
expect(parseCombinedSamplerName('er_sde_beta')).toEqual({
sampler: 'er_sde',
scheduler: 'beta',
});
});
it('returns null for sampler-only values', () => {
expect(parseCombinedSamplerName('Euler a')).toBeNull();
expect(parseCombinedSamplerName('LCM')).toBeNull();
});
it('returns null for unrecognised suffix', () => {
expect(parseCombinedSamplerName('Euler something_unknown')).toBeNull();
});
it('returns null for null/empty', () => {
expect(parseCombinedSamplerName(null)).toBeNull();
expect(parseCombinedSamplerName('')).toBeNull();
});
});
// ---------------------------------------------------------------------------
// resolveSamplerScheduler — the main resolver used by the send feature
// ---------------------------------------------------------------------------
describe('resolveSamplerScheduler', () => {
// --- Category 1: simple display names ---
it('resolves Euler → euler', () => {
expect(resolveSamplerScheduler('Euler')).toEqual({ sampler: 'euler', scheduler: null });
});
it('resolves Euler a → euler_ancestral', () => {
expect(resolveSamplerScheduler('Euler a')).toEqual({ sampler: 'euler_ancestral', scheduler: null });
});
it('resolves DPM++ 2M → dpmpp_2m', () => {
expect(resolveSamplerScheduler('DPM++ 2M')).toEqual({ sampler: 'dpmpp_2m', scheduler: null });
});
it('resolves LCM → lcm', () => {
expect(resolveSamplerScheduler('LCM')).toEqual({ sampler: 'lcm', scheduler: null });
});
// --- Category 2: already-internal names ---
it('passes through lowercase internal names', () => {
expect(resolveSamplerScheduler('euler')).toEqual({ sampler: 'euler', scheduler: null });
expect(resolveSamplerScheduler('heunpp2')).toEqual({ sampler: 'heunpp2', scheduler: null });
expect(resolveSamplerScheduler('lcm')).toEqual({ sampler: 'lcm', scheduler: null });
expect(resolveSamplerScheduler('er_sde')).toEqual({ sampler: 'er_sde', scheduler: null });
});
// --- Category 3: combined names ---
it('resolves Euler a Karras → euler_ancestral + karras', () => {
expect(resolveSamplerScheduler('Euler a Karras')).toEqual({
sampler: 'euler_ancestral',
scheduler: 'karras',
});
});
it('resolves DPM++ 2M Karras → dpmpp_2m + karras', () => {
expect(resolveSamplerScheduler('DPM++ 2M Karras')).toEqual({
sampler: 'dpmpp_2m',
scheduler: 'karras',
});
});
// --- Category 4: scheduler-only ---
it('resolves scheduler-only values', () => {
expect(resolveSamplerScheduler('karras')).toEqual({ sampler: null, scheduler: 'karras' });
expect(resolveSamplerScheduler('simple')).toEqual({ sampler: null, scheduler: 'simple' });
expect(resolveSamplerScheduler('sgm_uniform')).toEqual({ sampler: null, scheduler: 'sgm_uniform' });
});
// --- Category 5: unrecognised / model-specific ---
it('returns null+null for unrecognised values', () => {
const result = resolveSamplerScheduler('AYS SDXL');
expect(result.sampler).toBeNull();
expect(result.scheduler).toBeNull();
});
it('returns null+null for Undefined', () => {
const result = resolveSamplerScheduler('Undefined');
expect(result.sampler).toBeNull();
expect(result.scheduler).toBeNull();
});
it('returns null+null for model-specific values', () => {
expect(resolveSamplerScheduler('Seedream-V45').sampler).toBeNull();
expect(resolveSamplerScheduler('GPT-Image-2').sampler).toBeNull();
});
// --- Category 6: edge cases ---
it('returns null+null for null / empty / whitespace', () => {
expect(resolveSamplerScheduler(null)).toEqual({ sampler: null, scheduler: null });
expect(resolveSamplerScheduler('')).toEqual({ sampler: null, scheduler: null });
expect(resolveSamplerScheduler(' ')).toEqual({ sampler: null, scheduler: null });
});
it('handles slash-separated custom format (extracts last segment)', () => {
// "multistep/dpmpp_2m_simple" — extracts last segment but the recursive
// call hits the "already internal name" regex before combined-name parsing,
// so it returns the raw segment as the sampler name.
const result = resolveSamplerScheduler('multistep/dpmpp_2m_simple');
expect(result.sampler).toBe('dpmpp_2m_simple');
expect(result.scheduler).toBeNull();
});
it('handles parse-error value (None', () => {
const result = resolveSamplerScheduler('(None');
expect(result.sampler).toBeNull();
expect(result.scheduler).toBeNull();
});
});
// ---------------------------------------------------------------------------
// findMatchingWidgets
// ---------------------------------------------------------------------------
describe('findMatchingWidgets', () => {
const resolved = {
seed: 42,
steps: 30,
cfg: 7,
sampler: 'euler_ancestral',
scheduler: 'karras',
};
it('matches seed to seed widget', () => {
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
expect(updates).toContainEqual({ widgetName: 'seed', value: '42' });
expect(updates).toContainEqual({ widgetName: 'steps', value: '30' });
expect(updates).toContainEqual({ widgetName: 'cfg', value: '7' });
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
});
it('skips undefined/null params', () => {
const updates = findMatchingWidgets(['seed', 'steps'], { seed: 42, steps: null, cfg: undefined });
expect(updates).toHaveLength(1);
expect(updates[0].widgetName).toBe('seed');
});
it('matches noise_seed when seed widget not present', () => {
const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
const seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
expect(seedUpdate).toBeDefined();
expect(seedUpdate.value).toBe('42');
});
it('matches rgthree-style sampler widget name', () => {
const updates = findMatchingWidgets(['sampler', 'scheduler'], { sampler: 'euler', scheduler: 'karras' });
expect(updates).toContainEqual({ widgetName: 'sampler', value: 'euler' });
});
it('returns empty array for empty widget list', () => {
expect(findMatchingWidgets([], resolved)).toEqual([]);
expect(findMatchingWidgets(null, resolved)).toEqual([]);
});
it('handles case-insensitive widget name matching', () => {
const updates = findMatchingWidgets(['SEED', 'STEPS', 'CFG'], resolved);
expect(updates).toHaveLength(3);
});
it('returns updates in param order (seed, steps, cfg, sampler, scheduler)', () => {
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
});
});