Files
Gnuboard7/tests/Unit/Repositories/Concerns/CalculatesJsonContentDiffTest.php
T
HeuJung 0b6f329249 fix(core): 레이아웃 버전 변경량 계산의 메모리 초과와 편집기 확장 편집·재로드·충돌 안내·버전 기록 결함 수정
- 버전 이력 변경량(+N/-N 줄) 계산이 변경 영역 (줄 수)² 크기의 LCS 표를 만들어
 큰 공통 레이아웃의 첫 편집기 저장이 PHP 기본 메모리 한도(128M) 서버에서
 500 으로 끝났다. 두 행 DP 로 길이만 구하도록 바꿔 메모리가 줄 수에 비례하고
 표시 숫자는 종전과 같다(참조 구현 동치·메모리 상한 회귀 테스트)
- 확장 편집 모드 저장이 overlay 확장의 injections 를 비우던 결함: 서버가 주입
 노드 출처 메타에 injection 순번을 싣고, 편집기는 그 순번(없으면 원본 노드 id)
 으로 되돌리며, 되돌릴 수 없는 노드가 있으면 저장하지 않고 안내한다
- 서빙 캐시 키를 서버 현재 확장 캐시 버전으로만 조립해 저장·복원 두 번째부터
 재로드·「최신 불러오기」가 옛 내용을 받던 결함 수정(요청 v 는 HTTP 캐시 우회용)
- 409 충돌 안내가 errors 아래의 버전을 읽지 못해 「최신 버전: -1」 로 표시되던
 결함을 세 저장 경로 공용 판독으로 수정
- 버전 저장 시 저장자를 기록하고, 복원 시에도 잠금 번호를 올린다
- 회귀 테스트(PHPUnit·Vitest), 트러블슈팅 사례 32~34, 규정·API 문서, ja 언어팩,
 편집기 번들 재빌드, 이력 문서 동반
2026-09-09 12:19:12 +09:00

226 lines
8.5 KiB
PHP

<?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.' (역방향)');
}
}
}