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:
HeuJung
2026-09-09 12:19:12 +09:00
parent 72f46a6468
commit 0b6f329249
34 changed files with 1080 additions and 97 deletions
@@ -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