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

+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)});