fix(core): 레이아웃 버전 변경량 계산의 메모리 초과와 편집기 확장 편집·재로드·충돌 안내·버전 기록 결함 수정
- 버전 이력 변경량(+N/-N 줄) 계산이 변경 영역 (줄 수)² 크기의 LCS 표를 만들어 큰 공통 레이아웃의 첫 편집기 저장이 PHP 기본 메모리 한도(128M) 서버에서 500 으로 끝났다. 두 행 DP 로 길이만 구하도록 바꿔 메모리가 줄 수에 비례하고 표시 숫자는 종전과 같다(참조 구현 동치·메모리 상한 회귀 테스트) - 확장 편집 모드 저장이 overlay 확장의 injections 를 비우던 결함: 서버가 주입 노드 출처 메타에 injection 순번을 싣고, 편집기는 그 순번(없으면 원본 노드 id) 으로 되돌리며, 되돌릴 수 없는 노드가 있으면 저장하지 않고 안내한다 - 서빙 캐시 키를 서버 현재 확장 캐시 버전으로만 조립해 저장·복원 두 번째부터 재로드·「최신 불러오기」가 옛 내용을 받던 결함 수정(요청 v 는 HTTP 캐시 우회용) - 409 충돌 안내가 errors 아래의 버전을 읽지 못해 「최신 버전: -1」 로 표시되던 결함을 세 저장 경로 공용 판독으로 수정 - 버전 저장 시 저장자를 기록하고, 복원 시에도 잠금 번호를 올린다 - 회귀 테스트(PHPUnit·Vitest), 트러블슈팅 사례 32~34, 규정·API 문서, ja 언어팩, 편집기 번들 재빌드, 이력 문서 동반
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Public;
|
||||
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Enums\LayoutExtensionType;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Models\LayoutExtension;
|
||||
use App\Models\Template;
|
||||
use App\Services\LayoutExtensionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* [case:backend-34] overlay 확장이 주입한 노드의 `__source` 에 `injectionIndex` 가 실린다
|
||||
*
|
||||
* 편집기 확장 편집 모드는 호스트 병합 트리(`with_source_meta=1`)에서 현재 확장의 노드를
|
||||
* 추출해 `injections[].components` 로 되돌려 저장한다. 어느 injection 에서 왔는지가
|
||||
* 메타에 없으면 재조립이 모든 노드를 버려 저장본의 injections 가 통째로 비워진다
|
||||
* (실측: 이커머스 → `_user_base` 헤더 통화 선택기 18,405B → 148B). 백엔드가 주입 시점에
|
||||
* injection 순번을 메타로 싣는 것이 SSoT 다.
|
||||
*/
|
||||
class LayoutOverlayInjectionIndexMetaTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $requiredExtensions = [
|
||||
'plugins/sirsoft-gdpr',
|
||||
];
|
||||
|
||||
#[Test]
|
||||
public function overlay_injected_nodes_carry_injection_index_in_source_meta(): void
|
||||
{
|
||||
$template = Template::factory()->create([
|
||||
'identifier' => 'sirsoft-basic',
|
||||
'type' => 'user',
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
]);
|
||||
|
||||
$extension = LayoutExtension::create([
|
||||
'template_id' => $template->id,
|
||||
'extension_type' => LayoutExtensionType::Overlay,
|
||||
'target_name' => '_user_base',
|
||||
// 출처를 템플릿 자신으로 두어 모듈 활성 여부 게이트(isExtensionSourceActive)에 걸리지 않게 한다.
|
||||
'source_type' => LayoutSourceType::Template,
|
||||
'source_identifier' => 'sirsoft-basic',
|
||||
'content' => [
|
||||
'target_layout' => '_user_base',
|
||||
'injections' => [
|
||||
// 0 — inject_props (components 없음). 순번은 이 injection 도 센다.
|
||||
['target_id' => 'anchor_a', 'position' => 'inject_props', 'props' => ['className' => 'x']],
|
||||
// 1 — 컴포넌트 주입
|
||||
[
|
||||
'target_id' => 'anchor_b',
|
||||
'position' => 'append_child',
|
||||
'components' => [
|
||||
[
|
||||
'type' => 'basic',
|
||||
'name' => 'Span',
|
||||
'id' => 'ext_b',
|
||||
'children' => [
|
||||
['type' => 'basic', 'name' => 'Span', 'id' => 'ext_b_child'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'priority' => 320,
|
||||
],
|
||||
'priority' => 320,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$layout = [
|
||||
'layout_name' => '_user_base',
|
||||
'meta' => ['title' => 'Base'],
|
||||
'components' => [
|
||||
['type' => 'basic', 'name' => 'Div', 'id' => 'anchor_a'],
|
||||
['type' => 'basic', 'name' => 'Div', 'id' => 'anchor_b'],
|
||||
],
|
||||
];
|
||||
|
||||
// 편집 모드(with_source_meta) 병합 — PublicLayoutController::serve 가 같은 인자로 호출한다.
|
||||
$merged = $this->app->make(LayoutExtensionService::class)
|
||||
->applyExtensions($layout, $template->id, true);
|
||||
|
||||
$injected = $this->findNodeById($merged['components'], 'ext_b');
|
||||
$this->assertNotNull($injected, '주입 노드 ext_b 가 병합 결과에 있어야 한다');
|
||||
$this->assertSame('extension', $injected['__source']['kind'] ?? null);
|
||||
$this->assertSame($extension->id, $injected['__source']['extensionId'] ?? null);
|
||||
$this->assertSame(
|
||||
1,
|
||||
$injected['__source']['injectionIndex'] ?? null,
|
||||
'주입 노드 진입점의 __source 에 원래 injection 순번(1)이 실려야 편집기가 저장 시 되돌릴 수 있다'
|
||||
);
|
||||
|
||||
// 자식도 같은 순번을 상속(진입점 판정은 자식으로 내려가지 않지만 메타는 일관).
|
||||
$child = $this->findNodeById($merged['components'], 'ext_b_child');
|
||||
$this->assertSame(1, $child['__source']['injectionIndex'] ?? null);
|
||||
|
||||
// 운영 렌더(with_source_meta=false)에는 메타가 전혀 없다 — 비파괴.
|
||||
$plain = $this->app->make(LayoutExtensionService::class)
|
||||
->applyExtensions($layout, $template->id, false);
|
||||
$plainInjected = $this->findNodeById($plain['components'], 'ext_b');
|
||||
$this->assertNotNull($plainInjected);
|
||||
$this->assertArrayNotHasKey('__source', $plainInjected);
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리에서 id 로 노드를 찾는다.
|
||||
*
|
||||
* @param array<int, mixed>|null $nodes 컴포넌트 배열
|
||||
* @param string $id 찾을 id
|
||||
* @return array<string, mixed>|null 노드
|
||||
*/
|
||||
private function findNodeById(?array $nodes, string $id): ?array
|
||||
{
|
||||
foreach ($nodes ?? [] as $node) {
|
||||
if (! is_array($node)) {
|
||||
continue;
|
||||
}
|
||||
if (($node['id'] ?? null) === $id) {
|
||||
return $node;
|
||||
}
|
||||
$found = $this->findNodeById($node['children'] ?? null, $id);
|
||||
if ($found !== null) {
|
||||
return $found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Public;
|
||||
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayout;
|
||||
use App\Services\LayoutService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* [case:backend-33] 편집기가 부팅 시점 cache_version 으로 계속 요청해도 저장 뒤에는 최신 content 가 온다
|
||||
*
|
||||
* 레이아웃 편집기는 `window.G7Config.cache_version`(부팅 시점 값)에 nonce 만 붙여
|
||||
* `?v={부팅버전}.{nonce}` 로 재로드한다. 저장·복원은 `ext.cache_version` 을 `time()` 으로
|
||||
* 올리고 **서버 현재 버전** 키만 지우므로, 서빙 캐시 키를 클라이언트가 보낸 버전으로 조립하면
|
||||
* 두 번째 bump 부터 부팅 버전 키가 영영 지워지지 않아 초기화·복원·409 「최신 불러오기」가
|
||||
* 옛 content 를 받는다(실측: 초기화 직후 lock 4 응답, DB 는 lock 7). 서빙 캐시 키는 서버 현재
|
||||
* 버전으로만 조립하고 `?v` 는 브라우저 HTTP 캐시 우회용으로만 쓴다.
|
||||
*/
|
||||
class LayoutServingStaleClientVersionKeyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
protected array $requiredExtensions = [
|
||||
'plugins/sirsoft-gdpr',
|
||||
];
|
||||
|
||||
#[Test]
|
||||
public function serve_with_boot_time_client_version_returns_fresh_content_after_two_saves(): void
|
||||
{
|
||||
$template = Template::create([
|
||||
'identifier' => 'sirsoft-admin_basic',
|
||||
'vendor' => 'sirsoft',
|
||||
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
||||
'version' => '1.0.0',
|
||||
'type' => 'admin',
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
||||
]);
|
||||
|
||||
$layout = TemplateLayout::create([
|
||||
'template_id' => $template->id,
|
||||
'name' => 'dashboard',
|
||||
'content' => $this->content('Before'),
|
||||
]);
|
||||
|
||||
// 편집기 부팅 시점의 확장 캐시 버전 — 이후 요청은 모두 이 정수에 nonce 만 붙인다.
|
||||
Cache::put('g7:core:ext.cache_version', 1000);
|
||||
$url = "/api/layouts/{$template->identifier}/dashboard.json?v=1000.";
|
||||
|
||||
$this->assertSame('Before', $this->getJson($url.'0')->assertStatus(200)->json('data.meta.title'));
|
||||
|
||||
$service = app(LayoutService::class);
|
||||
|
||||
// 저장 1 — 서빙 캐시 무효화 + cache_version bump(time()).
|
||||
$service->updateLayout($template->id, 'dashboard', [
|
||||
'content' => $this->content('After 1'),
|
||||
'expected_lock_version' => (int) $layout->fresh()->lock_version,
|
||||
]);
|
||||
// 편집기 재로드(초기화·복원 뒤) — 부팅 버전 키가 다시 채워진다.
|
||||
$this->assertSame('After 1', $this->getJson($url.'1')->assertStatus(200)->json('data.meta.title'));
|
||||
|
||||
// 저장 2 — 서버는 현재(bump 된) 버전 키만 지운다.
|
||||
$service->updateLayout($template->id, 'dashboard', [
|
||||
'content' => $this->content('After 2'),
|
||||
'expected_lock_version' => (int) $layout->fresh()->lock_version,
|
||||
]);
|
||||
|
||||
// 편집기 재로드 — 부팅 버전으로 요청해도 최신이어야 한다(종전: 'After 1' stale).
|
||||
$this->assertSame(
|
||||
'After 2',
|
||||
$this->getJson($url.'2')->assertStatus(200)->json('data.meta.title'),
|
||||
'부팅 시점 cache_version 으로 조립된 서빙 캐시 키가 두 번째 저장 뒤 stale 로 남는다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $title meta.title
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function content(string $title): array
|
||||
{
|
||||
return [
|
||||
'meta' => ['title' => $title],
|
||||
'data_sources' => [],
|
||||
'components' => [
|
||||
['type' => 'basic', 'name' => 'Div', 'props' => ['className' => 'container'], 'children' => []],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Extension\Traits\InvalidatesLayoutCache;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayout;
|
||||
use App\Services\ExtensionStaticCacheService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
@@ -70,7 +71,9 @@ class LayoutEditorCacheInvalidationTest extends TestCase
|
||||
]);
|
||||
|
||||
$cache = $this->coreCache();
|
||||
$version = (int) $cache->get('ext.cache_version', 0);
|
||||
// 트레이트 게터와 같은 근거 — 원시 키 읽기는 키 부재 시 0 을 돌려주어 실제 무효화 키(`.v{time}`)와
|
||||
// 어긋난 `.v0` 키를 만든다(트레이트는 부재 시 재생성). 무효화 대상 키는 게터가 SSoT 다.
|
||||
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
|
||||
// PublicLayoutController::serve() 가 쓰는 두 캐시 키 — 일반 + 편집기(.meta)
|
||||
$generalKey = "layout.sirsoft-basic.home.v{$version}";
|
||||
@@ -105,7 +108,9 @@ class LayoutEditorCacheInvalidationTest extends TestCase
|
||||
]);
|
||||
|
||||
$cache = $this->coreCache();
|
||||
$version = (int) $cache->get('ext.cache_version', 0);
|
||||
// 트레이트 게터와 같은 근거 — 원시 키 읽기는 키 부재 시 0 을 돌려주어 실제 무효화 키(`.v{time}`)와
|
||||
// 어긋난 `.v0` 키를 만든다(트레이트는 부재 시 재생성). 무효화 대상 키는 게터가 SSoT 다.
|
||||
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
|
||||
$metaKey = "layout.sirsoft-basic.home.v{$version}.meta";
|
||||
$cache->put($metaKey, ['stale' => 'editor-old-content'], 3600);
|
||||
|
||||
|
||||
@@ -232,15 +232,20 @@ class PublicLayoutControllerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집기 캐시-버스트 nonce(`?v={version}.{nonce}`) 요청 시 서버 캐시 키는 **정수 버전**만 쓴다.
|
||||
* 편집기 캐시-버스트 nonce(`?v={version}.{nonce}`) 요청 시 서버 캐시 키는 **서버 현재 버전**만 쓴다.
|
||||
*
|
||||
* 레이아웃 편집기는 같은 세션 저장/복원 후 브라우저 HTTP 캐시를 우회하려고 `?v={cacheVersion}.{nonce}`
|
||||
* 형식으로 요청한다. 종전엔 `serve` 가 이 문자열을 그대로 캐시 키에 써(`v{ver}.{nonce}.meta`),
|
||||
* 저장 경로 `LayoutService::clearPublicServingCache` 가 `(int) ext.cache_version` 으로 nonce 없는 키만
|
||||
* forget 하므로 무효화가 빗나가 편집기가 stale 응답을 받았다. 본 테스트는 nonce 가 붙은 요청도
|
||||
* **정수 버전 키**(`v{ver}.meta`)로만 캐싱돼, 저장 경로 무효화 키와 정합함을 잠근다.
|
||||
* forget 하므로 무효화가 빗나가 편집기가 stale 응답을 받았다(#588 — 정수부만 키에 사용).
|
||||
*
|
||||
* 그 뒤에도 사각이 남았다 — 편집기는 **부팅 시점** 버전을 계속 보내고 서버는 저장·복원마다 버전을
|
||||
* 올리며 **현재** 버전 키만 지우므로, 두 번째 bump 부터 부팅 버전 키가 영영 남아 초기화·복원·409
|
||||
* 「최신 불러오기」가 옛 content 를 받았다. 본 테스트는 클라이언트 `?v` 가 서버 버전과 달라도 서버
|
||||
* 캐시 키가 **서버 현재 버전**(`v{server}.meta`)으로만 만들어져, 무효화 키와 굽기 키가 항상 같은
|
||||
* 키임을 잠근다.
|
||||
*/
|
||||
public function test_editor_nonce_versioned_request_uses_integer_cache_key(): void
|
||||
public function test_editor_nonce_versioned_request_uses_server_cache_version_key(): void
|
||||
{
|
||||
$template = Template::create([
|
||||
'identifier' => 'sirsoft-admin_basic',
|
||||
@@ -258,8 +263,11 @@ class PublicLayoutControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$coreCache = app(CacheInterface::class);
|
||||
// 클라이언트(편집기 부팅 시점) 버전과 서버 현재 버전을 다르게 둔다 — 저장 뒤의 실제 상태.
|
||||
$version = 1781151505;
|
||||
$serverVersion = 1781151999;
|
||||
$nonce = 7;
|
||||
Cache::put('g7:core:ext.cache_version', $serverVersion);
|
||||
// 편집 모드(.meta) — `core.templates.layouts.edit` 권한 보유 사용자로 인증.
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create([
|
||||
@@ -275,7 +283,8 @@ class PublicLayoutControllerTest extends TestCase
|
||||
]);
|
||||
$role->permissions()->attach($perm->id);
|
||||
$admin->roles()->attach($role->id);
|
||||
// 정수 버전 키(저장 경로가 forget 하는 키)와 nonce 부착 키를 모두 비워 둔다.
|
||||
// 서버 버전 키(저장 경로가 forget 하는 키)·클라이언트 정수 키·nonce 부착 키를 모두 비워 둔다.
|
||||
$coreCache->forget("layout.{$template->identifier}.{$layout->name}.v{$serverVersion}.meta");
|
||||
$coreCache->forget("layout.{$template->identifier}.{$layout->name}.v{$version}.meta");
|
||||
$coreCache->forget("layout.{$template->identifier}.{$layout->name}.v{$version}.{$nonce}.meta");
|
||||
|
||||
@@ -284,10 +293,16 @@ class PublicLayoutControllerTest extends TestCase
|
||||
->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json?with_source_meta=1&v={$version}.{$nonce}")
|
||||
->assertStatus(200);
|
||||
|
||||
// 서버 캐시 키는 **정수 버전**(nonce 없는 키)에 생성돼야 한다 — 저장 경로 무효화 키와 일치.
|
||||
// 서버 캐시 키는 **서버 현재 버전**(nonce 없는 키)에 생성돼야 한다 — 저장 경로 무효화 키와 일치.
|
||||
$this->assertTrue(
|
||||
$coreCache->has("layout.{$template->identifier}.{$layout->name}.v{$serverVersion}.meta"),
|
||||
'요청의 ?v 와 무관하게 서버 현재 버전 캐시 키(`v{server}.meta`)로 캐싱돼야 저장 경로 무효화와 정합함',
|
||||
);
|
||||
// 클라이언트가 보낸 정수 버전 키는 생성되지 않아야 한다 — 저장 경로가 지우지 않는 키라
|
||||
// 두 번째 저장부터 stale 로 남는다(편집기 초기화·복원·409 「최신 불러오기」 옛 content 회귀 차단).
|
||||
$this->assertFalse(
|
||||
$coreCache->has("layout.{$template->identifier}.{$layout->name}.v{$version}.meta"),
|
||||
'nonce 부착 요청도 정수 버전 캐시 키(`v{ver}.meta`)로 캐싱돼야 저장 경로 무효화와 정합함',
|
||||
'클라이언트 부팅 버전으로 키를 만들면 저장 경로(서버 현재 버전 키 forget)가 무효화하지 못해 stale 회귀',
|
||||
);
|
||||
// nonce 가 박힌 키(`v{ver}.{nonce}.meta`)는 생성되지 않아야 한다(무효화 빗나감 회귀 차단).
|
||||
$this->assertFalse(
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Repositories\Concerns;
|
||||
|
||||
use App\Repositories\Concerns\CalculatesJsonContentDiff;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 버전 변경량(라인 LCS 카운트) 계산의 메모리 상한과 카운트 불변을 고정합니다. [case:backend-32]
|
||||
*
|
||||
* 큰 공통 레이아웃(직렬화 2,000줄 이상)의 첫 편집기 저장은 변경 영역이 파일 전체가 된다 —
|
||||
* 편집기가 `comment` 키를 떼어내 첫 줄과 끝 근처가 동시에 달라져 prefix/suffix 트리밍이
|
||||
* 무력해지기 때문이다. 종전 구현은 그 영역의 (줄 수)² 크기 PHP 배열을 만들어 2,350줄에서
|
||||
* 약 146MB 를 썼고, PHP 기본 memory_limit(128M) 인 서버에서 저장이 500 으로 끝났다.
|
||||
* 개발 머신은 512M 이라 드러나지 않았다.
|
||||
*
|
||||
* 카운트는 LCS 길이만으로 결정된다(추가 = 새 줄 − LCS, 삭제 = 옛 줄 − LCS). 두 행만 쓰는
|
||||
* 계산으로 바꿔도 숫자가 종전(전체 표 + backtrack)과 같아야 목록의 +N/-N 이 diff 뷰와
|
||||
* 계속 일치한다 — 그 동치를 참조 구현 대비 무작위 입력으로 잠근다.
|
||||
*/
|
||||
class CalculatesJsonContentDiffTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 트레이트를 조합한 익명 객체 (private 메서드를 공개 래퍼로 노출)
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
private function subject(): object
|
||||
{
|
||||
return new class
|
||||
{
|
||||
use CalculatesJsonContentDiff;
|
||||
|
||||
/** @param array<string> $a @param array<string> $b @return array{0: int, 1: int} */
|
||||
public function counts(array $a, array $b): array
|
||||
{
|
||||
return $this->diffLineCounts($a, $b);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $content @return array<string> */
|
||||
public function lines(array $content): array
|
||||
{
|
||||
return $this->contentToLines($content);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 종전 구현(전체 DP 표 + backtrack) — 카운트 동치의 참조 기준.
|
||||
*
|
||||
* @param array<string> $a 이전 라인
|
||||
* @param array<string> $b 새 라인
|
||||
* @return array{0: int, 1: int} [추가, 삭제]
|
||||
*/
|
||||
private function referenceCounts(array $a, array $b): array
|
||||
{
|
||||
$na = count($a);
|
||||
$nb = count($b);
|
||||
$dp = array_fill(0, $na + 1, array_fill(0, $nb + 1, 0));
|
||||
for ($i = $na - 1; $i >= 0; $i--) {
|
||||
for ($j = $nb - 1; $j >= 0; $j--) {
|
||||
$dp[$i][$j] = $a[$i] === $b[$j]
|
||||
? $dp[$i + 1][$j + 1] + 1
|
||||
: max($dp[$i + 1][$j], $dp[$i][$j + 1]);
|
||||
}
|
||||
}
|
||||
$added = 0;
|
||||
$removed = 0;
|
||||
$i = 0;
|
||||
$j = 0;
|
||||
while ($i < $na && $j < $nb) {
|
||||
if ($a[$i] === $b[$j]) {
|
||||
$i++;
|
||||
$j++;
|
||||
} elseif ($dp[$i + 1][$j] >= $dp[$i][$j + 1]) {
|
||||
$removed++;
|
||||
$i++;
|
||||
} else {
|
||||
$added++;
|
||||
$j++;
|
||||
}
|
||||
}
|
||||
|
||||
return [$added + ($nb - $j), $removed + ($na - $i)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 직렬화하면 2,300줄을 넘는 레이아웃 형태의 content 를 만듭니다.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function largeLayout(): array
|
||||
{
|
||||
$components = [];
|
||||
for ($i = 0; $i < 260; $i++) {
|
||||
$components[] = [
|
||||
'id' => "node_{$i}",
|
||||
'type' => 'basic',
|
||||
'name' => 'Div',
|
||||
'props' => ['className' => "p-{$i} flex", 'title' => "노드 {$i}"],
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'components' => $components,
|
||||
'layout_name' => 'big',
|
||||
'version' => '1.0.0',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경 영역이 파일 전체인 큰 레이아웃의 카운트 계산이 메모리 예산 안에 머물러야 합니다.
|
||||
*
|
||||
* 이전 content 에는 정렬 시 맨 앞에 오는 `comment` 와 맨 뒤에 오는 `zzz` 가 있고 새
|
||||
* content 에는 둘 다 없다 — 편집기 첫 저장이 만드는 정확히 그 형태다. 종전 구현은 여기서
|
||||
* 146MB 를 더 썼다.
|
||||
*
|
||||
* @effects changes_summary_memory_bounded_for_full_span_change
|
||||
*/
|
||||
public function test_full_span_change_of_large_layout_stays_within_memory_budget(): void
|
||||
{
|
||||
$subject = $this->subject();
|
||||
$new = $this->largeLayout();
|
||||
$old = ['comment' => '상속원 설명'] + $new + ['zzz' => 'x'];
|
||||
|
||||
$oldLines = $subject->lines($old);
|
||||
$newLines = $subject->lines($new);
|
||||
$this->assertGreaterThan(2300, count($oldLines), '재현 조건: 직렬화 2,300줄 이상');
|
||||
|
||||
gc_collect_cycles();
|
||||
$before = memory_get_peak_usage();
|
||||
[$added, $removed] = $subject->counts($oldLines, $newLines);
|
||||
$grown = memory_get_peak_usage() - $before;
|
||||
|
||||
$this->assertLessThan(
|
||||
16 * 1024 * 1024,
|
||||
$grown,
|
||||
sprintf('카운트 계산이 %.1fMB 를 더 썼습니다 — (줄 수)² 표를 만들고 있습니다', $grown / 1048576)
|
||||
);
|
||||
// comment 삭제 + zzz 삭제 + `"version": "1.0.0",` → `"version": "1.0.0"` (삭제 1·추가 1)
|
||||
$this->assertSame([1, 3], [$added, $removed]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카운트는 종전 구현(전체 표 + backtrack)과 무작위 입력에서 항상 같아야 합니다 —
|
||||
* 목록의 +N/-N 이 diff 뷰(프론트 lineDiff.ts)와 일치한다는 S11 계약의 근거다.
|
||||
*
|
||||
* @effects changes_summary_uses_line_lcs_unit_matching_diff_view
|
||||
*/
|
||||
public function test_counts_equal_full_table_reference_on_random_inputs(): void
|
||||
{
|
||||
$subject = $this->subject();
|
||||
mt_srand(20260909);
|
||||
|
||||
for ($case = 0; $case < 600; $case++) {
|
||||
$alphabet = mt_rand(1, 6); // 작은 알파벳 = 중복 라인(`}`, `],`)이 많은 레이아웃 형태
|
||||
$a = [];
|
||||
$b = [];
|
||||
for ($i = 0, $n = mt_rand(0, 40); $i < $n; $i++) {
|
||||
$a[] = chr(97 + mt_rand(0, $alphabet));
|
||||
}
|
||||
for ($i = 0, $m = mt_rand(0, 40); $i < $m; $i++) {
|
||||
$b[] = chr(97 + mt_rand(0, $alphabet));
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
$this->referenceCounts($a, $b),
|
||||
$subject->counts($a, $b),
|
||||
sprintf('case %d: %s vs %s', $case, implode('', $a), implode('', $b))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 형태의 변형(노드 추가·삭제·prop 변경·순서 변경)에서도 참조 구현과 같아야 합니다.
|
||||
*
|
||||
* @effects changes_summary_uses_line_lcs_unit_matching_diff_view
|
||||
*/
|
||||
public function test_counts_equal_reference_on_layout_shaped_mutations(): void
|
||||
{
|
||||
$subject = $this->subject();
|
||||
$base = [
|
||||
'components' => [
|
||||
['id' => 'a', 'type' => 'basic', 'name' => 'Div', 'props' => ['className' => 'x'], 'children' => []],
|
||||
['id' => 'b', 'type' => 'basic', 'name' => 'Span', 'props' => ['text' => '안녕'], 'children' => []],
|
||||
['id' => 'c', 'type' => 'composite', 'name' => 'Header', 'props' => ['logo' => '/a.png']],
|
||||
],
|
||||
'layout_name' => 'home',
|
||||
'version' => '1.0.0',
|
||||
];
|
||||
|
||||
$mutations = [
|
||||
'prop 변경' => (function () use ($base) {
|
||||
$base['components'][2]['props']['logo'] = '/api/templates/x/layout-attachments/1/file';
|
||||
|
||||
return $base;
|
||||
})(),
|
||||
'노드 추가' => (function () use ($base) {
|
||||
$base['components'][] = ['id' => 'd', 'type' => 'basic', 'name' => 'P', 'props' => []];
|
||||
|
||||
return $base;
|
||||
})(),
|
||||
'노드 삭제' => (function () use ($base) {
|
||||
unset($base['components'][1]);
|
||||
$base['components'] = array_values($base['components']);
|
||||
|
||||
return $base;
|
||||
})(),
|
||||
'순서 변경' => (function () use ($base) {
|
||||
$base['components'] = [$base['components'][2], $base['components'][0], $base['components'][1]];
|
||||
|
||||
return $base;
|
||||
})(),
|
||||
'빈 content' => [],
|
||||
];
|
||||
|
||||
$baseLines = $subject->lines($base);
|
||||
foreach ($mutations as $label => $mutated) {
|
||||
$mutatedLines = $subject->lines($mutated);
|
||||
$this->assertSame($this->referenceCounts($baseLines, $mutatedLines), $subject->counts($baseLines, $mutatedLines), $label);
|
||||
$this->assertSame($this->referenceCounts($mutatedLines, $baseLines), $subject->counts($mutatedLines, $baseLines), $label.' (역방향)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Unit\Repositories;
|
||||
use App\Models\LayoutExtension;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayoutExtensionVersion;
|
||||
use App\Models\User;
|
||||
use App\Repositories\LayoutExtensionVersionRepository;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -212,4 +213,37 @@ class LayoutExtensionVersionRepositoryTest extends TestCase
|
||||
$this->assertSame([], $this->repository->getCurrentVersionsByExtensionIds([]));
|
||||
$this->assertSame([], $this->repository->getCurrentVersionsByExtensionIds([$this->extension->id]));
|
||||
}
|
||||
|
||||
/**
|
||||
* saveVersion 은 인증 사용자를 created_by 로 기록한다(레이아웃 본체와 동형).
|
||||
*/
|
||||
public function test_save_version_records_authenticated_user_as_creator(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$version = $this->repository->saveVersion($this->extension->id, ['extension_point' => 'header', 'components' => []]);
|
||||
|
||||
$this->assertSame($user->id, $version->created_by);
|
||||
}
|
||||
|
||||
/**
|
||||
* restoreVersion 은 확장 lock_version 을 올린다(레이아웃 본체와 동형).
|
||||
*/
|
||||
public function test_restore_version_increments_extension_lock_version(): void
|
||||
{
|
||||
$oldContent = ['extension_point' => 'header', 'components' => [['type' => 'basic', 'name' => 'Span']]];
|
||||
$this->extension->forceFill(['lock_version' => 2, 'content' => ['extension_point' => 'footer']])->save();
|
||||
$target = TemplateLayoutExtensionVersion::factory()->create([
|
||||
'extension_id' => $this->extension->id,
|
||||
'version' => 1,
|
||||
'content' => $oldContent,
|
||||
]);
|
||||
|
||||
$this->repository->restoreVersion($this->extension->id, $target->id);
|
||||
|
||||
$this->extension->refresh();
|
||||
$this->assertSame(3, (int) $this->extension->lock_version);
|
||||
$this->assertEquals($oldContent, $this->extension->content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Unit\Repositories;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayout;
|
||||
use App\Models\TemplateLayoutVersion;
|
||||
use App\Models\User;
|
||||
use App\Repositories\LayoutVersionRepository;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
@@ -687,4 +688,41 @@ class LayoutVersionRepositoryTest extends TestCase
|
||||
|
||||
$this->assertSame([], $map);
|
||||
}
|
||||
|
||||
/**
|
||||
* saveVersion 은 인증 사용자를 created_by 로 기록한다 — 종전엔 항상 NULL 이라 버전 목록의
|
||||
* 저장자가 「알 수 없음」으로만 표시됐다.
|
||||
*/
|
||||
public function test_save_version_records_authenticated_user_as_creator(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$version = $this->repository->saveVersion($this->layout->id, ['components' => []]);
|
||||
|
||||
$this->assertSame($user->id, $version->created_by);
|
||||
}
|
||||
|
||||
/**
|
||||
* restoreVersion 은 레이아웃 lock_version 을 올린다 — 복원 직전 화면을 열어 둔 다른 편집기가
|
||||
* 복원 결과를 409 없이 덮지 못하게 한다.
|
||||
*/
|
||||
public function test_restore_version_increments_layout_lock_version(): void
|
||||
{
|
||||
$this->layout->forceFill([
|
||||
'lock_version' => 4,
|
||||
'content' => ['components' => [['type' => 'basic', 'name' => 'Span']]],
|
||||
])->save();
|
||||
$target = TemplateLayoutVersion::factory()->create([
|
||||
'layout_id' => $this->layout->id,
|
||||
'version' => 1,
|
||||
'content' => ['components' => []],
|
||||
]);
|
||||
|
||||
$this->repository->restoreVersion($this->layout->id, $target->id);
|
||||
|
||||
$this->layout->refresh();
|
||||
$this->assertSame(5, (int) $this->layout->lock_version);
|
||||
$this->assertEquals(['components' => []], $this->layout->content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,9 @@ effects:
|
||||
- stable_stringify_sorts_keys_preserves_array_order
|
||||
- changes_summary_uses_line_lcs_unit_matching_diff_view
|
||||
- changes_summary_recalculated_for_existing_versions
|
||||
- changes_summary_memory_bounded_for_full_span_change
|
||||
test_files:
|
||||
- tests/Unit/Repositories/Concerns/CalculatesJsonContentDiffTest.php
|
||||
- resources/js/core/template-engine/layout-editor/__tests__/components/VersionHistoryModal.test.tsx
|
||||
- resources/js/core/template-engine/layout-editor/__tests__/components/EditorToolbar.versionPreview.test.tsx
|
||||
- resources/js/core/template-engine/layout-editor/__tests__/components/VersionDiffView.test.tsx
|
||||
|
||||
Reference in New Issue
Block a user