205 lines
7.2 KiB
TypeScript
205 lines
7.2 KiB
TypeScript
|
|
import test from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
|
||
|
|
import {
|
||
|
|
parseCustomAccentRecord,
|
||
|
|
serializeCustomAccentRecord,
|
||
|
|
shouldApplyCustomAccentRecord,
|
||
|
|
shouldBroadcastCustomAccentChange,
|
||
|
|
type CustomAccentMutationSource,
|
||
|
|
type CustomAccentRecord,
|
||
|
|
} from './customAccentSync.ts';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Minimal model of the settings ↔ main custom-accent sync loop that caused
|
||
|
|
* #2743. Models both IPC rebroadcast and stale localStorage overwrites while
|
||
|
|
* the native color picker fires rapid onChange events.
|
||
|
|
*/
|
||
|
|
function simulateAccentDrag(options: {
|
||
|
|
shouldBroadcast: (
|
||
|
|
source: CustomAccentMutationSource,
|
||
|
|
persistMounted: boolean,
|
||
|
|
) => { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource };
|
||
|
|
shouldApply: (current: CustomAccentRecord, incoming: CustomAccentRecord) => boolean;
|
||
|
|
versioned: boolean;
|
||
|
|
}): { settingsValues: string[]; mainValues: string[]; storageWrites: string[] } {
|
||
|
|
let settings: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
|
||
|
|
let main: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
|
||
|
|
let settingsSource: CustomAccentMutationSource = 'local';
|
||
|
|
let mainSource: CustomAccentMutationSource = 'local';
|
||
|
|
let storage = options.versioned
|
||
|
|
? serializeCustomAccentRecord(settings)
|
||
|
|
: settings.color;
|
||
|
|
const settingsValues: string[] = [];
|
||
|
|
const mainValues: string[] = [];
|
||
|
|
const storageWrites: string[] = [];
|
||
|
|
|
||
|
|
const pendingIpc: Array<{ to: 'settings' | 'main'; record: CustomAccentRecord }> = [];
|
||
|
|
|
||
|
|
const writeStorage = (record: CustomAccentRecord) => {
|
||
|
|
const next = options.versioned
|
||
|
|
? serializeCustomAccentRecord(record)
|
||
|
|
: record.color;
|
||
|
|
if (next === storage) return;
|
||
|
|
storage = next;
|
||
|
|
storageWrites.push(next);
|
||
|
|
};
|
||
|
|
|
||
|
|
const applyLocal = (window: 'settings' | 'main', color: string) => {
|
||
|
|
const bump = (prev: CustomAccentRecord): CustomAccentRecord => (
|
||
|
|
options.versioned
|
||
|
|
? { color, version: prev.version + 1 }
|
||
|
|
: { color, version: 0 }
|
||
|
|
);
|
||
|
|
|
||
|
|
if (window === 'settings') {
|
||
|
|
settingsSource = 'local';
|
||
|
|
settings = bump(settings);
|
||
|
|
settingsValues.push(settings.color);
|
||
|
|
writeStorage(settings);
|
||
|
|
const decision = options.shouldBroadcast(settingsSource, true);
|
||
|
|
settingsSource = decision.nextSource;
|
||
|
|
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
mainSource = 'local';
|
||
|
|
main = bump(main);
|
||
|
|
mainValues.push(main.color);
|
||
|
|
writeStorage(main);
|
||
|
|
const decision = options.shouldBroadcast(mainSource, true);
|
||
|
|
mainSource = decision.nextSource;
|
||
|
|
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
|
||
|
|
};
|
||
|
|
|
||
|
|
const applyIncoming = (window: 'settings' | 'main', record: CustomAccentRecord) => {
|
||
|
|
if (window === 'settings') {
|
||
|
|
if (!options.shouldApply(settings, record)) return;
|
||
|
|
settingsSource = 'incoming';
|
||
|
|
settings = { ...record };
|
||
|
|
settingsValues.push(settings.color);
|
||
|
|
writeStorage(settings);
|
||
|
|
const decision = options.shouldBroadcast(settingsSource, true);
|
||
|
|
settingsSource = decision.nextSource;
|
||
|
|
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!options.shouldApply(main, record)) return;
|
||
|
|
mainSource = 'incoming';
|
||
|
|
main = { ...record };
|
||
|
|
mainValues.push(main.color);
|
||
|
|
writeStorage(main);
|
||
|
|
const decision = options.shouldBroadcast(mainSource, true);
|
||
|
|
mainSource = decision.nextSource;
|
||
|
|
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
|
||
|
|
};
|
||
|
|
|
||
|
|
const flushIpc = () => {
|
||
|
|
while (pendingIpc.length > 0) {
|
||
|
|
const next = pendingIpc.shift()!;
|
||
|
|
applyIncoming(next.to, next.record);
|
||
|
|
if (settingsValues.length > 40) break;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const deliverStorageTo = (window: 'settings' | 'main') => {
|
||
|
|
const record = options.versioned
|
||
|
|
? parseCustomAccentRecord(JSON.parse(storage))
|
||
|
|
: parseCustomAccentRecord(storage);
|
||
|
|
applyIncoming(window, record);
|
||
|
|
};
|
||
|
|
|
||
|
|
applyLocal('settings', '0 84% 60%');
|
||
|
|
deliverStorageTo('main');
|
||
|
|
applyLocal('settings', '199 89% 48%');
|
||
|
|
// Delayed IPC for the older color arrives after the newer local drag sample.
|
||
|
|
const delayed = pendingIpc.shift();
|
||
|
|
flushIpc();
|
||
|
|
if (delayed) applyIncoming(delayed.to, delayed.record);
|
||
|
|
deliverStorageTo('settings');
|
||
|
|
flushIpc();
|
||
|
|
|
||
|
|
return { settingsValues, mainValues, storageWrites };
|
||
|
|
}
|
||
|
|
|
||
|
|
test('parseCustomAccentRecord accepts legacy HSL tokens and versioned JSON', () => {
|
||
|
|
assert.deepEqual(parseCustomAccentRecord('199 89% 48%'), { color: '199 89% 48%', version: 0 });
|
||
|
|
assert.deepEqual(
|
||
|
|
parseCustomAccentRecord({ color: '0 84% 60%', version: 3 }),
|
||
|
|
{ color: '0 84% 60%', version: 3 },
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
parseCustomAccentRecord('{"color":"262.1 83.3% 57.8%","version":9}'),
|
||
|
|
{ color: '262.1 83.3% 57.8%', version: 9 },
|
||
|
|
);
|
||
|
|
assert.equal(parseCustomAccentRecord('bad').color, '221.2 83.2% 53.3%');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('shouldApplyCustomAccentRecord ignores stale revisions', () => {
|
||
|
|
const current = { color: '199 89% 48%', version: 2 };
|
||
|
|
assert.equal(shouldApplyCustomAccentRecord(current, { color: '0 84% 60%', version: 1 }), false);
|
||
|
|
assert.equal(shouldApplyCustomAccentRecord(current, { color: '330 81% 60%', version: 3 }), true);
|
||
|
|
assert.equal(shouldApplyCustomAccentRecord(current, { color: '199 89% 48%', version: 2 }), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('shouldBroadcastCustomAccentChange suppresses incoming rebroadcasts', () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
shouldBroadcastCustomAccentChange('incoming', true),
|
||
|
|
{ shouldBroadcast: false, nextSource: 'local' },
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
shouldBroadcastCustomAccentChange('local', true),
|
||
|
|
{ shouldBroadcast: true, nextSource: 'local' },
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
shouldBroadcastCustomAccentChange('local', false),
|
||
|
|
{ shouldBroadcast: false, nextSource: 'local' },
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('legacy unversioned always-broadcast accent sync oscillates during a fast drag', () => {
|
||
|
|
const alwaysBroadcast = (
|
||
|
|
_source: CustomAccentMutationSource,
|
||
|
|
persistMounted: boolean,
|
||
|
|
) => ({
|
||
|
|
shouldBroadcast: persistMounted,
|
||
|
|
nextSource: 'local' as const,
|
||
|
|
});
|
||
|
|
const alwaysApply = () => true;
|
||
|
|
|
||
|
|
const { settingsValues } = simulateAccentDrag({
|
||
|
|
shouldBroadcast: alwaysBroadcast,
|
||
|
|
shouldApply: alwaysApply,
|
||
|
|
versioned: false,
|
||
|
|
});
|
||
|
|
const unique = new Set(settingsValues);
|
||
|
|
assert.ok(
|
||
|
|
unique.has('0 84% 60%') && unique.has('199 89% 48%') && settingsValues.length > 2,
|
||
|
|
`expected oscillation between drag samples, got ${settingsValues.join(' | ')}`,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('versioned accent sync ignores stale peer echoes during a fast drag', () => {
|
||
|
|
const { settingsValues, mainValues } = simulateAccentDrag({
|
||
|
|
shouldBroadcast: shouldBroadcastCustomAccentChange,
|
||
|
|
shouldApply: shouldApplyCustomAccentRecord,
|
||
|
|
versioned: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.deepEqual(settingsValues, ['0 84% 60%', '199 89% 48%']);
|
||
|
|
assert.ok(mainValues.includes('199 89% 48%'));
|
||
|
|
assert.equal(
|
||
|
|
mainValues.includes('0 84% 60%') && mainValues[mainValues.length - 1] === '0 84% 60%',
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('serializeCustomAccentRecord round-trips through parse', () => {
|
||
|
|
const raw = serializeCustomAccentRecord({ color: '262.1 83.3% 57.8%', version: 9 });
|
||
|
|
assert.deepEqual(parseCustomAccentRecord(JSON.parse(raw)), {
|
||
|
|
color: '262.1 83.3% 57.8%',
|
||
|
|
version: 9,
|
||
|
|
});
|
||
|
|
});
|