feat(admin): единые процентные веса без старого формата (#28)

This commit is contained in:
Panfilov Konstantin committed 2026-10-26 04:56:32 +03:00
1 parent a22519e754
commit 47405b27d8
21 files changed
+326 -46

No files matched your search

+21
View File
@@ -228,3 +228,24 @@ API с обычными авторизацией и CSRF: `POST /api/targets/vis
loading/error/retry состояния и «Записано на хостинг» после подтверждённых Save/Copy.
Ошибка сохраняет прошлую дату; ввод во время чтения сохраняет черновик, но обновляет
метаданные чтения. Сохранение блокируется до завершения чтения.
## Процентный контракт (#28)
В `config.json` веса известных UTM хранятся непосредственно в процентах: числа
0–100 с точностью до двух знаков; сумма группы — 100 либо все значения равны 0.
Поле «Вес, %» показывает эти же числа, без пересчёта и перераспределения ввода.
Номер варианта и default не меняются при изменении веса. Промежуточная неверная
сумма допускается в черновике и блокирует сохранение с понятной причиной.
Config/Transport проверяют единый контракт для Save, Copy, Restore и проверки
нового подключения. Старые относительные конфиги и резервные копии не публикуются;
сами архивы сохраняются. Совместимость со старой единицей весов не поддерживается
по прямому решению владельца. Не путать это с неизменными alias/defaulttypes.
Публичный выбор варианта остаётся прежним: масштабирование до процентов сохраняет
доли; поведение полностью нулевой группы не заменяется новым fallback.
`PercentMigration` применяется только явно при миграции: заменяет числовые токены
известных weights, сохраняя остальные байты. Доли округляются независимо до сотых;
если сумма округлений не равна 100, операция останавливается до записи всех целей.
Остаток автоматически не распределяется. Приватный CLI с планом/receipt/CAS делает
резервные копии и проверяет точные bytes после каждой записи; в public его нет.
+1 -1
View File
@@ -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/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/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"}
}
+1 -1
View File
@@ -10,7 +10,7 @@
/* #23: compact numeric percentages; note rows span the whole matrix without sticky cells. */
.overview-percent{display:block;margin:6px 0;line-height:1.35}.overview-percent:first-child{margin-top:0}.overview-percent:last-child{margin-bottom:0}.overview-raw{font-size:12px;overflow-wrap:anywhere}#overview-table td.utm-note-cell{position:static;left:auto;width:auto;min-width:0;z-index:auto;background:#fafafa;white-space:normal}.utm-note-title{display:block;font-size:12px;color:#707070;margin-bottom:6px}.utm-note-text{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 8px}.utm-note-textarea{width:100%;max-width:100%;min-width:0;resize:vertical;min-height:90px}.utm-note-status{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.utm-note-status:empty{display:none}.utm-note-edit .toolbar{margin-bottom:0}.utm-note-cell .btn{white-space:normal}.utm-note-read:has(.utm-note-text[hidden]){display:flex;align-items:center}
/* #25: one editable matrix and independent saved-settings copy. */
.container{width:min(1440px,100%)}.overview-table{min-width:1280px}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){width:auto}.overview-table td{position:static!important}.overview-table tr[data-overview-utm] th{background:#fafafa}.overview-table .matrix-variant{display:grid;grid-template-columns:44px 58px 48px 1fr;align-items:center;gap:6px;margin-top:7px}.matrix-variant .form-control{height:30px;min-height:30px;min-width:0;padding:3px 5px;font-size:13px}.matrix-labels{display:grid;grid-template-columns:44px 58px 48px 1fr;gap:6px;color:#78818e;font-size:11px}.matrix-default{display:flex;gap:4px;align-items:center;font-size:12px;font-weight:400;white-space:nowrap}.matrix-default input{margin:0}.matrix-percent{font-size:12px;white-space:nowrap}.matrix-percent.positive{font-weight:600}.matrix-remove{padding:0;border:0;background:none;color:#7a8594;font-size:19px;cursor:pointer;line-height:24px}.matrix-remove:hover{color:#a94442}.matrix-variants+.btn{font-size:12px;border:0;background:none;padding:6px 0 0;color:#337ab7}.matrix-footer td{background:#f8f9fb}.matrix-footer .toolbar{margin:0;gap:7px}.matrix-status{font-size:12px;font-weight:400;margin:7px 0}.matrix-dirty{color:#9c6413}.matrix-environment{font-size:12px;font-weight:400}.copy-grid{display:grid;grid-template-columns:320px minmax(0,1fr);gap:32px}.copy-grid>label{font-weight:600}.copy-grid select{margin-top:8px}.copy-hint{display:block;margin-top:10px;font-size:12px;font-weight:400}.copy-grid .target-options{margin-top:8px}.copy-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;border-top:1px solid #e1e5eb;margin-top:20px;padding-top:16px}.copy-bottom p{margin:0;font-size:13px}.copy-bottom .btn{flex-shrink:0}#copy-reason{margin:12px 0 0}.utm-note-read{display:flex;align-items:center;justify-content:space-between;gap:14px}.utm-note-text{margin:0}.utm-note-cell .btn{flex-shrink:0}.overview-wrapper{overscroll-behavior-x:contain}.overview-table caption{font-size:12px}@media(max-width:600px){.overview-table{min-width:1240px}.copy-grid{grid-template-columns:1fr;gap:20px}.copy-bottom{align-items:stretch;flex-direction:column}.copy-bottom .btn{align-self:flex-start}.overview-table th:first-child{width:90px}.matrix-footer .toolbar{flex-wrap:nowrap}.matrix-footer .btn{font-size:12px}.utm-note-read{align-items:flex-start}.utm-note-cell .btn{max-width:150px}.overview-table .form-control{font-size:13px}}
.container{width:min(1440px,100%)}.overview-table{min-width:1280px}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){width:auto}.overview-table td{position:static!important}.overview-table tr[data-overview-utm] th{background:#fafafa}.overview-table .matrix-variant{display:grid;grid-template-columns:44px 76px 1fr;align-items:center;gap:6px;margin-top:7px}.matrix-variant .form-control{height:30px;min-height:30px;min-width:0;padding:3px 5px;font-size:13px}.matrix-labels{display:grid;grid-template-columns:44px 76px 1fr;gap:6px;color:#78818e;font-size:11px}.matrix-default{display:flex;gap:4px;align-items:center;font-size:12px;font-weight:400;white-space:nowrap}.matrix-default input{margin:0}.matrix-total,.matrix-validation{font-size:12px;font-weight:400;margin:7px 0 0}.matrix-remove{padding:0;border:0;background:none;color:#7a8594;font-size:19px;cursor:pointer;line-height:24px}.matrix-remove:hover{color:#a94442}.matrix-variants+.btn{font-size:12px;border:0;background:none;padding:6px 0 0;color:#337ab7}.matrix-footer td{background:#f8f9fb}.matrix-footer .toolbar{margin:0;gap:7px}.matrix-status{font-size:12px;font-weight:400;margin:7px 0}.matrix-dirty{color:#9c6413}.matrix-environment{font-size:12px;font-weight:400}.copy-grid{display:grid;grid-template-columns:320px minmax(0,1fr);gap:32px}.copy-grid>label{font-weight:600}.copy-grid select{margin-top:8px}.copy-hint{display:block;margin-top:10px;font-size:12px;font-weight:400}.copy-grid .target-options{margin-top:8px}.copy-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;border-top:1px solid #e1e5eb;margin-top:20px;padding-top:16px}.copy-bottom p{margin:0;font-size:13px}.copy-bottom .btn{flex-shrink:0}#copy-reason{margin:12px 0 0}.utm-note-read{display:flex;align-items:center;justify-content:space-between;gap:14px}.utm-note-text{margin:0}.utm-note-cell .btn{flex-shrink:0}.overview-wrapper{overscroll-behavior-x:contain}.overview-table caption{font-size:12px}@media(max-width:600px){.overview-table{min-width:1240px}.copy-grid{grid-template-columns:1fr;gap:20px}.copy-bottom{align-items:stretch;flex-direction:column}.copy-bottom .btn{align-self:flex-start}.overview-table th:first-child{width:90px}.matrix-footer .toolbar{flex-wrap:nowrap}.matrix-footer .btn{font-size:12px}.utm-note-read{align-items:flex-start}.utm-note-cell .btn{max-width:150px}.overview-table .form-control{font-size:13px}}
#notice{position:static;box-shadow:none}
+8 -6
View File
@@ -99,12 +99,14 @@
if(!group.key){cell.append(el('span',{class:'text-muted',text:'Группа отсутствует'}));return;}
if(!raw||typeof raw!=='object'||Array.isArray(raw)||!raw.weights||typeof raw.weights!=='object'||Array.isArray(raw.weights)||!digitFor(marker,raw.default)||Object.entries(raw.weights).some(([key,w])=>!digitFor(marker,key)||typeof w!=='number'||!Number.isFinite(w)||w<0)||new Set(Object.keys(raw.weights).map(k=>digitFor(marker,k))).size!==Object.keys(raw.weights).length){cell.append(el('p',{class:'overview-error',text:'Некорректная группа. Редактирование недоступно.'}));return;}
const draft={key:group.key,original:structuredClone(raw),rows:[],defaultRow:null,added:false};entry.groups.set(marker,draft);renderGroup(draft);for(const [key,w]of Object.entries(raw.weights))addVariant(draft,key,w);const defaultDigit=digitFor(marker,raw.default);if(!draft.rows.some(row=>digitFor(marker,row.variant.value)===defaultDigit))addVariant(draft,String(raw.default),0);draft.defaultRow=draft.rows.find(row=>digitFor(marker,row.variant.value)===defaultDigit);draft.defaultRow.radio.checked=true;percentages(draft);
function renderGroup(draft){draft.marker=marker;cell.replaceChildren();draft.list=el('div',{class:'matrix-variants'});cell.append(el('div',{class:'matrix-labels','aria-hidden':'true'},el('span',{text:'Вариант'}),el('span',{text:'Вес'}),el('span',{text:'Доля'}),el('span',{text:'Default'})),draft.list);}
function addVariant(draft,key,weight){const wrapper=el('div',{class:'matrix-variant'}),variant={value:key};const input=numberInput(weight,0);input.setAttribute('aria-label',marker+' вес '+key+' '+entry.id);const percent=el('span',{class:'matrix-percent'}),radio=el('input',{type:'radio',name:'default-'+entry.id+'-'+marker,'aria-label':marker+' default '+key+' '+entry.id});const row={wrapper,variant,weight:input,percent,radio};draft.rows.push(row);wrapper.append(el('span',{class:'matrix-variant-number',text:digitFor(marker,key)}),input,percent,el('label',{class:'matrix-default'},radio,el('span',{text:'default'})));draft.list.append(wrapper);input.addEventListener('input',()=>{percentages(draft);draft.touched=true;matrixChanged(entry);});radio.addEventListener('change',()=>{draft.defaultRow=row;draft.touched=true;matrixChanged(entry);});percentages(draft);}
function renderGroup(draft){draft.marker=marker;cell.replaceChildren();draft.list=el('div',{class:'matrix-variants'});draft.total=el('p',{class:'matrix-total text-muted','aria-live':'polite'});draft.validation=el('p',{class:'matrix-validation overview-error','aria-live':'polite'});cell.append(el('div',{class:'matrix-labels','aria-hidden':'true'},el('span',{text:'Вариант'}),el('span',{text:'Вес, %'}),el('span',{text:'Default'})),draft.list,draft.total,draft.validation);}
function addVariant(draft,key,weight){const wrapper=el('div',{class:'matrix-variant'}),variant={value:key};const input=numberInput(weight,0);input.max='100';input.step='0.01';input.setAttribute('aria-label',marker+' вес '+key+' '+entry.id);const radio=el('input',{type:'radio',name:'default-'+entry.id+'-'+marker,'aria-label':marker+' default '+key+' '+entry.id});const row={wrapper,variant,weight:input,radio};draft.rows.push(row);wrapper.append(el('span',{class:'matrix-variant-number',text:digitFor(marker,key)}),input,el('label',{class:'matrix-default'},radio,el('span',{text:'default'})));draft.list.append(wrapper);input.addEventListener('input',()=>{percentages(draft);draft.touched=true;matrixChanged(entry);});radio.addEventListener('change',()=>{draft.defaultRow=row;draft.touched=true;matrixChanged(entry);});percentages(draft);}
}
function percentages(group){if(group.addControl){const values=enums[group.marker]?Object.keys(enums[group.marker]):Array.from({length:9},(_,i)=>String(i+1)),exhausted=values.every(v=>group.rows.some(r=>digitFor(group.marker,r.variant.value)===v));group.addControl.dataset.exhausted=String(exhausted);group.addControl.disabled=exhausted;}const max=Math.max(0,...group.rows.map(r=>Number(r.weight.value)||0)),sum=max?group.rows.reduce((n,r)=>n+(Number(r.weight.value)||0)/max,0):0;for(const row of group.rows){const p=sum?Math.round((Number(row.weight.value)||0)/max/sum*10000)/100:0;row.percent.textContent=p+'%';row.percent.classList.toggle('positive',p>0);}}
function matrixChanges(entry){const changes=[];if(entry.hostTouched&&entry.host&&entry.host.value!==entry.data.config.host){if(!/^(?:https:\/\/)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$/i.test(entry.host.value)||entry.host.value.includes('..'))throw Error('Адрес сайта должен быть HTTPS-хостом без пути.');changes.push({path:['host'],value:entry.host.value});}for(const [marker,group]of entry.groups){if(!group.touched&&!group.added)continue;if(!group.rows.length)throw Error(marker+': добавьте хотя бы один вариант.');const weights={};for(const row of group.rows){const key=row.variant.value,w=Number(row.weight.value);if(!Object.hasOwn(group.original.weights,key)&&w===0)continue;if(!digitFor(marker,key)||Object.hasOwn(weights,key))throw Error(marker+': варианты должны быть уникальны.');if(row.weight.value===''||!Number.isFinite(w)||w<0)throw Error(marker+': вес должен быть неотрицательным числом.');weights[key]=w;}if(!group.defaultRow)throw Error(marker+': выберите default.');const digit=digitFor(marker,group.defaultRow.variant.value),original=group.original;const rawDefault=original&&digitFor(marker,original.default)===digit?original.default:typeof original?.default==='number'?Number(digit):Object.values(enums[marker]||{}).includes(String(original?.default))?enums[marker][digit]:digit;if(group.added){changes.push({path:['utm',marker],value:{default:rawDefault,weights}});}else{if(!equivalent(weights,original.weights))changes.push({path:['utm',group.key,'weights'],value:weights});if(!equivalent(rawDefault,original.default))changes.push({path:['utm',group.key,'default'],value:rawDefault});}}return changes;}
function populateMatrix(entry){entry.groups.clear();entry.hostTouched=false;entry.validation='';for(const [key,cell]of entry.cells){cell.replaceChildren();if(key==='host'){entry.host=el('input',{class:'form-control',value:entry.data.config.host??'','data-host':'','aria-label':'Основной сайт '+entry.id});entry.host.addEventListener('input',()=>{entry.hostTouched=true;matrixChanged(entry);});cell.append(entry.host);}else matrixGroup(entry,key,cell);}updateMatrixStatus(entry);}
function percentError(group){let sum=0;for(const row of group.rows){const text=row.weight.value,w=Number(text);if(text===''||!Number.isFinite(w)||w<0||w>100)return group.marker+': вес должен быть числом от 0 до 100%.';if(w!==Number(w.toFixed(2)))return group.marker+': в весе допустимо не более двух знаков после запятой.';sum+=Math.round(w*100);}return sum!==0&&sum!==10000?group.marker+': сумма весов должна быть 100% или все веса должны быть 0%.':'';}
function percentages(group){const values=group.rows.map(row=>row.weight.value===''?NaN:Number(row.weight.value)),sum=values.reduce((total,w)=>total+w,0);group.total.textContent='Сумма: '+(Number.isFinite(sum)?String(Math.round(sum*1000000)/1000000)+'%':'—');group.validation.textContent=percentError(group);group.validation.hidden=!group.validation.textContent;}
function matrixValidation(entry){for(const marker of markers){const group=groupFor(entry.data?.config,marker);if(group.key&&!entry.groups.has(marker))return marker+': некорректная группа. Обновите конфигурацию.';const draft=entry.groups.get(marker);if(draft){const error=percentError(draft);if(error)return error;}}return '';}
function matrixChanges(entry){const invalid=matrixValidation(entry);if(invalid)throw Error(invalid);const changes=[];if(entry.hostTouched&&entry.host&&entry.host.value!==entry.data.config.host){if(!/^(?:https:\/\/)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$/i.test(entry.host.value)||entry.host.value.includes('..'))throw Error('Адрес сайта должен быть HTTPS-хостом без пути.');changes.push({path:['host'],value:entry.host.value});}for(const [marker,group]of entry.groups){if(!group.touched&&!group.added)continue;if(!group.rows.length)throw Error(marker+': добавьте хотя бы один вариант.');const weights={};for(const row of group.rows){const key=row.variant.value,w=Number(row.weight.value);if(!Object.hasOwn(group.original.weights,key)&&w===0)continue;if(!digitFor(marker,key)||Object.hasOwn(weights,key))throw Error(marker+': варианты должны быть уникальны.');if(row.weight.value===''||!Number.isFinite(w)||w<0)throw Error(marker+': вес должен быть неотрицательным числом.');weights[key]=w;}if(!group.defaultRow)throw Error(marker+': выберите default.');const digit=digitFor(marker,group.defaultRow.variant.value),original=group.original;const rawDefault=original&&digitFor(marker,original.default)===digit?original.default:typeof original?.default==='number'?Number(digit):Object.values(enums[marker]||{}).includes(String(original?.default))?enums[marker][digit]:digit;if(group.added){changes.push({path:['utm',marker],value:{default:rawDefault,weights}});}else{if(!equivalent(weights,original.weights))changes.push({path:['utm',group.key,'weights'],value:weights});if(!equivalent(rawDefault,original.default))changes.push({path:['utm',group.key,'default'],value:rawDefault});}}return changes;}
function populateMatrix(entry){entry.groups.clear();entry.hostTouched=false;entry.validation='';for(const [key,cell]of entry.cells){cell.replaceChildren();if(key==='host'){entry.host=el('input',{class:'form-control',value:entry.data.config.host??'','data-host':'','aria-label':'Основной сайт '+entry.id});entry.host.addEventListener('input',()=>{entry.hostTouched=true;matrixChanged(entry);});cell.append(entry.host);}else matrixGroup(entry,key,cell);}entry.validation=matrixValidation(entry);updateMatrixStatus(entry);}
function renderOverview(){
const focused=document.activeElement,selection=focused&&'selectionStart'in focused?{start:focused.selectionStart,end:focused.selectionEnd}:null;const signature=state.targets.map(t=>t.id).join('|'),table=$('overview-table');table.style.minWidth=Math.max(760,110+state.targets.length*292)+'px';if(matrix.structure!==signature&&matrix.structure.split('|').slice().sort().join('|')===signature.split('|').slice().sort().join('|')&&table.querySelector('thead tr')){const ids=state.targets.map(t=>t.id);for(const row of table.querySelectorAll('tr')){if(row.hasAttribute('data-utm-note'))continue;const children=[...row.children];if(children.length===ids.length+1)for(const id of ids){const oldIndex=matrix.structure.split('|').indexOf(id);row.append(children[oldIndex+1]);}}matrix.structure=signature;if(focused?.isConnected){focused.focus({preventScroll:true});if(selection&&selection.start!==null)focused.setSelectionRange(selection.start,selection.end);}}if(matrix.structure!==signature||!table.querySelector('thead tr')){matrix.structure=signature;const head=el('tr',{},el('th',{scope:'col',text:'Параметр'})),body=table.querySelector('tbody');body.replaceChildren();table.querySelector('thead').replaceChildren(head);const hostrow=el('tr',{'data-overview-extra':'host'},el('th',{scope:'row',text:'Основной сайт'}));body.append(hostrow);for(const target of state.targets){const entry=matrixEntry(target.id);entry.statusNode=el('p',{class:'matrix-status',role:'status'});entry.readAtNode=el('p',{class:'matrix-read-at text-muted'});entry.reload=button('Обновить',()=>{if(entry.dirty&&!confirm('Загрузить сохранённые настройки и сбросить черновик '+target.domain+'?'))return;return refreshOverviewTarget(target.id,true);});entry.reload.setAttribute('aria-label','Обновить настройки '+target.id);head.append(el('th',{'data-overview-target':target.id},domainHeading(target),el('div',{class:'text-muted matrix-environment',text:target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено')}),entry.statusNode,entry.readAtNode,entry.reload));const cell=entry.cells.get('host')||el('td',{'data-matrix-target':target.id});entry.cells.set('host',cell);hostrow.append(cell);}
for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{'aria-hidden':'true'}));for(const target of state.targets){const cell=matrixEntry(target.id).cells.get(marker)||el('td',{'data-matrix-target':target.id,'data-marker':marker});matrixEntry(target.id).cells.set(marker,cell);row.append(cell);}body.append(noteEntry(marker).row,row);}
@@ -141,7 +143,7 @@
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'?'Обновите настройки источника после ошибки чтения.':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.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'),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)?'домена':'доменов');}
async function copySettings(){
if($('copy-submit').disabled)return;
const from=matrixEntry(matrix.copySource),ids=[...matrix.copyTargets],generation=state.sessionGeneration,operation=++matrix.copyEpoch;
+1
View File
@@ -56,6 +56,7 @@ final class Config
}
public static function validate(\stdClass $config, callable $setExists,?array $catalog=null): array {
self::assertWritable($config);
PercentWeights::validate($config);
if (isset($config->host)) {
$host = $config->host;
if (!is_string($host) || !preg_match('~^(?:https://)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$~iD',$host) || str_contains($host,'..')) throw new Problem('Адрес сайта должен быть HTTPS-хостом без пути.');
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Явная миграция #28: заменяет исключительно числовые токены известных весов. */
final class PercentMigration
{
private const KEYS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus'];
public static function patch(string $raw): string
{
$config=Config::decode($raw);
$offset=0;
$tree=self::node($raw,$offset);
$utm=self::property($tree,'utm');
if($utm===null||$utm['type']!=='object')throw new Problem('Миграция: отсутствует объект utm.');
foreach([['reg','registration'],['banch','chob','bchoice']] as $aliases){
$present=array_filter($aliases,static fn($key)=>self::property($utm,$key)!==null);
if(count($present)>1)throw new Problem('Миграция: конфликт UTM-псевдонимов.');
}
// Запрещаем потерю положительных значений при json_decode (underflow).
foreach($utm['properties'] as $key=>$setting){
if(!in_array((string)$key,self::KEYS,true))continue;
$weights=self::property($setting,'weights');
if($weights===null||$weights['type']!=='object')throw new Problem('Миграция: отсутствует объект весов '.$key.'.');
foreach($weights['properties'] as $variant=>$node){
$token=substr($raw,$node['start'],$node['end']-$node['start']);
if($node['type']!=='number')throw new Problem('Миграция: вес должен быть числом.');
$number=json_decode($token,false,64,JSON_THROW_ON_ERROR);
if(!is_finite((float)$number)||((float)$number===0.0&&preg_match('/[1-9]/',explode('e',strtolower($token))[0])))throw new Problem('Миграция: вес вне безопасного числового диапазона.');
}
}
$normalized=PercentWeights::normalizeRelative($config);
PercentWeights::validate($normalized);
$replacements=[];
foreach($utm['properties'] as $key=>$setting){
if(!in_array((string)$key,self::KEYS,true))continue;
$weights=self::property($setting,'weights');
foreach($weights['properties'] as $variant=>$node){
$value=$normalized->utm->$key->weights->$variant;
$old=$config->utm->$key->weights->$variant;
if((float)$old===(float)$value)continue;
$text=json_encode($value,JSON_THROW_ON_ERROR);
$replacements[]=[$node['start'],$node['end'],$text];
}
}
usort($replacements,static fn($a,$b)=>$b[0]<=>$a[0]);
foreach($replacements as [$start,$end,$text])$raw=substr_replace($raw,$text,$start,$end-$start);
PercentWeights::validate(Config::decode($raw));
return $raw;
}
/** Грамматика предварительно проверена Config::decode; диапазоны аналогичны ensure-utm-config.php. */
private static function node(string $raw,int &$offset): array
{
$length=strlen($raw);
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
$start=$offset;$char=$raw[$offset]??'';
if($char==='"'){
$offset++;
while($offset<$length){if($raw[$offset]==='\\'){$offset+=2;continue;}if($raw[$offset++]==='"')break;}
return ['start'=>$start,'end'=>$offset,'type'=>'string','value'=>json_decode(substr($raw,$start,$offset-$start),true,64,JSON_THROW_ON_ERROR)];
}
if($char==='{'||$char==='['){
$object=$char==='{';$close=$object?'}':']';$offset++;$properties=[];
while(true){
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
if(($raw[$offset]??'')===$close){$offset++;break;}
if($object){
$key=self::node($raw,$offset)['value'];
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
$offset++;
if(array_key_exists($key,$properties))throw new Problem('Миграция: повторный JSON-ключ; запись запрещена.');
$properties[$key]=self::node($raw,$offset);
}else{self::node($raw,$offset);}
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
if(($raw[$offset]??'')===','){$offset++;continue;}
$offset++;break;
}
return ['start'=>$start,'end'=>$offset,'type'=>$object?'object':'array','properties'=>$properties];
}
while($offset<$length&&!str_contains(",]} \t\r\n",$raw[$offset]))$offset++;
return ['start'=>$start,'end'=>$offset,'type'=>preg_match('/^-?[0-9]/',substr($raw,$start,$offset-$start))?'number':'scalar'];
}
private static function property(array $node,string $key): ?array {return $node['properties'][$key]??null;}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Контракт хранимых процентов. Миграция вызывается только явно, отдельно от чтения и записи. */
final class PercentWeights
{
public const GROUPS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus'];
private static function groups(\stdClass $config): array
{
if (!property_exists($config,'utm')) return [];
if (!$config->utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
$groups=[];
foreach (get_object_vars($config->utm) as $key=>$group) {
if (!in_array($key,self::GROUPS,true)) continue;
if (!$group instanceof \stdClass || !property_exists($group,'weights') || !$group->weights instanceof \stdClass) throw new Problem("Веса UTM {$key} должны быть объектом процентов.");
$groups[$key]=$group;
}
return $groups;
}
private static function number(mixed $value,string $key): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float)$value) || $value<0) throw new Problem("Вес UTM {$key} должен быть конечным неотрицательным числом процентов.");
return (float)$value;
}
/** Допуск покрывает только погрешность двоичного float при переводе в сотые процента. */
private static function basisPoints(float $value,string $key): int
{
if ($value!==round($value,2)) throw new Problem("Вес UTM {$key} должен содержать не более двух знаков после запятой.");
$scaled=$value*100;$rounded=round($scaled);
if (($value>0 && $rounded===0.0) || abs($scaled-$rounded)>PHP_FLOAT_EPSILON*max(1.0,abs($scaled))*8) throw new Problem("Вес UTM {$key} должен содержать не более двух знаков после запятой; точные доли должны представляться с шагом 0,01%.");
return (int)$rounded;
}
public static function validate(\stdClass $config): void
{
foreach (self::groups($config) as $key=>$group) {
$sum=0;
foreach (get_object_vars($group->weights) as $weight) {
$value=self::number($weight,$key);
if ($value>100) throw new Problem("Вес UTM {$key} должен быть от 0 до 100%.");
$sum+=self::basisPoints($value,$key);
if ($sum>10000) throw new Problem("Сумма весов UTM {$key} должна быть 100% либо все веса — 0%.");
}
if ($sum!==0 && $sum!==10000) throw new Problem("Сумма весов UTM {$key} должна быть 100% либо все веса — 0%. Старые относительные веса требуют явной миграции.");
}
}
/** Только для явной миграции: округляем каждую долю, остаток не перераспределяем. */
public static function normalizeRelative(\stdClass $config): \stdClass
{
// Клонирование не через JSON: сохраняем типы чисел и проверяем INF/NAN сами.
$clone=static function(mixed $value) use (&$clone): mixed {
if ($value instanceof \stdClass) {$copy=new \stdClass();foreach (get_object_vars($value) as $key=>$item) $copy->$key=$clone($item);return $copy;}
if (is_array($value)) {foreach ($value as $key=>$item) $value[$key]=$clone($item);}
return $value;
};
$copy=$clone($config);
foreach (self::groups($copy) as $key=>$group) {
$values=[];$max=0.0;
foreach (get_object_vars($group->weights) as $variant=>$weight) {$values[$variant]=self::number($weight,$key);$max=max($max,$values[$variant]);}
if ($max===0.0) continue;
$sum=0.0;foreach ($values as $weight) $sum+=$weight/$max;
foreach ($values as $variant=>$weight) {
$percent=($weight/$max)/$sum*100;
if ($weight>0 && $percent===0.0) throw new Problem("Доля UTM {$key} слишком мала для точного представления с шагом 0,01%.");
$points=(int)round($percent*100);
$group->weights->{(string)$variant}=$points%100===0?intdiv($points,100):$points/100;
}
}
self::validate($copy);
return $copy;
}
}
+8 -2
View File
@@ -75,10 +75,16 @@ final class Transport
if(!is_string($key)||!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'=')))throw new Problem('Ключ SFTP хоста не совпадает.');
$secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY'))$secret=PublicKeyLoader::loadPrivateKey($secret);if(!$sftp->login($t['user'],$secret))throw new Problem('Не удалось войти в SFTP.');return $sftp;
}
public function test(): array {return $this->call(['op'=>'test']);}
public function test(): array {
$result=$this->call(['op'=>'test']);
$read=$this->readConfig();
if (!hash_equals($result['version'],$read['version'])) throw new Conflict();
PercentWeights::validate($read['config']);
return $result;
}
public function readConfig(): array {$r=$this->call(['op'=>'read']);return ['config'=>Config::decode($r['content']),'version'=>$r['version'],'content'=>$r['content']];}
public function writeConfig(string $version,string $content): array {
Config::assertWritable(Config::decode($content));
$config=Config::decode($content);Config::assertWritable($config);PercentWeights::validate($config);
$before=$this->call(['op'=>'read']);if(!hash_equals($version,$before['version']))throw new Conflict();
$this->store->backup($this->target['id'],$before['content']);
return $this->call(['op'=>'write','version'=>$version,'content'=>$content]);
+5 -5
View File
@@ -7,16 +7,16 @@ require dirname(__DIR__,2).'/components/utm-codec.php';
$resolverConfig=['utm'=>['indpic'=>['default'=>'2','weights'=>['1'=>0,'2'=>0]]]];
check(prelandResolveUtm(['indpic'=>'abcd2'],$resolverConfig)['digits']['indpic']==='2','configured zero-weight indpic remains selectable explicitly by actual resolver');
check(prelandResolveUtm(['indpic'=>'abcd0'],$resolverConfig)['digits']['indpic']==='1','actual generic resolver zero-sum selects first configured variant');
$legacy=Config::decode('{"host":"zoomzilla.ru","utm":{"registration":{"default":"before","weights":{"before":1,"after":0},"extra":true}}}');
$legacy=Config::decode('{"host":"zoomzilla.ru","utm":{"registration":{"default":"before","weights":{"before":100,"after":0},"extra":true}}}');
$changed=Config::patch($legacy,[['path'=>['utm','reg','default'],'value'=>'2']]);check($changed->utm->registration->default==='after'&&!isset($changed->utm->reg)&&$changed->utm->registration->extra===true,'canonical default adapts to legacy target without duplicate alias');
$changed=Config::patch($legacy,[['path'=>['utm','reg','weights'],'value'=>(object)['1'=>0,'2'=>2]]]);check($changed->utm->registration->weights->after===2,'numeric weight group adapts to legacy representation');
$modern=Config::decode('{"host":"zoomzilla.ru","utm":{"reg":{"default":"1","weights":{"1":1,"2":0}}}}');$changed=Config::patch($modern,[['path'=>['utm','registration','default'],'value'=>'after']]);check($changed->utm->reg->default==='2'&&!isset($changed->utm->registration),'legacy default adapts to modern target');
$modern=Config::decode('{"host":"zoomzilla.ru","utm":{"reg":{"default":"1","weights":{"1":100,"2":0}}}}');$changed=Config::patch($modern,[['path'=>['utm','registration','default'],'value'=>'after']]);check($changed->utm->reg->default==='2'&&!isset($changed->utm->registration),'legacy default adapts to modern target');
$changed=Config::patch($modern,[['path'=>['utm','registration','weights','before'],'value'=>5]]);check($changed->utm->reg->weights->{'1'}===5,'legacy weight path adapts to numeric target');
rejects(fn()=>Config::patch(Config::decode('{"utm":{"reg":{},"registration":{}}}'),[['path'=>['utm','reg','default'],'value'=>'2']]),'pre-existing two aliases rejected before adapter');
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":1,"9":0}}}}'),static fn($n)=>$n===1),'zero-weight indpic variant requires complete physical set');
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":100,"9":0}}}}'),static fn($n)=>$n===1),'zero-weight indpic variant requires complete physical set');
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"1","weights":{}}}}'),static fn()=>true)===[],'enum default does not invent weights-membership restriction');
$catalog=['host'=>'https://ZOOMZILLA.RU:443','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[['number'=>1,'name'=>'one']],'cons'=>[['number'=>1,'name'=>'one']]]];$store->write('catalog.json',$catalog);$cfg=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"1","weights":{"1":1}}}}');$fresh=Catalog::read($store,$cfg);check($fresh!==null&&Config::validate($cfg,static fn()=>true,$fresh)===[],'fresh matching catalog validates actual available number without fallback warning');
$invalid=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"9","weights":{"9":1}}}}');rejects(fn()=>Config::validate($invalid,static fn()=>true,$fresh),'fresh catalog rejects unknown number');$catalog['generated_at']=gmdate(DATE_ATOM,time()-181);$store->write('catalog.json',$catalog);check(Catalog::read($store,$cfg)===null&&count(Config::validate($invalid,static fn()=>true,null))===1,'stale catalog falls back to explicit unverified warning');
$catalog=['host'=>'https://ZOOMZILLA.RU:443','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[['number'=>1,'name'=>'one']],'cons'=>[['number'=>1,'name'=>'one']]]];$store->write('catalog.json',$catalog);$cfg=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"1","weights":{"1":100}}}}');$fresh=Catalog::read($store,$cfg);check($fresh!==null&&Config::validate($cfg,static fn()=>true,$fresh)===[],'fresh matching catalog validates actual available number without fallback warning');
$invalid=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"9","weights":{"9":100}}}}');rejects(fn()=>Config::validate($invalid,static fn()=>true,$fresh),'fresh catalog rejects unknown number');$catalog['generated_at']=gmdate(DATE_ATOM,time()-181);$store->write('catalog.json',$catalog);check(Catalog::read($store,$cfg)===null&&count(Config::validate($invalid,static fn()=>true,null))===1,'stale catalog falls back to explicit unverified warning');
$validPrepared=$sets->prepareZip($zipPath,1);$source=$sets->manifest($target,1);$desired=Sets::preparedManifest($validPrepared,1);
$original=file_get_contents($root.'/site/1/01_desk_1-source.jpg');$img=new Imagick();$img->newImage(1696,920,'#aabbcc');$img->setImageFormat('jpg');file_put_contents($root.'/site/1/01_desk_1-source.jpg',$img->getImagesBlob());$img->clear();$foreign=$sets->manifest($target,1);
rejects(fn()=>$sets->upload($target,1,$validPrepared,'CAS',$source,true),'set CAS rejects third-party manifest changed after read');check($sets->manifest($target,1)===$foreign,'set CAS preserves third-party images');
+2 -2
View File
@@ -3,7 +3,7 @@ declare(strict_types=1);
require __DIR__.'/backend.php';
use PrelandAdmin\{Config,Jobs,Sets,IndpicSetStorage,Auth,Problem};
function rejects(callable $call,string $message): void {try{$call();}catch(\Throwable $e){check(true,$message);return;}throw new RuntimeException('Accepted invalid input: '.$message);}
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"reg":{"default":"1","weights":{"1":1}},"registration":{"default":"before","weights":{"before":1}}}}'),static fn()=>true),'old/new UTM alias conflict rejected');
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"reg":{"default":"1","weights":{"1":100}},"registration":{"default":"before","weights":{"before":100}}}}'),static fn()=>true),'old/new UTM alias conflict rejected');
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"theme":{"default":"3","weights":{}}}}'),static fn()=>true),'invalid enum rejected');
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"degr":{"default":"1","weights":{"1":-1}}}}'),static fn()=>true),'negative UTM weight rejected');
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":0}}}}'),static fn()=>false),'incomplete default rejected');
@@ -28,7 +28,7 @@ $inflated=$root.'/inflated.zip';file_put_contents($inflated,rawZip([['1/01_desk_
$result=$sets->upload($target,1,$prepared,'Тестовый набор');check(str_contains($result['message'],'90'),'local set publication revalidates delivered files');
$sets->check($target,1);check(is_file($root.'/site/1/01_desk_1-source.jpg'),'set directory published');
check((fileperms($root.'/site/1')&0777)===0755,'published set directory is readable by independent web-server UID');
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"2","weights":{"1":1}}}}'),static fn()=>true),'default without weight variant rejected instead of runtime fallback');
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"2","weights":{"1":100}}}}'),static fn()=>true),'default without weight variant rejected instead of runtime fallback');
$kept=hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg');$badFiles=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename)$badFiles[$filename]=['data'=>base64_encode(file_get_contents($prepared.'/1/'.$filename)),'sha256'=>str_repeat('0',64)];
rejects(fn()=>$transport->call(['op'=>'set-write','number'=>1,'files'=>$badFiles,'expected_manifest'=>$sets->manifest($target,1)]),'delivery hash mismatch rejects activation');check(hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg')===$kept,'failed set publication preserves previous working image');
// Настоящий формат должен совпадать с расширением, даже если размеры верны.
+22 -12
View File
@@ -12,8 +12,8 @@ function fixture(string $id,string $json): void {global $paths;file_put_contents
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":3,"after":1},"source_only":true},"theme":{"default":2,"weights":{"1":0,"2":7}},"bchoice":{"default":"a","weights":{"b":2,"a":1}},"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":1},"target_only":{"keep":true}},"theme":{"default":"dark","weights":{"dark":1}},"chob":{"default":"b","weights":{"b":1}},"indpic":{"default":"9","weights":{"9":0}},"unknown":{"raw":"target"}}}';
$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);
@@ -30,8 +30,8 @@ try{
$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'}===3&&$a->utm->reg->weights->{'2'}===1&&$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===7&&$a->utm->chob->default==='a'&&!property_exists($a->utm,'bchoice'),'target semantic schema and alias preserved');
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');
@@ -41,23 +41,33 @@ try{
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":1}},"bchoice":{"default":"a","weights":{"a":1}}}}','{"utm":{"reg":null}}','{"utm":{"theme":{"default":"dark","weights":{"dark":1,"1":2}}}}','{"utm":{"focus":{"default":true,"weights":{"1":1}}}}','{"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":1}}}}');fixture('c','{"host":"keep.example","utm":{"focus":{"default":2,"weights":{"2":0},"meta":[]}},"x":{}}');
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":1}}}}');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']==='error'&&readCopy('c')['content']===$before&&count($store->backups('c'))===$backupCount,'copy validates physical indpic on recipient and fails before write/backup');
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']==='error'&&readCopy('c')['content']===$before&&count($store->backups('c'))===$backupCount,'copy validates physical indpic on recipient and fails before write/backup');
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();}
$jobs->retry($jobId,['c']);$jobs->run($jobId);copyCheck($jobs->get($jobId)['results'][0]['status']==='success','failed copy retries same snapshot after missing physical set is repaired');
fixture('source','{"host":"both.example","utm":{"tp":{"default":"2","weights":{"2":3}},"cons":{"default":1,"weights":{"1":0}},"chb":null}}');
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'}===3&&$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'}===1&&!property_exists($c['config']->utm,'chb')&&is_dir($paths['c'].'/1'),'absent source indpic preserves valid recipient group/set; source chb is excluded');
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":1}}}}');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']==='error'&&readCopy('c')['content']===$before,'copy validates target result against fresh catalog for copied host');
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']==='error'&&readCopy('c')['content']===$before,'copy validates target result against fresh catalog for copied host');
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":1}},"bchoice":{"default":"b","weights":{"b":1}}}}');[$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','{"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";
}finally{proc_terminate($server);proc_close($server);fclose($log);}
+1 -1
View File
@@ -4,7 +4,7 @@ require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Store,Transport,Remote,Jobs,Problem};
function obsoleteCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
function obsoleteReject(callable $call,string $message): void {try{$call();}catch(Problem $e){obsoleteCheck($e->status===422&&str_contains($e->getMessage(),'utm.chb'),$message);return;}throw new RuntimeException('Accepted obsolete key: '.$message);}
$legacy='{"host":"zoomzilla.ru","utm":{"chb":null,"unknown":{"raw":true},"cons":{"default":"2","weights":{"1":0,"2":7}}},"custom":{"chb":true}}';
$legacy='{"host":"zoomzilla.ru","utm":{"chb":null,"unknown":{"raw":true},"cons":{"default":"2","weights":{"1":0,"2":100}}},"custom":{"chb":true}}';
$raw=Config::decode($legacy);obsoleteCheck(property_exists($raw->utm,'chb')&&Config::decode(Config::encode($raw))->custom->chb===true,'legacy raw remains readable and encodable without migration');
obsoleteReject(fn()=>Config::validate($raw,static fn()=>true),'validation rejects exact deprecated key even null');
foreach([
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,PercentMigration,Problem};
function migrationCheck(bool $ok,string $label): void {if(!$ok)throw new RuntimeException($label);echo "PASS {$label}\n";}
function migrationReject(string $raw,string $label): void {try{PercentMigration::patch($raw);}catch(Problem){migrationCheck(true,$label);return;}throw new RuntimeException('Accepted '.$label);}
$raw="{\r\n \"host\":\"untouched.example\", \"requisites\": \"weights:1/3\",\n \"utm\": { \"r\\u0065gistration\":{\"default\": 2, \"weights\": {\"after\": 3e0, \"before\": 1.0}, \"extra\": {\"weights\":{\"x\":7}}},\n \"chob\": {\"weights\":{\"b\":0.8,\"a\":0.2},\"default\":\"b\"},\n \"theme\":{\"default\":\"2\",\"weights\":{\"2\":1,\"1\":0}},\n \"unknown\": {\"weights\":{\"1\":1,\"2\":3},\"nested\":[{\"escaped\\\"key\":\"weights\\\":1\"}]}} ,\"unknown\": 9e+10\r\n}\n";
$expected=str_replace(['"after": 3e0','"before": 1.0','"b":0.8','"a":0.2','"2":1,"1":0'],['"after": 75','"before": 25','"b":80','"a":20','"2":100,"1":0'],$raw);
$migrated=PercentMigration::patch($raw);
migrationCheck($migrated===$expected,'only expected numeric token spans change; every other byte stays exact');
$before=Config::decode($raw);$after=Config::decode($migrated);
foreach(['registration','chob','theme'] as $key){migrationCheck(array_keys(get_object_vars($before->utm->$key->weights))===array_keys(get_object_vars($after->utm->$key->weights)),'variant order and keys preserved '.$key);unset($before->utm->$key->weights,$after->utm->$key->weights);}
migrationCheck($before==$after,'all remaining JSON values/default types/aliases/unknown groups preserved');
migrationCheck(PercentMigration::patch($migrated)===$migrated,'second run is byte-identical');
foreach(['{"x":0,"y":0}','{"x":100.0,"y":0e0}'] as $weights){$source='{"utm":{"bchoice":{"default":1,"weights":'.$weights.'}}}';migrationCheck(PercentMigration::patch($source)===$source,'all-zero and already-percent tokens preserved');}
foreach([['1','3',25,75],['2','1',66.67,33.33],['1e308','1e308',50,50],['1e-300','3e-300',25,75]] as [$x,$y,$a,$b]){$source='{"utm":{"focus":{"weights":{"x":'.$x.',"y":'.$y.'}}}}';$out=Config::decode(PercentMigration::patch($source));migrationCheck((float)$out->utm->focus->weights->x===(float)$a&&(float)$out->utm->focus->weights->y===(float)$b,'safe normalization '.$x.':'.$y);}
foreach([
'{"utm":{"reg":{"weights":{"1":1,"2":1,"3":1}}}}',
'{"utm":{"reg":{"weights":{"1":"1","2":3}}}}',
'{"utm":{"reg":{"weights":{"1":-1,"2":3}}}}',
'{"utm":{"reg":{"weights":{"1":1e999,"2":3}}}}',
'{"utm":{"reg":{"weights":{"1":1e-999,"2":3}}}}',
'{"utm":{"reg":{"weights":{"1":1,"1":3}}}}',
'{"utm":{"reg":{"weights":{"1":1,"\\u0031":3}}}}',
'{"utm":{"reg":{"weights":{"1":1}}},"utm":{}}',
'{"utm":{"reg":{"weights":{"1":1}}},"extra":{"x":1,"x":2}}',
'{"utm":{"reg":{"weights":{"1":1}},"registration":{"weights":{"1":1}}}}',
'{"utm":{"theme":{"weights":[1,3]}}}',
'{"utm":null}',
] as $i=>$source)migrationReject($source,'unsafe/ambiguous input rejected '.$i);
echo "Raw percent migration complete\n";
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,PercentWeights,Problem,Store,Transport,Jobs,Copy};
function percentCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
function percentReject(callable $call,string $message): void {try{$call();}catch(Problem $e){percentCheck($e->status===422,$message);return;}throw new RuntimeException('Accepted: '.$message);}
function percentConfig(mixed $weights,string $key='theme'): stdClass {return (object)['utm'=>(object)[$key=>(object)['default'=>1,'weights'=>$weights,'extra'=>(object)['keep'=>true]]],'custom'=>(object)['weights'=>[1,2]]];}
foreach(['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus'] as $key)foreach([(object)['1'=>100],(object)['1'=>33.33,'2'=>66.67],(object)['1'=>0,'2'=>0],new stdClass()] as $weights)PercentWeights::validate(percentConfig($weights,$key));
percentCheck(true,'all canonical groups, aliases, two decimals and zero groups accepted');
$precisionCases=['{"1":33.33000000000001,"2":66.67}','{"1":99.9999999999999}','{"1":0.010000000000000002,"2":99.99}'];$acceptedPrecision=[];
foreach ($precisionCases as $json) {try {PercentWeights::validate(percentConfig(json_decode($json,false,16,JSON_THROW_ON_ERROR)));$acceptedPrecision[]=$json;echo "FAIL excessive precision accepted {$json}\n";}catch(Problem $e){percentCheck($e->status===422,'strict stored precision rejects '.$json);}}
percentCheck($acceptedPrecision===[],'strict precision does not use binary tolerance to accept extra submitted decimals');
foreach([(object)['1'=>1],(object)['1'=>33.333,'2'=>66.667],(object)['1'=>100.01],(object)['1'=>-1,'2'=>101],(object)['1'=>'100'],(object)['1'=>true],(object)['1'=>null],(object)['1'=>INF],(object)['1'=>NAN],(object)['1'=>50,'2'=>49.99],[],null] as $weights)percentReject(fn()=>PercentWeights::validate(percentConfig($weights)),'invalid stored weights rejected');
percentReject(fn()=>PercentWeights::validate((object)['utm'=>[]]),'invalid utm container rejected');
percentReject(fn()=>PercentWeights::validate((object)['utm'=>(object)['theme'=>null]]),'invalid known group rejected');
percentReject(fn()=>Config::validate(percentConfig((object)['1'=>1]),static fn()=>true),'Config validation rejects relative weights');
$source=percentConfig((object)['1'=>3,'2'=>1],'registration');$before=Config::encode($source);$migrated=PercentWeights::normalizeRelative($source);PercentWeights::validate($migrated);
percentCheck(get_object_vars($migrated->utm->registration->weights)===['1'=>75,'2'=>25],'explicit migration produces integer percentages');
$rounded=PercentWeights::normalizeRelative(percentConfig((object)['1'=>2,'2'=>1]));percentCheck(get_object_vars($rounded->utm->theme->weights)===['1'=>66.67,'2'=>33.33],'migration rounds each share without redistribution');
percentCheck(Config::encode($source)===$before&&$migrated->utm->registration->default===1&&$migrated->custom==$source->custom,'migration clones and preserves defaults types unknown data');
foreach([[1,1,1],[1.0e-300,1.0e300]] as $values)percentReject(fn()=>PercentWeights::normalizeRelative(percentConfig((object)$values)),'unrepresentable shares stop migration');
foreach([[1.0e308,1.0e308],[1.0e-300,1.0e-300]] as $values){$result=PercentWeights::normalizeRelative(percentConfig((object)$values));percentCheck(array_values(get_object_vars($result->utm->theme->weights))===[50,50],'normalization avoids overflow and underflow');}
$zero=percentConfig((object)['2'=>0,'1'=>0]);percentCheck(Config::encode(PercentWeights::normalizeRelative($zero))===Config::encode($zero),'all-zero order and default preserved');
$root=sys_get_temp_dir().'/percent-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store=new Store($root.'/state');$store->saveTarget(['id'=>'a','name'=>'A','domain'=>'a.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$transport=new Transport($store,$store->target('a'));
$old=Config::encode(percentConfig((object)['1'=>1]));file_put_contents($root.'/site/config.json',$old);percentReject(fn()=>$transport->test(),'connection test refuses relative weights');percentCheck($transport->readConfig()['content']===$old,'read returns old raw without normalization');$store->verifyTarget('a');$transport=new Transport($store,$store->target('a'));$jobs=new Jobs($store);$read=$transport->readConfig();
$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$read['version']]],'changes'=>[['path'=>['host'],'value'=>'new.example']]]);$jobs->run($job['id']);percentCheck($jobs->get($job['id'])['results'][0]['status']==='error'&&file_get_contents($root.'/site/config.json')===$old&&$store->backups('a')===[],'apply rejects old weights before publication and backup');
$backup=$store->backup('a',$old);$valid=Config::encode(percentConfig((object)['1'=>100]));file_put_contents($root.'/site/config.json',$valid);$backups=$store->backups('a');$read=$transport->readConfig();$job=$jobs->create('restore',['target'=>'a','version'=>$read['version'],'id'=>$backup]);$jobs->run($job['id']);$out=$jobs->get($job['id'])['results'][0];percentCheck($out['status']==='error'&&str_contains($out['message'],'100')&&file_get_contents($root.'/site/config.json')===$valid&&$store->backups('a')===$backups&&$store->backupContent('a',$backup)===$old,'restore rejects historical relative backup with clear error and retains exact archives');
percentCheck($transport->test()['ok']===true,'valid percentages pass actual connection test');
$saved=$transport->readConfig();$backupCount=count($store->backups('a'));
foreach ([(object)['1'=>33.333,'2'=>66.667],(object)['1'=>99.99],(object)['1'=>'100']] as $weights) {
$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$saved['version']]],'changes'=>[['path'=>['utm','theme','weights'],'value'=>$weights]]]);$jobs->run($job['id']);
percentCheck($jobs->get($job['id'])['results'][0]['status']==='error'&&$transport->readConfig()['content']===$saved['content']&&count($store->backups('a'))===$backupCount,'submitted precision, sum and type errors reject without normalization or publication');
}
$weights=(object)['1'=>33.33,'2'=>66.67];$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$saved['version']]],'changes'=>[['path'=>['utm','theme','weights'],'value'=>$weights]]]);$jobs->run($job['id']);$saved=$transport->readConfig();
percentCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$saved['config']->utm->theme->weights==$weights&&$saved['config']->utm->theme->default===1,'actual apply saves exact submitted percentages and preserves numeric default');
require dirname(__DIR__,2).'/components/utm-codec.php';
foreach(['theme','indpic'] as $key)foreach([(object)['1'=>3,'2'=>1],(object)['2'=>0,'1'=>0]] as $weights){$relative=percentConfig($weights,$key);$percent=PercentWeights::normalizeRelative($relative);mt_srand(2828);$a=[];for($i=0;$i<500;$i++)$a[]=prelandResolveUtm([$key=>'abcd0'],json_decode(Config::encode($relative),true));mt_srand(2828);$b=[];for($i=0;$i<500;$i++)$b[]=prelandResolveUtm([$key=>'abcd0'],json_decode(Config::encode($percent),true));percentCheck($a===$b,'actual seeded runtime resolver invariant across explicit normalization for '.$key);}
echo "Percent weights contract complete\n";
+5 -3
View File
@@ -12,17 +12,19 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st
const d=readFile('d');delete d.utm.cons;writeFile('d',d);
await page.goto('/');assert.match(await page.title(),/Прелендинги/);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 saved(id);csrf=(await request('state')).csrf;
assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('.matrix-read-at').count(),4);assert.equal(await page.locator('#overview').getByText(/requisites|chb/).count(),0);
const weights=cell('a','theme').locator('input[type=number]');await weights.nth(0).fill('3');await weights.nth(1).fill('1');assert.deepEqual(await cell('a','theme').locator('.matrix-percent').allTextContents(),['75%','25%']);await weights.nth(0).fill('0');await weights.nth(1).fill('0');assert.deepEqual(await cell('a','theme').locator('.matrix-percent').allTextContents(),['0%','0%']);await cancel('a').click();assert.deepEqual(await cell('a','theme').locator('.matrix-percent').allTextContents(),['25%','75%']);
const weights=cell('a','theme').locator('input[type=number]');assert.equal(await page.locator('.matrix-percent').count(),0);assert.equal(await weights.first().getAttribute('min'),'0');assert.equal(await weights.first().getAttribute('max'),'100');assert.equal(await weights.first().getAttribute('step'),'0.01');assert.match(await cell('a','theme').locator('.matrix-labels').innerText(),/Вес, %/);
const defaultBefore=readFile('a').utm.theme.default;await weights.nth(0).fill('33.33');assert.equal(await weights.nth(1).inputValue(),'75','typing never redistributes other weights');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/сумма/);await weights.nth(1).fill('66.67');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a','theme').locator('.matrix-total').innerText(),'Сумма: 100%');await save('a').click();await saved('a');assert.deepEqual(readFile('a').utm.theme.weights,{'1':33.33,'2':66.67});assert.equal(readFile('a').utm.theme.default,defaultBefore);await page.getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');assert.equal(await weights.first().inputValue(),'33.33');
await weights.first().fill('33.333');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/двух знаков/);await weights.first().fill('0.00000000001');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/двух знаков/);await weights.first().fill('-1');assert.equal(await save('a').isDisabled(),true);await weights.first().fill('101');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/от 0 до 100/);await weights.first().fill('');assert.equal(await save('a').isDisabled(),true);await weights.first().fill('0');await weights.nth(1).fill('0');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a','theme').locator('.matrix-total').innerText(),'Сумма: 0%');await save('a').click();await saved('a');assert.deepEqual(readFile('a').utm.theme.weights,{'1':0,'2':0});assert.equal(readFile('a').utm.theme.default,defaultBefore);await weights.first().fill('25');await weights.nth(1).fill('75');await save('a').click();await saved('a');
assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] button').count(),0);
assert.equal(await cell('d','cons').innerText(),'Группа отсутствует');assert.equal(readFile('d').utm.cons,undefined);
const beforeDefault=readFile('a');await cell('a','theme').locator('input[type=radio]').first().check();await save('a').click();await saved('a');assert.equal(await header('a').locator('.matrix-status').innerText(),'Записано на хостинг');assert.deepEqual(readFile('a').utm.theme.weights,beforeDefault.utm.theme.weights);assert.equal(readFile('a').utm.theme.default,'1');assert.equal(typeof readFile('a').utm.theme.default,typeof beforeDefault.utm.theme.default);await cell('a','theme').locator('input[type=radio]').nth(1).check();await save('a').click();await saved('a');assert.deepEqual(readFile('a'),beforeDefault);
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('7');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,7);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();
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();
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('5');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(),'5');await context.setOffline(false);await save('b').click();await saved('b');assert.equal(readFile('b').utm.theme.weights.dark,5);
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();
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);
+2 -2
View File
@@ -23,12 +23,12 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st
const save=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();const body=await(await response).json();assert.ok(body.ok,body.error);await textarea().waitFor({state:'hidden'});return body.note;};
try{
await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'});
await page.locator('#overview-table tr[data-overview-utm=reg] td .matrix-percent').first().waitFor({timeout:6000});
await page.locator('#overview-table tr[data-overview-utm=reg] td .matrix-total').first().waitFor({timeout:6000});
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const header=page.locator('tr[data-utm-note='+marker+']'),data=page.locator('tr[data-overview-utm='+marker+']');assert.equal(await header.locator('th').innerText(),marker);assert.equal(await data.locator('th').innerText(),'');assert.equal(await data.evaluate(n=>n.previousElementSibling.dataset.utmNote),marker);}
const state=await request('state');csrf=state.csrf;const snapshots=await Promise.all(state.targets.map(async t=>{const r=await page.request.get('/api/config?target='+t.id).then(r=>r.json());return {id:t.id,...r};}));const jobsBefore=(await request('jobs')).jobs.map(j=>j.id).sort();
const enumMap={theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'}};
const alias={reg:['registration'],banch:['chob','bchoice']};
for(let index=0;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index),expected=items.map(([,w])=>(sum>0?Number((w/sum*100).toFixed(2)):0)+'%');assert.deepEqual(await cell.locator('.matrix-percent').allTextContents(),expected);assert.equal(await cell.locator('input[type=radio]:checked').count(),1);for(const label of await cell.locator('.matrix-default').all())assert.ok(await label.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default normal weight');}} await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'});
for(let index=0;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index),expected=items.map(([,w])=>String(w));assert.deepEqual(await cell.locator('input[type=number]').evaluateAll(ns=>ns.map(n=>n.value)),expected);assert.equal(await cell.locator('.matrix-percent').count(),0);assert.equal(await cell.locator('.matrix-total').innerText(),'Сумма: '+sum+'%');assert.equal(await cell.locator('input[type=radio]:checked').count(),1);for(const label of await cell.locator('.matrix-default').all())assert.ok(await label.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default normal weight');}} await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'});
await enterEdit();const draft='Заметка UI — '+Date.now()+'\n<img src=x onerror=alert(1)>\nТолько обычный текст';await textarea().fill(draft);const handle=await textarea().elementHandle();await textarea().evaluate(n=>{document.querySelector('#overview-refresh').click();n.focus();n.setSelectionRange(7,11);});await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);assert.equal(await textarea().inputValue(),draft);assert.deepEqual(await handle.evaluate(n=>({connected:n.isConnected,focused:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focused:true,start:7,end:11});
await save();assert.equal((await request('utm-notes')).notes.reg.text,draft);assert.equal(await row().locator('img').count(),0);await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await row().getByText(draft,{exact:true}).waitFor();
const newer=draft+'\nСохранено после начала старого GET';
+6
View File
@@ -0,0 +1,6 @@
/* 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);
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)});
+1 -1
View File
@@ -5,6 +5,6 @@ const login=async()=>{await page.getByLabel('Логин',{exact:true}).fill('adm
const logout=async()=>{await page.getByRole('button',{name:'Выйти',exact:true}).click();await page.locator('#login-panel').waitFor({state:'visible'});};
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(String(Number(await weight.inputValue())+1));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');
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');
}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)});
+1 -1
View File
@@ -8,7 +8,7 @@ $adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.
<main class="container"><div id="notice" role="status" aria-live="polite" hidden></div>
<section id="login-panel" class="panel login-panel"><h1>Вход в админку</h1><form id="login-form"><label>Логин<input class="form-control" name="username" autocomplete="username" required></label><label>Пароль<input class="form-control" name="password" type="password" autocomplete="current-password" required></label><label class="checkbox remember-choice"><input name="remember" type="checkbox"> Запомнить меня на 90 дней</label><button class="btn btn-primary" type="submit">Войти</button></form></section>
<div id="workspace" hidden><h1>Настройка прелендингов</h1><nav class="tabs" aria-label="Разделы"><a href="#overview">Настройки</a><a href="#copy-panel">Копирование</a><a href="#link-builder">Конструктор ссылки</a><a href="#indpic">indpic</a><a href="#connections">Подключения</a><a href="#history">Журнал и резерв</a></nav>
<section id="overview" class="panel"><h2>Настройки доменов</h2><p class="text-muted">Редактируйте веса и default прямо в ячейках. Проценты рассчитываются из весов. Сохранение — отдельно для каждого домена.</p><div class="overview-toolbar"><button id="overview-refresh" type="button" class="btn btn-default">Обновить настройки</button><button id="hidden-toggle" type="button" class="hidden-link">Скрытые домены (0)</button></div><div class="table-responsive overview-wrapper"><table id="overview-table" class="table table-bordered overview-table"><caption class="text-muted">Таблицу можно прокручивать по горизонтали.</caption><thead></thead><tbody></tbody></table></div></section>
<section id="overview" class="panel"><h2>Настройки доменов</h2><p class="text-muted">Редактируйте веса в процентах и default прямо в ячейках. Сумма группы — 100% либо все веса — 0%. Допустимо до двух знаков после запятой. Сохранение — отдельно для каждого домена.</p><div class="overview-toolbar"><button id="overview-refresh" type="button" class="btn btn-default">Обновить настройки</button><button id="hidden-toggle" type="button" class="hidden-link">Скрытые домены (0)</button></div><div class="table-responsive overview-wrapper"><table id="overview-table" class="table table-bordered overview-table"><caption class="text-muted">Таблицу можно прокручивать по горизонтали.</caption><thead></thead><tbody></tbody></table></div></section>
<section id="hidden-domains" class="panel" hidden><div class="overview-toolbar"><h2>Скрытые домены</h2><button id="hidden-close" type="button" class="hidden-link">Вернуться к настройкам</button></div><p class="text-muted">Скрытые домены убраны из рабочих списков. Сайт и файлы продолжают работать.</p><div id="hidden-list"></div></section><section id="copy-panel" class="panel"><h2>Копировать настройки</h2><div class="copy-grid"><label>Откуда<select id="copy-source" class="form-control"><option value="">Выберите домен</option></select><span class="text-muted copy-hint">Берём настройки, уже сохранённые на домене.</span></label><div><strong>Куда</strong><div id="copy-targets" class="target-options"></div><div class="toolbar"><button id="copy-all" type="button" class="btn btn-default btn-xs">Выбрать все</button><button id="copy-none" type="button" class="btn btn-default btn-xs">Снять выбор</button></div></div></div><div class="copy-bottom"><p class="text-muted">Основной сайт и все UTM: варианты, веса и default.<br>Заметки и файлы наборов изображений не копируются. Отсутствующая группа источника сохраняет группу получателя.</p><button id="copy-submit" class="btn btn-primary" type="button" disabled>Копировать в 0 доменов</button></div><div id="copy-results" aria-live="polite"></div><p id="copy-reason" class="text-muted" role="status" aria-live="polite"></p></section><section id="link-builder" class="panel"><h2>Конструктор ссылки</h2><p class="text-muted">Выберите значения UTM или «Рандом». Последний сформированный выбор хранится отдельно для каждого домена. Конфигурация, веса и default не меняются.</p><label class="builder-domain">Домен ссылки<select id="builder-target" class="form-control"><option value="">Выберите домен</option></select></label><p id="builder-state" class="text-muted" role="status" aria-live="polite">Выберите домен.</p><div id="builder-fields" class="form-grid"></div><div id="builder-warnings" aria-live="polite"></div><button id="builder-save" type="button" class="btn btn-primary" disabled>Сформировать и сохранить</button><div id="builder-result" hidden><label>Готовая ссылка<input id="builder-url" class="form-control" type="url" readonly spellcheck="false"></label><div class="toolbar"><button id="builder-copy" type="button" class="btn btn-default">Скопировать</button><a id="builder-open" class="btn btn-default" target="_blank" rel="noopener noreferrer">Открыть ссылку</a></div><p id="builder-saved-at" class="text-muted"></p></div></section>
<section id="indpic" class="panel"><h2>indpic= UTM</h2><p class="text-muted">Выбор домена и целей ниже относится только к наборам и резервным копиям. Загрузка не меняет default и веса.</p><div class="toolbar"><label class="grow">Открытый домен<select id="source-target" class="form-control"><option value="">Выберите домен</option></select></label><button id="reload" class="btn btn-default" type="button">Перечитать</button></div><p id="read-state" class="text-muted">Выберите домен.</p><p class="text-muted">Цели загрузки ZIP и проверки</p><div id="targets" class="target-options"></div><div class="table-responsive"><table class="table table-bordered table-hover indpic-sets-table"><thead><tr><th>Номер / название</th><th class="indpic-default-column">Default</th><th class="indpic-check-column">Проверка</th><th>ZIP</th><th class="indpic-actions-column">Действия</th></tr></thead><tbody id="sets-body"></tbody></table></div><h3>Добавить Set</h3><form id="set-add" class="indpic-add-form"><label>№<input name="number" class="form-control indpic-number-input" type="number" min="1" max="9" required></label><label>Название Set<input name="name" class="form-control" maxlength="255"></label><label class="checkbox"><input name="sftp" type="checkbox"> Загружен по SFTP</label><label class="zip-field">ZIP<input name="file" type="file" accept=".zip,application/zip" class="form-control" required></label><button class="btn btn-default">Загрузить и добавить</button></form></section>
<section id="connections" class="panel"><h2>Домены и подключения</h2><p class="text-muted">Удаление подключения оставляет сайт и файлы на хостинге. Секрет можно заменить; прочитать сохранённый секрет нельзя.</p><div id="connection-list"></div><h3 id="connection-title">Добавить подключение</h3><form id="connection-form"><div class="form-grid"><label>ID<input name="id" class="form-control" required pattern="[A-Za-z0-9_-]+"></label><label>Название<input name="name" class="form-control" required></label><label>Домен<input name="domain" class="form-control" required></label><label>Окружение<select name="environment" class="form-control"><option value="stage">stage</option><option value="production">production</option></select></label><label>Тип<select name="type" class="form-control"><option value="sftp">SFTP / SSH</option><option value="local">Локальная папка</option></select></label><label>Хост SSH<input name="host" class="form-control"></label><label>Порт<input name="port" type="number" min="1" max="65535" value="22" class="form-control"></label><label>Пользователь SSH<input name="user" class="form-control"></label><label>Корневая папка<input name="root" class="form-control" required></label><label>Проверенный ключ хоста<input name="host_key" class="form-control" autocomplete="off"></label><label>Новый секрет / пароль<input name="secret" type="password" class="form-control" autocomplete="new-password"></label><label class="checkbox"><input name="enabled" type="checkbox"> Включено для записи</label></div><div class="toolbar"><button class="btn btn-primary">Сохранить подключение</button><button class="btn btn-default" id="connection-new" type="button">Очистить форму</button></div></form></section>