api#347: сохранить part в именованных наборах и процентных контрактах

This commit is contained in:
Panfilov Konstantin committed 2026-10-28 05:06:11 +03:00
1 parent 1993f7b4b1
commit f7d1792e23
10 files changed
+17 -13

No files matched your search

+1 -1
View File
@@ -53,7 +53,7 @@ $targets = $linkStore->read('targets.json');$targets['first']['domain']='renamed
$renamed = $builder->get('first');
linkCheck(str_starts_with($renamed['url'],'https://renamed.example/?') && !str_contains($renamed['url'],'first.example'), 'domain rename rebases saved URL to current origin');
linkCheck($renamed['selection']===$modified && $renamed['saved_at']===$updated['saved_at'] && parse_url($renamed['url'],PHP_URL_QUERY)===parse_url($updated['url'],PHP_URL_QUERY), 'domain rename preserves selection, timestamp and existing query noise');
foreach (array_keys($selection) as $key) {
foreach (array_diff(array_keys($selection),['part']) as $key) {
$manualSelection=array_fill_keys(array_keys($selection),'rnd');$manualSelection[$key]='9';
$manual=$builder->save('second',$manualSelection);parse_str(parse_url($manual['url'],PHP_URL_QUERY),$manualParameters);
linkCheck($builder->get('second')===$manual&&$manual['selection'][$key]==='9'&&$manualParameters[$key][4]==='9','manual digit 9 encodes and persists for '.$key);
+4 -4
View File
@@ -5,17 +5,17 @@ require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Copy,LinkBuilder,Notes,Store,Problem};
function partCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS $message\n";}
$valid=Config::decode('{"host":"keep.example","metadata":{"keep":true},"utm":{"part":{"default":"2","weights":{"1":0,"2":100}},"unknown":{"keep":true}}}');
Config::validate($valid,static fn()=>true);
foreach (['{"default":"3","weights":{}}','{"default":"1","weights":{"9":1}}','{"default":"1","weights":{"2":-1}}'] as $group) {
Config::validate($valid);
foreach (['{"default":"3","weights":{}}','{"default":"1","weights":{"9":1}}','{"default":"1","weights":{"2":-1}}','{"default":"1","weights":{"1":1}}'] as $group) {
$invalid=Config::decode('{"utm":{"part":'.$group.'}}');$rejected=false;
try {Config::validate($invalid,static fn()=>true);}catch(Problem){$rejected=true;}
try {Config::validate($invalid);}catch(Problem){$rejected=true;}
partCheck($rejected,'invalid part enum/weights rejected');
}
$project=new ReflectionMethod(Copy::class,'project');$changes=$project->invoke(null,$valid);
$target=Config::decode('{"host":"before.example","notes":"keep","utm":{"part":{"default":"1","weights":{"1":1},"metadata":"keep"},"unknown":{"keep":true}}}');
$patched=Config::patch($target,$changes);
partCheck($patched->utm->part->default==='2'&&$patched->utm->part->weights->{'2'}===100,'copy includes part default and weights');
partCheck($patched->notes==='keep'&&$patched->utm->part->metadata==='keep'&&$patched->utm->unknown->keep===true,'copy preserves metadata and unknown groups');
partCheck($patched->host==='before.example'&&$patched->notes==='keep'&&$patched->utm->part->metadata==='keep'&&$patched->utm->unknown->keep===true,'copy preserves metadata and unknown groups');
$root=sys_get_temp_dir().'/part347-'.bin2hex(random_bytes(6));$store=new Store($root);
$notes=new Notes($store);$old=$notes->all();unset($old['part']);$store->write('utm-notes.json',$old);
$loaded=$notes->all();partCheck(isset($loaded['part'])&&array_diff_key($loaded,['part'=>true])===$old,'old notes gain empty part without losing notes/versions');
+3
View File
@@ -29,3 +29,6 @@ foreach([
'{"utm":null}',
] as $i=>$source)migrationReject($source,'unsafe/ambiguous input rejected '.$i);
echo "Raw percent migration complete\n";
$partRaw='{"metadata":"keep","utm":{"part":{"default":"2","weights":{"1":1,"2":3}}}}';
migrationCheck(PercentMigration::patch($partRaw)===str_replace('{"1":1,"2":3}','{"1":25,"2":75}',$partRaw),'part relative weights normalize only explicit numeric spans');
+1 -1
View File
@@ -5,7 +5,7 @@ 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));
foreach(['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus','part'] 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);}}
+1 -1
View File
@@ -2,7 +2,7 @@
module.exports=async function choosePreset(page,id){
const state=await page.request.get('/api/state').then(r=>r.json());
const read=await page.request.get('/api/config?target='+id).then(r=>r.json()),utm={};
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const aliases=marker==='reg'?['reg','registration']:marker==='banch'?['banch','chob','bchoice']:[marker];const key=aliases.find(k=>Object.hasOwn(read.config.utm||{},k));if(key)utm[marker]={default:read.config.utm[key].default,weights:read.config.utm[key].weights};}
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus','part']){const aliases=marker==='reg'?['reg','registration']:marker==='banch'?['banch','chob','bchoice']:[marker];const key=aliases.find(k=>Object.hasOwn(read.config.utm||{},k));if(key)utm[marker]={default:read.config.utm[key].default,weights:read.config.utm[key].weights};}
const response=await page.request.post('/api/utm-presets',{headers:{'X-CSRF-Token':state.csrf},data:{name:'UI-'+Date.now()+'-'+Math.random(),utm}}),saved=await response.json();
if(!saved.ok)throw Error(saved.error);await page.locator('#preset-reload').click();await page.waitForFunction(id=>[...document.querySelector('#copy-source').options].some(o=>o.value===id),saved.preset.id);await page.locator('#copy-source').selectOption(saved.preset.id);return saved.preset.id;
};
+3 -3
View File
@@ -5,14 +5,14 @@ use PrelandAdmin\{UtmPresets,Store,Copy,Config,Jobs,Transport,Problem};
function checkPreset(bool $ok,string $label):void{if(!$ok)throw new RuntimeException($label);echo "PASS $label\n";}
function rejectsPreset(callable $f,string $label):void{try{$f();}catch(Problem){checkPreset(true,$label);return;}throw new RuntimeException($label);}
$root=sys_get_temp_dir().'/presets-'.bin2hex(random_bytes(6));mkdir($root,0700);$store=new Store($root.'/state');$presets=new UtmPresets($store);
$utm=Config::decode('{"theme":{"default":"3","weights":{"1":33.33,"3":66.67}},"cons":{"default":2,"weights":{"1":0,"2":0}}}');
$utm=Config::decode('{"theme":{"default":"3","weights":{"1":33.33,"3":66.67}},"cons":{"default":2,"weights":{"1":0,"2":0}},"part":{"default":"2","weights":{"1":0,"2":100}}}');
$saved=$presets->create(['name'=>'Осень','utm'=>$utm]);
checkPreset(array_keys($saved)===['id','name','utm'],'preset has no source domain or target binding');
$utm->theme->weights->{'1'}=100;checkPreset($presets->get($saved['id'])['utm']->theme->weights->{'1'}===33.33,'draft is an immutable independent snapshot');
checkPreset((new UtmPresets(new Store($store->dir)))->get($saved['id'])['name']==='Осень','preset survives store reload');
foreach([['name'=>'','utm'=>$saved['utm']],['name'=>' ','utm'=>$saved['utm']],['name'=>'осень','utm'=>$saved['utm']],['name'=>'x','utm'=>$saved['utm'],'domain'=>'a.invalid'],['name'=>'x','utm'=>(object)[]],['name'=>'x','utm'=>(object)['theme'=>(object)['default'=>1,'weights'=>(object)['1'=>80]]]],['name'=>'x','utm'=>(object)['theme'=>(object)['default'=>1,'weights'=>(object)['1'=>100],'note'=>'secret']]],['name'=>'x','utm'=>(object)['unknown'=>(object)[]]]] as $bad)rejectsPreset(fn()=>$presets->create($bad),'strict independent preset validation');
$paths=[];foreach(['a','b'] as $id){$paths[$id]=$root.'/'.$id;mkdir($paths[$id]);}putenv('ADMIN_LOCAL_ROOTS='.implode(',',$paths));
$raw='{"host":"keep.invalid","utm":{"theme":{"default":"dark","weights":{"dark":100},"note":"keep"},"reg":{"default":2,"weights":{"2":100}},"custom":{"a":1}},"requisites":["keep"],"unknown":{}}';
$raw='{"host":"keep.invalid","utm":{"theme":{"default":"dark","weights":{"dark":100},"note":"keep"},"reg":{"default":2,"weights":{"2":100}},"part":{"default":"1","weights":{"1":100},"metadata":{"keep":true}},"custom":{"a":1}},"requisites":["keep"],"unknown":{}}';
foreach($paths as $id=>$path){file_put_contents($path.'/config.json',$raw);$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$path,'enabled'=>true]);$store->verifyTarget($id);}
$store->write('utm-notes.json',['theme'=>['text'=>'keep note']]);$notes=file_get_contents($store->path('utm-notes.json'));
$read=fn($id)=>(new Transport($store,$store->target($id)))->readConfig();$selection=fn($id)=>(object)['id'=>$id,'version'=>$read($id)['version']];
@@ -20,7 +20,7 @@ $copy=new Copy($store);$job=$copy->create(['preset'=>$saved['id'],'targets'=>[$s
$presets->delete($saved['id']);rejectsPreset(fn()=>$presets->get($saved['id']),'deleted preset unavailable');
file_put_contents($paths['b'].'/config.json',str_replace('keep.invalid','foreign.invalid',$raw));$jobs=new Jobs($store);$jobs->run($job['id']);$done=$jobs->get($job['id']);
checkPreset($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='conflict','queued snapshot survives deletion and isolates CAS conflict');
$a=$read('a')['config'];checkPreset($a->host==='keep.invalid'&&$a->utm->theme->note==='keep'&&$a->utm->reg->default===2&&$a->utm->custom->a===1&&$a->unknown instanceof stdClass,'host, absent groups, metadata and unknown types unchanged');
$a=$read('a')['config'];checkPreset($a->utm->part->default==='2'&&$a->utm->part->weights->{'1'}===0&&$a->utm->part->weights->{'2'}===100&&$a->utm->part->metadata->keep===true,'part preserved through independent preset queue, CAS, metadata and exact backup');checkPreset($a->host==='keep.invalid'&&$a->utm->theme->note==='keep'&&$a->utm->reg->default===2&&$a->utm->custom->a===1&&$a->unknown instanceof stdClass,'host, absent groups, metadata and unknown types unchanged');
checkPreset($a->utm->theme->default==='3'&&$a->utm->theme->weights->dark===33.33&&$a->utm->theme->weights->{'3'}===66.67&&$a->utm->cons->weights->{'2'}===0,'manual variants, semantic targets and all-zero percentages copied');
checkPreset($store->backupContent('a',$store->backups('a')[0]['id'])===$raw&&$store->backups('b')===[],'exact backup only for successful recipient');
checkPreset(file_get_contents($store->path('utm-notes.json'))===$notes,'notes stay byte identical');