diff --git a/admin/README.md b/admin/README.md
index f540a36..6c95019 100644
--- a/admin/README.md
+++ b/admin/README.md
@@ -260,3 +260,11 @@ Config/Transport проверяют единый контракт для Save, C
если сумма округлений не равна 100, операция останавливается до записи всех целей.
Остаток автоматически не распределяется. Приватный CLI с планом/receipt/CAS делает
резервные копии и проверяет точные bytes после каждой записи; в public его нет.
+
+## Независимые UTM-наборы — preland#32
+
+Дискета перед глазиком создаёт снимок текущих UTM-полей таблицы, включая ещё не опубликованные правки. Сохранение набора не пишет на хостинг. Обязательно собственное название; дубликат без учёта регистра отклоняется. Приватный utm-presets.json содержит только id/name/utm: ни домен, ни ID подключения, ни происхождение не сохраняются. Host, заметки, реквизиты и файлы сетов не входят в набор.
+
+GET/POST/DELETE /api/utm-presets требуют вход; записи требуют CSRF. POST принимает только name и utm (канонические группы, только default/weights). DELETE принимает только id. Копирование POST /api/copy принимает preset и targets с ID/версией каждого получателя; прежний источник-домен больше не поддерживается. Очередь фиксирует собственный UTM-снимок, поэтому удаление набора или любого подключения-источника не влияет на задание. Получателем может быть любой доступный домен. Основной сайт и заметки сохраняются, отсутствующая группа набора не удаляет группу получателя. CAS, резерв и подтверждённое перечитывание сохранены.
+
+Проверки: tests/utm-presets.php, обновлённый tests/copy.php и ui-utm-presets.cjs; helper preset-ui-helper.cjs адаптирует прежние copy-регрессии к независимому источнику. UI покрывает черновик без публикации, имена/дубликаты, два получателя, reload, гонку list GET/delete, 401/relogin, отмену удаления и API-удаление, мобильную ширину. Тестовые удаления выполняются только над созданными этим тестом наборами.
diff --git a/admin/composer.json b/admin/composer.json
index 60d8966..6c6d29f 100644
--- a/admin/composer.json
+++ b/admin/composer.json
@@ -4,5 +4,5 @@
"license": "proprietary",
"require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "ext-zlib": "*", "phpseclib/phpseclib": "^3.0"},
"config": {"sort-packages": true},
- "scripts": {"test": ["php tests/manual-variants.php", "php tests/percent-weights.php", "php tests/percent-migration.php", "php tests/backend-review.php", "php tests/link-builder.php", "php tests/targets.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php", "php tests/copy.php"], "test:worker": "php tests/backend-worker.php"}
+ "scripts": {"test": ["php tests/utm-presets.php", "php tests/manual-variants.php", "php tests/percent-weights.php", "php tests/percent-migration.php", "php tests/backend-review.php", "php tests/link-builder.php", "php tests/targets.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php", "php tests/copy.php"], "test:worker": "php tests/backend-worker.php"}
}
diff --git a/admin/public/assets/admin.css b/admin/public/assets/admin.css
index e242a88..6c2ef02 100644
--- a/admin/public/assets/admin.css
+++ b/admin/public/assets/admin.css
@@ -19,3 +19,4 @@
.matrix-read-at{font-size:11px;font-weight:400;margin:4px 0 8px;line-height:1.4}.matrix-variant-number{font-size:13px;padding:3px 5px}
.matrix-add-variant{display:inline-flex;align-items:center;border:0;background:transparent;color:#337ab7;padding:8px 0 0;font-size:12px;cursor:pointer}.matrix-add-variant:hover{color:#23527c;text-decoration:underline}.matrix-add-variant:focus-visible{outline:2px solid #337ab7;outline-offset:3px}.matrix-add-variant:disabled{opacity:.5;cursor:default}.matrix-new-number{width:44px}.indpic-add-form{align-items:end}
+.domain-preset{display:inline-flex;align-items:center;justify-content:center;padding:3px;border:0;background:transparent;color:#8092a2;border-radius:3px;cursor:pointer;flex-shrink:0}.domain-preset:hover{color:#337ab7;background:#e9f1f7}.domain-preset:disabled{opacity:.5;cursor:default}.preset-select-row{display:flex;gap:8px;align-items:stretch;margin-top:8px}.preset-select-row select{flex:1;min-width:0}.preset-icon{display:inline-flex;align-items:center;justify-content:center;padding:7px 9px;flex-shrink:0}.preset-icon:hover{color:#a94442}.preset-list-tools{margin:8px 0}.preset-dialog{width:460px}.preset-dialog .modal-body{white-space:normal}.preset-dialog label{display:block;font-weight:600;margin-bottom:8px}.preset-help{font-size:12px;margin:12px 0 0}.preset-dialog #preset-error:not(:empty){margin:12px 0 0}.preset-dialog .modal-footer{display:flex;justify-content:flex-end;gap:8px}
diff --git a/admin/public/assets/admin.js b/admin/public/assets/admin.js
index a6c0e0c..7e578b9 100644
--- a/admin/public/assets/admin.js
+++ b/admin/public/assets/admin.js
@@ -140,33 +140,78 @@
}catch(error){if(isCurrent()){entry.error=error.message;entry.conflict=entry.conflict||error.status===409;}}
finally{if(isCurrent()){entry.saving=false;updateMatrixStatus(entry);renderCopy();}}
}
+ const presets={items:[],loaded:false,loading:false,error:'',serial:0,busy:false,snapshot:null};
+ async function loadPresets(){
+ const serial=++presets.serial,generation=state.sessionGeneration;presets.loading=true;presets.error='';renderCopy();
+ try{const result=await api('utm-presets');if(serial!==presets.serial||generation!==state.sessionGeneration||!state.csrf)return;presets.items=result.presets;presets.loaded=true;if(!presets.items.some(p=>p.id===matrix.copySource))matrix.copySource='';}
+ catch(error){if(serial===presets.serial&&generation===state.sessionGeneration)presets.error=error.message;}
+ finally{if(serial===presets.serial&&generation===state.sessionGeneration){presets.loading=false;renderCopy();}}
+ }
+ function currentUtm(entry){
+ if(!entry.data||entry.reading||entry.saving)throw Error('Дождитесь чтения настроек и завершения сохранения.');
+ const invalid=matrixValidation(entry);if(invalid)throw Error(invalid);
+ const utm={};
+ for(const marker of markers){const group=entry.groups.get(marker),raw=groupFor(entry.data.config,marker);if(!raw.key&&!group?.added)continue;
+ if(!group)throw Error(marker+': некорректная группа.');
+ if(!group.touched&&!group.added){utm[marker]={default:structuredClone(raw.value.default),weights:structuredClone(raw.value.weights)};continue;}
+ if(!group.rows.length||!group.defaultRow)throw Error(marker+': заполните варианты и выберите default.');
+ const weights={};for(const row of group.rows){if(!row.isNew&&!Object.hasOwn(group.original.weights,row.variant.value)&&Number(row.weight.value)===0)continue;weights[row.variant.value]=Number(row.weight.value);}
+ utm[marker]={default:group.defaultRow.variant.value,weights};
+ }
+ if(!Object.keys(utm).length)throw Error('Нет UTM для сохранения.');return utm;
+ }
+ function openPreset(id){
+ try{presets.snapshot=currentUtm(matrixEntry(id));$('preset-name').value='';$('preset-name').disabled=false;$('preset-save').disabled=false;$('preset-cancel').disabled=false;$('preset-error').textContent='';$('preset-dialog').showModal();$('preset-name').focus();}
+ catch(error){notice(error.message);}
+ }
+ async function savePreset(event){
+ event.preventDefault();if(presets.busy||!presets.snapshot)return;const generation=state.sessionGeneration;
+ presets.busy=true;$('preset-save').disabled=true;$('preset-cancel').disabled=true;$('preset-name').disabled=true;$('preset-error').textContent='';
+ try{const result=await api('utm-presets',{name:$('preset-name').value,utm:presets.snapshot});if(generation!==state.sessionGeneration||!state.csrf)return;
+ presets.serial++;presets.loading=false;presets.loaded=true;presets.error='';presets.items=presets.items.filter(p=>p.id!==result.preset.id);presets.items.push({id:result.preset.id,name:result.preset.name});matrix.copySource=result.preset.id;
+ $('preset-dialog').close();presets.snapshot=null;notice('UTM-набор «'+result.preset.name+'» сохранён в админке. Настройки сайтов не изменены.','success');renderCopy();
+ }catch(error){if(generation===state.sessionGeneration)$('preset-error').textContent=error.message;}
+ finally{if(generation===state.sessionGeneration){presets.busy=false;$('preset-save').disabled=false;$('preset-cancel').disabled=false;$('preset-name').disabled=false;}}
+ }
+ async function deletePreset(){
+ const selected=presets.items.find(p=>p.id===matrix.copySource);if(!selected||presets.busy||presets.loading||matrix.copyBusy)return;
+ if(!confirm('Удалить набор «'+selected.name+'» из админки? Настройки сайтов и заметки не изменятся.'))return;
+ const generation=state.sessionGeneration;presets.busy=true;renderCopy();
+ try{await api('utm-presets',{id:selected.id},'DELETE');if(generation!==state.sessionGeneration||!state.csrf)return;presets.serial++;presets.items=presets.items.filter(p=>p.id!==selected.id);if(matrix.copySource===selected.id)matrix.copySource='';presets.error='';notice('Набор удалён.','success');}
+ catch(error){if(generation===state.sessionGeneration)presets.error=error.message;}
+ finally{if(generation===state.sessionGeneration){presets.busy=false;renderCopy();}}
+ }
function renderCopy(){
$('copy-all').disabled=matrix.copyBusy;$('copy-none').disabled=matrix.copyBusy;
const resultBox=$('copy-results');resultBox.replaceChildren();if(matrix.copyError)resultBox.append(el('p',{class:'overview-error',text:matrix.copyError}));for(const result of orderedResults(matrix.copyResults)){const id=typeof result.target==='object'?result.target.id:result.target;resultBox.append(el('p',{class:'status-'+result.status,text:targetName(id)+' — '+(statuses[result.status]||result.status)+(result.message?' · '+result.message:'')}));}
- const source=$('copy-source'),ids=state.targets.map(t=>t.id);if([...source.options].slice(1).map(o=>o.value).join('|')!==ids.join('|'))source.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:t.domain})));for(const option of [...source.options]){const target=state.targets.find(t=>t.id===option.value);if(target)option.textContent=target.domain;}source.value=matrix.copySource;source.disabled=matrix.copyBusy;const box=$('copy-targets');box.replaceChildren();for(const target of state.targets){if(target.id===matrix.copySource){matrix.copyTargets.delete(target.id);continue;}const input=el('input',{type:'checkbox',checked:matrix.copyTargets.has(target.id),disabled:matrix.copyBusy||!target.enabled||!target.verified,'aria-label':'Копировать в '+target.domain});input.addEventListener('change',()=>{if(input.checked)matrix.copyTargets.add(target.id);else matrix.copyTargets.delete(target.id);renderCopy();});box.append(el('label',{},input,target.domain));}const from=matrix.entries.get(matrix.copySource),targets=[...matrix.copyTargets].map(id=>matrixEntry(id));let reason=matrix.copyBusy?'Копирование…':!from?'Выберите исходный домен.':!state.targets.find(t=>t.id===from.id)?.enabled||!state.targets.find(t=>t.id===from.id)?.verified?'Источник отключён или соединение не проверено.':!from.data?'Дождитесь чтения исходного домена.':overview.entries.get(from.id)?.status!=='success'?'Обновите настройки источника после ошибки чтения.':matrixValidation(from)?'Источник: '+matrixValidation(from):from.dirty||from.saving?'Сначала сохраните или отмените изменения источника.':from.conflict?'Обновите настройки источника.':!targets.length?'Выберите домены получателей.':targets.some(e=>!state.targets.find(t=>t.id===e.id)?.enabled||!state.targets.find(t=>t.id===e.id)?.verified)?'Получатель отключён или соединение не проверено.':targets.some(e=>e.data&&matrixValidation(e))?'Получатель: '+matrixValidation(targets.find(e=>e.data&&matrixValidation(e))):targets.some(e=>e.dirty||e.saving)?'Сначала сохраните или отмените изменения получателей.':targets.some(e=>!e.data||e.conflict||overview.entries.get(e.id)?.status!=='success')?'Обновите настройки получателей.':'';$('copy-reason').textContent=reason;$('copy-submit').disabled=!!reason;$('copy-submit').textContent='Копировать в '+targets.length+' '+(targets.length%10===1&&targets.length%100!==11?'домен':targets.length%10>=2&&targets.length%10<=4&&(targets.length%100<12||targets.length%100>14)?'домена':'доменов');}
+ const source=$('copy-source');source.replaceChildren(el('option',{value:'',text:'Выберите сохранённый набор'}),...presets.items.map(p=>el('option',{value:p.id,text:p.name})));source.value=matrix.copySource;source.disabled=matrix.copyBusy||presets.busy||presets.loading;
+ $('preset-delete').disabled=!presets.items.some(p=>p.id===matrix.copySource)||matrix.copyBusy||presets.busy||presets.loading;
+ $('preset-reload').disabled=presets.loading||presets.busy||matrix.copyBusy;
+ $('preset-list-status').textContent=presets.loading?'Загрузка наборов…':presets.error?'Не удалось обновить список: '+presets.error:presets.loaded&&!presets.items.length?'Пока нет сохранённых наборов. Нажмите дискету рядом с доменом.':'';
+ const box=$('copy-targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',checked:matrix.copyTargets.has(target.id),disabled:matrix.copyBusy||!target.enabled||!target.verified,'aria-label':'Копировать в '+target.domain});input.addEventListener('change',()=>{if(input.checked)matrix.copyTargets.add(target.id);else matrix.copyTargets.delete(target.id);renderCopy();});box.append(el('label',{},input,target.domain));}
+ const targets=[...matrix.copyTargets].map(id=>matrixEntry(id));let reason=matrix.copyBusy?'Копирование…':presets.loading?'Дождитесь загрузки наборов.':presets.error?'Обновите список сохранённых наборов.':!presets.items.some(p=>p.id===matrix.copySource)?'Выберите сохранённый набор.':!targets.length?'Выберите домены получателей.':targets.some(e=>!state.targets.find(t=>t.id===e.id)?.enabled||!state.targets.find(t=>t.id===e.id)?.verified)?'Получатель отключён или соединение не проверено.':targets.some(e=>e.data&&matrixValidation(e))?'Получатель: '+matrixValidation(targets.find(e=>e.data&&matrixValidation(e))):targets.some(e=>e.dirty||e.saving)?'Сначала сохраните или отмените изменения получателей.':targets.some(e=>!e.data||e.conflict||overview.entries.get(e.id)?.status!=='success')?'Обновите настройки получателей.':'';
+ $('copy-reason').textContent=reason;$('copy-submit').disabled=!!reason||presets.busy;$('copy-submit').textContent='Копировать в '+targets.length+' '+(targets.length%10===1&&targets.length%100!==11?'домен':targets.length%10>=2&&targets.length%10<=4&&(targets.length%100<12||targets.length%100>14)?'домена':'доменов');
+ }
async function copySettings(){
if($('copy-submit').disabled)return;
- const from=matrixEntry(matrix.copySource),ids=[...matrix.copyTargets],generation=state.sessionGeneration,operation=++matrix.copyEpoch;
- const entries=new Map([from.id,...ids].map(id=>[id,matrix.entries.get(id)]));
+ const preset=matrix.copySource,ids=[...matrix.copyTargets],generation=state.sessionGeneration,operation=++matrix.copyEpoch;
+ const entries=new Map(ids.map(id=>[id,matrix.entries.get(id)]));
const sameOperation=()=>generation===state.sessionGeneration&&!!state.csrf&&matrix.copyEpoch===operation;
const isCurrent=()=>sameOperation()&&[...entries].every(([id,entry])=>matrix.entries.get(id)===entry);
matrix.copyError='';matrix.copyResults=[];matrix.copyBusy=true;for(const entry of entries.values()){entry.epoch++;entry.saving=true;updateMatrixStatus(entry);}renderCopy();
try{
- const result=await api('copy',{source:{id:from.id,version:from.data.version},targets:ids.map(id=>({id,version:entries.get(id).data.version}))});if(!isCurrent())return;
- const job=await waitMatrixJob(result.job,isCurrent);if(!isCurrent()||!job)return;
- matrix.copyResults=job.results||[];
- for(const outcome of job.results||[]){
- if(!isCurrent())return;const id=typeof outcome.target==='object'?outcome.target.id:outcome.target,entry=entries.get(id);if(!entry)continue;
+ const result=await api('copy',{preset,targets:ids.map(id=>({id,version:entries.get(id).data.version}))});if(!isCurrent())return;
+ const job=await waitMatrixJob(result.job,isCurrent);if(!isCurrent()||!job)return;matrix.copyResults=job.results||[];
+ for(const outcome of job.results||[]){if(!isCurrent())return;const id=typeof outcome.target==='object'?outcome.target.id:outcome.target,entry=entries.get(id);if(!entry)continue;
if(outcome.status==='success'){entry.saving=false;await refreshOverviewTarget(id);if(!isCurrent())return;if(overview.entries.get(id)?.status==='success')entry.written=true;}
- else{entry.error=outcome.message||'Не удалось скопировать настройки.';entry.conflict=outcome.status==='conflict';updateMatrixStatus(entry);}
- }
+ else{entry.error=outcome.message||'Не удалось скопировать настройки.';entry.conflict=outcome.status==='conflict';updateMatrixStatus(entry);}}
if(!isCurrent())return;notice('Копирование завершено. Результат каждого домена показан в журнале.','info');
if(state.source&&ids.includes(state.source)){await fetchRead(state.source);if(!isCurrent())return;await loadSets();if(!isCurrent())return;await loadBackups();}
- }catch(error){if(isCurrent()){matrix.copyError=error.message;notice(error.message);if(error.status===409){from.conflict=true;from.error=error.message;updateMatrixStatus(from);}}}
+ }catch(error){if(isCurrent()){matrix.copyError=error.message;notice(error.message);if(error.status===404)run(loadPresets);}}
finally{if(sameOperation()){matrix.copyBusy=false;for(const [id,entry]of entries)if(matrix.entries.get(id)===entry){entry.saving=false;updateMatrixStatus(entry);}renderCopy();}}
}
function syncOverview(refresh=true){
- const ids=new Set(state.targets.map(target=>target.id));for(const id of matrix.entries.keys())if(!ids.has(id)){matrix.entries.get(id).epoch++;matrix.entries.delete(id);matrix.copyTargets.delete(id);matrix.operationTargets.delete(id);}if(!ids.has(matrix.copySource))matrix.copySource='';for(const id of overview.entries.keys())if(!ids.has(id)){overview.entries.delete(id);overview.pending.delete(id);builder.entries.delete(id);}
+ const ids=new Set(state.targets.map(target=>target.id));for(const id of matrix.entries.keys())if(!ids.has(id)){matrix.entries.get(id).epoch++;matrix.entries.delete(id);matrix.copyTargets.delete(id);matrix.operationTargets.delete(id);}for(const id of overview.entries.keys())if(!ids.has(id)){overview.entries.delete(id);overview.pending.delete(id);builder.entries.delete(id);}
if(builder.target&&!ids.has(builder.target))builder.target='';
const select=$('builder-target');select.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));select.value=builder.target;renderOverview();if(builder.target&&builder.entries.get(builder.target)?.domain!==state.targets.find(t=>t.id===builder.target)?.domain)run(()=>openBuilder(builder.target,true));else renderBuilder();if(refresh)run(refreshOverview);
}
@@ -231,7 +276,7 @@
if(generation!==state.sessionGeneration){const error=new Error('Ответ относится к завершённому сеансу.');error.staleSession=true;throw error;}if(!response.ok||!body.ok){if(response.status===401&&generation===state.sessionGeneration)showLogin();const error=new Error(body.error||'Не удалось выполнить операцию (HTTP '+response.status+').');error.status=response.status;throw error;}return body;
}
async function run(action){try{await action();}catch(error){if(!error.staleSession)notice(error.message);}}
- function showLogin(){state.sessionGeneration++;matrix.copyEpoch++;matrix.copyBusy=false;for(const entry of matrix.entries.values())entry.epoch++;matrix.entries.clear();matrix.structure='';matrix.copySource='';matrix.copyError='';matrix.copyResults=[];matrix.copyTargets.clear();matrix.operationTargets.clear();utmNotes.serial++;utmNotes.entries.clear();utmNotes.loading=false;utmNotes.error='';overview.entries.clear();overview.pending.clear();builder.entries.clear();builder.target='';for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.targetsEpoch++;state.targetsBusy=false;state.targets=[];state.allTargets=[];state.source='';state.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;}
+ function showLogin(){state.sessionGeneration++;presets.serial++;presets.items=[];presets.loaded=false;presets.loading=false;presets.busy=false;presets.snapshot=null;presets.error='';$('preset-dialog').close();matrix.copyEpoch++;matrix.copyBusy=false;for(const entry of matrix.entries.values())entry.epoch++;matrix.entries.clear();matrix.structure='';matrix.copySource='';matrix.copyError='';matrix.copyResults=[];matrix.copyTargets.clear();matrix.operationTargets.clear();utmNotes.serial++;utmNotes.entries.clear();utmNotes.loading=false;utmNotes.error='';overview.entries.clear();overview.pending.clear();builder.entries.clear();builder.target='';for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.targetsEpoch++;state.targetsBusy=false;state.targets=[];state.allTargets=[];state.source='';state.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;}
function selected(){return [...$('targets').querySelectorAll('input:checked')].map(n=>n.value);}
function targetName(id){const t=state.targets.find(t=>t.id===id);return t? t.domain+' ('+t.environment+')':id;}
function equivalent(a,b){return JSON.stringify(a)===JSON.stringify(b);}
@@ -242,7 +287,7 @@
async function fetchRead(id){const generation=state.sessionGeneration;const data=await api('config?target='+encodeURIComponent(id));if(generation===state.sessionGeneration&&state.targets.some(t=>t.id===id))state.reads.set(id,data);return data;}
async function openTarget(id){if(id&&!state.targets.some(t=>t.id===id))return;if(id!==state.source)matrix.operationTargets=new Set(id?[id]:[]);state.source=id;$('sets-body').replaceChildren();$('backups').replaceChildren();renderTargetOptions();if(!id){$('read-state').textContent='Выберите домен.';return;}state.loading++;$('read-state').textContent='Чтение конфигурации для наборов и резерва…';try{await fetchRead(id);await Promise.all([loadSets(),loadBackups()]);if(state.source===id)$('read-state').textContent='Домен прочитан.';}finally{state.loading--;}} function renderTargetOptions(){const box=$('targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',value:target.id,checked:matrix.operationTargets.has(target.id)&&target.enabled,disabled:!target.enabled});const status=el('span',{class:'text-muted'});const label=el('label',{},input,targetName(target.id),status);box.append(label);input.addEventListener('change',()=>run(async()=>{if(input.checked)matrix.operationTargets.add(target.id);else matrix.operationTargets.delete(target.id);if(input.checked&&!state.reads.has(target.id)){status.textContent=' · чтение версии…';state.loading++;try{await fetchRead(target.id);status.textContent=' · прочитано';}catch(e){input.checked=false;status.textContent=' · недоступен';throw e;}finally{state.loading--;}}}));}}
- async function loadState(){if(state.targetsBusy)return;const epoch=state.targetsEpoch,generation=state.sessionGeneration;const data=await api('state');if(generation!==state.sessionGeneration||epoch!==state.targetsEpoch||state.targetsBusy)return;state.csrf=data.csrf;$('login-panel').hidden=true;$('workspace').hidden=false;$('logout').hidden=false;acceptTargets(data.targets||[]);await loadJobs();}
+ async function loadState(){if(state.targetsBusy)return;const epoch=state.targetsEpoch,generation=state.sessionGeneration;const data=await api('state');if(generation!==state.sessionGeneration||epoch!==state.targetsEpoch||state.targetsBusy)return;state.csrf=data.csrf;$('login-panel').hidden=true;$('workspace').hidden=false;$('logout').hidden=false;acceptTargets(data.targets||[]);run(loadPresets);await loadJobs();}
function orderedResults(results){return (results||[]).filter(visibleResult).slice().sort((a,b)=>{const id=r=>typeof r.target==='object'?r.target.id:r.target;return state.targets.findIndex(t=>t.id===id(a))-state.targets.findIndex(t=>t.id===id(b));});}
function visibleResult(result){const id=typeof result.target==='object'?result.target.id:result.target;return state.targets.some(t=>t.id===id);}
function acceptTargets(targets,refresh=true){
@@ -268,7 +313,9 @@
grip.addEventListener('pointerdown',event=>startDomainDrag(event,target.id));
const hide=el('button',{type:'button',class:'domain-hide',title:'Скрыть домен','aria-label':'Скрыть домен '+target.domain,onclick:()=>run(()=>hideTarget(target.id))});
hide.innerHTML='';
- return el('div',{class:'domain-heading'},grip,el('span',{class:'overview-domain',text:target.domain}),hide);
+ const save=el('button',{type:'button',class:'domain-preset',title:'Сохранить UTM-настройки','aria-label':'Сохранить UTM-набор '+target.domain,onclick:()=>openPreset(target.id)});
+ save.innerHTML='';
+ return el('div',{class:'domain-heading'},grip,el('span',{class:'overview-domain',text:target.domain}),save,hide);
}
function startDomainDrag(event,id){
if(event.button!==0||state.targetsBusy)return;event.preventDefault();const grip=event.currentTarget,wrapper=$('overview-table').closest('.overview-wrapper'),header=grip.closest('th'),preview=header.cloneNode(true),marker=el('div',{class:'domain-insertion'});preview.className='domain-drag-preview';preview.style.width=header.offsetWidth+'px';document.body.append(preview,marker);let before=id,x=event.clientX,y=event.clientY,frame;
@@ -302,9 +349,14 @@
$('logout').addEventListener('click',()=>run(async()=>{if(matrixDirty()&&!confirm('Есть несохранённые изменения настроек или заметок. Выйти?'))return;await api('logout',{});for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();showLogin();notice('Сеанс завершён.','info');}));
$('source-target').addEventListener('change',event=>run(()=>openTarget(event.target.value)));
$('reload').addEventListener('click',()=>run(()=>openTarget(state.source)));
- $('copy-source').addEventListener('change',e=>{matrix.copySource=e.target.value;matrix.copyError='';matrix.copyResults=[];matrix.copyTargets.delete(e.target.value);renderCopy();});
- $('copy-all').addEventListener('click',()=>{matrix.copyTargets=new Set(state.targets.filter(t=>t.id!==matrix.copySource&&t.enabled&&t.verified).map(t=>t.id));renderCopy();});
+ $('copy-source').addEventListener('change',e=>{matrix.copySource=e.target.value;matrix.copyError='';matrix.copyResults=[];renderCopy();});
+ $('copy-all').addEventListener('click',()=>{matrix.copyTargets=new Set(state.targets.filter(t=>t.enabled&&t.verified).map(t=>t.id));renderCopy();});
$('copy-none').addEventListener('click',()=>{matrix.copyTargets.clear();renderCopy();});
+ $('preset-form').addEventListener('submit',event=>run(()=>savePreset(event)));
+ $('preset-cancel').addEventListener('click',()=>{$('preset-dialog').close();presets.snapshot=null;});
+ $('preset-dialog').addEventListener('cancel',event=>{if(presets.busy)event.preventDefault();else presets.snapshot=null;});
+ $('preset-delete').addEventListener('click',()=>run(deletePreset));
+ $('preset-reload').addEventListener('click',()=>run(loadPresets));
$('copy-submit').addEventListener('click',()=>run(copySettings)); $('set-add').elements.sftp.addEventListener('change',event=>{const form=$('set-add');form.querySelector('.zip-field').hidden=event.target.checked;form.elements.file.required=!event.target.checked;form.querySelector('button').textContent=event.target.checked?'Проверить и добавить':'Загрузить сет';});
$('set-add').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const number=Number(form.elements.number.value);if(form.elements.sftp.checked){const r=await api('sets/register',{targets:safeTargets(),number,name:form.elements.name.value});await showJob(r.job,true);}else await uploadSet(form.elements.file.files[0],number,form.elements.name.value,false);});});
$('connection-new').addEventListener('click',()=>{$('connection-form').reset();$('connection-form').elements.id.readOnly=false;$('connection-title').textContent='Добавить подключение';});
diff --git a/admin/public/index.php b/admin/public/index.php
index f171c9e..2c5ea9a 100644
--- a/admin/public/index.php
+++ b/admin/public/index.php
@@ -1,7 +1,7 @@
target((string)($_GET['target']??''));$read=(new Transport($store,$target))->readConfig();$readAt=gmdate(DATE_ATOM);
reply(['config'=>$read['config'],'version'=>$read['version'],'read_at'=>$readAt,'warnings'=>[],'sets'=>$sets->list($target,$read['config'])]);
}
+ if($path==='/api/utm-presets'){
+ $presets=new UtmPresets($store);
+ if($method==='GET'){if($_GET)throw new Problem('Список наборов не привязан к доменам.');reply(['presets'=>$presets->all()]);}
+ if($method==='POST')reply(['preset'=>$presets->create($data)],201);
+ if($method==='DELETE'){if(count($data)!==1||!is_string($data['id']??null))throw new Problem('Передайте только ID набора.');$presets->delete($data['id']);reply([]);}
+ throw new Problem('Метод наборов не поддерживается.',405);
+ }
if($path==='/api/copy'){
if($method!=='POST')throw new Problem('Метод копирования не поддерживается.',405);
reply(['job'=>$enqueue(fn()=>(new Copy($store))->create($data),[])],202);
diff --git a/admin/src/Copy.php b/admin/src/Copy.php
index 52d9c92..22b238f 100644
--- a/admin/src/Copy.php
+++ b/admin/src/Copy.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace PrelandAdmin;
-/** Копирует только сохранённые host и canonical UTM; клиент не передаёт значения config. */
+/** Применяет независимый именованный UTM-снимок, сохраняя остальные поля получателя. */
final class Copy
{
private const GROUPS = ['indpic','theme','degr','reg','banch','tp','cons','focus'];
@@ -13,28 +13,27 @@ final class Copy
public function create(array $data): array
{
- if (count($data)!==2 || array_diff(array_keys($data),['source','targets'])) throw new Problem('Передайте только источник и получателей копирования.');
- $source=$this->selection($data['source']??null);
+ if (count($data)!==2 || array_diff(array_keys($data),['preset','targets']) || !is_string($data['preset']??null)) throw new Problem('Передайте только ID набора и получателей копирования.');
$targets=$data['targets']??null;
if (!is_array($targets) || !array_is_list($targets) || !$targets || count($targets)>100) throw new Problem('Выберите от 1 до 100 получателей.');
- $selected=[];$seen=[$source['id']=>true];
+ $selected=[];$seen=[];
foreach ($targets as $entry) {
$target=$this->selection($entry);
- if (isset($seen[$target['id']])) throw new Problem('Источник нельзя выбирать получателем; получатели не должны повторяться.');
+ if (isset($seen[$target['id']])) throw new Problem('Получатели не должны повторяться.');
$seen[$target['id']]=true;$this->available($target['id']);$selected[]=$target;
}
- $target=$this->available($source['id']);
- $read=(new Transport($this->store,$target))->readConfig();
- if (!hash_equals($read['version'],$source['version'])) throw new Problem('Настройки источника изменились. Перечитайте источник перед копированием.',409);
- $changes=self::project($read['config']);
- if (!$changes) throw new Problem('В источнике нет host или UTM для копирования.');
+ return $this->store->locked('utm-presets',function()use($data,$selected){
+ $preset=(new UtmPresets($this->store))->get($data['preset']);
+ $changes=self::project((object)['utm'=>$preset['utm']]);
+ if (!$changes) throw new Problem('В наборе нет UTM для копирования.');
// Jobs сериализует каждое значение отдельно: JSON-объекты и массивы сохраняют типы.
- return (new Jobs($this->store))->create('copy',['source'=>$source,'targets'=>$selected,'changes'=>$changes]);
+ return (new Jobs($this->store))->create('copy',['preset'=>['id'=>$preset['id'],'name'=>$preset['name']],'targets'=>$selected,'changes'=>$changes]);
+ });
}
private function selection(mixed $value): array
{
- if (!$value instanceof \stdClass) throw new Problem('Источник и получатель должны быть объектами с ID и версией.');
+ if (!$value instanceof \stdClass) throw new Problem('Получатель должен быть объектом с ID и версией.');
$value=get_object_vars($value);
if (count($value)!==2 || array_diff(array_keys($value),['id','version']) || !is_string($value['id']??null) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$value['id']) || !is_string($value['version']??null) || !preg_match('/^[a-f0-9]{64}$/D',$value['version'])) throw new Problem('Укажите корректные ID и версию сохранённых настроек.');
return $value;
@@ -59,14 +58,10 @@ final class Copy
}
}
- private static function project(\stdClass $config): array
+ public static function project(\stdClass $config): array
{
self::assertAliases($config);
$projection=new \stdClass();$changes=[];
- if (property_exists($config,'host')) {
- if (!is_string($config->host)) throw new Problem('Адрес сайта источника должен быть строкой.');
- $projection->host=$config->host;$changes[]=['path'=>['host'],'value'=>$config->host];
- }
$projection->utm=new \stdClass();
foreach (self::GROUPS as $canonical) {
$group=null;$present=false;
diff --git a/admin/src/UtmPresets.php b/admin/src/UtmPresets.php
new file mode 100644
index 0000000..03e8c73
--- /dev/null
+++ b/admin/src/UtmPresets.php
@@ -0,0 +1,33 @@
+['id'=>$p['id'],'name'=>$p['name']],$this->store->read('utm-presets.json')));
+ }
+ public function get(string $id): array {
+ if(!preg_match('/^[a-f0-9]{24}$/D',$id))throw new Problem('Некорректный ID набора.');
+ $presets=$this->store->read('utm-presets.json');if(!isset($presets[$id]))throw new Problem('Набор удалён или не найден. Обновите список.',404);
+ $p=$presets[$id];$p['utm']=Config::decode(json_encode($p['utm'],JSON_THROW_ON_ERROR));return $p;
+ }
+ public function create(array $data): array {
+ if(count($data)!==2||array_diff(array_keys($data),['name','utm'])||!is_string($data['name']??null)||!(($data['utm']??null) instanceof \stdClass))throw new Problem('Передайте только название и объект UTM.');
+ $name=trim($data['name']);if($name===''||!preg_match('/^[^\x00-\x1f\x7f]{1,120}$/uD',$name))throw new Problem('Укажите название от 1 до 120 символов без управляющих знаков.');
+ $utm=$data['utm'];if(!get_object_vars($utm)||array_diff(array_keys(get_object_vars($utm)),self::GROUPS))throw new Problem('Набор должен содержать только редактируемые группы UTM.');
+ foreach(get_object_vars($utm) as $group){if(!$group instanceof \stdClass||count(get_object_vars($group))!==2||array_diff(array_keys(get_object_vars($group)),['default','weights']))throw new Problem('В группе разрешены только default и weights, без заметок.');}
+ // Канонизация использует тот же контракт, что применение на домен.
+ $changes=Copy::project((object)['utm'=>$utm]);$config=Config::patch((object)[], $changes);Config::validate($config);
+ return $this->store->locked('utm-presets',function()use($name,$config){
+ $presets=$this->store->read('utm-presets.json');foreach($presets as $p)if(preg_match('/^'.preg_quote($name,'/').'$/iuD',$p['name']))throw new Problem('Набор с таким названием уже существует. Укажите другое имя.',409);
+ $id=bin2hex(random_bytes(12));$record=['id'=>$id,'name'=>$name,'utm'=>$config->utm];$presets[$id]=$record;$this->store->write('utm-presets.json',$presets);return $record;
+ });
+ }
+ public function delete(string $id): void {
+ $this->store->locked('utm-presets',function()use($id){$this->get($id);$presets=$this->store->read('utm-presets.json');unset($presets[$id]);$this->store->write('utm-presets.json',$presets);});
+ }
+}
diff --git a/admin/tests/copy.php b/admin/tests/copy.php
index b0dcf04..623030e 100644
--- a/admin/tests/copy.php
+++ b/admin/tests/copy.php
@@ -1,75 +1,25 @@
$path){$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$path,'enabled'=>true]);$store->verifyTarget($id);}
-function fixture(string $id,string $json): void {global $paths;file_put_contents($paths[$id].'/config.json',$json);}
-function readCopy(string $id): array {global $store;return (new Transport($store,$store->target($id)))->readConfig();}
-function selection(string $id): object {return (object)['id'=>$id,'version'=>readCopy($id)['version']];}
-function copyBody(array $ids=['a']): array {return ['source'=>selection('source'),'targets'=>array_map('selection',$ids)];}
-$source='{"host":"saved.example","requisites":["source"],"extra":{"source":true},"utm":{"registration":{"default":"after","weights":{"before":75,"after":25},"source_only":true},"theme":{"default":2,"weights":{"1":0,"2":100}},"bchoice":{"default":"a","weights":{"b":66.67,"a":33.33}},"focus":{"default":"face","weights":{"face":0,"center":0}},"unknown":{"raw":"source"}}}';
-$target='{"host":"old.example","requisites":["target"],"extra":{"empty":{},"array":[],"flag":false,"zero":0},"utm":{"reg":{"default":1,"weights":{"1":100},"target_only":{"keep":true}},"theme":{"default":"dark","weights":{"dark":100}},"chob":{"default":"b","weights":{"b":100}},"indpic":{"default":"9","weights":{"9":0}},"unknown":{"raw":"target"}}}';
-// Missing source indpic must preserve it; use a valid recipient without indpic for initial success.
-$target=str_replace(',"indpic":{"default":"9","weights":{"9":0}}','',$target);
-fixture('source',$source);foreach(['a','b','c'] as $id)fixture($id,$target);
-$port=random_int(19000,29000);$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='copy-admin';$env['ADMIN_PASSWORD_HASH']=password_hash('copy-fixture-password',PASSWORD_DEFAULT);
+use PrelandAdmin\{Store,Config,Jobs,Copy,Problem};
+function httpCheck(bool $ok,string $label):void{if(!$ok)throw new RuntimeException($label);echo "PASS $label\n";}
+$root=sys_get_temp_dir().'/preset-http-'.bin2hex(random_bytes(6));mkdir($root,0700);$store=new Store($root.'/state');$port=random_int(19000,29000);$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='test';$env['ADMIN_PASSWORD_HASH']=password_hash('fixture',PASSWORD_DEFAULT);
$log=fopen($root.'/http.log','a');$server=proc_open([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);$cookie='';
-function copyRequest(array $body,?string $csrf=null,string $path='/api/copy'): array {global $port,$cookie;$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;$ctx=stream_context_create(['http'=>['method'=>'POST','header'=>implode("\r\n",$headers),'content'=>json_encode($body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);preg_match('~HTTP/\S+ (\d+)~',$http_response_header[0]??'',$match);foreach($http_response_header as $header)if(str_starts_with(strtolower($header),'set-cookie: '))$cookie=explode(';',substr($header,12))[0];return [(int)($match[1]??0),json_decode((string)$raw,true,64,JSON_THROW_ON_ERROR)];}
+function presetRequest(string $method,string $path,?array $body=null,?string $csrf=null):array{global $port,$cookie;$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;$ctx=stream_context_create(['http'=>['method'=>$method,'header'=>implode("\r\n",$headers),'content'=>$body===null?'':json_encode($body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);preg_match('~HTTP/\S+ (\d+)~',$http_response_header[0]??'',$m);foreach($http_response_header as $h)if(str_starts_with(strtolower($h),'set-cookie: '))$cookie=explode(';',substr($h,12))[0];return [(int)($m[1]??0),json_decode((string)$raw,true,64,JSON_THROW_ON_ERROR)];}
try{
- $deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(20000);}while(microtime(true)<$deadline);
- [$status]=copyRequest(copyBody());copyCheck($status===401,'copy requires authentication');
- [$status,$body]=copyRequest(['username'=>'copy-admin','password'=>'copy-fixture-password'],null,'/api/login');$csrf=$body['csrf'];
- [$status]=copyRequest(copyBody());copyCheck($status===403,'copy requires CSRF');
- [$status,$body]=copyRequest(copyBody(['a','b']),$csrf);copyCheck($status===202&&isset($body['job']['id']),'HTTP copy queues server-owned saved snapshot');$job=$body['job'];$jobs=new Jobs($store);
- fixture('source',str_replace('saved.example','changed.example',$source));
- $staleTarget=readCopy('b');fixture('b',str_replace('old.example','foreign.example',$target));
- $jobs->run($job['id']);$done=$jobs->get($job['id']);copyCheck($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='conflict','copy isolates success and target CAS conflict');
- $a=readCopy('a')['config'];copyCheck($a->host==='saved.example'&&readCopy('b')['config']->host==='foreign.example','queued source snapshot is immutable and conflict target untouched');
- copyCheck($a->utm->reg->default==='2'&&$a->utm->reg->weights->{'1'}===75&&$a->utm->reg->weights->{'2'}===25&&$a->utm->reg->target_only->keep===true,'source semantic registration projects to target numeric reg and preserves target group metadata');
- copyCheck($a->utm->theme->default==='light'&&$a->utm->theme->weights->dark===0&&$a->utm->theme->weights->light===100&&$a->utm->chob->default==='a'&&!property_exists($a->utm,'bchoice'),'target semantic schema and alias preserved');
- copyCheck($a->utm->focus->default==='1'&&$a->utm->focus->weights->{'1'}===0&&$a->utm->focus->weights->{'2'}===0,'missing recipient group added canonically and all-zero weights retained');
- copyCheck($a->requisites===['target']&&$a->extra->empty instanceof stdClass&&$a->extra->array===[]&&$a->extra->flag===false&&$a->extra->zero===0&&$a->utm->unknown->raw==='target'&&!property_exists($a->utm->reg,'source_only'),'noncopy fields and JSON types preserved, source unknown fields excluded');
- copyCheck(count($store->backups('a'))===1&&$store->backupContent('a',$store->backups('a')[0]['id'])===$target&&$store->backups('b')===[],'successful target gets exact backup and conflict gets none');
- $old=copyBody();fixture('source',str_replace('changed.example','newer.example',file_get_contents($paths['source'].'/config.json')));$before=count(glob($store->dir.'/jobs/*.json'));[$status]=copyRequest($old,$csrf);copyCheck($status===409&&count(glob($store->dir.'/jobs/*.json'))===$before,'source version conflict returns HTTP 409 before enqueue');
- $valid=copyBody();$invalid=[[],array_merge($valid,['config'=>(object)[]]),array_replace($valid,['source'=>['source']]),array_replace($valid,['targets'=>(object)['a'=>selection('a')]]),array_replace($valid,['targets'=>[]]),array_replace($valid,['targets'=>[selection('a'),selection('a')]]),array_replace($valid,['targets'=>[selection('source')]]),array_replace($valid,['targets'=>array_fill(0,101,selection('a'))])];
- foreach(['id'=>1,'version'=>123,'extra'=>true] as $key=>$value){$bad=clone $valid['source'];$bad->$key=$value;$invalid[]=array_replace($valid,['source'=>$bad]);$bad=clone $valid['targets'][0];$bad->$key=$value;$invalid[]=array_replace($valid,['targets'=>[$bad]]);}
- foreach(['','abc',str_repeat('G',64)] as $version){$bad=clone $valid['source'];$bad->version=$version;$invalid[]=array_replace($valid,['source'=>$bad]);}
- foreach($invalid as $i=>$request){[$status]=copyRequest($request,$csrf);copyCheck($status===422,'strict copy JSON rejects invalid shape/types/unknown keys/duplicates '.$i);}
- foreach(['source','a'] as $id)foreach(['enabled','verified'] as $field){$registry=$store->read('targets.json');$registry[$id][$field]=false;$store->write('targets.json',$registry);[$status]=copyRequest(copyBody(),$csrf);copyCheck($status===422,'copy rejects '.$field.' false for '.$id);$registry[$id][$field]=true;$store->write('targets.json',$registry);}
- foreach(['{"utm":{"chob":{"default":"a","weights":{"a":100}},"bchoice":{"default":"a","weights":{"a":100}}}}','{"utm":{"reg":null}}','{"utm":{"theme":{"default":"dark","weights":{"dark":33.33,"1":66.67}}}}','{"utm":{"focus":{"default":true,"weights":{"1":100}}}}','{"host":null}','{"utm":[]}'] as $json){fixture('source',$json);[$status]=copyRequest(copyBody(),$csrf);copyCheck($status===422,'source invalid/ambiguous canonical structure rejected before job');}
- fixture('source','{"utm":{"reg":{"default":"before","weights":{"before":100}}}}');fixture('c','{"host":"keep.example","utm":{"focus":{"default":2,"weights":{"2":0},"meta":[]}},"x":{}}');
- [$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);$c=readCopy('c')['config'];copyCheck($c->host==='keep.example'&&$c->utm->focus->default===2&&$c->utm->focus->meta===[]&&$c->x instanceof stdClass,'missing source host/groups leave recipient values and types unchanged');
- fixture('source','{"utm":{"indpic":{"default":"1","weights":{"1":100}}}}');fixture('c','{"utm":{}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobId=$body['job']['id'];$before=readCopy('c')['content'];$backupCount=count($store->backups('c'));$jobs->run($jobId);copyCheck($jobs->get($jobId)['results'][0]['status']==='success'&&readCopy('c')['config']->utm->indpic->default==='1'&&count($store->backups('c'))===$backupCount+1&&!is_dir($paths['c'].'/1'),'copy writes manual indpic without requiring physical images');
- mkdir($paths['c'].'/1',0700);foreach(IndpicSetStorage::expectedDimensions() as $name=>$size){$image=new Imagick();$image->newImage($size['width'],$size['height'],'#777777');$image->setImageFormat(pathinfo($name,PATHINFO_EXTENSION));file_put_contents($paths['c'].'/1/'.$name,$image->getImagesBlob());$image->clear();}
- fixture('source','{"host":"both.example","utm":{"tp":{"default":"2","weights":{"2":100}},"cons":{"default":1,"weights":{"1":0}},"chb":null}}');
- [$status,$body]=copyRequest(copyBody(['a','c']),$csrf);$both=$body['job']['id'];$cBefore=readCopy('c');$jobs->run($both);$done=$jobs->get($both);$c=readCopy('c');
- copyCheck($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='success'&&readCopy('a')['config']->utm->tp->weights->{'2'}===100&&$c['config']->utm->cons->default===1,'one saved snapshot copies to two successful recipients including tp/cons');
- copyCheck($c['config']->utm->indpic->default==='1'&&$c['config']->utm->indpic->weights->{'1'}===100&&!property_exists($c['config']->utm,'chb')&&is_dir($paths['c'].'/1'),'absent source indpic preserves valid recipient group/set; source chb is excluded');
- $backupCount=count($store->backups('c'));$record=$store->read('jobs/'.$both.'.json');$record['status']='running';$record['results'][1]=['target'=>'c','status'=>'running','desired_hash'=>$c['version']];$store->write('jobs/'.$both.'.json',$record);$jobs->run($both);
- copyCheck($jobs->get($both)['results'][1]['status']==='success'&&readCopy('c')['content']===$c['content']&&count($store->backups('c'))===$backupCount,'copy desired-hash recovery confirms already-written config without second write or backup');
- $rejected=false;try{$jobs->retry($both,['a','c']);}catch(Problem $e){$rejected=$e->status===422;}
- copyCheck($rejected&&count($store->backups('c'))===$backupCount,'successful copy recipients cannot be retried and do not acquire duplicate backups');
- fixture('source','{"host":"catalog.example","utm":{"degr":{"default":"2","weights":{"2":100}}}}');fixture('c','{"host":"old.example","utm":{}}');$store->write('catalog.json',['host'=>'catalog.example','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[],'cons'=>[]]]);[$status,$body]=copyRequest(copyBody(['c']),$csrf);$before=readCopy('c')['content'];$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='success'&&readCopy('c')['config']->utm->degr->default==='2','copy ignores catalog and preserves manually selected group');
- fixture('source','{"utm":{"theme":{"default":"3","weights":{"1":0,"2":0,"3":100}}}}');fixture('c','{"utm":{"theme":{"default":"light","weights":{"dark":0,"light":100},"extra":"keep"},"focus":{"default":"2","weights":{"2":100}}}}');
- [$status,$body]=copyRequest(copyBody(['c']),$csrf);copyCheck($status===202,'copy queues arbitrary theme3');$jobs->run($body['job']['id']);$copied=readCopy('c')['config'];
- copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='success'&&$copied->utm->theme->default==='3'&&get_object_vars($copied->utm->theme->weights)===['dark'=>0,'light'=>0,3=>100]&&$copied->utm->theme->extra==='keep'&&$copied->utm->focus->default==='2','copy keeps numeric theme3 beside semantic aliases and retains recipient-only group');
- fixture('source','{"host":"safe.example"}');fixture('c','{"utm":{"chb":null}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error'&&property_exists(readCopy('c')['config']->utm,'chb'),'copy preserves obsolete chb write guard rather than silently deleting it');
- fixture('c','{"utm":{"chob":{"default":"a","weights":{"a":100}},"bchoice":{"default":"b","weights":{"b":100}}}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error','ambiguous target aliases rejected per domain even if source group absent');
- fixture('c','{"host":"safe.example"}');$backup=$store->backup('c','{"utm":{"chb":null}}');$restore=$jobs->create('restore',['target'=>'c','version'=>readCopy('c')['version'],'id'=>$backup]);$jobs->run($restore['id']);copyCheck($jobs->get($restore['id'])['results'][0]['status']==='error'&&readCopy('c')['config']->host==='safe.example','restore retains chb publication guard after copy integration');
- fixture('source','{"utm":{"reg":{"default":"1","weights":{"1":3,"2":1}}}}');
- $jobCount=count(glob($store->dir.'/jobs/*.json'));[$status]=copyRequest(copyBody(['c']),$csrf);
- copyCheck($status===422&&count(glob($store->dir.'/jobs/*.json'))===$jobCount,'relative source weights rejected by HTTP copy before enqueue');
- fixture('source','{"host":"safe.example"}');fixture('c','{"utm":{"theme":{"default":"dark","weights":{"dark":1}}}}');
- $before=readCopy('c')['content'];$backupCount=count($store->backups('c'));[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);
- copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error'&&readCopy('c')['content']===$before&&count($store->backups('c'))===$backupCount,'copy rejects retained relative target group before write or backup');
- $registry=$store->read('targets.json');$registry['c']['verified']=false;$store->write('targets.json',$registry);[$status]=copyRequest(['id'=>'c'],$csrf,'/api/targets/test');
- copyCheck($status===422&&!$store->target('c')['verified'],'connection HTTP test cannot mark relative runtime as verified');
- fixture('c','{"utm":{"theme":{"default":"dark","weights":{"dark":100}}}}');[$status]=copyRequest(['id'=>'c'],$csrf,'/api/targets/test');
- copyCheck($status===200&&$store->target('c')['verified'],'connection verification recovers with valid percentage runtime');
- echo "Copy HTTP/jobs contract complete\n";
+ $deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(20000);}while(microtime(true)<$deadline);
+ foreach(['GET','POST','DELETE'] as $method){[$status]=presetRequest($method,'/api/utm-presets');httpCheck($status===401,'presets auth '.$method);}
+ [$status,$login]=presetRequest('POST','/api/login',['username'=>'test','password'=>'fixture']);$csrf=$login['csrf'];
+ $payload=['name'=>'HTTP набор','utm'=>Config::decode('{"theme":{"default":3,"weights":{"3":100}}}')];
+ foreach(['POST','DELETE'] as $method){[$status]=presetRequest($method,'/api/utm-presets',$payload);httpCheck($status===403,'presets CSRF '.$method);}
+ [$status,$saved]=presetRequest('POST','/api/utm-presets',$payload,$csrf);httpCheck($status===201&&array_keys($saved['preset'])===['id','name','utm'],'HTTP creates strictly domain-free snapshot');$id=$saved['preset']['id'];
+ [$status,$list]=presetRequest('GET','/api/utm-presets');httpCheck($status===200&&$list['presets']===[['id'=>$id,'name'=>'HTTP набор']],'global list without domain');
+ [$status]=presetRequest('GET','/api/utm-presets?target=anything');httpCheck($status===422,'domain list filter rejected');
+ [$status]=presetRequest('POST','/api/utm-presets',$payload,$csrf);httpCheck($status===409,'duplicate name conflict');
+ foreach(['source','domain','host','notes','target'] as $key){[$status]=presetRequest('POST','/api/utm-presets',$payload+[$key=>'forbidden'],$csrf);httpCheck($status===422,'reject preset binding or unrelated field '.$key);}
+ [$status]=presetRequest('POST','/api/copy',['source'=>(object)['id'=>'old','version'=>str_repeat('a',64)],'targets'=>[]],$csrf);httpCheck($status===422,'legacy domain-copy API rejected');
+ [$status]=presetRequest('DELETE','/api/utm-presets',['id'=>$id],$csrf);httpCheck($status===200,'delete independent preset');
+ [$status]=presetRequest('DELETE','/api/utm-presets',['id'=>$id],$csrf);httpCheck($status===404,'repeat deletion returns not found');
+ [$status,$list]=presetRequest('GET','/api/utm-presets');httpCheck($list['presets']===[],'deleted preset absent from global list');
+ echo "Preset HTTP contract complete\n";
}finally{proc_terminate($server);proc_close($server);fclose($log);}
diff --git a/admin/tests/preset-ui-helper.cjs b/admin/tests/preset-ui-helper.cjs
new file mode 100644
index 0000000..4bb2dea
--- /dev/null
+++ b/admin/tests/preset-ui-helper.cjs
@@ -0,0 +1,8 @@
+// Источник старых регрессий заменён независимым UTM-снимком. Доменные поля в POST не передаются.
+module.exports=async function choosePreset(page,id){
+ const state=await page.request.get('/api/state').then(r=>r.json());
+ const read=await page.request.get('/api/config?target='+id).then(r=>r.json()),utm={};
+ for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const aliases=marker==='reg'?['reg','registration']:marker==='banch'?['banch','chob','bchoice']:[marker];const key=aliases.find(k=>Object.hasOwn(read.config.utm||{},k));if(key)utm[marker]={default:read.config.utm[key].default,weights:read.config.utm[key].weights};}
+ const response=await page.request.post('/api/utm-presets',{headers:{'X-CSRF-Token':state.csrf},data:{name:'UI-'+Date.now()+'-'+Math.random(),utm}}),saved=await response.json();
+ if(!saved.ok)throw Error(saved.error);await page.locator('#preset-reload').click();await page.waitForFunction(id=>[...document.querySelector('#copy-source').options].some(o=>o.value===id),saved.preset.id);await page.locator('#copy-source').selectOption(saved.preset.id);return saved.preset.id;
+};
diff --git a/admin/tests/ui-manual-variants.cjs b/admin/tests/ui-manual-variants.cjs
index 8e13baa..8f18f4f 100644
--- a/admin/tests/ui-manual-variants.cjs
+++ b/admin/tests/ui-manual-variants.cjs
@@ -1,7 +1,8 @@
+const choosePreset=require('./preset-ui-helper.cjs');
/* preland#30: native manual config editing; Browser plugin not available. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',ids=['a','b','d'],original=new Map(ids.map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());page.setDefaultTimeout(7000);const cell=(id,m)=>page.locator('[data-matrix-target='+id+'][data-marker='+m+']'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}),cancel=id=>page.getByRole('button',{name:'Отменить настройки '+id,exact:true}),read=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const settled=async id=>page.waitForFunction(id=>/^(Прочитано с хостинга|Записано на хостинг)$/.test(document.querySelector('[data-overview-target='+id+'] .matrix-status').textContent),id,{timeout:60000});const add=async(id,m,n)=>{await cell(id,m).getByRole('button',{name:'Добавить вариант '+m+' '+id,exact:true}).click();const number=cell(id,m).getByLabel('Новый вариант '+m+' '+id,{exact:true}).last();assert.equal(await number.inputValue(),'');assert.equal(await cell(id,m).getByLabel(m+' вес новый '+id,{exact:true}).last().inputValue(),'');assert.equal(await cell(id,m).getByLabel(m+' default новый '+id,{exact:true}).last().isChecked(),false);assert.equal(await save(id).isDisabled(),true);await number.fill('0');assert.equal(await save(id).isDisabled(),true);await number.fill(n);await cell(id,m).getByLabel(m+' вес '+n+' '+id,{exact:true}).last().fill('0');};try{const semantic=read('a');semantic.utm.theme={default:'dark',weights:{dark:100,light:0}};semantic.utm.focus={default:'1',weights:{'2':100}};fs.writeFileSync(root+'/a/config.json',JSON.stringify(semantic));const d=read('d');delete d.utm.cons;fs.writeFileSync(root+'/d/config.json',JSON.stringify(d));await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await settled(id);
for(const m of ['indpic','theme','degr','reg','banch','tp','cons','focus']){await add('a',m,'9');assert.equal(await cell('a',m).getByLabel(m+' вес 9 a',{exact:true}).inputValue(),'0');assert.equal(await cell('a',m).getByLabel(m+' default 9 a',{exact:true}).isChecked(),false);}await save('a').click();await settled('a');for(const m of ['indpic','theme','degr','reg','banch','tp','cons','focus'])assert.equal(read('a').utm[m].weights['9'],0,'new zero variant must persist '+m);
assert.equal(Object.hasOwn(read('a').utm.focus.weights,'1'),false,'synthetic missing default row must not be materialized by a manual zero addition');await add('a','theme','9');assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/уникальны/);await cell('a','theme').getByLabel('Новый вариант theme a',{exact:true}).last().fill('3');await cell('a','theme').getByLabel('theme default 3 a',{exact:true}).check();await save('a').click();await settled('a');assert.equal(read('a').utm.theme.default,'3');assert.equal(read('a').utm.theme.weights['3'],0);await page.getByRole('button',{name:'Обновить настройки a',exact:true}).click();await settled('a');assert.equal(await cell('a','theme').getByLabel('theme default 3 a',{exact:true}).isChecked(),true);
await add('d','cons','3');assert.equal(await save('d').isDisabled(),true,'new group requires explicit default');await cell('d','cons').getByLabel('cons default 3 d',{exact:true}).check();await save('d').click();await settled('d');assert.deepEqual(read('d').utm.cons,{default:'3',weights:{'3':0}});
-await page.locator('#copy-source').selectOption('a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,null,{timeout:60000});assert.equal(read('b').utm.theme.default,'3');assert.equal(read('b').utm.theme.weights['3'],0);assert.equal(read('b').utm.focus.weights['9'],0);await add('a','theme','8');await cancel('a').click();assert.equal(await cell('a','theme').getByLabel('theme вес 8 a',{exact:true}).count(),0);await page.locator('#builder-target').selectOption('a');await page.getByLabel('Ссылка: theme',{exact:true}).selectOption('3');await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.equal((await page.request.get('/api/link-builder?target=a').then(r=>r.json())).selection.theme,'3');assert.doesNotMatch(await page.locator('#builder-warnings').innerText(),/каталог/);await page.locator('#overview').screenshot({path:out+'/manual-variants-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/manual-variants-mobile.png'});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS all8 manual variants, zero Save/reread, theme3/default/builder, duplicate recovery, missing group/default, cancel, mobile, no JS errors');}finally{await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)});
+await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,null,{timeout:60000});assert.equal(read('b').utm.theme.default,'3');assert.equal(read('b').utm.theme.weights['3'],0);assert.equal(read('b').utm.focus.weights['9'],0);await add('a','theme','8');await cancel('a').click();assert.equal(await cell('a','theme').getByLabel('theme вес 8 a',{exact:true}).count(),0);await page.locator('#builder-target').selectOption('a');await page.getByLabel('Ссылка: theme',{exact:true}).selectOption('3');await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.equal((await page.request.get('/api/link-builder?target=a').then(r=>r.json())).selection.theme,'3');assert.doesNotMatch(await page.locator('#builder-warnings').innerText(),/каталог/);await page.locator('#overview').screenshot({path:out+'/manual-variants-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/manual-variants-mobile.png'});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS all8 manual variants, zero Save/reread, theme3/default/builder, duplicate recovery, missing group/default, cancel, mobile, no JS errors');}finally{await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)});
diff --git a/admin/tests/ui-matrix.cjs b/admin/tests/ui-matrix.cjs
index e752842..97728f6 100644
--- a/admin/tests/ui-matrix.cjs
+++ b/admin/tests/ui-matrix.cjs
@@ -1,3 +1,4 @@
+const choosePreset=require('./preset-ui-helper.cjs');
/* preland#25: real UI, actual CAS/jobs and isolated fixture. Browser plugin not available. */
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');const fs=require('node:fs');
(async()=>{
@@ -21,17 +22,17 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st
console.log('PASS edit/default/missing'); // Two independent drafts and note cursor remain through foreign-column save and read.
await host('a').fill('draft-a.invalid');const handle=await host('a').elementHandle();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});await cell('b','theme').locator('input[type=number]').first().fill('70');await cell('b','theme').locator('input[type=number]').nth(1).fill('30');await save('b').click();await saved('b');assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);assert.equal(readFile('b').utm.theme.weights.dark,70);assert.equal(readFile('b').utm.theme.default,'light');assert.deepEqual(readFile('b').custom,JSON.parse(original.get('b')).custom);assert.deepEqual(readFile('b').requisites,JSON.parse(original.get('b')).requisites);await refresh();assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);await cancel('a').click();
console.log('PASS foreign save'); // A late force GET must not erase input typed after reload starts.
- let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);let held=false;const delayed=async route=>{if(!held){held=true;const response=await route.fetch();started();await gate;await route.fulfill({response});}else await route.continue();};await page.route('**/api/config?target=a',delayed);await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click({noWaitAfter:true});await seen;await host('a').fill('after-get.invalid');release();await page.waitForTimeout(200);assert.equal(await host('a').inputValue(),'after-get.invalid');await page.locator('#copy-source').selectOption('a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();assert.doesNotMatch(await page.locator('#copy-reason').innerText(),/после ошибки чтения/,'successful late read must finish its loading snapshot');await page.locator('#copy-none').click();await page.unroute('**/api/config?target=a',delayed);await cancel('a').click();
+ let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);let held=false;const delayed=async route=>{if(!held){held=true;const response=await route.fetch();started();await gate;await route.fulfill({response});}else await route.continue();};await page.route('**/api/config?target=a',delayed);await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click({noWaitAfter:true});await seen;await host('a').fill('after-get.invalid');release();await page.waitForTimeout(200);assert.equal(await host('a').inputValue(),'after-get.invalid');await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();assert.doesNotMatch(await page.locator('#copy-reason').innerText(),/после ошибки чтения/,'successful late read must finish its loading snapshot');await page.locator('#copy-none').click();await page.unroute('**/api/config?target=a',delayed);await cancel('a').click();
console.log('PASS late get'); // Real remote version conflict, explicit reload and offline recovery preserve drafts.
await host('a').fill('my-draft.invalid');const external=readFile('a');external.custom.external='concurrent';writeFile('a',external);await save('a').click();await header('a').getByText(/измен|верс|конфликт/i).waitFor();assert.equal(await host('a').inputValue(),'my-draft.invalid');await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');assert.equal(await host('a').inputValue(),external.host);
await cell('b','theme').locator('input[type=number]').first().fill('50');await cell('b','theme').locator('input[type=number]').nth(1).fill('50');await context.setOffline(true);await save('b').click();await header('b').getByText(/Связь/).waitFor();assert.equal(await cell('b','theme').locator('input[type=number]').first().inputValue(),'50');await context.setOffline(false);await save('b').click();await saved('b');assert.equal(readFile('b').utm.theme.weights.dark,50);
console.log('PASS conflict/offline'); // Source and recipient drafts block copying. Copy selected recipients only, semantic aliases preserved.
- await page.locator('#copy-source').selectOption('a');assert.equal(await page.getByLabel('Копировать в a.invalid',{exact:true}).count(),0);await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.getByLabel('Копировать в c.invalid',{exact:true}).check();await host('a').fill('source-draft.invalid');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/источника/);await cancel('a').click();await host('b').fill('target-draft.invalid');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/получателей/);await cancel('b').click();
+ await choosePreset(page,'a');assert.equal(await page.getByLabel('Копировать в a.invalid',{exact:true}).count(),1);await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.getByLabel('Копировать в c.invalid',{exact:true}).check();await host('a').fill('source-draft.invalid');assert.equal(await page.locator('#copy-submit').isEnabled(),true,'independent preset ignores original domain drafts');await cancel('a').click();await host('b').fill('target-draft.invalid');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/получателей/);await cancel('b').click();
const beforeD=fs.readFileSync(root+'/d/config.json','utf8'),beforeB=readFile('b'),source=(await get('a')).config;await page.locator('#copy-submit').click();assert.equal(await page.locator('#copy-all').isDisabled(),true);assert.equal(await page.locator('#copy-none').isDisabled(),true);await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});assert.equal(await page.locator('#copy-results .status-success').count(),2);await saved('b');await saved('c');assert.equal(await header('b').locator('.matrix-status').innerText(),'Записано на хостинг');assert.equal(await header('c').locator('.matrix-status').innerText(),'Записано на хостинг');const copiedB=readFile('b');assert.deepEqual(copiedB.utm.theme.weights,{dark:source.utm.theme.weights['1'],light:source.utm.theme.weights['2']});assert.deepEqual(copiedB.requisites,beforeB.requisites);assert.deepEqual(copiedB.custom,beforeB.custom);assert.equal(fs.readFileSync(root+'/d/config.json','utf8'),beforeD);
// Real target CAS partial failure and source CAS preflight failure.
- const staleC=readFile('c');staleC.custom.concurrent='target';writeFile('c',staleC);const partialResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();const partial=(await(await partialResponse).json()).job;await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});const partialJob=(await request('jobs?id='+partial.id)).job;assert.deepEqual(partialJob.results.map(r=>r.status),['success','conflict']);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.equal(await page.locator('#copy-results .status-conflict').count(),1);assert.equal(readFile('c').custom.concurrent,'target');await header('c').getByRole('button',{name:'Обновить настройки c',exact:true}).click();await saved('c');const staleSource=readFile('a');staleSource.custom.concurrent='source';writeFile('a',staleSource);const sourceResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();assert.equal((await sourceResponse).status(),409);await page.locator('#copy-results .overview-error').waitFor();await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');console.log('PASS actual partial copy and source version conflict'); await page.locator('#copy-none').click();assert.equal(await page.locator('#copy-targets input:checked').count(),0);await page.locator('#copy-all').click();assert.equal(await page.locator('#copy-targets input:checked').count(),3);await page.locator('#copy-none').click();
+ const staleC=readFile('c');staleC.custom.concurrent='target';writeFile('c',staleC);const partialResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();const partial=(await(await partialResponse).json()).job;await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});const partialJob=(await request('jobs?id='+partial.id)).job;assert.deepEqual(partialJob.results.map(r=>r.status),['success','conflict']);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.equal(await page.locator('#copy-results .status-conflict').count(),1);assert.equal(readFile('c').custom.concurrent,'target');await header('c').getByRole('button',{name:'Обновить настройки c',exact:true}).click();await saved('c');const staleSource=readFile('a');staleSource.custom.concurrent='source';writeFile('a',staleSource);const sourceResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();assert.equal((await sourceResponse).status(),202);await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});assert.equal(await page.locator('#copy-results .status-success').count(),2);await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');console.log('PASS actual partial copy and source version conflict'); await page.locator('#copy-none').click();assert.equal(await page.locator('#copy-targets input:checked').count(),0);await page.locator('#copy-all').click();assert.equal(await page.locator('#copy-targets input:checked').count(),4);await page.locator('#copy-none').click();
console.log('PASS copy'); // Operations keep their own selection independent from copy UI.
- await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();await page.locator('#copy-source').selectOption('c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['a','b']);await page.locator('#source-target').selectOption('c');assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['c']);await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();
+ await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();await choosePreset(page,'c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['a','b']);await page.locator('#source-target').selectOption('c');assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['c']);await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();
const unchangedBeforeZip=(await get('a')).version;await page.locator('#set-add input[name=number]').fill('9');await page.locator('#set-add input[name=name]').fill('Invalid ZIP #25');await page.locator('#set-add input[type=file]').setInputFiles({name:'invalid.zip',mimeType:'application/zip',buffer:Buffer.from('not ZIP')});const zipResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));await page.locator('#set-add button').click();const invalidZip=await(await zipResponse).json();if(invalidZip.ok){await page.locator('#indpic-result-modal').waitFor({state:'visible'});await page.locator('#result-body').getByText(/Ошибка/).waitFor({timeout:60000});await page.getByRole('button',{name:'Готово',exact:true}).click();}else await page.locator('#notice.alert-danger').waitFor();assert.equal((await get('a')).version,unchangedBeforeZip);console.log('PASS actual invalid ZIP preserves runtime'); await page.locator('#builder-target').selectOption('a');await page.locator('#builder-fields select').first().waitFor();await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.match(await page.locator('#builder-url').inputValue(),/^https:\/\/a.invalid/);
// Notes preserve the same textarea node, caret and draft during matrix refresh.
const note=page.locator('[data-utm-note=reg]');await note.getByRole('button',{name:/(Добавить|Редактировать) заметку reg/}).click();const textarea=note.getByLabel('Заметка reg',{exact:true});await textarea.fill('Заметка #25\nПроверка курсора');const noteHandle=await textarea.elementHandle();await textarea.evaluate(n=>{n.focus();n.setSelectionRange(3,7);document.querySelector('#overview-refresh').click();});await page.waitForFunction(()=>!document.querySelector('#overview-refresh').disabled);assert.deepEqual(await noteHandle.evaluate(n=>({connected:n.isConnected,focus:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focus:true,start:3,end:7});await note.getByRole('button',{name:'Отменить заметку reg',exact:true}).click();
diff --git a/admin/tests/ui-percent.cjs b/admin/tests/ui-percent.cjs
index e30d36b..7befea0 100644
--- a/admin/tests/ui-percent.cjs
+++ b/admin/tests/ui-percent.cjs
@@ -1,6 +1,7 @@
+const choosePreset=require('./preset-ui-helper.cjs');
/* preland#28: actual stored percentages, strict inline validation and saved Copy gates. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
-(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',ids=['a','b'],original=new Map(ids.map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());const cell=id=>page.locator('[data-matrix-target='+id+'][data-marker=theme]'),weight=id=>cell(id).locator('input[type=number]'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true});const reload=async id=>{await page.getByRole('button',{name:'Обновить настройки '+id,exact:true}).click();await page.waitForFunction(id=>!document.querySelector('[data-overview-target='+id+'] .matrix-status').textContent.includes('Чтение'),id);};const invalid=id=>{const c=JSON.parse(original.get(id)),weights=c.utm.theme.weights;for(const key of Object.keys(weights))weights[key]=0;weights[Object.keys(weights)[0]]=1;fs.writeFileSync(root+'/'+id+'/config.json',JSON.stringify(c));};try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await page.waitForFunction(()=>[...document.querySelectorAll('.matrix-status')].every(n=>!n.textContent.includes('Чтение')));await page.locator('#copy-source').selectOption('a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();assert.equal(await page.locator('#copy-submit').isEnabled(),true);
-invalid('a');await reload('a');assert.equal(await weight('a').first().inputValue(),'1','GET must show stored 1%, without relative conversion');assert.equal(await cell('a').locator('.matrix-total').innerText(),'Сумма: 1%');assert.match(await cell('a').locator('.matrix-validation').innerText(),/100%.*0%/);assert.equal(await save('a').isDisabled(),true);assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/Источник:.*сумма/);await page.getByLabel('Основной сайт a',{exact:true}).fill('draft-percent.invalid');assert.equal(await save('a').isDisabled(),true,'unrelated host edit cannot publish an invalid untouched weight group');await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();assert.match(await cell('a').locator('.matrix-validation').innerText(),/сумма/,'cancel retains invalid saved config warning');await page.locator('#overview').screenshot({path:out+'/percent-invalid-saved.png'});fs.writeFileSync(root+'/a/config.json',original.get('a'));await reload('a');assert.equal(await page.locator('#copy-submit').isEnabled(),true);
+(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',ids=['a','b'],original=new Map(ids.map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());const cell=id=>page.locator('[data-matrix-target='+id+'][data-marker=theme]'),weight=id=>cell(id).locator('input[type=number]'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true});const reload=async id=>{await page.getByRole('button',{name:'Обновить настройки '+id,exact:true}).click();await page.waitForFunction(id=>!document.querySelector('[data-overview-target='+id+'] .matrix-status').textContent.includes('Чтение'),id);};const invalid=id=>{const c=JSON.parse(original.get(id)),weights=c.utm.theme.weights;for(const key of Object.keys(weights))weights[key]=0;weights[Object.keys(weights)[0]]=1;fs.writeFileSync(root+'/'+id+'/config.json',JSON.stringify(c));};try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await page.waitForFunction(()=>[...document.querySelectorAll('.matrix-status')].every(n=>!n.textContent.includes('Чтение')));await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();assert.equal(await page.locator('#copy-submit').isEnabled(),true);
+invalid('a');await reload('a');assert.equal(await weight('a').first().inputValue(),'1','GET must show stored 1%, without relative conversion');assert.equal(await cell('a').locator('.matrix-total').innerText(),'Сумма: 1%');assert.match(await cell('a').locator('.matrix-validation').innerText(),/100%.*0%/);assert.equal(await save('a').isDisabled(),true);assert.equal(await page.locator('#copy-submit').isEnabled(),true,'saved preset independent from changed origin');await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();assert.equal(await page.locator('#preset-dialog').isVisible(),false);assert.match(await page.locator('#notice').innerText(),/сумма/);await page.getByLabel('Основной сайт a',{exact:true}).fill('draft-percent.invalid');assert.equal(await save('a').isDisabled(),true,'unrelated host edit cannot publish an invalid untouched weight group');await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();assert.match(await cell('a').locator('.matrix-validation').innerText(),/сумма/,'cancel retains invalid saved config warning');await page.locator('#overview').screenshot({path:out+'/percent-invalid-saved.png'});fs.writeFileSync(root+'/a/config.json',original.get('a'));await reload('a');assert.equal(await page.locator('#copy-submit').isEnabled(),true);
invalid('b');await reload('b');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/Получатель:.*сумма/);fs.writeFileSync(root+'/b/config.json',original.get('b'));await reload('b');assert.equal(await page.locator('#copy-submit').isEnabled(),true);
const before=JSON.parse(original.get('a'));await weight('a').first().click();await weight('a').first().press('ControlOrMeta+A');await weight('a').first().pressSequentially('33.33');assert.equal(await weight('a').nth(1).inputValue(),String(before.utm.theme.weights['2']));assert.equal(await save('a').isDisabled(),true);await weight('a').nth(1).fill('66.67');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a').locator('input[type=radio]:checked').getAttribute('aria-label'),'theme default '+before.utm.theme.default+' a');await save('a').click();await page.waitForFunction(()=>document.querySelector('[data-overview-target=a] .matrix-status').textContent==='Записано на хостинг',null,{timeout:60000});await reload('a');const actual=JSON.parse(fs.readFileSync(root+'/a/config.json','utf8')),expected=structuredClone(before);expected.utm.theme.weights={'1':33.33,'2':66.67};assert.deepEqual(actual,expected);assert.equal(await weight('a').first().inputValue(),'33.33');assert.equal(await weight('a').nth(1).inputValue(),'66.67');await page.locator('#overview').screenshot({path:out+'/percent-saved-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.screenshot({path:out+'/percent-mobile.png',fullPage:false});assert.deepEqual(errors,[]);console.log('PASS raw stored1%/inline sum; saved invalid source+target Copy blocking/recovery; unrelated edits blocked; native typing 33.33+66.67 Save+reread/default+other fields preserved; desktop/mobile/no JS errors');}finally{await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)});
diff --git a/admin/tests/ui-session-jobs.cjs b/admin/tests/ui-session-jobs.cjs
index b66ab04..f96f261 100644
--- a/admin/tests/ui-session-jobs.cjs
+++ b/admin/tests/ui-session-jobs.cjs
@@ -1,3 +1,4 @@
+const choosePreset=require('./preset-ui-helper.cjs');
/* preland#25: real accepted jobs, delayed completed HTTP response across logout/login. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root='/fixture',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),context=await browser.newContext({baseURL:'http://host.docker.internal:18093'}),page=await context.newPage();page.on('dialog',d=>d.accept());page.setDefaultTimeout(15000);const errors=[];page.on('pageerror',e=>errors.push(e.message));
@@ -6,5 +7,5 @@ const logout=async()=>{await page.getByRole('button',{name:'Выйти',exact:tr
const host=id=>page.getByLabel('Основной сайт '+id,{exact:true});
const holdJob=async()=>{let release,ready,held=false;const gate=new Promise(r=>release=r),captured=new Promise(r=>ready=r);const handler=async route=>{if(held)return route.continue();held=true;const url=route.request().url();let body;for(let n=0;n<160;n++){body=await page.request.get(url).then(r=>r.json());assert.ok(body.ok,body.error);if(body.job.status==='completed')break;await new Promise(r=>setTimeout(r,100));}assert.equal(body.job.status,'completed');ready(body.job);await gate;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body)});};await page.route('**/api/jobs?id=*',handler);return {captured,release,stop:()=>page.unroute('**/api/jobs?id=*',handler)};};
try{await page.goto('/');await login();let held=await holdJob();const weight=page.locator('[data-matrix-target=a][data-marker=theme] input[type=number]').first();await weight.fill('40');await page.locator('[data-matrix-target=a][data-marker=theme] input[type=number]').nth(1).fill('60');await page.getByRole('button',{name:'Сохранить настройки a',exact:true}).click();await held.captured;await logout();await login();await host('a').fill('new-session-draft.invalid');const node=await host('a').elementHandle(),notice=await page.locator('#notice').innerText();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});held.release();await page.waitForTimeout(400);assert.equal(await host('a').inputValue(),'new-session-draft.invalid','old Save completion cannot reset a new session draft');assert.deepEqual(await node.evaluate(n=>({connected:n.isConnected,focus:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focus:true,start:2,end:5});assert.equal(await page.locator('#notice').innerText(),notice);await held.stop();await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();console.log('PASS old accepted Save completion ignored after logout/login');
-held=await holdJob();await page.locator('#copy-source').selectOption('a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await held.captured;await logout();await login();await host('b').fill('new-session-recipient-draft.invalid');await page.locator('#copy-source').selectOption('c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.equal(await page.locator('#copy-submit').isEnabled(),true,'new login is not blocked by old copyBusy');const current=await holdJob();await page.locator('#copy-submit').click();await current.captured;const currentNotice=await page.locator('#notice').innerText();held.release();await page.waitForTimeout(400);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');assert.equal(await page.locator('#copy-source').isDisabled(),true,'old finally cannot unlock new copy');assert.equal(await page.locator('#copy-results').innerText(),'','old outcomes cannot replace pending new operation');assert.equal(await page.locator('#notice').innerText(),currentNotice);current.release();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.match(await page.locator('#copy-results').innerText(),/d.invalid/);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');await current.stop();await held.stop();assert.deepEqual(errors,[]);console.log('PASS old accepted Copy completion/finally ignored; new copyBusy/outcomes/notice/draft preserved');
+held=await holdJob();await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await held.captured;await logout();await login();await host('b').fill('new-session-recipient-draft.invalid');await choosePreset(page,'c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.equal(await page.locator('#copy-submit').isEnabled(),true,'new login is not blocked by old copyBusy');const current=await holdJob();await page.locator('#copy-submit').click();await current.captured;const currentNotice=await page.locator('#notice').innerText();held.release();await page.waitForTimeout(400);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');assert.equal(await page.locator('#copy-source').isDisabled(),true,'old finally cannot unlock new copy');assert.equal(await page.locator('#copy-results').innerText(),'','old outcomes cannot replace pending new operation');assert.equal(await page.locator('#notice').innerText(),currentNotice);current.release();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.match(await page.locator('#copy-results').innerText(),/d.invalid/);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');await current.stop();await held.stop();assert.deepEqual(errors,[]);console.log('PASS old accepted Copy completion/finally ignored; new copyBusy/outcomes/notice/draft preserved');
}finally{await browser.close();for(const [id,text]of original)fs.writeFileSync(root+'/'+id+'/config.json',text);}})().catch(e=>{console.error(e);process.exit(1)});
diff --git a/admin/tests/ui-utm-presets.cjs b/admin/tests/ui-utm-presets.cjs
new file mode 100644
index 0000000..6bfa83a
--- /dev/null
+++ b/admin/tests/ui-utm-presets.cjs
@@ -0,0 +1,16 @@
+/* Browser plugin not available; local disposable fixture, real HTTP/jobs and rendered UI. */
+const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
+(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({args:['--no-sandbox']}),context=await browser.newContext({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.dismiss());page.setDefaultTimeout(12000);let csrf;const created=[];
+const login=async()=>{await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await page.waitForFunction(()=>[...document.querySelectorAll('.matrix-status')].every(e=>!e.textContent.includes('Чтение')));csrf=(await page.request.get('/api/state').then(r=>r.json())).csrf;};
+const read=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const weight=(id,n)=>page.getByLabel('theme вес '+n+' '+id,{exact:true});const name='UI набор '+Date.now();
+try{await page.goto('/');await login();await weight('a','1').fill('80');await weight('a','2').fill('20');await page.getByLabel('Основной сайт a',{exact:true}).fill('invalid host /');await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();await page.getByLabel('Название набора',{exact:true}).fill(' ');await page.locator('#preset-save').click();await page.locator('#preset-error').getByText(/Укажите название/).waitFor();await page.getByLabel('Название набора',{exact:true}).fill(name);await page.locator('#preset-save').click();await page.locator('#preset-dialog').waitFor({state:'hidden'});const id=await page.locator('#copy-source').inputValue();created.push(id);assert.equal(fs.readFileSync(root+'/a/config.json','utf8'),original.get('a'),'saving draft does not publish');
+await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();await page.getByLabel('Название набора',{exact:true}).fill(name);await page.locator('#preset-save').click();await page.locator('#preset-error').getByText(/уже существует/).waitFor();assert.equal(await page.locator('#preset-name').inputValue(),name);await page.locator('#preset-cancel').click();
+await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();for(const target of ['b','c'])await page.getByLabel('Копировать в '+target+'.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await page.waitForFunction(()=>document.querySelectorAll('#copy-results .status-success').length===2,null,{timeout:60000});assert.equal(read('b').utm.theme.weights.dark,80);assert.equal(read('c').utm.theme.weights['1'],80);assert.equal(read('b').host,JSON.parse(original.get('b')).host);assert.equal(fs.readFileSync(root+'/d/config.json','utf8'),original.get('d'));await page.locator('#copy-panel').screenshot({path:out+'/presets-copy.png'});
+await page.reload();await page.locator('#copy-source option[value="'+id+'"]').waitFor({state:'attached'});await page.locator('#copy-source').selectOption(id);assert.equal(await page.getByLabel('Копировать в a.invalid',{exact:true}).count(),1,'origin has no special status');
+// A pending list refresh must disable deletion, then recover.
+let release,seen;const gate=new Promise(r=>release=r),captured=new Promise(r=>seen=r);await page.route('**/api/utm-presets',async route=>{if(route.request().method()!=='GET')return route.continue();const response=await route.fetch();seen();await gate;await route.fulfill({response});});await page.locator('#preset-reload').click();await captured;assert.equal(await page.locator('#preset-delete').isDisabled(),true,'delete blocked while list GET pending');release();await page.waitForFunction(()=>!document.querySelector('#preset-reload').disabled);await page.unroute('**/api/utm-presets');
+// 401 during save must not leave controls disabled after re-login.
+await page.route('**/api/utm-presets',async route=>route.request().method()==='POST'?route.fulfill({status:401,contentType:'application/json',body:JSON.stringify({ok:false,error:'Истекла сессия теста'})}):route.continue());await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();await page.locator('#preset-name').fill(name+' session');await page.locator('#preset-save').click();await page.locator('#login-panel').waitFor();await page.unroute('**/api/utm-presets');await login();await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();assert.equal(await page.locator('#preset-name').isEnabled(),true,'new session resets preset form');assert.equal(await page.locator('#preset-save').isEnabled(),true);assert.equal(await page.locator('#preset-cancel').isEnabled(),true);await page.locator('#preset-cancel').click();
+await page.locator('#copy-source').selectOption(id);await page.locator('#preset-delete').click();assert.equal(await page.locator('#copy-source option[value="'+id+'"]').count(),1,'cancel deletion leaves preset');const deleted=await page.request.delete('/api/utm-presets',{headers:{'X-CSRF-Token':csrf},data:{id}});assert.equal(deleted.ok(),true);await page.locator('#preset-reload').click();await page.locator('#copy-source option[value="'+id+'"]').waitFor({state:'detached'});assert.equal(read('b').utm.theme.weights.dark,80,'deleting preset does not revert recipient');
+await page.setViewportSize({width:390,height:844});await page.locator('#copy-panel').screenshot({path:out+'/presets-mobile.png'});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS preset draft/name/duplicate/apply2/host preservation/reload/any recipient/delete cancel+API/list race/session recovery/mobile');
+}catch(error){console.error('UI evidence',await page.locator('#copy-panel').innerText(),errors);await page.screenshot({path:out+'/presets-failure.png'});throw error;}finally{for(const id of created)if(csrf)await page.request.delete('/api/utm-presets',{headers:{'X-CSRF-Token':csrf},data:{id}}).catch(()=>{});await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)});
diff --git a/admin/tests/utm-presets.php b/admin/tests/utm-presets.php
new file mode 100644
index 0000000..ea86fae
--- /dev/null
+++ b/admin/tests/utm-presets.php
@@ -0,0 +1,31 @@
+create(['name'=>'Осень','utm'=>$utm]);
+checkPreset(array_keys($saved)===['id','name','utm'],'preset has no source domain or target binding');
+$utm->theme->weights->{'1'}=100;checkPreset($presets->get($saved['id'])['utm']->theme->weights->{'1'}===33.33,'draft is an immutable independent snapshot');
+checkPreset((new UtmPresets(new Store($store->dir)))->get($saved['id'])['name']==='Осень','preset survives store reload');
+foreach([['name'=>'','utm'=>$saved['utm']],['name'=>' ','utm'=>$saved['utm']],['name'=>'осень','utm'=>$saved['utm']],['name'=>'x','utm'=>$saved['utm'],'domain'=>'a.invalid'],['name'=>'x','utm'=>(object)[]],['name'=>'x','utm'=>(object)['theme'=>(object)['default'=>1,'weights'=>(object)['1'=>80]]]],['name'=>'x','utm'=>(object)['theme'=>(object)['default'=>1,'weights'=>(object)['1'=>100],'note'=>'secret']]],['name'=>'x','utm'=>(object)['unknown'=>(object)[]]]] as $bad)rejectsPreset(fn()=>$presets->create($bad),'strict independent preset validation');
+$paths=[];foreach(['a','b'] as $id){$paths[$id]=$root.'/'.$id;mkdir($paths[$id]);}putenv('ADMIN_LOCAL_ROOTS='.implode(',',$paths));
+$raw='{"host":"keep.invalid","utm":{"theme":{"default":"dark","weights":{"dark":100},"note":"keep"},"reg":{"default":2,"weights":{"2":100}},"custom":{"a":1}},"requisites":["keep"],"unknown":{}}';
+foreach($paths as $id=>$path){file_put_contents($path.'/config.json',$raw);$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$path,'enabled'=>true]);$store->verifyTarget($id);}
+$store->write('utm-notes.json',['theme'=>['text'=>'keep note']]);$notes=file_get_contents($store->path('utm-notes.json'));
+$read=fn($id)=>(new Transport($store,$store->target($id)))->readConfig();$selection=fn($id)=>(object)['id'=>$id,'version'=>$read($id)['version']];
+$copy=new Copy($store);$job=$copy->create(['preset'=>$saved['id'],'targets'=>[$selection('a'),$selection('b')]]);
+$presets->delete($saved['id']);rejectsPreset(fn()=>$presets->get($saved['id']),'deleted preset unavailable');
+file_put_contents($paths['b'].'/config.json',str_replace('keep.invalid','foreign.invalid',$raw));$jobs=new Jobs($store);$jobs->run($job['id']);$done=$jobs->get($job['id']);
+checkPreset($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='conflict','queued snapshot survives deletion and isolates CAS conflict');
+$a=$read('a')['config'];checkPreset($a->host==='keep.invalid'&&$a->utm->theme->note==='keep'&&$a->utm->reg->default===2&&$a->utm->custom->a===1&&$a->unknown instanceof stdClass,'host, absent groups, metadata and unknown types unchanged');
+checkPreset($a->utm->theme->default==='3'&&$a->utm->theme->weights->dark===33.33&&$a->utm->theme->weights->{'3'}===66.67&&$a->utm->cons->weights->{'2'}===0,'manual variants, semantic targets and all-zero percentages copied');
+checkPreset($store->backupContent('a',$store->backups('a')[0]['id'])===$raw&&$store->backups('b')===[],'exact backup only for successful recipient');
+checkPreset(file_get_contents($store->path('utm-notes.json'))===$notes,'notes stay byte identical');
+$retry=$store->read('jobs/'.$job['id'].'.json');$retry['status']='running';$retry['results'][0]['status']='running';$store->write('jobs/'.$job['id'].'.json',$retry);$jobs->run($job['id']);checkPreset(count($store->backups('a'))===1,'desired hash recovery does not repeat write');
+$next=$presets->create(['name'=>'После удаления','utm'=>$saved['utm']]);$store->setTargetVisibility('b',true);$store->deleteTarget('b');checkPreset($presets->get($next['id'])['name']==='После удаления','target deletion cannot delete or detach preset');
+foreach([['source'=>$selection('a'),'targets'=>[$selection('a')]],['preset'=>$next['id'],'targets'=>[]],['preset'=>$next['id'],'targets'=>[$selection('a'),$selection('a')]],['preset'=>$next['id'],'targets'=>[(object)['id'=>'a','version'=>'bad']]],['preset'=>$next['id'],'targets'=>[$selection('a')],'utm'=>(object)[]]] as $bad)rejectsPreset(fn()=>$copy->create($bad),'strict copy request');
+$same=$copy->create(['preset'=>$next['id'],'targets'=>[$selection('a')]]);$jobs->run($same['id']);checkPreset($jobs->get($same['id'])['results'][0]['status']==='success','any domain can receive independent preset');
+echo "ALL PRESET CHECKS PASS\n";
diff --git a/admin/views/dashboard.php b/admin/views/dashboard.php
index 377449c..c8e252d 100644
--- a/admin/views/dashboard.php
+++ b/admin/views/dashboard.php
@@ -9,10 +9,11 @@ $adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.
Вход в админку
Редактируйте веса в процентах и default прямо в ячейках. Сумма группы — 100% либо все веса — 0%. Допустимо до двух знаков после запятой. Сохранение — отдельно для каждого домена.
Основной сайт и все UTM: варианты, веса и default.
Заметки и файлы наборов изображений не копируются. Отсутствующая группа источника сохраняет группу получателя.
Выберите значения UTM или «Рандом». Последний сформированный выбор хранится отдельно для каждого домена. Конфигурация, веса и default не меняются.
Выберите домен.
Только UTM: варианты, веса и default.
Основной сайт, заметки и файлы сетов не меняются. Отсутствующая в наборе группа сохраняется у получателя.
Выберите значения UTM или «Рандом». Последний сформированный выбор хранится отдельно для каждого домена. Конфигурация, веса и default не меняются.
Выберите домен.
Выбор домена и целей ниже относится только к наборам и резервным копиям. Загрузка не меняет default и веса.
Выберите домен.
Цели загрузки ZIP и проверки
| Номер / название | Default | Проверка | ZIP | Действия |
|---|
Удаление подключения оставляет сайт и файлы на хостинге. Секрет можно заменить; прочитать сохранённый секрет нельзя.
Успешные цели не повторяются. После конфликта перечитайте домен и проверьте изменения; неизвестный результат требует проверки фактического состояния.